mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
Remove the deprecated in-frame single-player path
Single player only runs on its server thread now. --in-frame and --no-server-thread print a notice, and HORIZON_SERVER_THREAD is ignored. This deletes the code that only the in-frame path reached: the window's world loader and local player tick, its portal travel, the loading screen and ring-by-ring streaming, and the in-frame LAN host (the host player in the hub, its models of the guests, the reserved uuid). The world-upgrade progress screen with its Cancel button moves to the server-thread start. Tools, the harnesses and the docs no longer use the in-frame path. Closes #6473 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
29ba9fbb09
commit
23f8bd4fed
103 files changed
+468
-3284
No files matched your search
@@ -47,7 +47,7 @@ line are in the output) and the Rust fn before changing anything.
|
||||
That one line is what makes the next agent find it.
|
||||
- Fix the path players run: single player is a client of the integrated server, so server logic lives in
|
||||
`game/lan*.rs` and `src/lan/`, and what only the client's copy shows (particles, local sounds, hand
|
||||
animation) must be reached from `game/remote*.rs`. A fix on the old in-frame path alone fixes nothing.
|
||||
animation) must be reached from `game/remote*.rs`.
|
||||
- Look at the neighbours the ledger lists for the same class: a `client` or `seam` gap next to your fix is
|
||||
usually the same bug on the other path.
|
||||
|
||||
|
||||
@@ -174,28 +174,24 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
|
||||
single player on its server thread), `lan` (`LanHost`: this world is open to the network), `integrated`
|
||||
(the window's own server thread), `serving` (this `Game` *is* that server thread), `options.dedicated`.
|
||||
`fn tick` returns `tick_remote()` when `remote` is set; nothing else of the local simulation runs.
|
||||
- A server's players run through the same gameplay code as the local player: `as_guest` swaps the guest's
|
||||
`fn tick` returns `tick_remote()` when `remote` is set; a server ticks its levels (`fn tick_world`).
|
||||
- A server's players run through the shared gameplay code: `as_guest` swaps the guest's
|
||||
state (position, inventory, mode, mining, item use) into `Game` for one call (`game/lan_actions.rs`), and
|
||||
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
|
||||
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
|
||||
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
|
||||
(`pack`), both in game.rs.
|
||||
|
||||
## The two single-player paths
|
||||
## Single player runs on a server thread
|
||||
|
||||
1. **Server thread (default since #1514):** `game/integrated.rs` runs a second `Game` (`serving`) on a
|
||||
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
|
||||
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
|
||||
`[server-thread]` issues list the gaps left.
|
||||
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`): DEPRECATED, never use it, it
|
||||
will be removed soon.** The window's `Game` ticks the world itself, one level that follows the player
|
||||
(`fn enter_dimension`). Compatibility does not matter: don't add features or fixes for it, don't write new
|
||||
tests against it, and move code off it when you touch it. The deterministic rule fixtures still run on it
|
||||
until they are ported.
|
||||
`game/integrated.rs` runs a second `Game` (`serving`) on a "Server thread" with one level per dimension;
|
||||
the window joins it as a client through memory (`remote`), exactly as a LAN guest or a dedicated server's
|
||||
player does (#1514). A window's `Game` never ticks a world itself: the old in-frame path is gone
|
||||
(`--in-frame` and `--no-server-thread` only print a notice; `HORIZON_SERVER_THREAD` is ignored).
|
||||
Open `[server-thread]` issues list the gaps left.
|
||||
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test the server
|
||||
thread when you touch player-facing gameplay:
|
||||
Single player, LAN guests and dedicated servers all run the server code, so a fix there serves all three.
|
||||
Test the server thread when you touch player-facing gameplay:
|
||||
|
||||
```sh
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
|
||||
|
||||
+3
-3
@@ -92,7 +92,7 @@ Owner: [ludovic111](https://github.com/ludovic111) · Tracking: [#3914](https://
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic ([#3873](https://github.com/horizon-rust/horizon/issues/3873)), the client's menu when the server replaces the player ([#3841](https://github.com/horizon-rust/horizon/issues/3841)), splashes when falling in water ([#3621](https://github.com/horizon-rust/horizon/issues/3621)). [docs/server-levels.md](docs/server-levels.md).
|
||||
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client. The open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic ([#3873](https://github.com/horizon-rust/horizon/issues/3873)), the client's menu when the server replaces the player ([#3841](https://github.com/horizon-rust/horizon/issues/3841)), splashes when falling in water ([#3621](https://github.com/horizon-rust/horizon/issues/3621)). [docs/server-levels.md](docs/server-levels.md).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -234,11 +234,11 @@ Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggr
|
||||
|
||||
#### One single-player path: remove the in-frame world
|
||||
|
||||
Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode goes once everything passes without it.
|
||||
Single player has one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode is removed.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) still ticks the world inside the window's `Game`, and most harnesses test that path. Delete it once `lan_e2e --server-thread` and the parity harnesses pass on the server thread, which also halves the bug surface. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
|
||||
`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) ticked the world inside the window's `Game`. [#6473](https://github.com/horizon-rust/horizon/issues/6473) removes it with the code only it reached (the window's world loader and player tick, its portal travel and loading screen, the in-frame LAN host); the harnesses run on the server thread. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -544,7 +544,7 @@ fn the_chat_option_filters_the_lines_both_ways() {
|
||||
assert_eq!(shown(&chat), ["client", "server", "player"]);
|
||||
}
|
||||
|
||||
/// The in-frame LAN host's chat: a guest's line is a player message, so the
|
||||
/// A server's own chat: a guest's line is a player message, so the
|
||||
/// Chat option's player-message permission hides it at Commands Only and
|
||||
/// Hidden; command feedback (`/list`, `/tp`) is a system message and stays
|
||||
/// at Commands Only.
|
||||
|
||||
@@ -394,8 +394,8 @@ pub struct Streaming {
|
||||
decoration_order: DecorationOrder,
|
||||
order_area: Option<((i32, i32), i32)>,
|
||||
/// What the queued jobs' priorities were computed from (`requeue`): the
|
||||
/// centre, the loading screen, the band, and the extra areas.
|
||||
queue_basis: (((i32, i32), bool, Option<Band>), Vec<((i32, i32), i32)>),
|
||||
/// centre, the band, and the extra areas.
|
||||
queue_basis: (((i32, i32), Option<Band>), Vec<((i32, i32), i32)>),
|
||||
finishing: FxHashSet<(i32, i32)>,
|
||||
decorates: bool,
|
||||
/// `ChunkGenerator::decorates_beside_final`.
|
||||
@@ -427,8 +427,6 @@ pub struct Streaming {
|
||||
pub forced: HashSet<(i32, i32)>,
|
||||
/// No work is scheduled while paused (title screen).
|
||||
pub paused: bool,
|
||||
/// The level loading screen is up: work goes ring by ring (`Ranges::rings`).
|
||||
pub loading: bool,
|
||||
/// Dimension flags: sky light, Nether face shading.
|
||||
pub sky_light: bool,
|
||||
pub nether_shading: bool,
|
||||
@@ -515,7 +513,6 @@ struct GenerationInputs<'a> {
|
||||
finishing: &'a FxHashSet<(i32, i32)>,
|
||||
decorates: bool,
|
||||
remote: bool,
|
||||
loading: bool,
|
||||
}
|
||||
impl GenerationScan {
|
||||
/// None means the scan was skipped; Some preserves the original candidate
|
||||
@@ -566,7 +563,7 @@ impl GenerationScan {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
wanted.push((input.ranges.priority(x, z, input.loading), x, z));
|
||||
wanted.push((input.ranges.priority(x, z), x, z));
|
||||
}
|
||||
}
|
||||
// Forced chunks and their generation neighbourhood: the ring that
|
||||
@@ -588,7 +585,7 @@ impl GenerationScan {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
wanted.push((input.ranges.priority(x, z, input.loading), x, z));
|
||||
wanted.push((input.ranges.priority(x, z), x, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -855,7 +852,6 @@ impl Streaming {
|
||||
radius: 8,
|
||||
forced: HashSet::new(),
|
||||
paused: false,
|
||||
loading: false,
|
||||
sky_light: true,
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
@@ -1031,7 +1027,7 @@ impl Streaming {
|
||||
/// render distance 32 nothing was decorated until every proto chunk had
|
||||
/// been generated.
|
||||
fn requeue(&mut self) {
|
||||
let basis = (self.center, self.loading, self.band);
|
||||
let basis = (self.center, self.band);
|
||||
if self.queue_basis.0 == basis && self.queue_basis.1 == self.extra {
|
||||
return;
|
||||
}
|
||||
@@ -1162,7 +1158,7 @@ impl Streaming {
|
||||
}
|
||||
/// The order jobs around the player run in, lowest first.
|
||||
fn distance(&self, x: i32, z: i32) -> i64 {
|
||||
self.ranges().priority(x, z, self.loading)
|
||||
self.ranges().priority(x, z)
|
||||
}
|
||||
/// The 3x3 a light job reads: the finished columns, and for a neighbour
|
||||
/// that will not finish (past the area), its decorated proto chunk, as
|
||||
@@ -1811,7 +1807,6 @@ impl Streaming {
|
||||
finishing: &self.finishing,
|
||||
decorates: self.decorates,
|
||||
remote: self.remote,
|
||||
loading: self.loading,
|
||||
};
|
||||
if let Some(wanted) = self.generation_scan.candidates(input, workers * 3) {
|
||||
for (_, x, z) in wanted {
|
||||
@@ -2298,33 +2293,14 @@ impl ExtraCover {
|
||||
}
|
||||
}
|
||||
impl Ranges<'_> {
|
||||
/// `distance`, or `rings` while the level loading screen is up.
|
||||
fn priority(&self, x: i32, z: i32, loading: bool) -> i64 {
|
||||
let own = if loading {
|
||||
self.rings(x, z)
|
||||
} else {
|
||||
self.distance(x, z)
|
||||
};
|
||||
/// `distance`, or the band's nearer priority close to it.
|
||||
fn priority(&self, x: i32, z: i32) -> i64 {
|
||||
let own = self.distance(x, z);
|
||||
match self.band {
|
||||
Some(band) if band.within(x, z, 4) => own.min(band.priority(x, z)),
|
||||
_ => own,
|
||||
}
|
||||
}
|
||||
/// Ring by ring: the Chebyshev distance to the nearest centre first, then
|
||||
/// the squared distance. Vanilla's chunk tasks run by ticket level, which
|
||||
/// spreads one level per ring of chunks (`ChunkTaskPriorityQueue`), so
|
||||
/// `LevelLoadingScreen`'s chunk map fills squares around the spawn.
|
||||
fn rings(&self, x: i32, z: i32) -> i64 {
|
||||
let d = |c: (i32, i32)| {
|
||||
let (dx, dz) = (i64::from(x - c.0), i64::from(z - c.1));
|
||||
let ring = dx.abs().max(dz.abs());
|
||||
(ring * ring) << 20 | (dx * dx + dz * dz)
|
||||
};
|
||||
self.extra
|
||||
.iter()
|
||||
.map(|(c, _)| d(*c))
|
||||
.fold(d(self.center), i64::min)
|
||||
}
|
||||
fn distance(&self, x: i32, z: i32) -> i64 {
|
||||
let d = |c: (i32, i32)| {
|
||||
let (dx, dz) = (i64::from(x - c.0), i64::from(z - c.1));
|
||||
@@ -2718,7 +2694,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_jobs_follow_the_centre_and_the_loading_screen() {
|
||||
fn queued_jobs_follow_the_centre() {
|
||||
let forced = HashSet::new();
|
||||
let ranges = |center| Ranges {
|
||||
center,
|
||||
@@ -2735,13 +2711,13 @@ mod tests {
|
||||
order,
|
||||
job: Job::Generate(x, z),
|
||||
};
|
||||
let loading = ranges((0, 0));
|
||||
// Both queued around an earlier centre, where the farther job came first.
|
||||
let earlier = ranges((40, 0));
|
||||
let current = ranges((0, 0));
|
||||
let queued = || {
|
||||
BinaryHeap::from(vec![
|
||||
// Queued while the loading screen was up: ring order.
|
||||
generate(3, 1, loading.priority(3, 1, true) * 4, 1),
|
||||
// Queued after it closed: plain distances, far below any ring past the first.
|
||||
generate(20, 0, loading.priority(20, 0, false) * 4, 2),
|
||||
generate(3, 1, earlier.priority(3, 1) * 4, 1),
|
||||
generate(20, 0, earlier.priority(20, 0) * 4, 2),
|
||||
Queued {
|
||||
priority: i64::MIN,
|
||||
at: None,
|
||||
@@ -2762,12 +2738,12 @@ mod tests {
|
||||
"the stale order"
|
||||
);
|
||||
let mut queue = queued();
|
||||
rank_again(&mut queue, |x, z| loading.priority(x, z, false));
|
||||
rank_again(&mut queue, |x, z| current.priority(x, z));
|
||||
assert_eq!(pops(queue), [None, Some((3, 1)), Some((20, 0))]);
|
||||
// The player moved: the job near the new centre goes first.
|
||||
let mut queue = BinaryHeap::from(vec![generate(3, 1, 0, 1), generate(20, 0, 1, 2)]);
|
||||
let moved = ranges((19, 0));
|
||||
rank_again(&mut queue, |x, z| moved.priority(x, z, false));
|
||||
rank_again(&mut queue, |x, z| moved.priority(x, z));
|
||||
assert_eq!(queue.pop().and_then(|q| q.at), Some((20, 0)));
|
||||
}
|
||||
|
||||
@@ -2951,7 +2927,6 @@ mod tests {
|
||||
finishing: &self.finishing,
|
||||
decorates: self.decorates,
|
||||
remote: self.remote,
|
||||
loading: false,
|
||||
}
|
||||
}
|
||||
fn fill(&mut self) {
|
||||
@@ -3307,33 +3282,6 @@ mod tests {
|
||||
/// from the save) and is not unloaded right after: either way it would be
|
||||
/// asked for and loaded again on the next frame, forever (8 such columns
|
||||
/// after a teleport at render distance 16 held 8 of the in-flight slots).
|
||||
/// #770: while the loading screen is up, chunks go ring by ring (a
|
||||
/// square front, as vanilla's ticket levels), nearest first in a ring.
|
||||
#[test]
|
||||
fn loading_goes_ring_by_ring() {
|
||||
let forced = HashSet::new();
|
||||
let r = Ranges {
|
||||
center: (2, -1),
|
||||
radius: 12,
|
||||
extra: &[],
|
||||
forced: &forced,
|
||||
cover: None,
|
||||
band: None,
|
||||
};
|
||||
let at = |dx: i32, dz: i32| (2 + dx, -1 + dz);
|
||||
let key = |(x, z): (i32, i32), loading| r.priority(x, z, loading);
|
||||
// The corner of ring 3 comes before the edge of ring 4 only while loading.
|
||||
assert!(key(at(3, 3), true) < key(at(0, 4), true));
|
||||
assert!(key(at(3, 3), false) > key(at(0, 4), false));
|
||||
// Inside a ring, nearest first; rings never interleave.
|
||||
assert!(key(at(0, 3), true) < key(at(2, -3), true));
|
||||
assert!(key(at(-3, -3), true) < key(at(4, 0), true));
|
||||
for dx in -20..=20 {
|
||||
for dz in -20..=20 {
|
||||
assert_eq!(key(at(dx, dz), false), r.distance(2 + dx, -1 + dz));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn requested_columns_stay_loaded() {
|
||||
let forced = HashSet::new();
|
||||
|
||||
@@ -204,8 +204,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Pro Bild an die GPU gesendete Mesh-Daten. Weniger ist flüssiger beim Laden.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Bildrate und Bildzeiten über dem Spiel, mit Diagramm.",
|
||||
"horizon.multiplayer.finding_friend": "Die Welt deines Freundes wird gesucht …",
|
||||
"horizon.loading.opening_world": "Welt wird geöffnet",
|
||||
"horizon.loading.cancelling_world": "Wird abgebrochen",
|
||||
"horizon.title.not_affiliated": "Nicht mit Mojang oder Microsoft verbunden",
|
||||
"horizon.extras.header.lighting": "Beleuchtung",
|
||||
"horizon.extras.header.third_person": "Dritte Person",
|
||||
|
||||
@@ -211,8 +211,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Mesh data sent to the GPU per frame. Lower is smoother while loading.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph.",
|
||||
"horizon.multiplayer.finding_friend": "Finding your friend's world...",
|
||||
"horizon.loading.opening_world": "Opening world",
|
||||
"horizon.loading.cancelling_world": "Cancelling world",
|
||||
"horizon.multiplayer.not_signed_in": "This server is in online mode. Sign in with your Microsoft account in the Horizon launcher to join it.",
|
||||
"horizon.multiplayer.invalid_address": "Invalid server address: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Connected straight to your friend...",
|
||||
|
||||
@@ -198,8 +198,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Datos de malla enviados a la GPU por fotograma. Menos es más fluido durante la carga.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Fotogramas por segundo y tiempos de fotograma sobre el juego, con un gráfico.",
|
||||
"horizon.multiplayer.finding_friend": "Buscando el mundo de tu amigo…",
|
||||
"horizon.loading.opening_world": "Abriendo el mundo",
|
||||
"horizon.loading.cancelling_world": "Cancelando",
|
||||
"horizon.multiplayer.not_signed_in": "Este servidor está en modo en línea. Inicia sesión con tu cuenta de Microsoft en el launcher de Horizon para unirte.",
|
||||
"horizon.multiplayer.invalid_address": "Dirección de servidor no válida: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Conectado directamente con tu amigo…",
|
||||
|
||||
@@ -204,8 +204,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Données de maillage vers le GPU par image. Moins : plus fluide.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Fréquence et temps d'image par-dessus le jeu, avec un graphique.",
|
||||
"horizon.multiplayer.finding_friend": "Recherche du monde de votre ami...",
|
||||
"horizon.loading.opening_world": "Ouverture du monde",
|
||||
"horizon.loading.cancelling_world": "Annulation en cours",
|
||||
"horizon.title.not_affiliated": "Non affilié à Mojang ou à Microsoft",
|
||||
"horizon.extras.header.lighting": "Éclairage",
|
||||
"horizon.extras.header.third_person": "Troisième personne",
|
||||
|
||||
@@ -204,8 +204,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Dane siatki wysyłane do GPU na klatkę. Mniej to płynniej podczas ładowania.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Liczba klatek i czasy klatek nad grą, z wykresem.",
|
||||
"horizon.multiplayer.finding_friend": "Szukanie świata znajomego…",
|
||||
"horizon.loading.opening_world": "Otwieranie świata",
|
||||
"horizon.loading.cancelling_world": "Anulowanie",
|
||||
"horizon.multiplayer.not_signed_in": "Ten serwer działa w trybie online. Zaloguj się kontem Microsoft w launcherze Horizon, aby dołączyć.",
|
||||
"horizon.multiplayer.invalid_address": "Nieprawidłowy adres serwera: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Połączono bezpośrednio ze znajomym…",
|
||||
|
||||
@@ -197,8 +197,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Данных меша в видеокарту за кадр. Меньше — плавнее во время загрузки.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком.",
|
||||
"horizon.multiplayer.finding_friend": "Ищем мир друга…",
|
||||
"horizon.loading.opening_world": "Открытие мира",
|
||||
"horizon.loading.cancelling_world": "Отмена",
|
||||
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
|
||||
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
|
||||
|
||||
@@ -204,8 +204,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Дані сітки до GPU за кадр. Менше — плавніше завантаження.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота та час кадрів поверх гри, з графіком.",
|
||||
"horizon.multiplayer.finding_friend": "Шукаємо світ друга…",
|
||||
"horizon.loading.opening_world": "Відкриття світу",
|
||||
"horizon.loading.cancelling_world": "Скасування",
|
||||
"horizon.multiplayer.not_signed_in": "Цей сервер працює в онлайн-режимі. Увійдіть за допомогою акаунта Microsoft у лаунчері Horizon, щоб приєднатися.",
|
||||
"horizon.multiplayer.invalid_address": "Недійсна адреса сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Пряме з'єднання з другом встановлено…",
|
||||
|
||||
@@ -198,8 +198,6 @@
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "每帧发送给 GPU 的网格数据。越低加载时越流畅。",
|
||||
"Frame rate and frame times over the game, with a graph.": "游戏运行时的帧率与帧时间,附带图表。",
|
||||
"horizon.multiplayer.finding_friend": "正在查找好友的世界…",
|
||||
"horizon.loading.opening_world": "正在打开世界",
|
||||
"horizon.loading.cancelling_world": "正在取消",
|
||||
"horizon.multiplayer.not_signed_in": "该服务器为在线模式服务器。请先在 Horizon 启动器中登录 Microsoft 账户再加入。",
|
||||
"horizon.multiplayer.invalid_address": "无效的服务器地址:%s",
|
||||
"horizon.multiplayer.connected_friend_direct": "已直连好友的世界…",
|
||||
|
||||
@@ -116,7 +116,7 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
|
||||
for (i, flags) in ticks.iter().enumerate() {
|
||||
mob.age = i as u32 + 1;
|
||||
bat::set_resting(mob, flags.as_i64().unwrap_or(0) & 1 != 0);
|
||||
if flag("@batInFrame") {
|
||||
if flag("@batServerTick") {
|
||||
bat::tick(mob);
|
||||
} else {
|
||||
bat::setup_animation_states(mob);
|
||||
|
||||
@@ -311,10 +311,6 @@ pub struct FixedStep {
|
||||
/// (`ServerTickRateManager.updateStepTicks` broadcasts each one).
|
||||
step_changes: u32,
|
||||
step_requested: i32,
|
||||
/// In-frame ticks dropped since the last overload warning (`drop_ticks`).
|
||||
dropped: u32,
|
||||
/// When the last overload warning was due, on the frame clock.
|
||||
last_overload_warning: std::time::Duration,
|
||||
}
|
||||
|
||||
impl Default for FixedStep {
|
||||
@@ -332,8 +328,6 @@ impl Default for FixedStep {
|
||||
client: false,
|
||||
step_changes: 0,
|
||||
step_requested: 0,
|
||||
dropped: 0,
|
||||
last_overload_warning: std::time::Duration::ZERO,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -448,41 +442,6 @@ impl FixedStep {
|
||||
pub fn sprinting(&self) -> bool {
|
||||
self.manager.is_sprinting()
|
||||
}
|
||||
/// Whether a frame that ran its world in the frame, and whose ticks so
|
||||
/// far took `spent`, may run another one. Vanilla's world ticks on the
|
||||
/// integrated server's thread, so a slow tick costs its frames nothing:
|
||||
/// the server falls behind and says so (`MinecraftServer.runServer`'s
|
||||
/// "Can't keep up!"). In the frame, a world whose ticks take longer than
|
||||
/// their length falls behind every frame, so every frame ran the client
|
||||
/// timer's ten: at 57 ms a tick, 1.7 FPS. So a frame stops once its
|
||||
/// ticks have taken a tick's length: ticks of a few milliseconds keep the
|
||||
/// full ten, and a world that cannot keep up runs one tick a frame,
|
||||
/// slower but drawn. A sprint (`/tick sprint`) runs its own frame's worth.
|
||||
pub fn tick_fits(&self, spent: std::time::Duration) -> bool {
|
||||
self.sprinting() || spent < self.tick_duration()
|
||||
}
|
||||
/// The frame skips its remaining `ticks` (`tick_fits`): the world runs
|
||||
/// slower than its tick rate. Returns vanilla's overload warning when one
|
||||
/// is due: a second's worth of ticks behind, at most every ten seconds
|
||||
/// (`OVERLOADED_THRESHOLD_NANOS`, `OVERLOADED_WARNING_INTERVAL_NANOS`).
|
||||
pub fn drop_ticks(&mut self, ticks: u32) -> Option<String> {
|
||||
self.dropped = self.dropped.saturating_add(ticks);
|
||||
let tick = self.tick_duration();
|
||||
let behind = tick * self.dropped;
|
||||
let threshold = std::time::Duration::from_secs(1) + tick * 20;
|
||||
let interval = std::time::Duration::from_secs(10) + tick * 100;
|
||||
if behind <= threshold || self.now < self.last_overload_warning + interval {
|
||||
return None;
|
||||
}
|
||||
let warning = format!(
|
||||
"Can't keep up! Is the server overloaded? Running {}ms or {} ticks behind",
|
||||
behind.as_millis(),
|
||||
self.dropped
|
||||
);
|
||||
self.dropped = 0;
|
||||
self.last_overload_warning = self.now;
|
||||
Some(warning)
|
||||
}
|
||||
/// `requestGameToSprint`: true when a sprint was already running.
|
||||
pub fn start_sprint(&mut self, ticks: u32) -> bool {
|
||||
self.manager
|
||||
@@ -554,37 +513,6 @@ impl FixedStep {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_frame_stops_ticking_after_a_ticks_length() {
|
||||
let clock = FixedStep::default();
|
||||
let ms = std::time::Duration::from_millis;
|
||||
// Ticks of 4 ms: the ten the timer allows fit in 50 ms.
|
||||
assert!(clock.tick_fits(ms(4 * 9)));
|
||||
// Ticks of 40 ms: the second one runs, not a third.
|
||||
assert!(clock.tick_fits(ms(40)));
|
||||
assert!(!clock.tick_fits(ms(80)));
|
||||
// One tick longer than its length still runs, once a frame.
|
||||
assert!(clock.tick_fits(ms(0)));
|
||||
assert!(!clock.tick_fits(ms(120)));
|
||||
}
|
||||
#[test]
|
||||
fn dropped_ticks_warn_once_a_second_behind_and_not_again_for_ten() {
|
||||
let mut clock = FixedStep::default();
|
||||
let s = std::time::Duration::from_secs;
|
||||
clock.now = s(20);
|
||||
assert_eq!(
|
||||
clock.drop_ticks(40),
|
||||
None,
|
||||
"two seconds: not past 1 s + 20 ticks"
|
||||
);
|
||||
let warning = clock.drop_ticks(1).expect("past the threshold");
|
||||
assert!(warning.contains("2050ms or 41 ticks behind"), "{warning}");
|
||||
clock.now = s(25);
|
||||
assert_eq!(clock.drop_ticks(100), None, "within ten seconds");
|
||||
clock.now = s(36);
|
||||
assert!(clock.drop_ticks(0).unwrap().contains("100 ticks behind"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_carries_fractions_and_caps_nothing_itself() {
|
||||
let mut t = Timer::new(20.0, 0);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -97,7 +97,7 @@ impl Game<'_> {
|
||||
let back = self.dim;
|
||||
for (dim, head) in std::mem::take(&mut self.player_beds) {
|
||||
self.enter_level(None, dim)?;
|
||||
// The in-frame game has only the level it is in.
|
||||
// A client holds no other levels (`enter_level`).
|
||||
if self.dim == dim {
|
||||
self.set_bed_occupied(head, false);
|
||||
}
|
||||
|
||||
@@ -486,23 +486,14 @@ impl Game<'_> {
|
||||
|
||||
/// The leads on boats that hang on a player or a mob, for the entity
|
||||
/// tracker: the holder's entity id (`ClientboundSetEntityLinkPacket`).
|
||||
pub(super) fn boat_leash_link(
|
||||
&self,
|
||||
b: &Boat,
|
||||
host_entity: Option<i32>,
|
||||
guests: &[(u128, i32)],
|
||||
) -> Option<i32> {
|
||||
pub(super) fn boat_leash_link(&self, b: &Boat, guests: &[(u128, i32)]) -> Option<i32> {
|
||||
match b.leash? {
|
||||
Holder::Player => {
|
||||
let uuid = self.boat_leash_player(b);
|
||||
match guests
|
||||
guests
|
||||
.iter()
|
||||
.find(|(u, _)| Some(horizon_client::lan::playerdata::uuid_ints(*u)) == uuid)
|
||||
{
|
||||
Some(&(_, id)) => Some(id),
|
||||
None if uuid == self.level.player_uuid => host_entity,
|
||||
None => None,
|
||||
}
|
||||
.map(|&(_, id)| id)
|
||||
}
|
||||
Holder::Mob(id) | Holder::Boat(id) => Some(id as i32),
|
||||
Holder::Knot(p) => self.knot_ids.get(&p).map(|&id| id as i32),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! paddling controls, collisions, riding (`positionRider`, `clampRotation`,
|
||||
//! dismounting), `BoatItem` placement, `VehicleEntity.hurtServer` breaking,
|
||||
//! saving and `AbstractBoatRenderer` with animated paddles.
|
||||
use super::{Game, InteractTarget, Keys, entity_culling::at, view_vector};
|
||||
use super::{Game, InteractTarget, entity_culling::at, view_vector};
|
||||
use horizon_client::{
|
||||
blocks::Fluid,
|
||||
collisions::{EntityCollisions, SceneCollisions},
|
||||
@@ -1053,43 +1053,6 @@ impl Game<'_> {
|
||||
max_y: b.bounds().max_position().y,
|
||||
})
|
||||
}
|
||||
/// The player's tick while riding: steer, tick the boat, sit in it.
|
||||
pub(super) fn tick_riding(&mut self, keys: Keys) -> horizon_client::Result<()> {
|
||||
if self.tick_cushion_riding(keys) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(i) = self.boats.iter().position(|b| Some(b.id) == self.riding) else {
|
||||
if self.tick_cart_riding(keys) || self.tick_mount_riding(keys) {
|
||||
return Ok(());
|
||||
}
|
||||
self.riding = None;
|
||||
return Ok(());
|
||||
};
|
||||
if keys.shift || self.boats[i].out_of_control >= 60.0 {
|
||||
self.dismount(i);
|
||||
return Ok(());
|
||||
}
|
||||
// The boat steers with the input `LocalPlayer.rideTick` latched on the
|
||||
// previous tick; this tick's keys are latched after it ticked.
|
||||
let mut b = self.tick_boat(self.boats[i].clone(), true);
|
||||
b.input = [keys.left, keys.right, keys.forward, keys.backward];
|
||||
// positionRider: the seat, turned with the boat; clampRotation to 105 degrees.
|
||||
self.yaw += b.delta_rotation;
|
||||
let delta = (self.yaw - b.yaw + 180.0).rem_euclid(360.0) - 180.0;
|
||||
self.yaw += delta.clamp(-105.0, 105.0) - delta;
|
||||
self.boats[i] = b;
|
||||
let seat = self.player_boat_seat(i);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
p.position = seat;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
}
|
||||
if self.boat_lava_tick(i) {
|
||||
self.riding = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `getDismountLocationForPassenger`: in front of the player if there is
|
||||
/// floor there, else on top of the boat.
|
||||
pub(super) fn dismount(&mut self, i: usize) {
|
||||
|
||||
@@ -718,8 +718,8 @@ impl Game<'_> {
|
||||
// The Chat option's Hidden level takes the server's messages too.
|
||||
self.local_chat_line(text, Some(SYSTEM_INDICATOR), ChatSource::Server);
|
||||
}
|
||||
/// A player's chat line on the window's own chat: a LAN guest's message
|
||||
/// while the host runs in-frame (`HostEvent::Chat`). `ChatListener`
|
||||
/// A player's chat line on the window's own chat: a guest's message on
|
||||
/// the server's own chat (`HostEvent::Chat`). `ChatListener`
|
||||
/// checks `canReceivePlayerMessages` for these, so Commands Only and
|
||||
/// Hidden drop them; command feedback (`/list`, `/tp`) is a system
|
||||
/// message and takes the system-message permission instead.
|
||||
@@ -902,7 +902,6 @@ impl Game<'_> {
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
self.lan_host_chat(&message);
|
||||
}
|
||||
}
|
||||
pub(super) fn chat_scroll(&mut self, amount: f32) {
|
||||
@@ -1380,45 +1379,6 @@ impl Game<'_> {
|
||||
log::warn!("the world clocks were not written: {e}");
|
||||
}
|
||||
}
|
||||
/// Death message in chat (CombatTracker.getDeathMessage, as far as the
|
||||
/// cause is known to the client).
|
||||
pub(super) fn death_message(&mut self) {
|
||||
let cause = self.ui.death_cause.take();
|
||||
let player = self.native_acting_id();
|
||||
self.native_player_died(player, cause.as_deref());
|
||||
self.ui.died_at = Some(std::time::Instant::now());
|
||||
self.ui.death_text = None;
|
||||
self.ui.death_confirm = None;
|
||||
if !self.ui.rules.bool("show_death_messages") {
|
||||
return;
|
||||
}
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let below_world = p.position.y < f64::from(self.world.bottom_y() - 64);
|
||||
let name = self.player_display_text();
|
||||
let message = match cause {
|
||||
Some(key) => Text::translate(&key, &[name]),
|
||||
None if below_world => Text::translate("death.attack.outOfWorld", &[name]),
|
||||
None => self.combat_death_message(),
|
||||
};
|
||||
self.ui.death_text = Some(message.clone());
|
||||
// ServerPlayer.die: the team's death message visibility decides who
|
||||
// hears it, the dying player's own chat included (the death screen
|
||||
// still shows it).
|
||||
let board = &self.score.board;
|
||||
if let Some(host) = &self.lan {
|
||||
host.hub.system_message_to(
|
||||
horizon_client::lan::data::component_nbt(&message.component()),
|
||||
|other| {
|
||||
horizon_client::scoreboard::death_message_reaches(board, PLAYER_NAME, other)
|
||||
},
|
||||
);
|
||||
}
|
||||
if horizon_client::scoreboard::death_message_reaches(board, PLAYER_NAME, PLAYER_NAME) {
|
||||
self.system_message(message);
|
||||
}
|
||||
}
|
||||
/// F1 hides the HUD (see `hud`); the screenshot key still works.
|
||||
pub(super) fn toggle_hide_gui(&mut self) {
|
||||
self.ui.hide_gui = !self.ui.hide_gui;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The window's own ambient effects of entities, which a client makes for
|
||||
//! itself (`level().isClientSide()` branches): endermen's and endermites'
|
||||
//! portal particles, blazes' smoke and burn, eyes of ender's trail. They run
|
||||
//! on the game that has the window, in-frame or following a server, never on
|
||||
//! on the game that has the window, following a server, never on
|
||||
//! a server: nothing of them goes over the network.
|
||||
use super::Game;
|
||||
use horizon_client::blocks::RenderShape;
|
||||
@@ -78,8 +78,7 @@ impl Game<'_> {
|
||||
runners.push((m.position, movement, None, f64::from(m.width())));
|
||||
}
|
||||
}
|
||||
let guests = self.lan.iter().flat_map(|host| host.models.values());
|
||||
for m in self.mobs.iter().chain(guests) {
|
||||
for m in self.mobs.iter() {
|
||||
// Another player's fluids aren't known here: none where they stand.
|
||||
let feet = [
|
||||
m.position.x.floor() as i32,
|
||||
@@ -175,8 +174,7 @@ impl Game<'_> {
|
||||
invisible,
|
||||
);
|
||||
}
|
||||
let guests = self.lan.iter().flat_map(|host| host.models.values());
|
||||
for m in self.mobs.iter().chain(guests) {
|
||||
for m in self.mobs.iter() {
|
||||
if !m.alive() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The player's `CombatTracker`: the damage taken lately, who dealt it and
|
||||
//! where the player fell from, turned into the vanilla death message.
|
||||
use super::{Game, chat::PLAYER_NAME, commands::Target};
|
||||
use super::{Game, commands::Target};
|
||||
use horizon_client::{
|
||||
health::HealthEvent,
|
||||
mobs::{Mob, Species},
|
||||
@@ -254,12 +254,6 @@ impl Game<'_> {
|
||||
.pending_direct
|
||||
.push((damage_type.to_owned(), name));
|
||||
}
|
||||
/// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the
|
||||
/// source's `DamageEffects` (`Player.getHurtSound`).
|
||||
pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) {
|
||||
// Player.getVoicePitch: always 1.0.
|
||||
self.audio.play(sound, Some(at), 1.0, 1.0);
|
||||
}
|
||||
/// `CombatTracker.recordDamage` for the motion events of this tick.
|
||||
pub(super) fn record_combat(&mut self, events: &[MotionEvent]) {
|
||||
self.recheck_combat();
|
||||
@@ -384,57 +378,6 @@ impl Game<'_> {
|
||||
damage.in_tag(id, tag) || damage.in_tag(id, &format!("minecraft:{tag}"))
|
||||
})
|
||||
}
|
||||
/// The killing blow as advancement predicates see it (`DamageSource` of the
|
||||
/// last combat entry): its type and the causing entity. The projectile
|
||||
/// damage types have the projectile as direct entity, not the shooter.
|
||||
pub(super) fn combat_killing_blow(&self) -> Option<horizon_client::advancements::DamageView> {
|
||||
use horizon_client::advancements::{DamageView, EntityView};
|
||||
let e = self.combat.entries.last()?;
|
||||
let source = e.attacker.as_ref().and_then(|(id, _)| {
|
||||
self.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == *id)
|
||||
.map(|m| self.mob_entity(m))
|
||||
});
|
||||
let projectile = match e.damage_type.as_str() {
|
||||
// The rocket is the direct entity of its `fireworks` damage.
|
||||
"minecraft:fireworks" => Some("minecraft:firework_rocket"),
|
||||
t @ ("minecraft:arrow"
|
||||
| "minecraft:trident"
|
||||
| "minecraft:mob_projectile"
|
||||
| "minecraft:fireball"
|
||||
| "minecraft:unattributed_fireball"
|
||||
| "minecraft:wither_skull"
|
||||
| "minecraft:wind_charge"
|
||||
| "minecraft:thrown"
|
||||
| "minecraft:spit") => Some(t),
|
||||
_ => None,
|
||||
};
|
||||
let direct = match projectile {
|
||||
Some(kind) => Some(EntityView {
|
||||
kind: kind.to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
None => source.clone(),
|
||||
};
|
||||
Some(DamageView {
|
||||
kind: e.damage_type.clone(),
|
||||
direct,
|
||||
source,
|
||||
is_direct: projectile.is_none(),
|
||||
original: e.damage,
|
||||
actual: e.damage,
|
||||
blocked: false,
|
||||
})
|
||||
}
|
||||
/// The type of the mob that dealt the killing blow (`killed_by`).
|
||||
pub(super) fn combat_killer_kind(&self) -> Option<String> {
|
||||
let (id, _) = self.combat.entries.last()?.attacker.clone()?;
|
||||
self.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map(|m| m.kind.id.clone())
|
||||
}
|
||||
/// `CombatTracker.getMostSignificantFall`.
|
||||
fn most_significant_fall(&self) -> Option<usize> {
|
||||
let (mut result, mut alternative) = (None, None);
|
||||
@@ -498,10 +441,6 @@ impl Game<'_> {
|
||||
.clone()
|
||||
.or_else(|| self.mob_main_hand_name(e.attacker.as_ref()?.0))
|
||||
}
|
||||
/// `CombatTracker.getDeathMessage`.
|
||||
pub(super) fn combat_death_message(&mut self) -> Text {
|
||||
self.combat_death_message_for(Text::literal(PLAYER_NAME))
|
||||
}
|
||||
/// `CombatTracker.getDeathMessage` for a player whose display name is
|
||||
/// `me`: a server's guest is not `PLAYER_NAME`.
|
||||
pub(super) fn combat_death_message_for(&mut self, me: Text) -> Text {
|
||||
|
||||
@@ -290,8 +290,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `CommandSourceStack.broadcastToAdmins` for a command block or a command
|
||||
/// minecart called `name`: `[name: message]`, gray and italic, for every
|
||||
/// operator when `send_command_feedback` (the world's host too: no player
|
||||
/// ran the command) and for a dedicated server's console when
|
||||
/// operator when `send_command_feedback` and for a dedicated server's console when
|
||||
/// `log_admin_commands`. Queued: `send_admin_feedback` sends it.
|
||||
pub(super) fn broadcast_source_to_admins(&mut self, name: Text, message: Text) {
|
||||
if message
|
||||
@@ -303,19 +302,10 @@ impl Game<'_> {
|
||||
let line = Text::translate("chat.type.admin", &[name, message])
|
||||
.with_color(text::GRAY)
|
||||
.italic();
|
||||
// Nobody else is in this game: its player is the only operator there is.
|
||||
if self.lan.is_none()
|
||||
&& self.lan_acting.is_none()
|
||||
&& self.serving.is_none()
|
||||
&& self.options.dedicated.is_none()
|
||||
{
|
||||
self.system_message(line);
|
||||
return;
|
||||
}
|
||||
let ops = self.ui.rules.bool("send_command_feedback");
|
||||
let console = self.options.dedicated.is_some() && self.ui.rules.bool("log_admin_commands");
|
||||
if ops || console {
|
||||
self.admin_feedback.push((line, ops, console, true));
|
||||
self.admin_feedback.push((line, ops, console));
|
||||
}
|
||||
}
|
||||
/// CommandSourceStack.broadcastToAdmins (`sendSuccess` with `broadcast`)
|
||||
@@ -359,7 +349,7 @@ impl Game<'_> {
|
||||
let line = Text::translate("chat.type.admin", &[who, message])
|
||||
.with_color(text::GRAY)
|
||||
.italic();
|
||||
self.admin_feedback.push((line, ops, console, false));
|
||||
self.admin_feedback.push((line, ops, console));
|
||||
}
|
||||
/// Player.getDisplayName: the name (team-formatted), suggesting a
|
||||
/// `/tell`, showing the player on hover, inserting the name.
|
||||
@@ -1603,43 +1593,6 @@ impl Game<'_> {
|
||||
Target::Frame(_) | Target::Painting(_) => {}
|
||||
}
|
||||
}
|
||||
/// Teleport the local player into the command source's dimension. Unlike
|
||||
/// portal travel, `/tp` keeps the coordinates given by the command.
|
||||
fn teleport_player_to_dimension(
|
||||
&mut self,
|
||||
dimension: Dim,
|
||||
pos: [f64; 3],
|
||||
rotation: Option<[f32; 2]>,
|
||||
) -> R<()> {
|
||||
let Some(mut player) = self.player.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
let from = self.dim;
|
||||
if self.switch_level(dimension, player.position).is_err() {
|
||||
self.player = Some(player);
|
||||
return Err(err("command.failed", &[]));
|
||||
}
|
||||
let at = player.position;
|
||||
self.trigger_dimension(from, dimension, [at.x, at.y, at.z]);
|
||||
let target = Vec3::new(pos[0], pos[1], pos[2]);
|
||||
player.position = target;
|
||||
player.velocity = Vec3::ZERO;
|
||||
player.fall_distance = 0.0;
|
||||
player.ground.on_ground = false;
|
||||
if let Some([yaw, pitch]) = rotation {
|
||||
let pitch = pitch.clamp(-90.0, 90.0);
|
||||
self.yaw = yaw;
|
||||
self.pitch = pitch;
|
||||
player.yaw = yaw;
|
||||
player.head_yaw = yaw;
|
||||
player.pitch = pitch;
|
||||
}
|
||||
self.travelling = Some((player, target));
|
||||
self.portals.travel = None;
|
||||
self.end_travel = false;
|
||||
self.end_portal_travel = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A server's player teleported into another level than theirs
|
||||
/// (ServerPlayer.teleport): queued to move after the command. False
|
||||
@@ -1657,7 +1610,7 @@ impl Game<'_> {
|
||||
_ => None,
|
||||
};
|
||||
let at = self.target_level(t);
|
||||
let Some(conn) = conn.filter(|_| at != level && self.options.dedicated.is_some()) else {
|
||||
let Some(conn) = conn.filter(|_| at != level) else {
|
||||
return false;
|
||||
};
|
||||
self.level_teleports
|
||||
@@ -1682,10 +1635,7 @@ impl Game<'_> {
|
||||
pub(super) fn network_entity_id(&self, t: Target) -> Option<i32> {
|
||||
let host = self.lan.as_ref();
|
||||
match t {
|
||||
Target::Player => match self.lan_acting {
|
||||
Some(conn) => host?.hub.guest(conn).map(|g| g.entity_id),
|
||||
None => host.map(|h| h.host_entity),
|
||||
},
|
||||
Target::Player => host?.hub.guest(self.lan_acting?).map(|g| g.entity_id),
|
||||
Target::Guest(conn) => host?.hub.guest(conn).map(|g| g.entity_id),
|
||||
other => non_player_entity_id(other),
|
||||
}
|
||||
@@ -1819,12 +1769,7 @@ impl Game<'_> {
|
||||
if self.teleport_across(t, source_level, pos, rotation) {
|
||||
continue;
|
||||
}
|
||||
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
|
||||
if t == Target::Player && dimension != self.dim {
|
||||
self.teleport_player_to_dimension(dimension, pos, rotation)?;
|
||||
} else {
|
||||
self.teleport_to(t, pos, rotation);
|
||||
}
|
||||
self.teleport_to(t, pos, rotation);
|
||||
// LookAt.perform after a successful teleportTo.
|
||||
if let Some(f) = facing {
|
||||
self.send_player_look_at(t, anchored_eyes, f, facing_entity);
|
||||
@@ -3547,27 +3492,16 @@ impl Game<'_> {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// ListPlayersCommand: every online player, the host of a hosted world
|
||||
/// first and then the guests in join order, with the count and the max.
|
||||
/// ListPlayersCommand: every online player in join order (a single-player
|
||||
/// owner is one of them), with the count and the max.
|
||||
fn cmd_list(&mut self, ctx: &Context) -> R<i32> {
|
||||
// A single-player owner is a placed guest of the in-process server, so
|
||||
// it is the host and needs no entry of its own.
|
||||
let owner_listed = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.is_some_and(|h| h.hub.singleplayer_owner.is_some());
|
||||
// The host is online unless this is a dedicated server, which has none.
|
||||
let host = (self.options.dedicated.is_none() && !owner_listed).then(|| match &self.lan {
|
||||
Some(h) => (h.host_uuid, h.host_name.clone()),
|
||||
None => (self.player_uuid(), PLAYER_NAME.to_owned()),
|
||||
});
|
||||
let guests = self
|
||||
let players: Vec<(u128, String)> = self
|
||||
.lan
|
||||
.iter()
|
||||
.flat_map(|h| h.hub.guests.iter())
|
||||
.filter(|g| g.placed)
|
||||
.map(|g| (g.uuid, g.name.clone()));
|
||||
let players: Vec<(u128, String)> = host.into_iter().chain(guests).collect();
|
||||
.map(|g| (g.uuid, g.name.clone()))
|
||||
.collect();
|
||||
let max = self
|
||||
.lan
|
||||
.as_ref()
|
||||
|
||||
@@ -306,10 +306,6 @@ impl Game<'_> {
|
||||
_ => self.set_camera(None),
|
||||
}
|
||||
}
|
||||
/// The eye height the camera follows (`Camera.tick`'s entity).
|
||||
pub(in crate::game) fn camera_eye_height(&self) -> Option<f32> {
|
||||
self.cmds.camera.map(|t| self.target_eye_height(t) as f32)
|
||||
}
|
||||
|
||||
// ---- /waypoint ------------------------------------------------------
|
||||
|
||||
@@ -968,11 +964,8 @@ impl Game<'_> {
|
||||
guest_commands: allow,
|
||||
force_game_mode: false,
|
||||
};
|
||||
let started = if self.serving() {
|
||||
self.publish(settings).is_ok()
|
||||
} else {
|
||||
self.lan_start(settings).is_ok()
|
||||
};
|
||||
// A dedicated server is open from the start (`published` above).
|
||||
let started = self.serving() && self.publish(settings).is_ok();
|
||||
if !started {
|
||||
return Err(err("commands.publish.failed", &[]));
|
||||
}
|
||||
@@ -1307,18 +1300,4 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// The player's profile id: the saved id, else the offline id of the
|
||||
/// player's name.
|
||||
pub(in crate::game) fn player_uuid(&self) -> u128 {
|
||||
self.level.player_uuid.map_or_else(
|
||||
|| horizon_protocol::crypto::offline_uuid(super::super::chat::PLAYER_NAME),
|
||||
|[a, b, c, d]| {
|
||||
(u128::from(a as u32) << 96)
|
||||
| (u128::from(b as u32) << 64)
|
||||
| (u128::from(c as u32) << 32)
|
||||
| u128::from(d as u32)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
impl Game<'_> {}
|
||||
@@ -210,8 +210,7 @@ impl Game<'_> {
|
||||
if !self.verify_test_structure(&def.structure) {
|
||||
return Ok(None);
|
||||
}
|
||||
// `server.getLevel(dimension)`: the server's levels; a client-only
|
||||
// game (deprecated in-frame mode) has the one it is in.
|
||||
// `server.getLevel(dimension)`: the server's levels.
|
||||
let dim = match def.dimension.as_str() {
|
||||
"minecraft:overworld" | "minecraft:the_nether" | "minecraft:the_end" => {
|
||||
Some(Dim::from_id(&def.dimension))
|
||||
|
||||
@@ -1117,7 +1117,6 @@ impl Game<'_> {
|
||||
self.tick_apparition();
|
||||
self.guardian_bubbles();
|
||||
self.villager_reputation_tick();
|
||||
self.merchant_tick();
|
||||
self.villager_item_sense();
|
||||
self.villager_pickups();
|
||||
for m in dead {
|
||||
|
||||
@@ -910,8 +910,7 @@ impl Game<'_> {
|
||||
}
|
||||
let lerp = |a: f64, b: f64| a + f64::from(alpha) * (b - a);
|
||||
let lerpf = |a: f32, b: f32| a + alpha * (b - a);
|
||||
let guests = self.lan.iter().flat_map(|h| h.models.values());
|
||||
for m in self.mobs.iter().chain(guests) {
|
||||
for m in self.mobs.iter() {
|
||||
if m.removed || m.invisible || m.has_effect("minecraft:invisibility") {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -172,8 +172,7 @@ impl Game<'_> {
|
||||
}
|
||||
"tps" => self.debug_tps_line(d),
|
||||
// DebugEntryChunkGeneration, where the world is generated
|
||||
// here: the window's server thread publishes its generators;
|
||||
// the deprecated in-frame path is not extended.
|
||||
// here: the window's server thread publishes its generators.
|
||||
"chunk_generation_stats" => {
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
let terrain = self
|
||||
@@ -521,10 +520,6 @@ impl Game<'_> {
|
||||
self.dragon_hit()
|
||||
.map(|(_, d)| (d, "minecraft:ender_dragon".to_owned())),
|
||||
);
|
||||
offer(
|
||||
self.guest_hit()
|
||||
.map(|(_, d)| (d, "minecraft:player".to_owned())),
|
||||
);
|
||||
best.map(|b| b.1)
|
||||
}
|
||||
|
||||
|
||||
@@ -912,7 +912,6 @@ impl Game<'_> {
|
||||
levels: self.level_shapes,
|
||||
seed: self.level.seed,
|
||||
max_players: props.max_players.max(0) as usize,
|
||||
dedicated: true,
|
||||
integrated: false,
|
||||
};
|
||||
let settings = LanSettings {
|
||||
@@ -923,7 +922,7 @@ impl Game<'_> {
|
||||
if let Some(sync) = sync {
|
||||
sync.adopt(&self.pack_files());
|
||||
}
|
||||
if let Err(e) = self.lan_host(settings, net, config, None) {
|
||||
if let Err(e) = self.lan_host(settings, net, config) {
|
||||
log("WARN", "**** FAILED TO BIND TO PORT!");
|
||||
log("WARN", &format!("The exception was: {e}"));
|
||||
log("WARN", "Perhaps a server is already running on that port?");
|
||||
|
||||
@@ -785,15 +785,15 @@ impl Game<'_> {
|
||||
(-2..=1).all(|cz| (-2..=1).all(|cx| self.streaming.is_lit(cx, cz)))
|
||||
}
|
||||
/// A player's entity id: the acting player's body has it, another
|
||||
/// player's comes from the LAN host (0 when it is out on a guest's turn,
|
||||
/// and for an in-frame host with none).
|
||||
/// player's comes from the hub (0 when it is out on a guest's turn, and
|
||||
/// for no player).
|
||||
fn fight_entity(&self, who: FightWho) -> i32 {
|
||||
if who == self.acting_player() {
|
||||
return self.player.as_ref().map_or(0, |p| p.entity_id);
|
||||
}
|
||||
let host = self.lan.as_ref();
|
||||
match who {
|
||||
FightWho::Host => host.map_or(0, |h| h.host_entity),
|
||||
FightWho::Host => 0,
|
||||
FightWho::Guest(conn) => host
|
||||
.and_then(|h| h.hub.guest(conn))
|
||||
.map_or(0, |g| g.entity_id),
|
||||
@@ -869,9 +869,6 @@ impl Game<'_> {
|
||||
/// `EnderDragonFight.tick` and the fight's entities, while the End is
|
||||
/// ticked (in place for its player, or on a server with players in it).
|
||||
pub(super) fn tick_fight(&mut self) {
|
||||
if self.travelling.is_some() {
|
||||
return;
|
||||
}
|
||||
// A dragon outside the End (`/summon`) has no fight: `ServerLevel.getDragonFight` is
|
||||
// null there, but `EnderDragon.aiStep` still flies it.
|
||||
if self.dim != Dim::End {
|
||||
|
||||
@@ -11,7 +11,6 @@ use horizon_client::{
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::Who,
|
||||
motion::PlayerMotion,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
projectiles::{Projectile, ProjectileKind},
|
||||
vanillagen::VanillaTerrain,
|
||||
@@ -115,7 +114,6 @@ fn exit_target(exit: [i32; 3], exact: bool, find: impl FnOnce([i32; 3]) -> [i32;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct EndState {
|
||||
job: Option<GatewayJob>,
|
||||
/// Ticks until the player can use a gateway again (TheEndGatewayBlockEntity cooldown).
|
||||
gateway_cooldown: u32,
|
||||
gateways: HashMap<u32, horizon_client::end_gateway::Traveller>,
|
||||
@@ -531,43 +529,6 @@ impl Game<'_> {
|
||||
if self.dim == Dim::End {
|
||||
self.ensure_exit_portal()?;
|
||||
}
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
if self.travelling.is_some() || p.health.dead_or_dying() {
|
||||
return Ok(());
|
||||
}
|
||||
let (portal, gateway) = self.inside_end_portals(
|
||||
p.position,
|
||||
f64::from(p.dimensions.width),
|
||||
f64::from(p.dimensions.height),
|
||||
);
|
||||
if portal && self.dim == Dim::End && !self.level.seen_credits {
|
||||
// EndPortalBlock.entityInside: the first exit shows the credits.
|
||||
self.show_end_credits()?;
|
||||
} else if portal {
|
||||
if self.portals.player.cooldown == 0 {
|
||||
let target = if self.dim == Dim::End {
|
||||
Dim::Overworld
|
||||
} else {
|
||||
Dim::End
|
||||
};
|
||||
self.end_portal_travel = true;
|
||||
let result = self.enter_dimension(target);
|
||||
self.end_portal_travel = false;
|
||||
result?;
|
||||
// Player.getDimensionChangingDelay.
|
||||
self.portals.player.cooldown = 10;
|
||||
} else {
|
||||
// Entity.setAsInsidePortal while cooling down restarts the cooldown.
|
||||
self.portals.player.cooldown = 10;
|
||||
}
|
||||
} else if let Some(g) = gateway
|
||||
&& self.end.gateway_cooldown == 0
|
||||
&& self.gateway_enter(g)
|
||||
{
|
||||
self.enter_gateway(g)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -629,55 +590,6 @@ impl Game<'_> {
|
||||
Some(Vec3::new(s[0].floor() + 0.5, s[1], s[2].floor() + 0.5))
|
||||
}
|
||||
|
||||
/// Arrival after End portal or gateway travel, once the destination is loaded.
|
||||
pub(super) fn place_after_end_travel(
|
||||
&mut self,
|
||||
mut player: PlayerMotion,
|
||||
target: Vec3,
|
||||
) -> Result<()> {
|
||||
self.end_travel = false;
|
||||
// Player.getDimensionChangingDelay (enter_dimension uses the Nether portal value).
|
||||
self.portals.player.cooldown = 10;
|
||||
if player.health.dead_or_dying() {
|
||||
// Respawning out of the End: a fresh player at the spawn.
|
||||
player = PlayerMotion::new(self.motion, target)?;
|
||||
self.eye = 1.62;
|
||||
self.previous_eye = 1.62;
|
||||
self.clock.reset();
|
||||
}
|
||||
let position = if let Some(job) = self.end.job.take() {
|
||||
self.resolve_gateway(job, target)?
|
||||
} else {
|
||||
let mut finder = self.player_spawn.take();
|
||||
let arrival = self.end_arrival(target, &mut finder);
|
||||
self.player_spawn = finder;
|
||||
let Some((position, yaw)) = arrival? else {
|
||||
// A spawn candidate's chunk is loading: try again next tick.
|
||||
self.travelling = Some((player, target));
|
||||
self.end_travel = true;
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(yaw) = yaw {
|
||||
self.yaw = yaw;
|
||||
player.yaw = yaw;
|
||||
player.head_yaw = yaw;
|
||||
}
|
||||
position
|
||||
};
|
||||
player.position = position;
|
||||
player.velocity = Vec3::ZERO;
|
||||
player.fall_distance = 0.0;
|
||||
self.previous_position = player.position;
|
||||
self.player = Some(player);
|
||||
self.pad.player_replaced();
|
||||
self.sway = Default::default();
|
||||
self.apply_equipment();
|
||||
let pitch = self.world.random.next_float() * 0.4 + 0.8;
|
||||
self.audio
|
||||
.play("minecraft:block.portal.travel", None, 0.25, pitch);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where an End portal's traveller lands once `target` has loaded, and
|
||||
/// the yaw it faces there: the platform below the End's spawn point,
|
||||
/// facing west, or the top of the Overworld's spawn column.
|
||||
@@ -735,27 +647,13 @@ impl Game<'_> {
|
||||
Ok(Some((Vec3::new(target.x, y, target.z), None)))
|
||||
}
|
||||
|
||||
/// Dying away from the Overworld respawns at the world spawn there.
|
||||
pub(super) fn respawn_out_of_end(&mut self) -> Result<()> {
|
||||
if self.dim == Dim::Overworld || self.player.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
self.end_portal_travel = true;
|
||||
let result = self.enter_dimension(Dim::Overworld);
|
||||
self.end_portal_travel = false;
|
||||
// The arrival places a fresh player at the spawn.
|
||||
self.end_travel = true;
|
||||
result
|
||||
}
|
||||
|
||||
/// EnderDragonFight without a dragon: once the centre of the main island
|
||||
/// is loaded, place the active exit portal (EndPodiumFeature) and the
|
||||
/// first gateway (spawnNewGateway) if the portal is not there yet. Like
|
||||
/// scanState and spawnExitPortal, only while the fight has no exit portal
|
||||
/// location: a podium that is later broken stays broken.
|
||||
fn ensure_exit_portal(&mut self) -> Result<()> {
|
||||
if self.travelling.is_some()
|
||||
|| !(-1..=0).all(|cz| (-1..=0).all(|cx| self.streaming.is_lit(cx, cz)))
|
||||
if !(-1..=0).all(|cz| (-1..=0).all(|cx| self.streaming.is_lit(cx, cz)))
|
||||
|| !self.fight_ready()
|
||||
{
|
||||
return Ok(());
|
||||
@@ -979,21 +877,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// EndGatewayBlock.entityInside and TheEndGatewayBlockEntity.getPortalPosition:
|
||||
/// travel within the End, waiting for the destination chunks to load.
|
||||
fn enter_gateway(&mut self, gateway: [i32; 3]) -> Result<()> {
|
||||
// TheEndGatewayBlockEntity.triggerCooldown: 40 ticks.
|
||||
self.end.gateway_cooldown = 40;
|
||||
let (job, destination) = self.gateway_job(gateway);
|
||||
let Some(player) = self.player.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
self.end.job = Some(job);
|
||||
self.end_travel = true;
|
||||
self.travelling = Some((player, destination));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where `gateway` leads (TheEndGatewayBlockEntity.getPortalPosition):
|
||||
/// its exit, or the search for one, and the spot to load before either
|
||||
/// is resolved ([`Game::resolve_gateway`]).
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! A Game's execution role is fixed at construction. Connecting or disconnecting
|
||||
//! changes `remote`, not this role. A client may simulate a world in-frame.
|
||||
//! changes `remote`, not this role. A client never simulates a world itself: single
|
||||
//! player's runs on its integrated server thread.
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum ExecutionRole {
|
||||
@@ -14,34 +15,10 @@ impl ExecutionRole {
|
||||
self == Self::Client
|
||||
}
|
||||
|
||||
/// The optional in-frame simulation remains a client execution mode.
|
||||
pub fn starts_integrated_server(self, server_thread: bool) -> bool {
|
||||
self.is_client() && server_thread
|
||||
}
|
||||
|
||||
/// Single-player worlds (in-frame or on the integrated server thread) keep
|
||||
/// the owner's state in `Level` and write `players/data/<singleplayer_uuid>.dat`
|
||||
/// with the world; a dedicated server saves each connected player separately.
|
||||
/// Single-player worlds (on the integrated server thread) keep the owner's state in
|
||||
/// `Level` and write `players/data/<singleplayer_uuid>.dat` with the world; a dedicated
|
||||
/// server saves each connected player separately.
|
||||
pub fn writes_singleplayer_player(self) -> bool {
|
||||
self != Self::DedicatedServer
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ExecutionRole::*;
|
||||
|
||||
#[test]
|
||||
fn world_loading_keeps_in_frame_clients_and_server_roles_separate() {
|
||||
for (role, client, threaded, writes_player) in [
|
||||
(Client, true, true, true),
|
||||
(IntegratedServer, false, false, true),
|
||||
(DedicatedServer, false, false, false),
|
||||
] {
|
||||
assert_eq!(role.is_client(), client);
|
||||
assert_eq!(role.starts_integrated_server(true), threaded);
|
||||
assert!(!role.starts_integrated_server(false));
|
||||
assert_eq!(role.writes_singleplayer_player(), writes_player);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -712,17 +712,6 @@ impl Game<'_> {
|
||||
/// (`Projectile.canHitEntity`), the host and the guests in the level.
|
||||
pub(super) fn refresh_hook_targets(&mut self, host: &LanHost) {
|
||||
let mut targets = Vec::new();
|
||||
if self.options.dedicated.is_none()
|
||||
&& !self.spectator()
|
||||
&& let Some(p) = self.player.as_ref()
|
||||
&& !p.health.dead_or_dying()
|
||||
{
|
||||
targets.push(HookTarget {
|
||||
id: host.host_entity as u32,
|
||||
conn: None,
|
||||
bounds: p.bounds(),
|
||||
});
|
||||
}
|
||||
for g in &host.hub.guests {
|
||||
if host.hub.here(g) && g.loaded && !g.dead && !g.spectator() {
|
||||
targets.push(HookTarget {
|
||||
@@ -747,9 +736,7 @@ impl Game<'_> {
|
||||
p.velocity = p.velocity.add_vector(delta);
|
||||
}
|
||||
};
|
||||
if id == host.host_entity as u32 {
|
||||
push(self);
|
||||
} else if let Some(conn) = host
|
||||
if let Some(conn) = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
@@ -1083,17 +1070,13 @@ impl Hook {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The hooks guests track: the host's and each guest's in the level.
|
||||
/// The hooks guests track: each player's in the level.
|
||||
pub(super) fn lan_hook_states(
|
||||
&self,
|
||||
host_entity: Option<i32>,
|
||||
play: &HashMap<u32, GuestPlay>,
|
||||
hub: &Hub,
|
||||
out: &mut States,
|
||||
) {
|
||||
if let (Some(h), Some(owner)) = (&self.hook, host_entity) {
|
||||
h.track(owner, out);
|
||||
}
|
||||
for g in hub.guests.iter().filter(|g| hub.here(g)) {
|
||||
if let Some(h) = play.get(&g.conn).and_then(|p| p.hook.as_ref()) {
|
||||
h.track(g.entity_id, out);
|
||||
|
||||
@@ -334,7 +334,6 @@ impl Game<'_> {
|
||||
|| before(self.interaction_hit().map(|h| h.1))
|
||||
|| before(self.dragon_hit().map(|h| h.1))
|
||||
|| before(self.mob_hit().map(|h| h.1))
|
||||
|| before(self.guest_hit().map(|h| h.1))
|
||||
|| before(self.redirectable_hit().map(|h| h.1))
|
||||
}
|
||||
/// `LocalPlayer.isHandsBusy`: paddling a boat.
|
||||
|
||||
@@ -6,10 +6,8 @@
|
||||
//! the world's options) with [`Integrated::call`], as vanilla's client calls
|
||||
//! into its `IntegratedServer`.
|
||||
//!
|
||||
//! On by default. `--in-frame` (or `--no-server-thread`, or
|
||||
//! `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead. That path
|
||||
//! is DEPRECATED: never use it, it gets no fixes and will be removed soon.
|
||||
//! LAN guests and dedicated servers use the same code either way.
|
||||
//! Every single-player world runs this way; LAN guests and dedicated servers
|
||||
//! use the same server code.
|
||||
use super::{
|
||||
Dim, Game, Options, PackResources, Resources, UiResources, WorldIcon,
|
||||
execution_role::ExecutionRole,
|
||||
@@ -72,6 +70,9 @@ struct Flags {
|
||||
/// What the server's `LevelLoadListener` reports, for the window's
|
||||
/// `LevelLoadingScreen` (`LevelLoadTracker.serverProgress/statusView`).
|
||||
load: Arc<horizon_client::level_loading::ServerLoadView>,
|
||||
/// What the world's upgrade has done (`UpgradeProgress`): the window shows it on
|
||||
/// `FileFixerProgressScreen` while the file fixers run, and its Cancel stops them.
|
||||
upgrade: Arc<horizon_world::datafix::progress::UpgradeProgress>,
|
||||
/// Where the window's debug entries want the server's lines for (its
|
||||
/// dimension and feet), and the lines of the last tick there.
|
||||
debug_at: Mutex<Option<(Dim, [i32; 3])>>,
|
||||
@@ -101,6 +102,10 @@ impl Integrated {
|
||||
pub fn load_view(&self) -> &Arc<horizon_client::level_loading::ServerLoadView> {
|
||||
&self.flags.load
|
||||
}
|
||||
/// The world's upgrade while the server opens it.
|
||||
pub fn upgrade(&self) -> &horizon_world::datafix::progress::UpgradeProgress {
|
||||
&self.flags.upgrade
|
||||
}
|
||||
pub fn smoothed_tick_ms(&self) -> f32 {
|
||||
f32::from_bits(self.flags.smoothed_tick_ms.load(Ordering::Relaxed))
|
||||
}
|
||||
@@ -355,7 +360,6 @@ impl Game<'_> {
|
||||
// A server without a console: no local player, no window, no control socket.
|
||||
// Compatibility for gameplay checks outside startup/world loading.
|
||||
options.dedicated = Some(Vec::new());
|
||||
options.server_thread = false;
|
||||
options.execution_role = ExecutionRole::IntegratedServer;
|
||||
options.control = None;
|
||||
options.benchmark = None;
|
||||
@@ -450,6 +454,12 @@ impl Game<'_> {
|
||||
let (pipe, world_name) = match starting.ready.try_recv() {
|
||||
Ok(Ok(ready)) => ready,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return Ok(()),
|
||||
// FileFixerProgressScreen's Cancel: the upgrade stopped, back to the title.
|
||||
Ok(Err(_)) if starting.handle.upgrade().is_canceled() => {
|
||||
self.integrated_starting = None;
|
||||
self.perf.cancel_world_opening();
|
||||
return Ok(());
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
self.integrated_starting = None;
|
||||
return Err(e.into());
|
||||
@@ -715,7 +725,6 @@ impl Game<'_> {
|
||||
seed: self.level.seed,
|
||||
max_players: MAX_PLAYERS,
|
||||
// No player of its own: the hub runs without a host.
|
||||
dedicated: true,
|
||||
integrated: true,
|
||||
};
|
||||
let owner = launch.owner;
|
||||
@@ -754,9 +763,7 @@ impl Game<'_> {
|
||||
guest_commands: false,
|
||||
force_game_mode: false,
|
||||
};
|
||||
startup::time("server.lan_host", || {
|
||||
self.lan_host(settings, net, config, None)
|
||||
})?;
|
||||
startup::time("server.lan_host", || self.lan_host(settings, net, config))?;
|
||||
let commands = self.allow_commands();
|
||||
let pipe = {
|
||||
let host = self
|
||||
@@ -1140,6 +1147,7 @@ fn serve(
|
||||
pack.clone(),
|
||||
options.clone(),
|
||||
settings.clone(),
|
||||
flags.upgrade.clone(),
|
||||
)
|
||||
});
|
||||
let opening = Opening {
|
||||
@@ -1180,7 +1188,6 @@ impl Game<'_> {
|
||||
let mut options = self.options.clone();
|
||||
options.execution_role = ExecutionRole::IntegratedServer;
|
||||
options.dedicated = Some(Vec::new());
|
||||
options.server_thread = false;
|
||||
let result = std::thread::Builder::new()
|
||||
.name("Server thread".into())
|
||||
.spawn(move || {
|
||||
|
||||
@@ -2,25 +2,22 @@
|
||||
//! server threads and the play-state hub, feeds it the world's entities each
|
||||
//! tick and turns what the guests do into gameplay. A world that is not open
|
||||
//! costs one `Option` check per tick.
|
||||
use super::{Game, shield::OFFHAND_SLOT, shoulders::shoulder_variants};
|
||||
use super::{Game, shield::OFFHAND_SLOT};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
items::ItemStack,
|
||||
lan::{
|
||||
self, Lan, MAX_PLAYERS, NetConfig,
|
||||
self, Lan, NetConfig,
|
||||
chunks::ChunkEncoder,
|
||||
data::SyncData,
|
||||
entity::{EntityState, States},
|
||||
hub::{BodyFlags, HostEvent, HostState, Hub, HubConfig, TickCtx},
|
||||
hub::{HostEvent, Hub, HubConfig, TickCtx},
|
||||
playerdata,
|
||||
},
|
||||
mobs::{Mob, Species, Who, leash::Holder},
|
||||
text::Text,
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::{
|
||||
crypto::offline_uuid,
|
||||
entity_data::{Value, serializer},
|
||||
packets::play::{self, Clientbound as C},
|
||||
};
|
||||
@@ -153,17 +150,6 @@ pub(super) struct LanHost {
|
||||
/// The mods heard that everyone still here leaves with the server.
|
||||
pub players_told_left: bool,
|
||||
pub settings: LanSettings,
|
||||
pub host_entity: i32,
|
||||
/// The host's swings the guests were told of (`Game::swings_started`).
|
||||
pub host_swings: u32,
|
||||
/// The host's crack the guests were told of: the block and its stage.
|
||||
pub host_crack: Option<([i32; 3], i32)>,
|
||||
pub host_uuid: u128,
|
||||
/// The host's profile name (the account's, or the offline name).
|
||||
pub host_name: String,
|
||||
/// The guests' models in the host's world.
|
||||
pub models: HashMap<u32, Mob>,
|
||||
host_mode: i32,
|
||||
pub play: HashMap<u32, super::lan_actions::GuestPlay>,
|
||||
/// Blocks broken this tick (their sound is a level event for the guests).
|
||||
pub broken: Vec<[f64; 3]>,
|
||||
@@ -1107,7 +1093,7 @@ fn enforce_guest_game_mode(
|
||||
return;
|
||||
}
|
||||
let mode = host.hub.config.default_game_mode;
|
||||
let spares_operators = !host.hub.config.dedicated || host.hub.config.integrated;
|
||||
let spares_operators = host.hub.config.integrated;
|
||||
let stale: Vec<u32> = host
|
||||
.hub
|
||||
.guests
|
||||
@@ -1168,7 +1154,8 @@ impl Game<'_> {
|
||||
match &self.lan {
|
||||
Some(h) if !self.serving() || self.published_port().is_some() => json!({
|
||||
"open": true,
|
||||
"host": h.host_name,
|
||||
// The owner's profile name.
|
||||
"host": self.options.player_name(),
|
||||
"port": h.settings.port,
|
||||
// The server's own world: its finished chunks and how many it
|
||||
// has generated or loaded so far.
|
||||
@@ -1203,17 +1190,6 @@ impl Game<'_> {
|
||||
],
|
||||
"chunks": g.sent_chunks.len(),
|
||||
"tracked": g.tracker.len(),
|
||||
// Whether the host draws this guest lying in a bed.
|
||||
"model_lying": h.models.get(&g.conn).map(|m| m.extra.ints[1] != 0),
|
||||
// The armour the host draws on this guest, head to feet.
|
||||
"model_armor": h.models.get(&g.conn).map(|m| {
|
||||
[&m.extra.head, &m.extra.chest, &m.extra.legs, &m.extra.feet]
|
||||
.map(|s| s.as_deref())
|
||||
}),
|
||||
// The host draws this guest's arm swinging.
|
||||
"model_swinging": h.models.get(&g.conn).map(|m| m.ai.swinging),
|
||||
// For how many ticks the host draws this guest using an item.
|
||||
"model_using": h.models.get(&g.conn).map(|m| m.extra.using_item),
|
||||
})).collect::<Vec<_>>(),
|
||||
}),
|
||||
_ => json!({ "open": false }),
|
||||
@@ -1229,8 +1205,7 @@ impl Game<'_> {
|
||||
} else if self.serving() {
|
||||
return self.publish(settings).map(drop).map_err(Into::into);
|
||||
} else {
|
||||
self.lan_start(settings)?;
|
||||
self.lan.as_ref().map_or(0, |l| l.settings.port)
|
||||
return Err("no world of this game to open".into());
|
||||
};
|
||||
self.system_message(Text::translate(
|
||||
"menu.multiplayerOptions.publish.started.lan",
|
||||
@@ -1238,45 +1213,6 @@ impl Game<'_> {
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
/// IntegratedServer.publishServer without the screen's announcement.
|
||||
pub(super) fn lan_start(&mut self, settings: LanSettings) -> Result<()> {
|
||||
if self.lan.is_some() || self.remote.is_some() || self.title.is_some() {
|
||||
return Err("already open".into());
|
||||
}
|
||||
// IntegratedServer.publishServer: the MOTD names the host's profile.
|
||||
let name = self.options.player_name();
|
||||
let motd = format!("{name} - {}", self.level.name);
|
||||
let host_uuid = self.level.player_uuid.map_or_else(
|
||||
|| offline_uuid(&name),
|
||||
|u| {
|
||||
u128::from(u[0] as u32) << 96
|
||||
| u128::from(u[1] as u32) << 64
|
||||
| u128::from(u[2] as u32) << 32
|
||||
| u128::from(u[3] as u32)
|
||||
},
|
||||
);
|
||||
let config = HubConfig {
|
||||
allow_flight: true,
|
||||
default_game_mode: self.level.mode(),
|
||||
guest_commands: settings.guest_commands,
|
||||
force_game_mode: settings.force_game_mode,
|
||||
hardcore: self.level.hardcore,
|
||||
view_distance: self.settings.render_distance.clamp(2, 32),
|
||||
simulation_distance: self.settings.render_distance.clamp(2, 32),
|
||||
dimension: self.dim.id().to_owned(),
|
||||
levels: self.level_shapes,
|
||||
seed: self.level.seed,
|
||||
max_players: MAX_PLAYERS,
|
||||
dedicated: false,
|
||||
integrated: false,
|
||||
};
|
||||
let net = NetConfig {
|
||||
levels: self.level_shapes,
|
||||
online: horizon_client::lan::OnlineMode::lan(),
|
||||
..NetConfig::lan(settings.port, motd, MAX_PLAYERS)
|
||||
};
|
||||
self.lan_host(settings, net, config, Some(host_uuid))
|
||||
}
|
||||
/// Start the network side and the play-state hub: for the LAN (with the
|
||||
/// host's own profile reserved) or for a dedicated server (no host).
|
||||
pub(super) fn lan_host(
|
||||
@@ -1284,7 +1220,6 @@ impl Game<'_> {
|
||||
settings: LanSettings,
|
||||
net: NetConfig,
|
||||
config: HubConfig,
|
||||
host: Option<u128>,
|
||||
) -> Result<()> {
|
||||
let sync = sync_data(&self.options.resources, &self.pack_files())?;
|
||||
self.sync_dialog_ids(&sync);
|
||||
@@ -1326,12 +1261,6 @@ impl Game<'_> {
|
||||
let far = (net.lod_distance > 0)
|
||||
.then(|| horizon_client::lod_net::Service::new(net.lod_distance, biome_names.clone()));
|
||||
let lan = Lan::start_with(sync.clone(), net)?;
|
||||
if let Some(uuid) = host {
|
||||
lan.reserve(uuid);
|
||||
}
|
||||
let host_uuid = host.unwrap_or(0);
|
||||
self.next_entity += 1;
|
||||
let host_entity = self.next_entity as i32;
|
||||
let mut hub = Hub::new(lan, sync, encoder, config);
|
||||
hub.flat_world = self.world_flat;
|
||||
hub.debug_world = self.world_debug;
|
||||
@@ -1342,13 +1271,6 @@ impl Game<'_> {
|
||||
players_told_left: false,
|
||||
hub,
|
||||
settings: LanSettings { port, ..settings },
|
||||
host_entity,
|
||||
host_swings: self.swings_started,
|
||||
host_crack: None,
|
||||
host_uuid,
|
||||
host_name: self.options.player_name(),
|
||||
models: HashMap::new(),
|
||||
host_mode: self.mode_id(),
|
||||
play: HashMap::new(),
|
||||
broken: Vec::new(),
|
||||
mob_state: HashMap::new(),
|
||||
@@ -1717,70 +1639,22 @@ impl Game<'_> {
|
||||
self.lan.as_ref().map(|h| h.settings.port)
|
||||
}
|
||||
|
||||
/// The host as the guests see them.
|
||||
fn lan_host_state(&self, host: &LanHost) -> Option<HostState> {
|
||||
let p = self.player.as_ref()?;
|
||||
let mut equipment: [Option<ItemStack>; 6] = Default::default();
|
||||
equipment[0] = self.inventory.get(self.selected).cloned().flatten();
|
||||
equipment[1] = self.offhand.clone();
|
||||
// Feet, legs, chest, head are slots 2..=5; armor is stored head first.
|
||||
equipment[2] = self.armor[3].clone();
|
||||
equipment[3] = self.armor[2].clone();
|
||||
equipment[4] = self.armor[1].clone();
|
||||
equipment[5] = self.armor[0].clone();
|
||||
Some(HostState {
|
||||
uuid: host.host_uuid,
|
||||
name: host.host_name.clone(),
|
||||
entity_id: host.host_entity,
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
yaw: self.yaw,
|
||||
pitch: self.pitch,
|
||||
head_yaw: self.yaw,
|
||||
on_ground: p.ground.on_ground,
|
||||
game_mode: self.mode_id(),
|
||||
sneaking: p.pose == horizon_client::pose::Pose::Crouching,
|
||||
sprinting: p.sprinting,
|
||||
equipment,
|
||||
health: p.health.health(),
|
||||
attributes: horizon_client::lan::attributes::syncable(&p.attributes),
|
||||
// Bit 4: Riptide's spin (LivingEntity.isAutoSpinAttack).
|
||||
living_flags: living_flags(self.using)
|
||||
| if p.auto_spin_attack_ticks > 0 { 4 } else { 0 },
|
||||
stuck: (self.combat.stuck.arrows, self.combat.stuck.stingers),
|
||||
shoulders: shoulder_variants(self.level.player_extra.as_ref()),
|
||||
bed: self.sleeping,
|
||||
body: BodyFlags {
|
||||
gliding: p.fall_flying,
|
||||
swimming: p.swimming,
|
||||
crawling: p.pose == horizon_client::pose::Pose::Swimming && !p.swimming,
|
||||
crouching: p.pose == horizon_client::pose::Pose::Crouching,
|
||||
spin_attack: p.auto_spin_attack_ticks > 0,
|
||||
on_fire: p.fire_ticks > 0,
|
||||
// ServerPlayer.updateInvisibilityStatus: a spectator is always invisible.
|
||||
invisible: self.potions.effects.get("invisibility").is_some() || self.spectator(),
|
||||
glowing: self.potions.effects.get("glowing").is_some(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// The entities guests track: mobs, items and the rest (game/lan_entities.rs).
|
||||
/// `mob_players` is the guest each mob sees as its player (game/lan_mobs.rs).
|
||||
fn lan_entities(
|
||||
&self,
|
||||
host_entity: Option<i32>,
|
||||
mob_players: &horizon_client::fast_hash::FxHashMap<u32, u32>,
|
||||
hub: &mut Hub,
|
||||
out: &mut States,
|
||||
) {
|
||||
out.clear();
|
||||
for m in self.mobs.iter().filter(|m| !m.removed) {
|
||||
// The lead's holder: the player the mob sees (a guest, else the
|
||||
// host), another mob or a fence knot (game/lan_entities.rs).
|
||||
// The lead's holder: the player the mob sees, another mob or a
|
||||
// fence knot (game/lan_entities.rs).
|
||||
let player_entity = mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| hub.guest(*conn))
|
||||
.map(|g| g.entity_id)
|
||||
.or(host_entity);
|
||||
.map(|g| g.entity_id);
|
||||
let leash = m.extra.leash.and_then(|h| match h {
|
||||
Holder::Player => player_entity,
|
||||
Holder::Mob(id) | Holder::Boat(id) => Some(id as i32),
|
||||
@@ -1831,7 +1705,7 @@ impl Game<'_> {
|
||||
e.meta
|
||||
.push((8, serializer::INT, Value::Int(o.value as i32)));
|
||||
}
|
||||
self.lan_object_states(host_entity, hub, out);
|
||||
self.lan_object_states(hub, out);
|
||||
}
|
||||
|
||||
/// Apply gameplay events produced while the hub handles guest packets.
|
||||
@@ -1844,7 +1718,6 @@ impl Game<'_> {
|
||||
&mut self,
|
||||
mut host: LanHost,
|
||||
events: impl IntoIterator<Item = HostEvent>,
|
||||
state: Option<&HostState>,
|
||||
entities: &[EntityState],
|
||||
spawn: [f64; 3],
|
||||
) -> Option<LanHost> {
|
||||
@@ -1879,7 +1752,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
HostEvent::Joined(conn) => {
|
||||
self.lan_guest_joined(&mut host, conn, state, entities, spawn);
|
||||
self.lan_guest_joined(&mut host, conn, entities, spawn);
|
||||
if self.options.dedicated.is_some()
|
||||
&& let Some(g) = host.hub.guest(conn)
|
||||
{
|
||||
@@ -1955,7 +1828,6 @@ impl Game<'_> {
|
||||
// PlayerList.remove: the vehicle it alone rides goes too.
|
||||
self.take_parent_vehicle(&mut host, &mut guest);
|
||||
self.save_guest(&host, &guest);
|
||||
host.models.remove(&guest.conn);
|
||||
host.play.remove(&guest.conn);
|
||||
self.guest_combat.remove(&guest.conn);
|
||||
if self.options.dedicated.is_some() && guest.placed {
|
||||
@@ -2016,12 +1888,6 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let holding = lan::Holding::start();
|
||||
let state = self.lan_host_state(&host);
|
||||
if state.is_none() && !host.hub.config.dedicated {
|
||||
self.lan = Some(host);
|
||||
self.level_pass.packets_drained = true;
|
||||
return;
|
||||
}
|
||||
// Last tick's tracked entities: enough for place collision and joins
|
||||
// before this tick moves anyone.
|
||||
let entities = std::mem::take(&mut host.states);
|
||||
@@ -2030,7 +1896,6 @@ impl Game<'_> {
|
||||
let clock_rate = self.network_clock_rate();
|
||||
let mut ctx = TickCtx {
|
||||
world: &mut self.world,
|
||||
host: state.as_ref(),
|
||||
entities: entities.as_slice(),
|
||||
next_entity: &mut self.next_entity,
|
||||
spawn,
|
||||
@@ -2057,9 +1922,7 @@ impl Game<'_> {
|
||||
events.extend(host.hub.drain_packets(&mut ctx));
|
||||
events
|
||||
};
|
||||
let Some(back) =
|
||||
self.lan_process_events(host, events, state.as_ref(), entities.as_slice(), spawn)
|
||||
else {
|
||||
let Some(back) = self.lan_process_events(host, events, entities.as_slice(), spawn) else {
|
||||
return;
|
||||
};
|
||||
host = back;
|
||||
@@ -2112,7 +1975,6 @@ impl Game<'_> {
|
||||
if !self.admin_feedback.is_empty() {
|
||||
self.send_admin_feedback(&mut host, None);
|
||||
}
|
||||
let state = self.lan_host_state(&host);
|
||||
// An animal whose rider left the game (or the level) stops taking
|
||||
// its input.
|
||||
for m in &mut self.mobs {
|
||||
@@ -2131,12 +1993,10 @@ impl Game<'_> {
|
||||
if !paused {
|
||||
self.tick_guests(&mut host);
|
||||
}
|
||||
// A dedicated server runs the hub without a host player.
|
||||
if state.is_some() || host.hub.config.dedicated {
|
||||
{
|
||||
let has_guests = !host.hub.guests.is_empty();
|
||||
let mut states = std::mem::take(&mut host.states);
|
||||
if has_guests {
|
||||
let host_entity = state.as_ref().map(|s| s.entity_id);
|
||||
self.lan_assign_ids();
|
||||
let guests: Vec<(u32, i32)> = host
|
||||
.hub
|
||||
@@ -2144,9 +2004,9 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.filter_map(|g| Some((g.vehicle?, g.entity_id)))
|
||||
.collect();
|
||||
self.lan_riding(host_entity, &guests, &mut host.hub.riding);
|
||||
self.lan_entities(host_entity, &host.mob_players, &mut host.hub, &mut states);
|
||||
self.lan_hook_states(host_entity, &host.play, &host.hub, &mut states);
|
||||
self.lan_riding(&guests, &mut host.hub.riding);
|
||||
self.lan_entities(&host.mob_players, &mut host.hub, &mut states);
|
||||
self.lan_hook_states(&host.play, &host.hub, &mut states);
|
||||
self.lan_entity_uuids(&mut states);
|
||||
} else {
|
||||
states.clear();
|
||||
@@ -2192,7 +2052,6 @@ impl Game<'_> {
|
||||
let clock_rate = self.network_clock_rate();
|
||||
let mut ctx = TickCtx {
|
||||
world: &mut self.world,
|
||||
host: state.as_ref(),
|
||||
entities,
|
||||
next_entity: &mut self.next_entity,
|
||||
spawn,
|
||||
@@ -2213,7 +2072,7 @@ impl Game<'_> {
|
||||
// IntegratedServer.getScaledTrackingDistance: the Entity Distance
|
||||
// option scales what an integrated server or a LAN host tracks (a
|
||||
// dedicated one keeps its entity-broadcast-range-percentage).
|
||||
if host.hub.config.integrated || !host.hub.config.dedicated {
|
||||
if host.hub.config.integrated {
|
||||
host.hub.entity_range_percent =
|
||||
(self.settings.entity_distance * 100.0).round() as i32;
|
||||
}
|
||||
@@ -2238,15 +2097,8 @@ impl Game<'_> {
|
||||
}
|
||||
events
|
||||
};
|
||||
if let Some(state) = &state
|
||||
&& host.host_mode != state.game_mode
|
||||
{
|
||||
host.host_mode = state.game_mode;
|
||||
host.hub.set_host_game_mode(state.uuid, state.game_mode);
|
||||
}
|
||||
self.wake_woken_guests(&mut host);
|
||||
let Some(back) = self.lan_process_events(host, events, state.as_ref(), entities, spawn)
|
||||
else {
|
||||
let Some(back) = self.lan_process_events(host, events, entities, spawn) else {
|
||||
return;
|
||||
};
|
||||
host = back;
|
||||
@@ -2305,8 +2157,7 @@ impl Game<'_> {
|
||||
self.tick_server_travel(&mut host);
|
||||
self.lan_swings(&mut host);
|
||||
self.lan_update_streaming(&host);
|
||||
self.lan_update_models(&mut host);
|
||||
self.lan_update_waypoints(&mut host, state.as_ref());
|
||||
self.lan_update_waypoints(&mut host);
|
||||
self.lan_sync_scoreboard(&mut host);
|
||||
self.lan_sync_border(&mut host);
|
||||
host.hub.anticheat_ping();
|
||||
@@ -2316,8 +2167,8 @@ impl Game<'_> {
|
||||
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
|
||||
self.lan = Some(host);
|
||||
self.flush_native_sounds();
|
||||
// `tick_server` resets the pass after all dimensions. The deprecated
|
||||
// in-frame path has no outer pass, so make its packet gate per-tick.
|
||||
// `tick_server` resets the pass after all dimensions; outside a pass
|
||||
// the packet gate is per-tick.
|
||||
if self.level_pass.running.is_none() {
|
||||
self.level_pass.packets_drained = false;
|
||||
}
|
||||
@@ -2327,7 +2178,6 @@ impl Game<'_> {
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
state: Option<&HostState>,
|
||||
entities: &[EntityState],
|
||||
spawn: [f64; 3],
|
||||
) {
|
||||
@@ -2364,11 +2214,10 @@ impl Game<'_> {
|
||||
g.food = 20;
|
||||
g.dimension = super::Dim::Overworld.id().to_owned();
|
||||
}
|
||||
// A server places each player in their own level
|
||||
// (`PlayerList.placeNewPlayer`); the in-frame game has one.
|
||||
// A server places each player in their own level (`PlayerList.placeNewPlayer`).
|
||||
let back = self.dim;
|
||||
let level = super::Dim::from_id(&g.dimension);
|
||||
let elsewhere = self.options.dedicated.is_some() && level != back;
|
||||
let elsewhere = level != back;
|
||||
if !elsewhere {
|
||||
g.dimension = in_place;
|
||||
}
|
||||
@@ -2380,8 +2229,8 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
// A dedicated server has no host player.
|
||||
self.lan_entities(None, &host.mob_players, &mut host.hub, &mut theirs);
|
||||
self.lan_hook_states(None, &host.play, &host.hub, &mut theirs);
|
||||
self.lan_entities(&host.mob_players, &mut host.hub, &mut theirs);
|
||||
self.lan_hook_states(&host.play, &host.hub, &mut theirs);
|
||||
self.lan_entity_uuids(&mut theirs);
|
||||
theirs.as_slice()
|
||||
} else {
|
||||
@@ -2390,7 +2239,6 @@ impl Game<'_> {
|
||||
let clock_rate = self.network_clock_rate();
|
||||
let mut ctx = TickCtx {
|
||||
world: &mut self.world,
|
||||
host: state,
|
||||
entities,
|
||||
next_entity: &mut self.next_entity,
|
||||
spawn,
|
||||
@@ -2523,15 +2371,10 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let told = if self.lan_acting.is_some() {
|
||||
let told = self.lan_acting.is_some();
|
||||
if told {
|
||||
self.guest_equipment_breaks.push((event, at));
|
||||
true
|
||||
} else if let Some(host) = &self.lan {
|
||||
host.hub.entity_event(host.host_entity, event, at);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
}
|
||||
if told && !self.options.execution_role.is_client() {
|
||||
return;
|
||||
}
|
||||
@@ -2642,14 +2485,14 @@ impl Game<'_> {
|
||||
{
|
||||
use horizon_client::mobs::{DamageKind as K, Who};
|
||||
let kind = super::creatures::source_damage_type(source);
|
||||
// The player a mob sees is the guest acting, else the host.
|
||||
let cause = source.attacker.map(|who| match who {
|
||||
// The player a mob sees is the guest acting.
|
||||
let cause = source.attacker.and_then(|who| match who {
|
||||
Who::Player => host
|
||||
.mob_players
|
||||
.get(&m.id)
|
||||
.and_then(|conn| host.hub.guest(*conn))
|
||||
.map_or(host.host_entity, |g| g.entity_id),
|
||||
Who::Mob(id) => id as i32,
|
||||
.map(|g| g.entity_id),
|
||||
Who::Mob(id) => Some(id as i32),
|
||||
});
|
||||
// A projectile is the direct entity of its hit: not tracked here.
|
||||
let melee = matches!(
|
||||
@@ -2695,48 +2538,20 @@ impl Game<'_> {
|
||||
}
|
||||
seen.insert(id, (d.hurt_time, dead, None));
|
||||
}
|
||||
// LivingEntity.hurtServer for the in-frame LAN host's player (guests
|
||||
// broadcast directly from HealthEvent::DamageBroadcast): a hit that
|
||||
// restarted the hurt time and was not blocked by a shield broadcasts
|
||||
// the damage event, so the clients around flash it red.
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
let id = host.host_entity;
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let hurt = p.health.hurt_animation_time;
|
||||
let dead = p.health.dead_or_dying();
|
||||
let old = host
|
||||
.mob_state
|
||||
.get(&self.dim)
|
||||
.and_then(|s| s.get(&(id as u32)));
|
||||
if hurt > old.map_or(0, |o| o.0) && !dead {
|
||||
let kind = p
|
||||
.health
|
||||
.hurt
|
||||
.last_source
|
||||
.and_then(|s| self.motion.health.damage.definition(s))
|
||||
.map_or("minecraft:generic", |d| d.name.as_str());
|
||||
host.hub.damage_event(id, kind, None, None, at);
|
||||
}
|
||||
seen.insert(id as u32, (hurt, dead, None));
|
||||
}
|
||||
host.mob_state.insert(self.dim, seen);
|
||||
// Level.broadcastEntityEvent(player, 2) of a spear's stab.
|
||||
for attacker in kinetic_hits {
|
||||
let hit = match attacker {
|
||||
None => self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| (host.host_entity, [p.position.x, p.position.y, p.position.z])),
|
||||
Some(conn) => host.hub.guest(conn).map(|g| (g.entity_id, g.position)),
|
||||
};
|
||||
let hit = attacker
|
||||
.and_then(|conn| host.hub.guest(conn))
|
||||
.map(|g| (g.entity_id, g.position));
|
||||
if let Some((id, at)) = hit {
|
||||
host.hub.entity_event(id, 2, at);
|
||||
}
|
||||
}
|
||||
for (attacker, target, magic) in critical_hits {
|
||||
let Some(attacker) = attacker.map_or(Some(host.host_entity), |conn| {
|
||||
host.hub.guest(conn).map(|g| g.entity_id)
|
||||
}) else {
|
||||
let Some(attacker) =
|
||||
attacker.and_then(|conn| host.hub.guest(conn).map(|g| g.entity_id))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
host.hub.critical_hit(attacker, target, magic);
|
||||
@@ -2778,146 +2593,6 @@ impl Game<'_> {
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// The guests' models, for the host to see them.
|
||||
fn lan_update_models(&mut self, host: &mut LanHost) {
|
||||
let Some(kind) = self.res.player_type().clone() else {
|
||||
return;
|
||||
};
|
||||
host.models
|
||||
.retain(|conn, _| host.hub.guest(*conn).is_some_and(|g| g.placed));
|
||||
for g in host.hub.guests.iter().filter(|g| g.placed) {
|
||||
let at = Vec3::new(
|
||||
g.position[0],
|
||||
g.position[1] - if g.sneaking { 0.125 } else { 0.0 },
|
||||
g.position[2],
|
||||
);
|
||||
let m = host
|
||||
.models
|
||||
.entry(g.conn)
|
||||
.or_insert_with(|| Mob::new(g.entity_id as u32, kind.clone(), at, g.yaw));
|
||||
let (dx, dy, dz) = (
|
||||
at.x - m.position.x,
|
||||
at.y - m.position.y,
|
||||
at.z - m.position.z,
|
||||
);
|
||||
m.previous = m.position;
|
||||
m.position = at;
|
||||
if dx * dx + dy * dy + dz * dz > 100.0 {
|
||||
m.previous = at;
|
||||
}
|
||||
m.previous_yaw = m.yaw;
|
||||
m.previous_body_yaw = m.body_yaw;
|
||||
m.previous_head_yaw = m.head_yaw;
|
||||
m.previous_head_pitch = m.head_pitch;
|
||||
m.yaw = g.yaw;
|
||||
m.head_yaw = g.head_yaw;
|
||||
m.head_pitch = g.pitch;
|
||||
let mut target = m.body_yaw;
|
||||
let wrap = |a: f32| (a + 180.0).rem_euclid(360.0) - 180.0;
|
||||
if dx * dx + dz * dz > 0.002_500_000_2 {
|
||||
// LivingEntity.tick: walking backwards the body faces away
|
||||
// from the walk direction, not along it.
|
||||
let walk = (dz.atan2(dx) as f32).to_degrees() - 90.0;
|
||||
let facing = (wrap(g.yaw) - walk).abs();
|
||||
target = if 95.0 < facing && facing < 265.0 {
|
||||
walk - 180.0
|
||||
} else {
|
||||
walk
|
||||
};
|
||||
}
|
||||
m.body_yaw += wrap(target - m.body_yaw) * 0.3;
|
||||
let diff = wrap(g.yaw - m.body_yaw);
|
||||
if diff.abs() > 50.0 {
|
||||
m.body_yaw += diff - diff.signum() * 50.0;
|
||||
}
|
||||
let dist = (dx * dx + dz * dz).sqrt() as f32;
|
||||
m.previous_limb_speed = m.limb_speed;
|
||||
m.limb_speed += ((dist * 4.0).min(1.0) - m.limb_speed) * 0.4;
|
||||
m.limb_position += m.limb_speed;
|
||||
m.age = m.age.wrapping_add(1);
|
||||
// The hurt red flash (LivingEntity.hurtTime).
|
||||
m.hurt_time = g
|
||||
.motion
|
||||
.as_ref()
|
||||
.map_or(0, |p| p.health.hurt_animation_time);
|
||||
m.extra
|
||||
.set_main_hand_stack(g.inventory.get(g.selected).cloned().flatten());
|
||||
m.extra.off_hand = g.offhand.as_ref().map(|s| s.item.clone());
|
||||
[m.extra.head, m.extra.chest, m.extra.legs, m.extra.feet] = g
|
||||
.armor
|
||||
.each_ref()
|
||||
.map(|s| s.as_ref().map(|s| s.item.clone()));
|
||||
m.extra.armor = g.armor.clone();
|
||||
m.extra.crouching = g.sneaking;
|
||||
m.extra.avatar_pose = g.pose();
|
||||
m.extra.sprinting = g.sprinting;
|
||||
// Entity.DATA_POSE and the shared and living flags the others
|
||||
// get (hub::guest_body): gliding, swimming, Riptide's spin.
|
||||
let body = lan::hub::guest_body(g);
|
||||
m.extra.fall_flying = body.gliding;
|
||||
m.extra.synced_pose = if body.swimming || body.crawling { 3 } else { 0 };
|
||||
m.extra.auto_spin_attack = body.spin_attack;
|
||||
m.extra.previous_velocity = m.velocity;
|
||||
(m.extra.arrow_count, m.extra.stinger_count) = g.stuck;
|
||||
m.extra.shoulder_parrots = g.shoulders;
|
||||
m.velocity = g
|
||||
.motion
|
||||
.as_ref()
|
||||
.map_or(Vec3::new(dx, dy, dz), |motion| motion.velocity);
|
||||
m.tick_avatar_pose(
|
||||
g.motion
|
||||
.as_ref()
|
||||
.is_some_and(|motion| motion.fluids.was_touching_water),
|
||||
);
|
||||
// CrossbowItem.isCharged: the held crossbow's projectiles.
|
||||
m.extra.charged = g
|
||||
.inventory
|
||||
.get(g.selected)
|
||||
.and_then(|s| s.as_ref())
|
||||
.is_some_and(|s| s.data().is_some_and(|d| !d.charged.is_empty()));
|
||||
// ClientInformation.mainHand (HumanoidArm: 0 left, 1 right).
|
||||
m.extra.main_hand_left = g.info.main_hand == 0;
|
||||
m.extra.hidden_model_parts = !g.info.model_customisation & 0x7F;
|
||||
m.extra.spectator = g.game_mode == horizon_client::lan::guest::SPECTATOR;
|
||||
// LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects.
|
||||
let visible = || g.effects.list.iter().filter(|e| e.visible);
|
||||
m.extra.effect_particles = visible()
|
||||
.filter_map(|e| super::client_ambient::effect_particle(e.id, e.ambient))
|
||||
.collect();
|
||||
m.extra.effect_ambient = visible().all(|e| e.ambient);
|
||||
// Entity.isInvisible: the invisibility effect hides the body (the
|
||||
// model pass, the name tag rules and `trader::is_invisible` read it).
|
||||
m.extra.effects.retain(|e| e.0 != "minecraft:invisibility");
|
||||
if let Some(e) = g.effects.get("invisibility") {
|
||||
m.extra
|
||||
.effects
|
||||
.push(("minecraft:invisibility", e.duration, e.amplifier));
|
||||
}
|
||||
// Lying in its bed (LivingEntity.getBedOrientation).
|
||||
m.extra.ints[1] = g.bed.map_or(0, |b| self.bed_facing(b));
|
||||
// LivingEntity.updateSwingTime: the arm swing lan_swings started.
|
||||
m.anim.attack_anim_o = m.anim.attack_anim;
|
||||
horizon_client::mobs::ai::tick_swing(m);
|
||||
// LivingEntity.onSyncedDataUpdated(DATA_LIVING_ENTITY_FLAGS): the
|
||||
// item in use and its hand (the arm poses of a bow, a shield...).
|
||||
let using = host.play.get(&g.conn).and_then(|p| p.using());
|
||||
m.extra.using_item = using.map_or(0, |(_, ticks)| ticks as i32 + 1);
|
||||
m.extra.using_off_hand = using.is_some_and(|(slot, _)| slot == OFFHAND_SLOT);
|
||||
// AvatarRenderer.getArmPose: the used item's BLOCK animation (an
|
||||
// item with `blocks_attacks`) raises the arm.
|
||||
m.extra.blocking = using
|
||||
.and_then(|(slot, _)| {
|
||||
if slot == OFFHAND_SLOT {
|
||||
m.extra.off_hand.as_ref()
|
||||
} else {
|
||||
m.extra.main_hand.as_ref()
|
||||
}
|
||||
})
|
||||
.and_then(|i| self.items().get(i))
|
||||
.is_some_and(|i| i.blocks_attacks.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
/// The bed at `head` as a sleeping model lies in it (`ints[1]`,
|
||||
/// `villager::sleep_pose`): its `facing`.
|
||||
pub(super) fn bed_facing(&self, head: [i32; 3]) -> i32 {
|
||||
@@ -2931,82 +2606,6 @@ impl Game<'_> {
|
||||
.map(String::as_str);
|
||||
horizon_client::mobs::villager::bed_facing_code(facing)
|
||||
}
|
||||
|
||||
/// The guests' models for the entity pass.
|
||||
pub(super) fn guest_model_geometry(
|
||||
&self,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
out: &mut Vec<horizon_client::mesher::Vertex>,
|
||||
) {
|
||||
let Some(host) = &self.lan else {
|
||||
return;
|
||||
};
|
||||
let atlas = &self.gui.atlas;
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
for (conn, m) in &host.models {
|
||||
let entity_start = out.len();
|
||||
let probe = super::perspective::entity_light_block(
|
||||
m.position,
|
||||
m.extra.crouching,
|
||||
m.eye_height(),
|
||||
);
|
||||
let light = self.world.light(probe[0], probe[1], probe[2]);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
horizon_client::mobs::render::render_with(
|
||||
m,
|
||||
alpha as f32,
|
||||
camera,
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
&host.hub.guest(*conn).map_or_else(
|
||||
|| self.worn_armor_layers(m),
|
||||
|g| self.stack_armor_layers(&g.armor),
|
||||
),
|
||||
out,
|
||||
);
|
||||
self.player_elytra_geometry(
|
||||
m,
|
||||
m.extra.chest.as_deref(),
|
||||
camera,
|
||||
alpha as f32,
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
if let Some(g) = host.hub.guest(*conn) {
|
||||
self.player_ears_geometry(m, &g.name, camera, alpha as f32, packed, out);
|
||||
}
|
||||
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
|
||||
if let Some(g) = host.hub.guest(*conn) {
|
||||
self.stuck_in_body_geometry(m, g.entity_id, camera, alpha as f32, packed, out);
|
||||
}
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
self.player_pumpkin_geometry(
|
||||
m,
|
||||
host.hub.guest(*conn).and_then(|g| g.armor[0].as_ref()),
|
||||
camera,
|
||||
alpha,
|
||||
packed,
|
||||
out,
|
||||
);
|
||||
// LivingEntity.isCurrentlyGlowing: the guest's Glowing effect.
|
||||
let guest = host.hub.guest(*conn);
|
||||
if m.glowing || guest.is_some_and(|g| g.effects.get("glowing").is_some()) {
|
||||
let team = guest.and_then(|g| self.score.board.players_team(&g.name));
|
||||
self.mark_outline(&out[entity_start..], super::glowing::team_color(team));
|
||||
} else {
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A chat line the host typed, for the guests.
|
||||
pub(super) fn lan_host_chat(&mut self, message: &str) {
|
||||
if let Some(host) = &self.lan {
|
||||
host.hub.chat(&host.host_name, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
|
||||
@@ -2471,39 +2471,23 @@ impl Game<'_> {
|
||||
/// player: the host's own, or a guest in the world.
|
||||
fn guest_spectate_teleport(&mut self, host: &mut LanHost, gi: usize, uuid: u128) {
|
||||
let own = host.hub.guests[gi].conn;
|
||||
let host_player = self
|
||||
.player
|
||||
.as_ref()
|
||||
.filter(|_| self.options.dedicated.is_none() && uuid == host.host_uuid)
|
||||
.map(|p| {
|
||||
(
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
self.yaw,
|
||||
self.pitch,
|
||||
self.dim,
|
||||
)
|
||||
});
|
||||
let target = host_player.or_else(|| {
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.placed && g.conn != own && g.uuid == uuid)
|
||||
.map(|g| (g.position, g.yaw, g.pitch, Dim::from_id(&g.dimension)))
|
||||
});
|
||||
let target = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.placed && g.conn != own && g.uuid == uuid)
|
||||
.map(|g| (g.position, g.yaw, g.pitch, Dim::from_id(&g.dimension)));
|
||||
let Some((at, yaw, pitch, dim)) = target else {
|
||||
return;
|
||||
};
|
||||
if Dim::from_id(&host.hub.guests[gi].dimension) != dim {
|
||||
// Only a dedicated server keeps its players in different levels.
|
||||
if self.options.dedicated.is_some() {
|
||||
self.level_teleports
|
||||
.push(super::server_travel::LevelTeleport {
|
||||
conn: own,
|
||||
dim,
|
||||
at,
|
||||
rotation: Some([yaw, pitch]),
|
||||
});
|
||||
}
|
||||
self.level_teleports
|
||||
.push(super::server_travel::LevelTeleport {
|
||||
conn: own,
|
||||
dim,
|
||||
at,
|
||||
rotation: Some([yaw, pitch]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
@@ -2680,9 +2664,6 @@ impl Game<'_> {
|
||||
|
||||
pub(super) fn guest_target_box(&self, host: &LanHost, gi: usize, id: i32) -> Option<Aabb> {
|
||||
let conn = host.hub.guests[gi].conn;
|
||||
if id == host.host_entity {
|
||||
return self.player.as_ref().map(|p| p.bounds());
|
||||
}
|
||||
if let Some(g) = host
|
||||
.hub
|
||||
.guests
|
||||
@@ -2728,12 +2709,7 @@ impl Game<'_> {
|
||||
}
|
||||
// Native mods, where Player.attack starts: a canceled blow does
|
||||
// nothing (game/native_hooks.rs).
|
||||
let aimed = if id == host.host_entity {
|
||||
self.player.as_ref().map(|p| {
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
("minecraft:player".to_owned(), at)
|
||||
})
|
||||
} else if let Some(g) = host
|
||||
let aimed = if let Some(g) = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
@@ -2761,8 +2737,7 @@ impl Game<'_> {
|
||||
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike())
|
||||
&& self.pvp_land(host, victim, &strike)
|
||||
{
|
||||
let host_on_ground = self.player.as_ref().map(|p| p.ground.on_ground);
|
||||
let on_ground = Self::pvp_victim_on_ground(host, victim, host_on_ground);
|
||||
let on_ground = Self::pvp_victim_on_ground(host, victim);
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.weapon_hit_player(on_ground);
|
||||
g.pvp_exhaustion();
|
||||
@@ -2894,12 +2869,7 @@ impl Game<'_> {
|
||||
// ListPlayersCommand needs no permission and lists every player.
|
||||
let words: Vec<&str> = command.trim_start_matches('/').split(' ').collect();
|
||||
if words[0] == "list" && (words.len() == 1 || words[1..] == ["uuids"]) {
|
||||
// A single-player owner is a placed guest already: no host entry.
|
||||
let me = (self.options.dedicated.is_none()
|
||||
&& self.player.is_some()
|
||||
&& host.hub.singleplayer_owner.is_none())
|
||||
.then_some((host.host_uuid, host.host_name.as_str()));
|
||||
let content = host.hub.list_reply(me, words.len() == 2);
|
||||
let content = host.hub.list_reply(words.len() == 2);
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
@@ -3025,14 +2995,9 @@ impl Game<'_> {
|
||||
/// operator gets them, and the console logs them as the server's own
|
||||
/// system messages (MinecraftServer.sendSystemMessage).
|
||||
pub(super) fn send_admin_feedback(&mut self, host: &mut LanHost, source: Option<u32>) {
|
||||
// PlayerList.isOp for the host of a world open to LAN: the owner when
|
||||
// the world allows commands, everyone when the LAN does.
|
||||
let host_ops =
|
||||
!host.hub.config.dedicated && (self.allow_commands() || host.hub.config.guest_commands);
|
||||
let host_op = source.is_some() && host_ops;
|
||||
// server.toml's `admin_feedback_to_ops` off: the console's log only.
|
||||
let to_ops = host.hub.admin_feedback_to_ops;
|
||||
for (line, ops, console, no_player) in std::mem::take(&mut self.admin_feedback) {
|
||||
for (line, ops, console) in std::mem::take(&mut self.admin_feedback) {
|
||||
if ops && to_ops {
|
||||
let content = horizon_client::lan::data::component_nbt(&line.component());
|
||||
for g in host
|
||||
@@ -3046,10 +3011,6 @@ impl Game<'_> {
|
||||
overlay: false,
|
||||
}));
|
||||
}
|
||||
// A line no player's command made (a command block's) reaches the host too.
|
||||
if host_op || (no_player && host_ops) {
|
||||
self.system_message(line.clone());
|
||||
}
|
||||
}
|
||||
if console {
|
||||
super::dedicated::log("INFO", &format!("System chat: {}", line.plain()));
|
||||
@@ -3251,7 +3212,6 @@ impl Game<'_> {
|
||||
let clock_rate = self.network_clock_rate();
|
||||
let ctx = TickCtx {
|
||||
world: &mut self.world,
|
||||
host: None,
|
||||
entities: &[],
|
||||
next_entity: &mut self.next_entity,
|
||||
spawn,
|
||||
@@ -4392,11 +4352,9 @@ impl Game<'_> {
|
||||
g.trigger_thrown_picked_up_by_player(&item.stack, thrower);
|
||||
});
|
||||
}
|
||||
self.guest_pickup(host, id, item);
|
||||
taken.push((item_id, before));
|
||||
}
|
||||
Some(rest) if rest.count < before => {
|
||||
self.guest_pickup(host, id, self.dropped[i].clone());
|
||||
self.dropped[i].stack = rest;
|
||||
if let Some(thrower) = thrower {
|
||||
let left = self.dropped[i].stack.clone();
|
||||
@@ -4475,13 +4433,6 @@ impl Game<'_> {
|
||||
self.creatures.projectiles = projectiles;
|
||||
for shot in picked {
|
||||
host.hub.take_item(shot.id as i32, id, 1, at);
|
||||
if let Some(target) = super::pickups::guest_target(host, id as u32) {
|
||||
self.add_pickup_toward(
|
||||
super::pickups::Taken::Arrow(Box::new(shot)),
|
||||
super::pickups::Collector::Guest(id as u32),
|
||||
target,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4575,14 +4526,6 @@ impl Game<'_> {
|
||||
pitch,
|
||||
);
|
||||
let value = orb.value;
|
||||
if let Some(target) = super::pickups::guest_target(host, id as u32) {
|
||||
use super::pickups::{Collector, Taken};
|
||||
self.add_pickup_toward(
|
||||
Taken::Orb(Box::new(orb)),
|
||||
Collector::Guest(id as u32),
|
||||
target,
|
||||
);
|
||||
}
|
||||
self.as_guest(host, conn, |game| {
|
||||
let rest = game.mending(value);
|
||||
if rest > 0 {
|
||||
@@ -4591,23 +4534,6 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
|
||||
/// The host's own `ItemPickupParticle` toward the guest's model.
|
||||
fn guest_pickup(
|
||||
&mut self,
|
||||
host: &LanHost,
|
||||
id: i32,
|
||||
item: horizon_client::entities::ItemEntity,
|
||||
) {
|
||||
use super::pickups::{Collector, Taken, guest_target};
|
||||
if let Some(target) = guest_target(host, id as u32) {
|
||||
self.add_pickup_toward(
|
||||
Taken::Item(Box::new(item)),
|
||||
Collector::Guest(id as u32),
|
||||
target,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `level.playSound(player, ...)` of a guest's use it predicted (`remote::hand_operated`
|
||||
/// blocks only, as the guest decides): the others hear it now, the guest already did.
|
||||
fn guest_use_sounds(&mut self, host: &mut LanHost, conn: u32, logged: usize, heard: usize) {
|
||||
@@ -4751,9 +4677,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The tick's arm swings for the players around (the guests' to everyone
|
||||
/// else, the host's to the guests), and `LivingEntity.updateSwingTime`
|
||||
/// for the guests' bodies here.
|
||||
/// The tick's arm swings for the players around (each guest's to everyone
|
||||
/// else), and `LivingEntity.updateSwingTime` for the guests' bodies here.
|
||||
pub(super) fn lan_swings(&mut self, host: &mut LanHost) {
|
||||
let conns: Vec<u32> = host.play.keys().copied().collect();
|
||||
for conn in conns {
|
||||
@@ -4766,55 +4691,9 @@ impl Game<'_> {
|
||||
if let (true, true, Some(c)) = (swung, here, play.swing.current) {
|
||||
let packet = swing_packet(id, c.hand, c.animation);
|
||||
host.hub.broadcast_near(at, 128.0, &packet, Some(conn));
|
||||
if let Some(m) = host.models.get_mut(&conn) {
|
||||
// The duration already counts the guest's haste or fatigue.
|
||||
m.swing_hand(c.hand == Hand::Off, c.duration_ticks);
|
||||
}
|
||||
}
|
||||
play.swing.tick();
|
||||
}
|
||||
self.lan_host_crack(host);
|
||||
if self.swings_started != host.host_swings {
|
||||
host.host_swings = self.swings_started;
|
||||
if let (Some(p), Some(c)) = (self.player.as_ref(), self.swing.current) {
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let packet = swing_packet(host.host_entity, c.hand, c.animation);
|
||||
host.hub.broadcast_near(at, 128.0, &packet, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The host player's crack, for the guests (`ServerLevel.destroyBlockProgress` from the
|
||||
/// host's mining): each new stage, and -1 once it stops or breaks the block.
|
||||
fn lan_host_crack(&mut self, host: &mut LanHost) {
|
||||
let now = self
|
||||
.mining
|
||||
.as_ref()
|
||||
.and_then(|m| (m.progress > 0.0).then(|| (m.pos, ((m.progress * 10.0) as i32).min(9))));
|
||||
if now == host.host_crack {
|
||||
return;
|
||||
}
|
||||
let packet = |pos: [i32; 3], progress: u8| {
|
||||
C::BlockDestruction(play::BlockDestruction {
|
||||
id: host.host_entity,
|
||||
pos,
|
||||
progress,
|
||||
})
|
||||
};
|
||||
let near = |pos: [i32; 3]| [f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])];
|
||||
if let Some((pos, _)) = host.host_crack
|
||||
&& now.is_none_or(|(p, _)| p != pos)
|
||||
{
|
||||
host.hub
|
||||
.broadcast_near(near(pos), 64.0, &packet(pos, 255), None);
|
||||
}
|
||||
if let Some((pos, stage)) = now {
|
||||
host.hub
|
||||
.broadcast_near(near(pos), 64.0, &packet(pos, stage as u8), None);
|
||||
}
|
||||
host.host_crack = now;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,13 +43,6 @@ impl Game<'_> {
|
||||
{
|
||||
return (!g.dead).then_some((g.position, [g.yaw, g.pitch]));
|
||||
}
|
||||
if id == host.host_entity {
|
||||
let p = self.player.as_ref()?;
|
||||
return Some((
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
[self.yaw, self.pitch],
|
||||
));
|
||||
}
|
||||
if let Some(special) = self.camera_extra(id) {
|
||||
return Some((special.position, special.rotation));
|
||||
}
|
||||
@@ -102,9 +95,6 @@ impl Game<'_> {
|
||||
if let Some(g) = host.hub.guests.iter().find(|g| g.entity_id == id) {
|
||||
return !g.spectator() && !g.dead;
|
||||
}
|
||||
if id == host.host_entity {
|
||||
return self.player.is_some() && !self.spectator();
|
||||
}
|
||||
if self.camera_extra(id).is_some() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -119,14 +119,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Who rides what (`Entity.getPassengers`): mobs on mobs, and the
|
||||
/// host in its boat or minecart.
|
||||
pub(super) fn lan_riding(
|
||||
&self,
|
||||
host_entity: Option<i32>,
|
||||
guests: &[(u32, i32)],
|
||||
riding: &mut Riding,
|
||||
) {
|
||||
/// Who rides what (`Entity.getPassengers`): the players in their vehicles and
|
||||
/// mobs on mobs.
|
||||
pub(super) fn lan_riding(&self, guests: &[(u32, i32)], riding: &mut Riding) {
|
||||
riding.clear();
|
||||
// The server's players in their boats and minecarts.
|
||||
for &(vehicle, guest) in guests {
|
||||
@@ -137,19 +132,11 @@ impl Game<'_> {
|
||||
riding.add(vehicle as i32, m.id as i32);
|
||||
}
|
||||
}
|
||||
if let (Some(host), Some(vehicle)) = (host_entity, self.riding) {
|
||||
riding.add(vehicle as i32, host);
|
||||
}
|
||||
}
|
||||
|
||||
/// Boats, minecarts, armor stands, item frames, paintings and the
|
||||
/// entities kept unsimulated, as guests track them.
|
||||
pub(super) fn lan_object_states(
|
||||
&self,
|
||||
host_entity: Option<i32>,
|
||||
hub: &mut Hub,
|
||||
out: &mut States,
|
||||
) {
|
||||
pub(super) fn lan_object_states(&self, hub: &mut Hub, out: &mut States) {
|
||||
let guests: Vec<(u128, i32)> = hub
|
||||
.guests
|
||||
.iter()
|
||||
@@ -160,7 +147,7 @@ impl Game<'_> {
|
||||
let p = b.position;
|
||||
let mut e = state(b.id, &b.entity_type(), [p.x, p.y, p.z], b.yaw, b.on_ground);
|
||||
// Leashable.setLeashedTo / dropLeash: who holds the boat's lead.
|
||||
e.leash = self.boat_leash_link(b, host_entity, &guests);
|
||||
e.leash = self.boat_leash_link(b, &guests);
|
||||
e.velocity = [b.velocity.x, b.velocity.y, b.velocity.z];
|
||||
e.meta = vehicle_meta(b.hurt_time, b.hurt_dir, b.damage);
|
||||
// AbstractBoat.DATA_ID_PADDLE_LEFT and DATA_ID_PADDLE_RIGHT.
|
||||
@@ -386,7 +373,7 @@ impl Game<'_> {
|
||||
));
|
||||
out.push(e);
|
||||
}
|
||||
self.lan_projectile_states(host_entity, hub, out);
|
||||
self.lan_projectile_states(hub, out);
|
||||
for o in &self.others {
|
||||
let p = o.position;
|
||||
let mut e = state(o.id, &o.kind, [p.x, p.y, p.z], o.yaw, false);
|
||||
@@ -526,11 +513,10 @@ impl Game<'_> {
|
||||
|
||||
/// Primed TNT, falling blocks, projectiles, tridents, firework rockets and
|
||||
/// end crystals, as guests track them.
|
||||
fn lan_projectile_states(&self, host_entity: Option<i32>, hub: &mut Hub, out: &mut States) {
|
||||
fn lan_projectile_states(&self, hub: &mut Hub, out: &mut States) {
|
||||
let owner = |who: Option<horizon_client::mobs::Who>| match who {
|
||||
Some(horizon_client::mobs::Who::Mob(id)) => id as i32,
|
||||
Some(horizon_client::mobs::Who::Player) => host_entity.unwrap_or(0),
|
||||
None => 0,
|
||||
Some(horizon_client::mobs::Who::Player) | None => 0,
|
||||
};
|
||||
let at = |p: horizon_core::geometry::Vec3| [p.x, p.y, p.z];
|
||||
for f in &self.fangs.list {
|
||||
@@ -687,7 +673,7 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|g| g.conn == conn)
|
||||
.map(|g| g.entity_id),
|
||||
None => host_entity,
|
||||
None => None,
|
||||
};
|
||||
if r.attached
|
||||
&& let Some(player) = boosted
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
//! Players hitting players in an open world (`Player.attack` on a player):
|
||||
//! the host hits a guest, a guest hits the host or another guest. The hit
|
||||
//! Players hitting players on a server (`Player.attack` on a player). The hit
|
||||
//! is worked out as the attacker (weapon, attack strength, critical hit,
|
||||
//! enchantments, a mace's smash) and lands on the victim like any player
|
||||
//! attack (blocking, armour, knockback), when the `pvp` game rule allows it
|
||||
//! (`ServerPlayer.canHarmPlayer`, `isPvpAllowed`). A player's arrow on
|
||||
//! another player lands the same way.
|
||||
use super::{Game, lan::LanHost, lan_mobs::guest_bounds, mace::Spared, ray_box};
|
||||
use super::{Game, lan::LanHost, lan_mobs::guest_bounds, mace::Spared};
|
||||
|
||||
use horizon_client::{
|
||||
Result,
|
||||
@@ -30,19 +29,16 @@ pub(super) fn meets_players(kind: &ProjectileKind) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Who a player's attack lands on.
|
||||
/// Who a player's attack lands on: a player by connection.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum Victim {
|
||||
Host,
|
||||
Guest(u32),
|
||||
}
|
||||
|
||||
impl Victim {
|
||||
fn conn(self) -> Option<u32> {
|
||||
match self {
|
||||
Victim::Host => None,
|
||||
Victim::Guest(conn) => Some(conn),
|
||||
}
|
||||
let Victim::Guest(conn) = self;
|
||||
Some(conn)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,53 +102,9 @@ impl Strike {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The guest on the host's crosshair and how far, within reach.
|
||||
pub(super) fn guest_hit(&self) -> Option<(u32, f64)> {
|
||||
let host = self.lan.as_ref()?;
|
||||
let (eye, dir) = self.aim()?;
|
||||
let reach = self.entity_reach;
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
|
||||
.filter_map(|g| Some((g.conn, ray_box(eye, dir, reach, guest_bounds(g))?)))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
}
|
||||
|
||||
/// The host's attack on the guest under the crosshair, when no mob or
|
||||
/// block is nearer.
|
||||
pub(super) fn attack_guest(&mut self) -> bool {
|
||||
let Some((conn, d)) = self.guest_hit() else {
|
||||
return false;
|
||||
};
|
||||
if self.mob_hit().is_some_and(|(_, m)| m < d) || !self.before_block(d) {
|
||||
return false;
|
||||
}
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return false;
|
||||
};
|
||||
if let Some(strike) = self.pvp_strike()
|
||||
&& self.pvp_land(&mut host, Victim::Guest(conn), &strike)
|
||||
{
|
||||
self.weapon_hit_player(Self::pvp_victim_on_ground(&host, Victim::Guest(conn), None));
|
||||
self.pvp_exhaustion();
|
||||
if strike.stops_sprint() {
|
||||
self.stop_sprint_after_extra_knockback();
|
||||
}
|
||||
}
|
||||
self.lan = Some(host);
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether the player a hit landed on stands on the ground (the host's
|
||||
/// `ground` is passed in as `host_on_ground` when the host is the victim).
|
||||
pub(super) fn pvp_victim_on_ground(
|
||||
host: &LanHost,
|
||||
victim: Victim,
|
||||
host_on_ground: Option<bool>,
|
||||
) -> bool {
|
||||
/// Whether the player a hit landed on stands on the ground.
|
||||
pub(super) fn pvp_victim_on_ground(host: &LanHost, victim: Victim) -> bool {
|
||||
match victim {
|
||||
Victim::Host => host_on_ground.unwrap_or(true),
|
||||
Victim::Guest(conn) => host
|
||||
.hub
|
||||
.guest(conn)
|
||||
@@ -163,9 +115,6 @@ impl Game<'_> {
|
||||
|
||||
/// The player `id` that guest `conn` attacks, if it is one.
|
||||
pub(super) fn pvp_victim(host: &LanHost, conn: u32, id: i32) -> Option<Victim> {
|
||||
if id == host.host_entity {
|
||||
return Some(Victim::Host);
|
||||
}
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
@@ -229,8 +178,7 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The player a player's projectile hit, when it hit one: the host for a
|
||||
/// guest's shot, or a guest.
|
||||
/// The player a player's projectile hit, when it hit one.
|
||||
pub(super) fn projectile_pvp_victim(&self, p: &Projectile, hit: Hit) -> Option<Victim> {
|
||||
if p.owner != Some(Who::Player) || !meets_players(&p.kind) {
|
||||
return None;
|
||||
@@ -239,7 +187,7 @@ impl Game<'_> {
|
||||
return None;
|
||||
};
|
||||
match who {
|
||||
Who::Player => p.guest.map(|_| Victim::Host),
|
||||
Who::Player => None,
|
||||
Who::Mob(id) => self
|
||||
.lan
|
||||
.as_ref()?
|
||||
@@ -273,7 +221,6 @@ impl Game<'_> {
|
||||
None => self.player_team().cloned(),
|
||||
};
|
||||
let target = match victim {
|
||||
Victim::Host => self.player_team().cloned(),
|
||||
Victim::Guest(conn) => host.hub.guest(conn).and_then(|g| team_of(self, &g.name)),
|
||||
};
|
||||
let refused = !(self.ui.rules.bool("pvp")
|
||||
@@ -284,16 +231,6 @@ impl Game<'_> {
|
||||
};
|
||||
let shooter = self.player_attacker(&host, p.guest);
|
||||
let result = match victim {
|
||||
Victim::Host => {
|
||||
self.lan = Some(host);
|
||||
self.combat.refuse_player_hit = refused;
|
||||
self.combat.projectile_attacker = shooter;
|
||||
let r = self.projectile_hit(p, on_player);
|
||||
self.combat.refuse_player_hit = false;
|
||||
// A refused or creative hit returns before hurt_player_from takes it.
|
||||
self.combat.projectile_attacker = None;
|
||||
return r;
|
||||
}
|
||||
Victim::Guest(conn) => self.guest_hit_by(&mut host, conn, |g| {
|
||||
g.combat.refuse_player_hit = refused;
|
||||
g.combat.projectile_attacker = shooter;
|
||||
@@ -313,10 +250,7 @@ impl Game<'_> {
|
||||
let g = host.hub.guest(conn)?;
|
||||
Some((g.entity_id as u32, self.player_text(&g.name, g.uuid)))
|
||||
}
|
||||
None => self.player.is_some().then(|| {
|
||||
let name = self.player_text(&host.host_name, host.host_uuid);
|
||||
(host.host_entity as u32, name)
|
||||
}),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,7 +260,6 @@ impl Game<'_> {
|
||||
// ServerPlayer.canHarmPlayer: the pvp game rule, then the victim's
|
||||
// team (Player.canHarmPlayer: allies only with friendly fire on).
|
||||
let team = match victim {
|
||||
Victim::Host => self.player_team(),
|
||||
Victim::Guest(conn) => host
|
||||
.hub
|
||||
.guest(conn)
|
||||
@@ -346,16 +279,11 @@ impl Game<'_> {
|
||||
// CombatTracker: the attacking player is the death message's killer.
|
||||
// Without it the message is built with one argument and a template
|
||||
// that names a killer ("%1$s was slain by %2$s") shows as written.
|
||||
let attacker_note = match s.attacker {
|
||||
Some(conn) => host
|
||||
.hub
|
||||
let attacker_note = s.attacker.and_then(|conn| {
|
||||
host.hub
|
||||
.guest(conn)
|
||||
.map(|a| (a.entity_id, self.player_text(&a.name, a.uuid))),
|
||||
None => Some((
|
||||
host.host_entity,
|
||||
self.player_text(&host.host_name, host.host_uuid),
|
||||
)),
|
||||
};
|
||||
.map(|a| (a.entity_id, self.player_text(&a.name, a.uuid)))
|
||||
});
|
||||
let land = |g: &mut Self| {
|
||||
g.combat.player_attacker = Some((s.from, s.disable_blocking));
|
||||
if let Some((id, name)) = attacker_note.clone() {
|
||||
@@ -373,7 +301,6 @@ impl Game<'_> {
|
||||
};
|
||||
let (hurt, thorns) = if self.ui.rules.bool("pvp") && harm {
|
||||
match victim {
|
||||
Victim::Host => land(self),
|
||||
Victim::Guest(conn) => self.guest_struck_by(host, conn, land).unwrap_or_default(),
|
||||
}
|
||||
} else {
|
||||
@@ -384,10 +311,6 @@ impl Game<'_> {
|
||||
let died = match victim {
|
||||
// A totem that fired is applied on the next tick_totem, so the
|
||||
// victim is still at zero health but is not dying.
|
||||
Victim::Host => self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.health.dead_or_dying() && !p.health.totem_used),
|
||||
Victim::Guest(conn) => host
|
||||
.hub
|
||||
.guest(conn)
|
||||
@@ -405,7 +328,6 @@ impl Game<'_> {
|
||||
}
|
||||
if hurt && (s.crit || s.magic) {
|
||||
let target = match victim {
|
||||
Victim::Host => self.player.as_ref().map(|p| (host.host_entity, p)),
|
||||
Victim::Guest(conn) => host
|
||||
.hub
|
||||
.guest(conn)
|
||||
@@ -454,7 +376,6 @@ impl Game<'_> {
|
||||
if let Some(at) = s.sweep.filter(|_| hurt) {
|
||||
// The sweep around the player that was hit, as its attacker.
|
||||
let (area, id) = match victim {
|
||||
Victim::Host => (self.player.as_ref().map(|p| p.bounds()), host.host_entity),
|
||||
Victim::Guest(conn) => {
|
||||
let g = host.hub.guest(conn);
|
||||
(g.map(guest_bounds), g.map_or(-1, |g| g.entity_id))
|
||||
@@ -531,16 +452,6 @@ impl Game<'_> {
|
||||
pub(super) fn sweep_players(&mut self, host: &mut LanHost, sweep: &Sweep) {
|
||||
let from = Vec3::new(sweep.from[0], sweep.from[1], sweep.from[2]);
|
||||
let mut victims = Vec::new();
|
||||
if sweep.attacker.is_some()
|
||||
&& self.options.dedicated.is_none()
|
||||
&& !self.spectator()
|
||||
&& let Some(p) = self.player.as_ref()
|
||||
&& !p.health.dead_or_dying()
|
||||
&& p.bounds().intersects(sweep.hit_box)
|
||||
&& p.position.distance_squared(from) < 9.0
|
||||
{
|
||||
victims.push((Victim::Host, host.host_entity, self.player_team().cloned()));
|
||||
}
|
||||
for g in &host.hub.guests {
|
||||
if host.hub.here(g)
|
||||
&& g.loaded
|
||||
@@ -572,12 +483,8 @@ impl Game<'_> {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
match victim {
|
||||
Victim::Host => land(self),
|
||||
Victim::Guest(conn) => {
|
||||
self.guest_struck_by(host, conn, land);
|
||||
}
|
||||
}
|
||||
let Victim::Guest(conn) = victim;
|
||||
self.guest_struck_by(host, conn, land);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,9 @@
|
||||
use super::{Game, lan::LanHost};
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
lan::{
|
||||
hub::HostState,
|
||||
waypoints::{self, HIDING_HEADS, Icon, PLAYER_RANGE},
|
||||
},
|
||||
lan::waypoints::{self, HIDING_HEADS, Icon, PLAYER_RANGE},
|
||||
};
|
||||
|
||||
/// The host player's connection id in the waypoint managers: its packets
|
||||
/// go to this client's locator bar rather than to a guest.
|
||||
const HOST_CONN: u32 = u32::MAX;
|
||||
|
||||
/// `waypoint_transmit_range`: hidden by crouching, invisibility or a head worn.
|
||||
fn transmit_range(crouching: bool, invisible: bool, head: Option<&ItemStack>) -> f64 {
|
||||
let hiding_head = head.is_some_and(|s| HIDING_HEADS.contains(&&*s.item));
|
||||
@@ -35,38 +28,8 @@ impl Game<'_> {
|
||||
/// level: a player who changes level leaves the old one's manager
|
||||
/// (`ServerLevel.removePlayerImmediately`: untracked for everyone there)
|
||||
/// and joins the new one's (`addDuringTeleport`).
|
||||
pub(super) fn lan_update_waypoints(&mut self, host: &mut LanHost, state: Option<&HostState>) {
|
||||
pub(super) fn lan_update_waypoints(&mut self, host: &mut LanHost) {
|
||||
let mut players: Vec<(&str, waypoints::Player)> = Vec::new();
|
||||
if let Some(h) = state {
|
||||
let icon = self
|
||||
.cmds
|
||||
.waypoint_icons
|
||||
.get(&super::commands::Target::Player)
|
||||
.map(|i| Icon {
|
||||
style: i.style.clone(),
|
||||
color: i.color,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
players.push((
|
||||
self.dim.id(),
|
||||
waypoints::Player {
|
||||
uuid: h.uuid,
|
||||
conn: Some(HOST_CONN),
|
||||
position: h.position,
|
||||
spectator: self.spectator(),
|
||||
transmit_range: transmit_range(
|
||||
h.sneaking,
|
||||
self.potions.effects.get("invisibility").is_some(),
|
||||
h.equipment[5].as_ref(),
|
||||
),
|
||||
receive_range: PLAYER_RANGE,
|
||||
first_tick: false,
|
||||
view_distance: host.hub.config.view_distance,
|
||||
team: self.waypoint_team(&h.name),
|
||||
icon,
|
||||
},
|
||||
));
|
||||
}
|
||||
let mut respawned = Vec::new();
|
||||
for g in host.hub.guests.iter().filter(|g| g.placed) {
|
||||
// PlayerList.respawn makes a new ServerPlayer.
|
||||
@@ -127,12 +90,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
for (conn, packet) in out {
|
||||
if conn == HOST_CONN {
|
||||
// The host's own connection: its locator bar.
|
||||
if let horizon_protocol::packets::play::Clientbound::TrackedWaypoint(w) = &packet {
|
||||
self.handle_waypoint(w);
|
||||
}
|
||||
} else if let Some(g) = host.hub.guest(conn) {
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
g.send(&packet);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
//! A server (the dedicated server and single player's server thread) keeps
|
||||
//! the other dimensions' levels here and ticks each one with players or forced
|
||||
//! chunks by swapping it into place (`enter_level`), the way `as_guest` swaps a player
|
||||
//! in, so the tick code runs unchanged, one level at a time. The in-frame
|
||||
//! game holds one level, which follows its player (`enter_dimension`).
|
||||
//! in, so the tick code runs unchanged, one level at a time.
|
||||
//!
|
||||
//! Shared by every level, as vanilla's derived levels share the
|
||||
//! Overworld's level data: the game time, the day time and the weather, the
|
||||
@@ -303,7 +302,7 @@ impl Game<'_> {
|
||||
mut host: Option<&mut LanHost>,
|
||||
target: Dim,
|
||||
) -> Result<()> {
|
||||
// The in-frame game keeps its one level (`enter_dimension`).
|
||||
// A client holds no levels.
|
||||
if self.options.execution_role.is_client() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ use horizon_core::{
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
/// `MaceItem.SMASH_ATTACK_FALL_THRESHOLD`.
|
||||
const FALL_THRESHOLD: f64 = 1.5;
|
||||
@@ -585,31 +584,6 @@ impl Game<'_> {
|
||||
};
|
||||
self.sweep_other_players(sweep);
|
||||
}
|
||||
/// `ServerPlayer.checkFallDamage` after a smash on a grounded target:
|
||||
/// the landing block's particles, 50 for every block fallen (200 at most).
|
||||
pub(super) fn smash_landing_particles(
|
||||
&mut self,
|
||||
state: BlockStateId,
|
||||
pos: BlockPos,
|
||||
fall_distance: f64,
|
||||
) {
|
||||
let Some((sprite, tint)) = self.particle_sprite(state, [pos.x, pos.y, pos.z]) else {
|
||||
return;
|
||||
};
|
||||
let count = (50.0 * fall_distance).clamp(0.0, 200.0) as u32;
|
||||
let spread = f64::from(0.3f32);
|
||||
self.particles.send_particles(
|
||||
&ParticleOptions::block(ParticleType::Block, sprite, tint),
|
||||
[
|
||||
f64::from(pos.x) + 0.5,
|
||||
f64::from(pos.y) + 1.0,
|
||||
f64::from(pos.z) + 0.5,
|
||||
],
|
||||
[spread; 3],
|
||||
f64::from(0.15f32),
|
||||
count,
|
||||
);
|
||||
}
|
||||
/// Level event 2013 (`ParticleUtils.spawnSmashAttackParticles`): dust
|
||||
/// pillars of the block at `pos`, a cluster over it and a ring 3.5 out.
|
||||
pub(super) fn smash_dust(&mut self, pos: BlockPos, count: i32) {
|
||||
|
||||
@@ -74,42 +74,6 @@ fn antenna_block_matrix(head: glam::Mat4, flowerbed_shift: bool) -> glam::Mat4 {
|
||||
pub(super) const APPARITION_TICKS: i32 = 30;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `CustomHeadLayer` for a pumpkin worn by a LAN guest drawn on the
|
||||
/// in-frame host, whose model has no worn stacks for `head_item_geometry`.
|
||||
pub(super) fn player_pumpkin_geometry(
|
||||
&self,
|
||||
mob: &Mob,
|
||||
stack: Option<&ItemStack>,
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let Some(stack) = stack.filter(|s| &*s.item == "minecraft:carved_pumpkin") else {
|
||||
return;
|
||||
};
|
||||
let Some(head) =
|
||||
render::part_transform(mob, alpha as f32, camera, self.res.mob_data(), "head")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let pose = head
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.25, 0.0))
|
||||
* glam::Mat4::from_rotation_y(std::f32::consts::PI)
|
||||
* glam::Mat4::from_scale(glam::Vec3::new(0.625, -0.625, -0.625));
|
||||
let owner = super::item_draw::owner_at(
|
||||
[mob.position.x, mob.position.y, mob.position.z],
|
||||
mob.body_yaw,
|
||||
&mob.kind.id,
|
||||
);
|
||||
let state = self.resolve_item(
|
||||
stack,
|
||||
horizon_assets::item_model::DisplayContext::Head,
|
||||
Some(&owner),
|
||||
mob.id as i32 + 5,
|
||||
);
|
||||
self.submit_item_world(&state, pose, light, out);
|
||||
}
|
||||
/// Count the elder guardian's apparition down.
|
||||
pub(super) fn tick_apparition(&mut self) {
|
||||
self.creatures.elder_apparition = (self.creatures.elder_apparition - 1).max(0);
|
||||
|
||||
@@ -54,17 +54,9 @@ impl Game<'_> {
|
||||
t.push(objective.display_text());
|
||||
Some(t)
|
||||
}
|
||||
/// The other players' models and names: the guests on a LAN host, the
|
||||
/// players a server sends on a client.
|
||||
/// The other players' models and names: the players a server sends.
|
||||
fn other_players(&self) -> Vec<(&Mob, String)> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(host) = &self.lan {
|
||||
for (conn, m) in &host.models {
|
||||
if let Some(g) = host.hub.guest(*conn) {
|
||||
out.push((m, g.name.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, name) in self.remote_player_names() {
|
||||
if let Some(m) = self.mobs.iter().find(|m| m.id == id) {
|
||||
out.push((m, name));
|
||||
|
||||
@@ -1412,8 +1412,7 @@ impl Game<'_> {
|
||||
let _ = layers;
|
||||
}
|
||||
/// The world's phases, read-only; `error_text` carries the engine's
|
||||
/// own open-failure text, empty otherwise. Server-side; the deprecated
|
||||
/// in-frame path delivers no lifecycle events. A server loop or final
|
||||
/// own open-failure text, empty otherwise. Server-side. A server loop or final
|
||||
/// save failure fires `WorldCloseFailed` with the error text.
|
||||
pub(super) fn native_world_lifecycle(
|
||||
&mut self,
|
||||
@@ -2902,12 +2901,10 @@ impl Game<'_> {
|
||||
/// 0 runs it out on its first tick, as `native_living_entity_use_item_tick`
|
||||
/// running it out, unless the item's own is 0 already), or set
|
||||
/// `canceled` and the use doesn't start. Both sides, as
|
||||
/// vanilla's: wherever the world runs (the server, single player
|
||||
/// in-frame, a LAN host) for every player of the world, and on a
|
||||
/// vanilla's: on the server for every player of the world, and on a
|
||||
/// server's client for the uses it predicts. Answer the same on both,
|
||||
/// and change the world only where this `Game` runs the world, not on a
|
||||
/// server's client: no helper tells the two apart yet (`native_window`
|
||||
/// is also true in-frame and on an in-frame LAN host). API 90.
|
||||
/// and change the world only on the server (`native_window` is false
|
||||
/// there), not on a server's client. API 90.
|
||||
pub(super) fn native_living_entity_use_item_start(
|
||||
&mut self,
|
||||
stack: &ItemStack,
|
||||
@@ -2953,9 +2950,8 @@ impl Game<'_> {
|
||||
/// the code. On a server's client a run-out use stays in use, frozen,
|
||||
/// so letting go still tells the server. Both sides, as vanilla's
|
||||
/// `updatingUsingItem`: answer the same on both, and change the world
|
||||
/// only where this `Game` runs the world, not on a server's client (no
|
||||
/// helper tells the two apart yet; `native_window` is also true
|
||||
/// in-frame and on an in-frame LAN host). Runs every tick of every use:
|
||||
/// only on the server (`native_window` is false there), not on a
|
||||
/// server's client. Runs every tick of every use:
|
||||
/// check the item first. API 131.
|
||||
pub(super) fn native_living_entity_use_item_tick(
|
||||
&mut self,
|
||||
@@ -3658,8 +3654,7 @@ impl Game<'_> {
|
||||
/// as on a refused hit, except that a canceled hit on the ender dragon
|
||||
/// still counts as landed (`EnderDragon.hurt` returns true whatever
|
||||
/// `reallyHurt` did: the arrow is spent, the melee effects run). It runs
|
||||
/// on the thread that ticks the world (the server thread; the window's
|
||||
/// in-frame): state shared with render thread hooks needs synchronising.
|
||||
/// on the thread that ticks the world (the server thread): state shared with render thread hooks needs synchronising.
|
||||
/// No `self`: the game is in the middle of the hit. API 50.
|
||||
pub(super) fn native_living_incoming_damage(
|
||||
entity: i32,
|
||||
@@ -3692,7 +3687,7 @@ impl Game<'_> {
|
||||
/// ender dragon's wings, Punch arrows, explosions, wind charges, a mace smash's area, a
|
||||
/// hoglin's throw), nor yet for the ones Horizon makes another way: a mob's melee push from
|
||||
/// its attack knockback or a Knockback weapon, an armor stand's, the ender dragon's. It runs
|
||||
/// on the thread that ticks the world (the server thread; the window's in-frame): state
|
||||
/// on the thread that ticks the world (the server thread): state
|
||||
/// shared with render thread hooks needs synchronising. No `self`: the game is in the middle
|
||||
/// of the hit. API 125.
|
||||
pub(super) fn native_living_knockback(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Read-only lifecycle observations for native mods on authoritative servers.
|
||||
//! The deprecated in-frame path delivers no lifecycle events. A paused pass
|
||||
//! A paused pass
|
||||
//! observes nothing; a frozen world reports `world_running: false` except during a step.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
@@ -398,37 +398,4 @@ mod tests {
|
||||
assert!(lifecycle.world_close_failed().is_none());
|
||||
assert!(lifecycle.world_close_final().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_delivery_stays_off_the_deprecated_in_frame_path() {
|
||||
let game = include_str!("../game.rs")
|
||||
.split("#[cfg(test)]")
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
for site in [
|
||||
"native_lifecycle_open_started()",
|
||||
"native_lifecycle_world_ready()",
|
||||
"native_lifecycle_world_closed()",
|
||||
"native_lifecycle.begin_close()",
|
||||
] {
|
||||
assert!(!game.contains(site), "in-frame site remains: {site}");
|
||||
}
|
||||
let saving = include_str!("saving.rs")
|
||||
.split("#[cfg(test)]")
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
assert!(!saving.contains("native_lifecycle"));
|
||||
|
||||
let integrated = include_str!("integrated.rs")
|
||||
.split("#[cfg(test)]")
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
let open = integrated
|
||||
.find("self.native_lifecycle_open_started();")
|
||||
.unwrap();
|
||||
let load = integrated
|
||||
.find("self.finish_open_world(opening.world?.finish()?)?;")
|
||||
.unwrap();
|
||||
assert!(open < load);
|
||||
}
|
||||
}
|
||||
@@ -5,20 +5,20 @@ use super::{Game, native_hooks::mob_kind};
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether this game is a server whose mounts the mods hear: the server thread or a
|
||||
/// dedicated server, never a client nor the deprecated in-frame path.
|
||||
/// dedicated server, never a client.
|
||||
fn native_hears_mounts(&self) -> bool {
|
||||
self.serving.is_some() || self.options.dedicated.is_some()
|
||||
}
|
||||
|
||||
/// The entity id of the acting player: a guest's while their action runs (kept with the
|
||||
/// action, as the hub is often out of `lan` then), else the host's.
|
||||
/// action, as the hub is often out of `lan` then), else none (0).
|
||||
fn native_acting_entity(&self) -> u32 {
|
||||
match self.lan_acting {
|
||||
Some(conn) => self.lan_acting_entity.unwrap_or_else(|| {
|
||||
let guests = self.lan_guest_entities();
|
||||
guests.iter().find(|g| g.0 == conn).map_or(0, |g| g.1)
|
||||
}),
|
||||
None => self.lan.as_ref().map_or(0, |host| host.host_entity as u32),
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,8 +46,7 @@ fn projectile_type(p: &Projectile) -> &'static str {
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether this game is a server that flies projectiles for the mods (the
|
||||
/// server thread or a dedicated server, never the deprecated in-frame path
|
||||
/// nor a client) and a mod asked to hear their impacts
|
||||
/// server thread or a dedicated server, never a client) and a mod asked to hear their impacts
|
||||
/// (`native_hears_projectile_impacts`). Without one, the tick keeps no copy
|
||||
/// of its projectiles: it costs nothing.
|
||||
pub(super) fn native_hears_impacts(&self) -> bool {
|
||||
@@ -77,7 +76,7 @@ impl Game<'_> {
|
||||
.into_iter()
|
||||
.find(|&(c, _)| c == conn)
|
||||
.map_or(0, |(_, id)| id),
|
||||
None => self.lan.as_ref().map_or(0, |host| host.host_entity as u32),
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,15 +165,12 @@ impl Game<'_> {
|
||||
let (entity, kind) = self.native_impact_entity(id);
|
||||
(Some(entity), kind, [0; 3], [0; 3], at)
|
||||
}
|
||||
// A server's players are hit as their entities, never as `Player`.
|
||||
Hit::Entity {
|
||||
who: Who::Player,
|
||||
at,
|
||||
who: Who::Player, ..
|
||||
} => {
|
||||
let Some(host) = self.lan.as_ref().map(|h| h.host_entity as u32) else {
|
||||
canceled.push(false);
|
||||
continue;
|
||||
};
|
||||
(Some(host), "minecraft:player".into(), [0; 3], [0; 3], at)
|
||||
canceled.push(false);
|
||||
continue;
|
||||
}
|
||||
Hit::None => {
|
||||
canceled.push(false);
|
||||
|
||||
@@ -53,7 +53,7 @@ impl TickRound {
|
||||
|
||||
impl Game<'_> {
|
||||
/// A round for a family about to tick: on a server only (the server thread or a dedicated
|
||||
/// server, never a client nor the deprecated in-frame path), and only while a mod listens
|
||||
/// server, never a client), and only while a mod listens
|
||||
/// (`native_entity_tick_listened`). Its list is kept between ticks.
|
||||
pub(super) fn native_tick_round(&mut self) -> TickRound {
|
||||
let server = self.serving.is_some() || self.options.dedicated.is_some();
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
//! cooldowns follow their level; shared `Arrival`s hold each traveller's
|
||||
//! saved compound until its destination chunks are loaded.
|
||||
use super::{Dim, Game, Screen};
|
||||
use horizon_client::Result;
|
||||
use horizon_client::fast_hash::FxHashMap;
|
||||
use horizon_client::nether_portal::{ENTITY_DELAY, PortalState};
|
||||
use horizon_core::{
|
||||
@@ -80,8 +79,6 @@ struct Arrival {
|
||||
pub(super) struct Portals {
|
||||
/// The player's.
|
||||
pub(super) player: PortalState,
|
||||
/// The player's travel, while the destination loads.
|
||||
pub(super) travel: Option<Plan>,
|
||||
/// `LocalPlayer.portalProcess.isInsidePortalThisTick`.
|
||||
client_inside: bool,
|
||||
/// Items and mobs, by entity id.
|
||||
@@ -476,25 +473,6 @@ impl Game<'_> {
|
||||
|
||||
// ---- the player ----
|
||||
|
||||
/// `NetherPortalBlock.entityInside` for the player: `canUsePortal`, then
|
||||
/// `setAsInsidePortal` (and the client's own, which drives the effect).
|
||||
pub(super) fn player_inside_nether_portal(&mut self, pos: BlockPos) {
|
||||
let alive = self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| !p.health.dead_or_dying());
|
||||
if !alive || self.riding.is_some() || self.sleeping.is_some() {
|
||||
return;
|
||||
}
|
||||
// The client's LocalPlayer has the same cooldown (the Respawn
|
||||
// packet's spawn info): standing in the arrival portal, its overlay
|
||||
// fades instead of building up again.
|
||||
let cooling = self.portals.player.cooldown > 0;
|
||||
self.portals.player.set_inside(pos, PLAYER_DELAY);
|
||||
if !cooling {
|
||||
self.portals.client_inside = true;
|
||||
}
|
||||
}
|
||||
/// The same on a server's client: `Entity.canUsePortal` (alive, not a
|
||||
/// passenger), then only the client's `setAsInsidePortal`, which drives
|
||||
/// the confusion effect (`client_portal_effect`). On a server's client
|
||||
@@ -513,62 +491,6 @@ impl Game<'_> {
|
||||
self.portals.client_inside = true;
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.handlePortal` (not while changing dimension: there is no
|
||||
/// player then). Players wait `players_nether_portal_default_delay`
|
||||
/// ticks, or the creative delay with invulnerable abilities.
|
||||
pub(super) fn tick_nether_portal(&mut self) -> Result<()> {
|
||||
// A server's portals are its own.
|
||||
if self.remote.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
if self.travelling.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let allowed = !p.health.dead_or_dying() && self.riding.is_none() && self.sleeping.is_none();
|
||||
let rule = if p.invulnerable {
|
||||
"players_nether_portal_creative_delay"
|
||||
} else {
|
||||
"players_nether_portal_default_delay"
|
||||
};
|
||||
let transition = self.ui.rules.int(rule).max(0);
|
||||
let Some(entry) = self
|
||||
.portals
|
||||
.player
|
||||
.handle(allowed, transition, PLAYER_DELAY)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
self.player_through_nether_portal(entry)
|
||||
}
|
||||
/// `Entity.handlePortal` once the player's portal process at `entry`
|
||||
/// finished: `NetherPortalBlock.getPortalDestination`, then
|
||||
/// `teleportToPortalDestination` (`isAllowedToEnterPortal`).
|
||||
pub(super) fn player_through_nether_portal(&mut self, entry: BlockPos) -> Result<()> {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let plan = self.portal_plan(
|
||||
entry,
|
||||
p.position,
|
||||
p.dimensions.width,
|
||||
p.dimensions.height,
|
||||
true,
|
||||
);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return Ok(());
|
||||
}
|
||||
self.portals.travel = Some(plan);
|
||||
self.end_portal_travel = false;
|
||||
self.enter_dimension(plan.dim)?;
|
||||
let centre = self.arrival_centre(&plan);
|
||||
if let Some((_, target)) = self.travelling.as_mut() {
|
||||
*target = centre;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Where to wait for the destination of `plan` to load, once its level
|
||||
/// is open: the portal its points of interest lead to, or the
|
||||
/// approximate exit.
|
||||
@@ -592,52 +514,6 @@ impl Game<'_> {
|
||||
f64::from(centre.z) + 0.5,
|
||||
)
|
||||
}
|
||||
/// The player's arrival once the destination has loaded around the
|
||||
/// approximate exit: the waiting entities first, then the exit portal
|
||||
/// (found or built), the position in it, the turned yaw and motion, and
|
||||
/// `TeleportTransition.PLAY_PORTAL_SOUND` (level event 1032).
|
||||
pub(super) fn arrive_through_nether_portal(
|
||||
&mut self,
|
||||
mut player: horizon_client::motion::PlayerMotion,
|
||||
target: Vec3,
|
||||
) -> Result<()> {
|
||||
self.resolve_arrivals();
|
||||
let plan = self.portals.travel.take();
|
||||
let Some(plan) = plan else {
|
||||
// A command teleport can change dimensions without going through
|
||||
// a portal. It has no portal placement, rotation, or sound effect.
|
||||
player.position = target;
|
||||
player.velocity = Vec3::ZERO;
|
||||
player.fall_distance = 0.0;
|
||||
self.previous_position = target;
|
||||
self.player = Some(player);
|
||||
self.pad.player_replaced();
|
||||
self.sway = Default::default();
|
||||
self.apply_equipment();
|
||||
return Ok(());
|
||||
};
|
||||
let exit = self.nether_exit_portal(&plan);
|
||||
let (position, rotation) = match exit {
|
||||
Some(exit) => self.arrival(&plan, exit),
|
||||
None => (target, 0.0),
|
||||
};
|
||||
player.position = position;
|
||||
// Relative.ROTATION and Relative.DELTA: the yaw turns, the motion
|
||||
// turns with it.
|
||||
player.yaw += rotation;
|
||||
player.head_yaw += rotation;
|
||||
self.yaw += rotation;
|
||||
player.velocity = rotate_y(player.velocity, -rotation);
|
||||
self.previous_position = player.position;
|
||||
self.player = Some(player);
|
||||
self.pad.player_replaced();
|
||||
self.sway = Default::default();
|
||||
self.apply_equipment();
|
||||
let pitch = self.world.random.next_float() * 0.4 + 0.8;
|
||||
self.audio
|
||||
.play("minecraft:block.portal.travel", None, 0.25, pitch);
|
||||
Ok(())
|
||||
}
|
||||
/// `LocalPlayer.handlePortalTransitionEffect`: whether the confusion
|
||||
/// effect grows this tick. Entering a portal closes screens other than
|
||||
/// the chat and death screens and plays `block.portal.trigger` when the
|
||||
@@ -809,7 +685,7 @@ impl Game<'_> {
|
||||
/// server players only, whom it carries along.
|
||||
/// `Entity.canUsePortal(false)` for a primed TNT: it carries no mob.
|
||||
fn tnt_can_use_portal(&self, id: u32) -> bool {
|
||||
// Id 0: not tracked yet (the in-frame path), nothing to key its portal state by.
|
||||
// Id 0: not tracked yet, nothing to key its portal state by.
|
||||
id != 0 && !self.mobs.iter().any(|m| m.extra.mount == Some(id))
|
||||
}
|
||||
fn vehicle_can_use_portal(&self, id: u32) -> bool {
|
||||
|
||||
@@ -188,10 +188,7 @@ impl Game<'_> {
|
||||
// until those resources exist, then apply the latest selection once.
|
||||
// A reload already under its overlay finishes first (vanilla's
|
||||
// `pendingReload`, which `runTick` starts once the overlay is gone).
|
||||
if reload_waits(
|
||||
self.rest_loader.is_some() || self.world_loader.is_some(),
|
||||
self.pack_reload.is_some(),
|
||||
) {
|
||||
if reload_waits(self.rest_loader.is_some(), self.pack_reload.is_some()) {
|
||||
self.pending_pack_reload = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1543,14 +1543,6 @@ impl Game<'_> {
|
||||
log::warn!("Horizon: failed to save entities: {e}");
|
||||
}
|
||||
}
|
||||
/// Leaving a dimension: its entities are saved with it.
|
||||
pub(super) fn save_dimension_entities(&mut self) -> Result<()> {
|
||||
self.spawn_loaded_entities();
|
||||
let keys: HashSet<(i32, i32)> = self.world.chunks.keys().copied().collect();
|
||||
let chunks = self.entity_chunks(&keys, true);
|
||||
self.persist = EntityMemory::default();
|
||||
self.save.save_entities(chunks)
|
||||
}
|
||||
}
|
||||
/// Only a mob is finalized; another entity drops the request.
|
||||
fn strip_finalize(tag: &mut Tag) {
|
||||
|
||||
@@ -1022,13 +1022,9 @@ impl Game<'_> {
|
||||
out,
|
||||
);
|
||||
}
|
||||
/// The entity id the local player has: the server's, else the LAN host's.
|
||||
/// The entity id the local player has on its server.
|
||||
fn own_entity_id(&self) -> i32 {
|
||||
match (&self.remote, &self.lan) {
|
||||
(Some(r), _) => r.entity_id,
|
||||
(None, Some(host)) => host.host_entity,
|
||||
_ => 0,
|
||||
}
|
||||
self.remote.as_ref().map_or(0, |r| r.entity_id)
|
||||
}
|
||||
/// `ArrowLayer` and `BeeStingerLayer` (`StuckInBodyLayer`) for a player:
|
||||
/// the arrows and bee stingers stuck in the body, placed from the entity
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! `ServerGamePacketListenerImpl.handlePickItemFromEntity`): the client aims,
|
||||
//! the server answers with `Entity.getPickResult`.
|
||||
use super::Game;
|
||||
use horizon_client::{items::ItemStack, lan::playerdata::uuid_ints, save::uuid_text};
|
||||
use horizon_client::{items::ItemStack, save::uuid_text};
|
||||
|
||||
impl Game<'_> {
|
||||
/// The entity the crosshair hits before any block, as `Entity.getId`; an
|
||||
@@ -23,9 +23,6 @@ impl Game<'_> {
|
||||
if let Some((i, d)) = self.mob_hit() {
|
||||
offer(d, Some(self.mobs[i].id as i32));
|
||||
}
|
||||
if let Some((_, d)) = self.guest_hit() {
|
||||
offer(d, None);
|
||||
}
|
||||
if let Some((_, d)) = self.redirectable_hit() {
|
||||
offer(d, None);
|
||||
}
|
||||
@@ -54,10 +51,6 @@ impl Game<'_> {
|
||||
if let Some((i, d)) = self.mob_hit() {
|
||||
offer(d, uuid_of(self.mobs[i].id));
|
||||
}
|
||||
if let Some((conn, d)) = self.guest_hit() {
|
||||
let guest = self.lan.as_ref().and_then(|h| h.hub.guest(conn));
|
||||
offer(d, guest.map(|g| uuid_ints(g.uuid)));
|
||||
}
|
||||
if let Some((i, d)) = self.redirectable_hit() {
|
||||
offer(d, uuid_of(self.creatures.projectiles[i].id));
|
||||
}
|
||||
|
||||
@@ -37,8 +37,6 @@ pub(super) enum Collector {
|
||||
Player,
|
||||
/// A mob, or another player on a server (both kept as mobs here).
|
||||
Mob(u32),
|
||||
/// A LAN guest, by entity id (the host draws it as a model).
|
||||
Guest(u32),
|
||||
}
|
||||
|
||||
pub(super) struct Pickup {
|
||||
@@ -80,13 +78,6 @@ pub(super) fn position(p: &Pickup, partial: f64) -> [f64; 3] {
|
||||
})
|
||||
}
|
||||
|
||||
/// A LAN guest's target, from the model the host draws for it.
|
||||
pub(super) fn guest_target(host: &super::lan::LanHost, id: u32) -> Option<[f64; 3]> {
|
||||
let m = host.models.values().find(|m| m.id == id)?;
|
||||
let (p, eye) = (m.position, m.eye_height());
|
||||
Some([p.x, (p.y + (p.y + eye)) / 2.0, p.z])
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ItemPickupParticle.updatePosition`: the collector's x and z, halfway up to its eyes.
|
||||
fn collector_target(&self, collector: Collector) -> Option<[f64; 3]> {
|
||||
@@ -99,9 +90,6 @@ impl Game<'_> {
|
||||
let m = self.mobs.iter().find(|m| m.id == id)?;
|
||||
(m.position, m.eye_height())
|
||||
}
|
||||
Collector::Guest(id) => {
|
||||
return guest_target(self.lan.as_ref()?, id);
|
||||
}
|
||||
};
|
||||
Some([p.x, (p.y + (p.y + eye)) / 2.0, p.z])
|
||||
}
|
||||
|
||||
@@ -3,59 +3,10 @@
|
||||
//! sinking, slowing, climbing and freezing are `PlayerMotion`'s
|
||||
//! (`crate::motion`); placing it is the powder snow bucket's `BlockItem.place`.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
items::ItemStack,
|
||||
mobs::Who,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource};
|
||||
use horizon_client::{Result, items::ItemStack, mobs::Who};
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
|
||||
impl Game<'_> {
|
||||
/// `ClientPacketListener.handleParticleEvent` for the one ALTERNATIVE
|
||||
/// snowflake `PowderSnowBlock.entityInside` sends: no spread, its speed.
|
||||
pub(super) fn snowflake(&mut self, position: Vec3, velocity: Vec3) {
|
||||
let r = self.particles.random();
|
||||
// nextDouble() * dist for each axis, the distances being 0.
|
||||
let offset = [
|
||||
r.next_double() * 0.0,
|
||||
r.next_double() * 0.0,
|
||||
r.next_double() * 0.0,
|
||||
];
|
||||
let options = ParticleOptions::simple(ParticleType::Snowflake);
|
||||
self.particles.add(
|
||||
&options,
|
||||
[
|
||||
position.x + offset[0],
|
||||
position.y + offset[1],
|
||||
position.z + offset[2],
|
||||
],
|
||||
[velocity.x, velocity.y, velocity.z],
|
||||
);
|
||||
}
|
||||
/// The EXTINGUISH effect of the powder snow the player went into this
|
||||
/// tick: burning, it destroys those blocks (a player always may), then
|
||||
/// its fire goes out (`Entity.clearFire`) with the extinguish sound.
|
||||
pub(super) fn powder_snow_fire(&mut self) -> Result<()> {
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let entered = std::mem::take(&mut player.powder_snow_entered);
|
||||
let at = [player.position.x, player.position.y, player.position.z];
|
||||
if entered.is_empty() || player.fire_ticks <= 0 {
|
||||
return Ok(());
|
||||
}
|
||||
for pos in entered {
|
||||
self.with_level(|b, level, ticks| b.destroy_block(level, ticks, pos, false));
|
||||
self.world.modified.insert((pos.x >> 4, pos.z >> 4));
|
||||
}
|
||||
self.process_level_events()?;
|
||||
if let Some(player) = self.player.as_mut() {
|
||||
player.fire_ticks = 0;
|
||||
}
|
||||
self.extinguish_sound(at);
|
||||
Ok(())
|
||||
}
|
||||
/// Entity.playEntityOnFireExtinguishedSound
|
||||
pub(super) fn extinguish_sound(&mut self, at: [f64; 3]) {
|
||||
let r = self.particles.random();
|
||||
@@ -83,13 +34,6 @@ impl Game<'_> {
|
||||
}
|
||||
self.extinguish_sound([at.x, at.y, at.z]);
|
||||
}
|
||||
/// The in-window player's rain extinguish (a server's player has its
|
||||
/// fire put out by the server).
|
||||
pub(super) fn rain_extinguish_in_frame(&mut self) {
|
||||
if self.remote.is_none() {
|
||||
self.rain_extinguish_player();
|
||||
}
|
||||
}
|
||||
/// Whether the block at `pos` is powder snow.
|
||||
pub(super) fn is_powder_snow(&self, pos: [i32; 3]) -> bool {
|
||||
&*self
|
||||
|
||||
@@ -882,7 +882,6 @@ impl Game<'_> {
|
||||
self.pickups.clear();
|
||||
self.orbs.clear();
|
||||
self.falling.clear();
|
||||
self.travelling = None;
|
||||
self.mining = None;
|
||||
self.container = None;
|
||||
self.carried = None;
|
||||
@@ -8511,8 +8510,7 @@ impl Game<'_> {
|
||||
/// dialogs release on the first tick after closing instead, as vanilla
|
||||
/// does.
|
||||
pub(super) fn release_use_for_keybind_screen(&mut self) {
|
||||
// In-frame (deprecated) has no server client; `tick_used_item` releases
|
||||
// the use on its own.
|
||||
// Only a server's client holds a use to release.
|
||||
if self.remote.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11,13 +11,11 @@ use horizon_client::{
|
||||
collisions::{SceneCollisions, no_collision_empty},
|
||||
items::ItemStack,
|
||||
mobs::Who,
|
||||
motion::PlayerMotion,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Axis, Vec3, VoxelShape},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_world::{collision_context::CollisionContext, registry::BlockStateId};
|
||||
|
||||
@@ -626,93 +624,6 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(stand.map(|p| (p, look_at_yaw(p, c.pos))))
|
||||
}
|
||||
/// The death screen's respawn with an anchor as the respawn point: travel
|
||||
/// to its dimension and wait for its chunks (`travelling`), then resolve
|
||||
/// it in [`Game::place_respawned`].
|
||||
pub(super) fn begin_anchor_respawn(&mut self) -> Result<bool> {
|
||||
let Some(config) = self.respawn_config else {
|
||||
return Ok(false);
|
||||
};
|
||||
if self.player.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
if self.dim != config.dim {
|
||||
self.end_portal_travel = false;
|
||||
self.enter_dimension(config.dim)?;
|
||||
} else {
|
||||
let player = self.player.take().ok_or("player")?;
|
||||
self.travelling = Some((player, Vec3::ZERO));
|
||||
}
|
||||
let target = Vec3::new(
|
||||
f64::from(config.pos[0]) + 0.5,
|
||||
f64::from(config.pos[1]),
|
||||
f64::from(config.pos[2]) + 0.5,
|
||||
);
|
||||
if let Some((_, t)) = self.travelling.as_mut() {
|
||||
*t = target;
|
||||
}
|
||||
self.end_travel = false;
|
||||
self.respawn_travel = Some(config);
|
||||
Ok(true)
|
||||
}
|
||||
/// `PlayerList.respawn` at an anchor once its dimension is loaded around
|
||||
/// it: a fresh player at the stand-up position facing the anchor and the
|
||||
/// deplete sound, or, with no anchor left, the world spawn and
|
||||
/// `block.minecraft.spawn.not_valid`.
|
||||
pub(super) fn place_respawned(&mut self, config: RespawnConfig) -> Result<()> {
|
||||
match self.find_respawn_and_use(config, true)? {
|
||||
Some((position, yaw)) => {
|
||||
let mut player = PlayerMotion::new(self.motion, position)?;
|
||||
player.yaw = yaw;
|
||||
player.head_yaw = yaw;
|
||||
self.yaw = yaw;
|
||||
self.pitch = 0.0;
|
||||
player.pitch = 0.0;
|
||||
self.eye = 1.62;
|
||||
self.previous_eye = 1.62;
|
||||
self.clock.reset();
|
||||
self.previous_position = position;
|
||||
self.player = Some(player);
|
||||
self.sway = Default::default();
|
||||
self.apply_equipment();
|
||||
// ClientboundSoundPacket at the block's corner, seeded from the level random.
|
||||
let state = self
|
||||
.world
|
||||
.block(config.pos[0], config.pos[1], config.pos[2]);
|
||||
if self.is_anchor(state) {
|
||||
let _seed = self.world.random.next_long();
|
||||
let at = [
|
||||
f64::from(config.pos[0]),
|
||||
f64::from(config.pos[1]),
|
||||
f64::from(config.pos[2]),
|
||||
];
|
||||
self.audio
|
||||
.play("minecraft:block.respawn_anchor.deplete", Some(at), 1.0, 1.0);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
// TeleportTransition.missingRespawnBlock: the world spawn, and
|
||||
// the point is forgotten.
|
||||
self.respawn_config = None;
|
||||
self.system_message(Text::translate("block.minecraft.spawn.not_valid", &[]));
|
||||
let dead = PlayerMotion::new(self.motion, Vec3::ZERO)?;
|
||||
if self.dim != Dim::Overworld {
|
||||
// A dead stand-in travels; the arrival makes a fresh player.
|
||||
let mut dead = dead;
|
||||
dead.set_health(0.0, false)?;
|
||||
self.player = Some(dead);
|
||||
self.end_portal_travel = true;
|
||||
let result = self.enter_dimension(Dim::Overworld);
|
||||
self.end_portal_travel = false;
|
||||
self.end_travel = true;
|
||||
return result;
|
||||
}
|
||||
self.player = None;
|
||||
self.spawn_player()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `RespawnPosAngle.calculateLookAtYaw`: the yaw from the position to the
|
||||
|
||||
@@ -453,17 +453,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ServerPlayer.die`: the death count.
|
||||
pub(super) fn score_player_died(&mut self) {
|
||||
let display = self.player_display_comp();
|
||||
let me = self.acting_player_name().to_owned();
|
||||
let board = &mut self.score.board;
|
||||
board.for_all_objectives_with(sb::CRITERIA_DEATH_COUNT, &me, Some(&display), |v| {
|
||||
v.wrapping_add(1)
|
||||
});
|
||||
// A respawned player has everything to record again.
|
||||
self.score.recorded = Recorded::default();
|
||||
}
|
||||
/// `ServerPlayer.awardKillScore`: total kills and the team kill counts.
|
||||
pub(super) fn score_player_killed(&mut self, victim: Target) {
|
||||
let victim_name = self.holder_name(victim);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
Result,
|
||||
command::args::GameMode,
|
||||
confirm_screen::Confirm,
|
||||
dialog::Dialog,
|
||||
hud::{HudBatch, rgba},
|
||||
@@ -664,56 +663,10 @@ impl Game<'_> {
|
||||
/// button, or at once with `immediate_respawn`).
|
||||
pub(super) fn respawn_player(&mut self) -> Result<()> {
|
||||
self.death_focus = None;
|
||||
if self.remote.is_none() {
|
||||
// PlayerList.respawn removes the dead player, and Player.remove's
|
||||
// inventoryMenu.removed drops what the cursor held, whatever
|
||||
// keep_inventory says (the 20-tick branch handles a later respawn).
|
||||
if let Some(stack) = self.carried.take()
|
||||
&& let Some(eye) = self.eye_position()
|
||||
{
|
||||
self.throw_from_eyes(stack, eye, false);
|
||||
}
|
||||
let keep = self.ui.rules.bool("keep_inventory") || self.spectator();
|
||||
self.experience.respawn(keep);
|
||||
// The guests are sent the respawned host as a new entity, not the dead body.
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
let id = host.host_entity;
|
||||
host.hub.respawned(id);
|
||||
}
|
||||
// Autosaves keep a login position; death must resolve the respawn point instead.
|
||||
self.level.position = None;
|
||||
// The dead player's Entity.setRemoved stopped its riding: the
|
||||
// new one is not put back into the vehicle.
|
||||
self.riding = None;
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
self.ui.death_text = None;
|
||||
self.toggles.respawn();
|
||||
self.remote_respawn();
|
||||
} else if self.respawn_config.is_some() {
|
||||
// LocalPlayer.respawn: KeyMapping.resetToggleKeys.
|
||||
self.toggles.respawn();
|
||||
// PlayerList.respawn at the respawn anchor, once it is loaded.
|
||||
self.begin_anchor_respawn()?;
|
||||
self.screen = Screen::Playing;
|
||||
self.ui.death_text = None;
|
||||
self.spawn_player()?;
|
||||
self.capture(true);
|
||||
} else {
|
||||
// LocalPlayer.respawn: KeyMapping.resetToggleKeys.
|
||||
self.toggles.respawn();
|
||||
self.respawn_out_of_end()?;
|
||||
self.player = None;
|
||||
self.screen = Screen::Playing;
|
||||
self.ui.death_text = None;
|
||||
self.spawn_player()?;
|
||||
self.capture(true);
|
||||
}
|
||||
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
|
||||
// respawned player only watches.
|
||||
if self.remote.is_none() && self.level.hardcore {
|
||||
self.set_mode(GameMode::Spectator);
|
||||
}
|
||||
self.ui.death_text = None;
|
||||
// LocalPlayer.respawn: KeyMapping.resetToggleKeys.
|
||||
self.toggles.respawn();
|
||||
self.remote_respawn();
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn death_click(&mut self) -> Result<()> {
|
||||
|
||||
@@ -346,7 +346,7 @@ impl Game<'_> {
|
||||
pub(super) fn tick_server_packs(&mut self) {
|
||||
self.server_packs.notifiers.retain_mut(PackNotifier::poll);
|
||||
// A reload waits for the world's resources (`reload_packs`).
|
||||
if self.rest_loader.is_some() || self.world_loader.is_some() {
|
||||
if self.rest_loader.is_some() {
|
||||
return;
|
||||
}
|
||||
self.drive_pack_reload();
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
//!
|
||||
//! A server holds a level per dimension (game/levels.rs). A player who steps
|
||||
//! into a Nether or End portal moves to the destination's level, which opens
|
||||
//! if nobody was there: the swap is the in-frame path's portal plans and
|
||||
//! arrival search, and the player hears it as `ServerPlayer.teleport` into
|
||||
//! if nobody was there: the portal plans and arrival search pick the exit,
|
||||
//! and the player hears it as `ServerPlayer.teleport` into
|
||||
//! another level: a Respawn packet keeping all data, then a teleport to the
|
||||
//! exit once the destination has loaded around them.
|
||||
|
||||
@@ -786,7 +786,6 @@ impl Game<'_> {
|
||||
let clock_rate = self.network_clock_rate();
|
||||
TickCtx {
|
||||
world: &mut self.world,
|
||||
host: None,
|
||||
entities: &[],
|
||||
next_entity: &mut self.next_entity,
|
||||
spawn,
|
||||
|
||||
@@ -82,10 +82,6 @@ impl Game<'_> {
|
||||
let Some(body) = self.apply_totem() else {
|
||||
return;
|
||||
};
|
||||
if let Some(host) = &self.lan {
|
||||
host.hub
|
||||
.entity_event(host.host_entity, TOTEM_EVENT, body.at);
|
||||
}
|
||||
self.totem_event(body, true);
|
||||
}
|
||||
/// Entity event 35 from a server, for this client's own player or an
|
||||
|
||||
@@ -478,55 +478,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Villager.customServerAiStep`: the TRADE reputation event of the last
|
||||
/// trade, a tick later; the prices follow the new reputation.
|
||||
pub(super) fn merchant_tick(&mut self) {
|
||||
// `AbstractVillager.stillValid`: the trade screen closes once the trader
|
||||
// is gone or dead, or the player is more than 4 blocks past their reach.
|
||||
if self.remote.is_none()
|
||||
&& self.screen == super::Screen::Inventory
|
||||
&& let Some(id) = self
|
||||
.station
|
||||
.as_ref()
|
||||
.and_then(|s| s.merchant.as_ref())
|
||||
.and_then(|m| m.mob)
|
||||
&& let Some(eye) = self.eye_position()
|
||||
{
|
||||
let valid = self.mobs.iter().find(|m| m.id == id).is_some_and(|m| {
|
||||
let (lo, hi) = (m.bounds().min_position(), m.bounds().max_position());
|
||||
let d = [
|
||||
eye[0] - eye[0].clamp(lo.x, hi.x),
|
||||
eye[1] - eye[1].clamp(lo.y, hi.y),
|
||||
eye[2] - eye[2].clamp(lo.z, hi.z),
|
||||
];
|
||||
let range = self.entity_reach + 4.0;
|
||||
m.alive() && d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < range * range
|
||||
});
|
||||
if !valid {
|
||||
self.close_container();
|
||||
self.screen = super::Screen::Playing;
|
||||
self.capture(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let Some(m) = self.station.as_mut().and_then(|s| s.merchant.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
if !m.session.villager || !m.session.last_traded {
|
||||
return;
|
||||
}
|
||||
m.session.last_traded = false;
|
||||
let Some(id) = m.mob else { return };
|
||||
self.villager_traded(id);
|
||||
let reputation = self.villager_reputation(id);
|
||||
self.menu_session(|menu, env, _| {
|
||||
let s = menu.merchant.as_mut()?;
|
||||
s.reputation = reputation;
|
||||
s.update_special_prices();
|
||||
menu.merchant_update(env);
|
||||
Some(true)
|
||||
});
|
||||
}
|
||||
/// `MerchantMenu.tryMoveItems` for the selected offer (the select trade packet).
|
||||
fn try_move_items(&mut self, index: usize) {
|
||||
if self.remote.is_some() {
|
||||
|
||||
@@ -237,38 +237,6 @@ impl Game<'_> {
|
||||
let (at, top) = (p.position, p.bounds().max_position().y);
|
||||
water || self.in_rain(at, top)
|
||||
}
|
||||
/// The living entities a spinning player can touch this tick, for
|
||||
/// `LivingEntity.checkAutoSpinAttack` (none when not spinning).
|
||||
pub(super) fn spin_targets(&self) -> Vec<(u32, Aabb)> {
|
||||
let Some(p) = self
|
||||
.player
|
||||
.as_ref()
|
||||
.filter(|p| p.auto_spin_attack_ticks > 0)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
// A spin moves a few blocks a tick at most.
|
||||
let near = p.bounds().inflate(Vec3::new(16.0, 16.0, 16.0));
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive() && m.bounds().intersects(near))
|
||||
.map(|m| (m.id, m.bounds()))
|
||||
.collect()
|
||||
}
|
||||
/// `Player.doAutoAttackOnTouch`: the touched entity takes `Player.attack`
|
||||
/// with the spin's damage; once the spin is over its damage is cleared.
|
||||
pub(super) fn end_spin_attack(&mut self, hit: Option<u32>) {
|
||||
if let Some(id) = hit
|
||||
&& let Some(index) = self.mobs.iter().position(|m| m.id == id)
|
||||
{
|
||||
self.attack_mob(index);
|
||||
}
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& p.auto_spin_attack_ticks <= 0
|
||||
{
|
||||
p.auto_spin_attack_dmg = 0.0;
|
||||
}
|
||||
}
|
||||
/// The `summon_entity` and `play_sound` effects of `channeling.json`: in a
|
||||
/// thunderstorm (`weather_check`) a spot that sees the sky (`can_see_sky`)
|
||||
/// gets a lightning bolt and the thunder sound.
|
||||
|
||||
@@ -204,9 +204,8 @@ impl super::Game<'_> {
|
||||
}
|
||||
|
||||
pub(super) fn tutorial_get_item(&mut self, stack: Option<&horizon_client::items::ItemStack>) {
|
||||
// The server thread's settings are a stale copy of the window's, and an
|
||||
// in-frame host acting for a guest is not its own player: a guest's
|
||||
// tutorial is their own client's to record.
|
||||
// The server thread's settings are a stale copy of the window's: a
|
||||
// guest's tutorial is their own client's to record.
|
||||
if self.options.dedicated.is_some()
|
||||
|| self.serving.is_some()
|
||||
|| self.lan_acting.is_some()
|
||||
@@ -230,9 +229,8 @@ impl super::Game<'_> {
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
progress: f32,
|
||||
) {
|
||||
// The server thread's settings are a stale copy of the window's, and an
|
||||
// in-frame host acting for a guest is not its own player: a guest's
|
||||
// tutorial is their own client's to record.
|
||||
// The server thread's settings are a stale copy of the window's: a
|
||||
// guest's tutorial is their own client's to record.
|
||||
if self.options.dedicated.is_some()
|
||||
|| self.serving.is_some()
|
||||
|| self.lan_acting.is_some()
|
||||
|
||||
@@ -17,9 +17,6 @@ use winit::{
|
||||
/// What closing the screen does (`WinScreen.onFinished`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Finish {
|
||||
/// `PERFORM_RESPAWN` in single player: the player, out of the End since
|
||||
/// the credits began, arrives in the Overworld.
|
||||
Respawn,
|
||||
/// `PERFORM_RESPAWN` sent to the server.
|
||||
RemoteRespawn,
|
||||
/// Back to Credits & Attribution.
|
||||
@@ -50,16 +47,6 @@ impl Game<'_> {
|
||||
self.screen = Screen::Win;
|
||||
self.capture(false);
|
||||
}
|
||||
/// `ServerPlayer.showEndCredits`: the player leaves the End now (no
|
||||
/// longer in any level until the credits end) and has seen the credits.
|
||||
pub(super) fn show_end_credits(&mut self) -> Result<()> {
|
||||
self.level.seen_credits = true;
|
||||
self.open_win_screen(true, Finish::Respawn);
|
||||
self.end_portal_travel = true;
|
||||
let result = self.enter_dimension(super::Dim::Overworld);
|
||||
self.end_portal_travel = false;
|
||||
result
|
||||
}
|
||||
/// Stop the scroll `scroll` pixels in (`horizon-ctl credits ... SCROLL`).
|
||||
pub(super) fn hold_win_screen(&mut self, scroll: f32) {
|
||||
if let Some(win) = self.win.as_mut() {
|
||||
@@ -70,12 +57,6 @@ impl Game<'_> {
|
||||
pub(super) fn win_screen_pauses(&self) -> bool {
|
||||
self.win.as_ref().is_some_and(|w| !w.screen.poem)
|
||||
}
|
||||
/// The player waits out of the world while the poem rolls.
|
||||
pub(super) fn win_screen_holds_arrival(&self) -> bool {
|
||||
self.win
|
||||
.as_ref()
|
||||
.is_some_and(|w| w.finish == Finish::Respawn)
|
||||
}
|
||||
pub(super) fn draw_win_screen(&mut self, batch: &mut HudBatch) {
|
||||
let Some(win) = self.win.as_mut() else {
|
||||
return;
|
||||
@@ -138,7 +119,7 @@ impl Game<'_> {
|
||||
match win.finish {
|
||||
Finish::Options => self.screen = Screen::Options,
|
||||
Finish::Closed if self.title.is_some() => self.screen = Screen::Playing,
|
||||
Finish::Respawn | Finish::RemoteRespawn | Finish::Closed => {
|
||||
Finish::RemoteRespawn | Finish::Closed => {
|
||||
if win.finish == Finish::RemoteRespawn {
|
||||
self.remote_respawn();
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ impl WorldRequest {
|
||||
) -> Result<PreparedWorld> {
|
||||
// Before any chunk is read: a loaded container with a loot table is rolled as its chunk
|
||||
// is decoded (`save::roll_loaded_loot`), and the spawn chunks are read while the world
|
||||
// opens. Every world opens here: in-frame, on the server thread, on a dedicated server.
|
||||
// opens. Every world opens here: on the server thread, on a dedicated server.
|
||||
let loot = || {
|
||||
horizon_client::structure_blocks::loot_data(
|
||||
&options.resources,
|
||||
@@ -280,52 +280,29 @@ impl WorldRequest {
|
||||
}
|
||||
|
||||
pub(super) struct WorldLoader {
|
||||
pub cancelled: bool,
|
||||
/// What the world's upgrade has done (`UpgradeProgress`), which
|
||||
/// `FileFixerProgressScreen` shows while the file fixers run.
|
||||
pub upgrade: Arc<horizon_world::datafix::progress::UpgradeProgress>,
|
||||
handle: JoinHandle<std::result::Result<PreparedWorld, String>>,
|
||||
}
|
||||
|
||||
impl WorldLoader {
|
||||
/// Prepare the world on its own thread. `upgrade` is what its upgrade has done
|
||||
/// (`UpgradeProgress`), which the window's `FileFixerProgressScreen` shows while the
|
||||
/// file fixers run, and where its Cancel stops them.
|
||||
pub fn start(
|
||||
request: WorldRequest,
|
||||
res: Arc<Resources>,
|
||||
pack: PackResources,
|
||||
options: Options,
|
||||
settings: Settings,
|
||||
upgrade: Arc<horizon_world::datafix::progress::UpgradeProgress>,
|
||||
) -> Result<Self> {
|
||||
let upgrade = Arc::new(horizon_world::datafix::progress::UpgradeProgress::new());
|
||||
let worker_upgrade = upgrade.clone();
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("World initialization".into())
|
||||
.spawn(move || {
|
||||
request
|
||||
.prepare(&res, &pack, &options, &settings, &worker_upgrade)
|
||||
.prepare(&res, &pack, &options, &settings, &upgrade)
|
||||
.map_err(|e| e.to_string())
|
||||
})?;
|
||||
Ok(Self {
|
||||
cancelled: false,
|
||||
upgrade,
|
||||
handle,
|
||||
})
|
||||
}
|
||||
|
||||
// Wait for construction and disposal off the window thread. Keeping this
|
||||
// handle prevents a queued reopen racing the old session lock.
|
||||
pub fn cancel(self) -> Self {
|
||||
Self {
|
||||
cancelled: true,
|
||||
upgrade: self.upgrade.clone(),
|
||||
handle: std::thread::spawn(move || {
|
||||
drop(self.finish());
|
||||
Err("world initialization cancelled".into())
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ready(&self) -> bool {
|
||||
self.handle.is_finished()
|
||||
Ok(Self { handle })
|
||||
}
|
||||
|
||||
pub fn finish(self) -> Result<PreparedWorld> {
|
||||
@@ -836,51 +813,9 @@ pub(super) fn make_streaming(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn polling_does_not_wait_for_world_initialization() {
|
||||
let (release, wait) = std::sync::mpsc::channel();
|
||||
let loader = WorldLoader {
|
||||
cancelled: false,
|
||||
upgrade: Default::default(),
|
||||
handle: std::thread::spawn(move || {
|
||||
wait.recv().unwrap();
|
||||
Err("test world failure".into())
|
||||
}),
|
||||
};
|
||||
assert!(!loader.ready());
|
||||
release.send(()).unwrap();
|
||||
assert_eq!(
|
||||
loader.finish().err().unwrap().to_string(),
|
||||
"test world failure"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_keeps_initialization_tracked_until_it_has_stopped() {
|
||||
let (release, wait) = std::sync::mpsc::channel();
|
||||
let loader = WorldLoader {
|
||||
cancelled: false,
|
||||
upgrade: Default::default(),
|
||||
handle: std::thread::spawn(move || {
|
||||
wait.recv().unwrap();
|
||||
Err("cancelled test world".into())
|
||||
}),
|
||||
}
|
||||
.cancel();
|
||||
assert!(loader.cancelled);
|
||||
assert!(!loader.ready());
|
||||
release.send(()).unwrap();
|
||||
assert_eq!(
|
||||
loader.finish().err().unwrap().to_string(),
|
||||
"world initialization cancelled"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_panics_reach_the_client() {
|
||||
let loader = WorldLoader {
|
||||
cancelled: false,
|
||||
upgrade: Default::default(),
|
||||
handle: std::thread::spawn(|| panic!("broken world initialization")),
|
||||
};
|
||||
assert_eq!(
|
||||
|
||||
@@ -67,45 +67,14 @@ pub struct HubConfig {
|
||||
pub allow_flight: bool,
|
||||
pub seed: i64,
|
||||
pub max_players: usize,
|
||||
/// A dedicated server: operators from `ops`, the dedicated-only commands.
|
||||
pub dedicated: bool,
|
||||
/// An integrated server (`dedicated` too, since it has no host player):
|
||||
/// the owner's level from `ops`, the others' from Allow Commands and
|
||||
/// the guests' command access, and the integrated-only commands.
|
||||
/// An integrated server: the owner's level from `ops`, the others' from Allow Commands
|
||||
/// and the guests' command access, and the integrated-only commands. Otherwise a
|
||||
/// dedicated server: operators from `ops`, the dedicated-only commands.
|
||||
pub integrated: bool,
|
||||
/// The shapes of the world's levels (their dimension types).
|
||||
pub levels: crate::chunk::LevelShapes,
|
||||
}
|
||||
|
||||
/// The host player as the other players see them.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HostState {
|
||||
pub uuid: u128,
|
||||
pub name: String,
|
||||
pub entity_id: i32,
|
||||
pub position: [f64; 3],
|
||||
pub yaw: f32,
|
||||
pub pitch: f32,
|
||||
pub head_yaw: f32,
|
||||
pub on_ground: bool,
|
||||
pub game_mode: i32,
|
||||
pub sneaking: bool,
|
||||
pub sprinting: bool,
|
||||
pub equipment: [Option<ItemStack>; 6],
|
||||
pub health: f32,
|
||||
/// The host player's client-syncable attribute instances.
|
||||
pub attributes: Vec<play::AttributeSnapshot>,
|
||||
/// `LivingEntity.DATA_LIVING_ENTITY_FLAGS` (the item in use).
|
||||
pub living_flags: i8,
|
||||
/// `LivingEntity.DATA_ARROW_COUNT_ID` and `DATA_STINGER_COUNT_ID`.
|
||||
pub stuck: (i32, i32),
|
||||
/// `Player.DATA_SHOULDER_PARROT_LEFT` and `_RIGHT`: the parrots' variants.
|
||||
pub shoulders: [Option<i32>; 2],
|
||||
/// The bed the host sleeps in (`LivingEntity.SLEEPING_POS`).
|
||||
pub bed: Option<[i32; 3]>,
|
||||
pub body: BodyFlags,
|
||||
}
|
||||
|
||||
/// What the other players see of a player's body besides sneaking and
|
||||
/// sprinting: the pose and the rest of `Entity.DATA_SHARED_FLAGS_ID`.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
@@ -131,8 +100,6 @@ pub struct BodyFlags {
|
||||
/// Everything the hub needs from the game for one tick.
|
||||
pub struct TickCtx<'a> {
|
||||
pub world: &'a mut World,
|
||||
/// The player playing on the host (none on a dedicated server).
|
||||
pub host: Option<&'a HostState>,
|
||||
pub entities: &'a [EntityState],
|
||||
pub next_entity: &'a mut u32,
|
||||
/// Where new players appear.
|
||||
@@ -760,15 +727,12 @@ impl Hub {
|
||||
/// ListPlayersCommand's reply: the players in join order (the host of a
|
||||
/// LAN world first), as display names or names with ids, joined by
|
||||
/// gray commas (ComponentUtils.formatList).
|
||||
pub fn list_reply(&self, host: Option<(u128, &str)>, uuids: bool) -> Tag {
|
||||
let players: Vec<(u128, &str)> = host
|
||||
.into_iter()
|
||||
.chain(
|
||||
self.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed)
|
||||
.map(|g| (g.uuid, g.name.as_str())),
|
||||
)
|
||||
pub fn list_reply(&self, uuids: bool) -> Tag {
|
||||
let players: Vec<(u128, &str)> = self
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| g.placed)
|
||||
.map(|g| (g.uuid, g.name.as_str()))
|
||||
.collect();
|
||||
let format = |(uuid, name): (u128, &str)| {
|
||||
if uuids {
|
||||
@@ -921,12 +885,8 @@ impl Hub {
|
||||
// IntegratedServer.getProfilePermissions / getCustomPermissionLevel.
|
||||
let guests = if self.config.guest_commands { 2 } else { 0 };
|
||||
self.ops.get(&uuid).copied().unwrap_or(guests)
|
||||
} else if self.config.dedicated {
|
||||
self.ops.get(&uuid).copied().unwrap_or(0)
|
||||
} else if self.config.guest_commands {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
self.ops.get(&uuid).copied().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1001,9 +961,6 @@ impl Hub {
|
||||
}));
|
||||
// The player list: everyone already here, then the newcomer to all.
|
||||
let mut entries = Vec::new();
|
||||
if let Some(host) = ctx.host {
|
||||
entries.push(self.player_info(host.uuid, &host.name, host.game_mode, 0));
|
||||
}
|
||||
for o in self.guests.iter().filter(|o| o.conn != conn && o.placed) {
|
||||
entries.push(self.player_info(
|
||||
o.uuid,
|
||||
@@ -1198,7 +1155,7 @@ impl Hub {
|
||||
let g = &self.guests[i];
|
||||
let packet = match &self.sync.commands {
|
||||
Some(tree) => {
|
||||
let dedicated = self.config.dedicated && !self.config.integrated;
|
||||
let dedicated = !self.config.integrated;
|
||||
tree.packet_with(
|
||||
g.permission,
|
||||
dedicated,
|
||||
@@ -2297,36 +2254,9 @@ impl Hub {
|
||||
out
|
||||
}
|
||||
|
||||
/// All entities as one client sees them: the world's, the host, the other guests.
|
||||
/// All entities as one client sees them: the world's and the other players.
|
||||
fn track_entities(&mut self, ctx: &mut TickCtx) {
|
||||
let mut all: Vec<EntityState> =
|
||||
Vec::with_capacity(ctx.entities.len() + self.guests.len() + 1);
|
||||
if let Some(h) = ctx.host {
|
||||
let equipment = {
|
||||
let e = &h.equipment;
|
||||
self.equipment_of(&e[0], &e[1], [&e[5], &e[4], &e[3], &e[2]])
|
||||
};
|
||||
let mut state = self.player_state(
|
||||
h.entity_id,
|
||||
h.uuid,
|
||||
h.position,
|
||||
(h.yaw, h.pitch, h.head_yaw),
|
||||
h.on_ground,
|
||||
(h.sneaking, h.sprinting, h.bed.is_some()),
|
||||
h.body,
|
||||
h.living_flags,
|
||||
h.bed,
|
||||
0x7F,
|
||||
h.health,
|
||||
equipment,
|
||||
h.attributes.clone(),
|
||||
&[],
|
||||
);
|
||||
state.spectator = h.game_mode == 3;
|
||||
stuck_meta(&mut state, h.stuck);
|
||||
shoulder_meta(&mut state, h.shoulders);
|
||||
all.push(state);
|
||||
}
|
||||
let mut all: Vec<EntityState> = Vec::with_capacity(ctx.entities.len() + self.guests.len());
|
||||
let placed: Vec<usize> = (0..self.guests.len())
|
||||
.filter(|&i| self.here(&self.guests[i]))
|
||||
.collect();
|
||||
@@ -3446,18 +3376,6 @@ impl Hub {
|
||||
g.send(&g.abilities());
|
||||
}
|
||||
|
||||
/// The host's game mode changed (PlayerInfo UPDATE_GAME_MODE for everyone).
|
||||
pub fn set_host_game_mode(&self, uuid: u128, mode: i32) {
|
||||
self.broadcast(&C::PlayerInfoUpdate(play::PlayerInfoUpdate {
|
||||
actions: 4,
|
||||
entries: vec![play::PlayerInfo {
|
||||
uuid,
|
||||
game_mode: Some(mode),
|
||||
..Default::default()
|
||||
}],
|
||||
}));
|
||||
}
|
||||
|
||||
pub fn disconnect_all(&mut self) {
|
||||
let reason = translatable("multiplayer.disconnect.server_shutdown", &[]);
|
||||
for g in &self.guests {
|
||||
|
||||
@@ -37,7 +37,7 @@ use horizon_protocol::{
|
||||
text::translatable,
|
||||
};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
collections::HashMap,
|
||||
io,
|
||||
net::{IpAddr, SocketAddr, TcpListener, TcpStream},
|
||||
sync::{
|
||||
@@ -337,8 +337,6 @@ pub struct Shared {
|
||||
max_players: AtomicUsize,
|
||||
/// PlayerList.players in join order: profile id and name.
|
||||
online: Mutex<Vec<(u128, String)>>,
|
||||
/// Profiles held by the host (the LAN host's own name).
|
||||
reserved: Mutex<HashSet<u128>>,
|
||||
next: AtomicU32,
|
||||
stop: AtomicBool,
|
||||
random: Mutex<u64>,
|
||||
@@ -380,13 +378,10 @@ impl Shared {
|
||||
{
|
||||
return Some(reason);
|
||||
}
|
||||
if self.reserved.lock().unwrap().contains(&uuid) {
|
||||
return Some(translatable("multiplayer.disconnect.duplicate_login", &[]));
|
||||
}
|
||||
{
|
||||
let online = self.online.lock().unwrap();
|
||||
let bypass = self.config.bypasses_limit.as_ref().is_some_and(|b| b(uuid));
|
||||
let taken = online.len() + self.reserved.lock().unwrap().len();
|
||||
let taken = online.len();
|
||||
if taken >= self.max_players() && !bypass {
|
||||
return Some(translatable("multiplayer.disconnect.server_full", &[]));
|
||||
}
|
||||
@@ -420,7 +415,7 @@ impl Shared {
|
||||
self.online.lock().unwrap().retain(|(u, _)| *u != uuid);
|
||||
}
|
||||
pub fn online(&self) -> usize {
|
||||
self.online.lock().unwrap().len() + self.reserved.lock().unwrap().len()
|
||||
self.online.lock().unwrap().len()
|
||||
}
|
||||
fn next_random(&self, bound: usize) -> usize {
|
||||
let mut r = self.random.lock().unwrap();
|
||||
@@ -552,7 +547,6 @@ impl Lan {
|
||||
motd: Mutex::new(config.motd.clone()),
|
||||
max_players: AtomicUsize::new(config.max_players),
|
||||
online: Mutex::new(Vec::new()),
|
||||
reserved: Mutex::new(HashSet::new()),
|
||||
next: AtomicU32::new(1),
|
||||
stop: AtomicBool::new(false),
|
||||
connections: AtomicUsize::new(0),
|
||||
@@ -656,10 +650,6 @@ impl Lan {
|
||||
pub fn poll(&self) -> Vec<NetEvent> {
|
||||
self.events.try_iter().collect()
|
||||
}
|
||||
/// Reserve a uuid for the host so a guest cannot take the name.
|
||||
pub fn reserve(&self, uuid: u128) {
|
||||
self.shared.reserved.lock().unwrap().insert(uuid);
|
||||
}
|
||||
pub fn stop(&mut self) {
|
||||
self.shared.stop.store(true, Ordering::Relaxed);
|
||||
self.stop_listening();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! What a client's copy of a mob makes on its own, in the
|
||||
//! `level().isClientSide()` branches of `aiStep`: nothing of it reaches the
|
||||
//! server or goes over the network, so the window's game runs it, whether it
|
||||
//! simulates the world (in-frame) or follows a server.
|
||||
//! server or goes over the network, so the window's game runs it as it follows
|
||||
//! a server.
|
||||
use super::{Mob, MobEvent, Species, bee};
|
||||
use horizon_core::geometry::trig::Mth;
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
@@ -10,7 +10,7 @@ use horizon_client::{
|
||||
chunks::ChunkEncoder,
|
||||
data::SyncData,
|
||||
entity::EntityState,
|
||||
hub::{HostEvent, HostState, Hub, HubConfig, TickCtx},
|
||||
hub::{HostEvent, Hub, HubConfig, TickCtx},
|
||||
},
|
||||
world::World,
|
||||
};
|
||||
@@ -172,30 +172,8 @@ struct Server {
|
||||
}
|
||||
impl Server {
|
||||
fn tick(&mut self, entities: &[EntityState]) {
|
||||
let host = HostState {
|
||||
uuid: 42,
|
||||
name: "Host".into(),
|
||||
entity_id: 1,
|
||||
position: [0.5, -59.0, 0.5],
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
head_yaw: 0.0,
|
||||
on_ground: true,
|
||||
game_mode: 1,
|
||||
sneaking: false,
|
||||
sprinting: false,
|
||||
equipment: Default::default(),
|
||||
health: 20.0,
|
||||
shoulders: [None; 2],
|
||||
attributes: Vec::new(),
|
||||
bed: None,
|
||||
living_flags: 0,
|
||||
stuck: (0, 0),
|
||||
body: Default::default(),
|
||||
};
|
||||
let mut ctx = TickCtx {
|
||||
world: &mut self.world,
|
||||
host: Some(&host),
|
||||
entities,
|
||||
next_entity: &mut self.next_entity,
|
||||
spawn: [8.5, f64::from(MIN_Y + 5), 8.5],
|
||||
@@ -269,8 +247,7 @@ fn server_sync(sync: Arc<SyncData>, net: NetConfig) -> Server {
|
||||
hardcore: false,
|
||||
view_distance: 3,
|
||||
simulation_distance: 3,
|
||||
dedicated: false,
|
||||
integrated: false,
|
||||
integrated: true,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
seed: 1234,
|
||||
max_players: 8,
|
||||
@@ -951,7 +928,7 @@ fn status_and_join() {
|
||||
// forceGameTimeSynchronization sends no clocks and may come first.
|
||||
C::SetTime(t) if !t.clocks.is_empty() => time = true,
|
||||
C::PlayerInfoUpdate(u) => {
|
||||
info |= u.entries.iter().any(|e| e.name.as_deref() == Some("Host"));
|
||||
info |= u.entries.iter().any(|e| e.name.as_deref() == Some("Steve"));
|
||||
}
|
||||
C::Commands(_) => commands = true,
|
||||
C::LevelChunkWithLight(l) => {
|
||||
@@ -1586,7 +1563,6 @@ fn a_respawned_guest_is_immune_until_its_client_loads_the_world() {
|
||||
let at = [8.5, f64::from(MIN_Y + 5), 8.5];
|
||||
let ctx = TickCtx {
|
||||
world: &mut s.world,
|
||||
host: None,
|
||||
entities: &[],
|
||||
next_entity: &mut s.next_entity,
|
||||
spawn: at,
|
||||
|
||||
@@ -34,7 +34,7 @@ from vanilla 26.3's own methods, and the hooks stay Horizon's own Rust.
|
||||
([Events](https://docs.neoforged.net/docs/concepts/events/), "Sided Events"); the rest fire on whichever
|
||||
side runs the method. Horizon has one binary with several
|
||||
`Game`s (the window, the server thread, a dedicated server), so each hook says its side:
|
||||
- `server`: the server's `Game` (single player's server thread, LAN host, dedicated) and the in-frame path.
|
||||
- `server`: the server's `Game` (single player's server thread, LAN host, dedicated).
|
||||
- `client`: the window's `Game`.
|
||||
- `both`: both, with the `Game`'s role available (`native_window`). Interactions are `both`: the client
|
||||
predicts, the server acts, and a mod that claims a use on one side only gets the two out of step.
|
||||
|
||||
+11
-12
@@ -175,7 +175,7 @@ schedules ticks with its `ticks` through the level's `native_schedule_block_tick
|
||||
`native_schedule_fluid_tick(ticks, pos, fluid, delay)` (vanilla's `ScheduledTickAccess.scheduleTick`; false for an
|
||||
unknown name or a position that isn't loaded). It runs often, at least six times per `setBlock` that updates shapes
|
||||
(more for redstone wire's diagonals and knock-on updates): keep it short. A LAN guest shows vanilla's shape for a
|
||||
moment, until the server's update arrives. The deprecated in-frame path and chunk generation fire none. Bundles that
|
||||
moment, until the server's update arrives. Chunk generation fires none. Bundles that
|
||||
use it need `"hooks": 32`.
|
||||
|
||||
API 33 adds random ticks a mod can handle through `native_random_tick`: on an authoritative server, each block
|
||||
@@ -192,7 +192,7 @@ sends every write to players whatever the flags: `UPDATE_CLIENTS` (2), `UPDATE_I
|
||||
(8) change nothing yet. It writes at any loaded position, returns false off an authoritative server (the `Game` one too: a window that isn't a server), for a property or
|
||||
value the block doesn't have, a position that isn't loaded, or a property that already has the value (nothing is written:
|
||||
vanilla's `Level.setBlock` returns false for an unchanged state, as `native_set_block` does), and true when the block changed. Elsewhere, `Game::native_set_block_property_with(pos, key, value, flags)` does the same, as a brewing
|
||||
stand's tick sets its bottles with `flags::UPDATE_CLIENTS`. The deprecated in-frame path fires none. Bundles that use
|
||||
stand's tick sets its bottles with `flags::UPDATE_CLIENTS`. Bundles that use
|
||||
them need `"hooks": 33`.
|
||||
|
||||
API 34 adds scheduled block ticks a mod can handle through `native_tick_block`: on an authoritative server, each due
|
||||
@@ -200,7 +200,7 @@ scheduled tick of a block that is still there (`ServerLevel.tickBlock` → `Bloc
|
||||
position and state, before the block handles it. The hook may change the block with the level's
|
||||
`native_set_block_property` and the flags the block's own tick would write with (as in API 33: an observer's tick
|
||||
uses `flags::UPDATE_CLIENTS`) and set `handled` to skip the block's own tick. Without `handled`, the block then takes
|
||||
its own tick as it is now, if it is still the tick's block. The deprecated in-frame path fires none. Bundles that use
|
||||
its own tick as it is now, if it is still the tick's block. Bundles that use
|
||||
it need `"hooks": 34`.
|
||||
|
||||
API 35 adds the commands a mod owns on the client: `native_commands` is asked for their names, so the chat box
|
||||
@@ -505,9 +505,8 @@ refused it. A canceled hit on the ender dragon still counts as landed, as vanill
|
||||
whatever `reallyHurt` did: the arrow is spent and the melee effects run. A ghast's canceled hit by a fireball it was
|
||||
sent back counts as landed (`Ghast.hurtServer`). A negative amount hurts by 0 and a NaN or infinite one by
|
||||
`Float.MAX_VALUE`, as vanilla clamps them. It fires for the players, mobs and the ender dragon the game hurts. It has
|
||||
no `self`: the game is in the middle of the hit. It runs on the thread that ticks the world (the server thread; the
|
||||
window's on the deprecated in-frame path), so state a mod shares with its render thread hooks needs synchronising. On
|
||||
the in-frame path the window's player has id 0, or the host's id once the world is open to LAN. `/kill` and
|
||||
no `self`: the game is in the middle of the hit. It runs on the thread that ticks the world (the server thread), so
|
||||
state a mod shares with its render thread hooks needs synchronising. `/kill` and
|
||||
`native_kill_mob` don't reach it yet: they set the health to 0 instead of hurting with `minecraft:generic_kill` as
|
||||
vanilla does.
|
||||
|
||||
@@ -633,7 +632,7 @@ drops when a neighbour changes), and wherever else vanilla asks. `state` may not
|
||||
mod plant (a block whose template is a vanilla plant, such as a crop): the blocks it can stand on, which its
|
||||
template's `canSurvive` asks, so it is refused elsewhere and pops off when its ground changes. Answer only for your
|
||||
own blocks and leave the answer empty otherwise; the level is read-only. Both run on every shape update of such a
|
||||
block: keep them short. The deprecated in-frame path fires neither. Bundles that use them need
|
||||
block: keep them short. Bundles that use them need
|
||||
`"hooks": 70`.
|
||||
|
||||
API 71 adds drawing mod block entities on the client. `native_block_entity_submit(pos, block, state, entity, draw)` runs once a frame
|
||||
@@ -720,7 +719,7 @@ API 88 adds `native_client_resources_reloaded()` fires on the render thread afte
|
||||
|
||||
API 89 adds `native_item_decorations(stack, x, y, batch)` appends custom GUI drawing after the default bar, cooldown shade and count have been painted for an item icon. The stack is the displayed stack, x/y are its icon's top-left in GUI units, the units the batch takes (no item-local translation remains active). Existing GUI helpers can draw badges, text and overlays. The shared path covers the hotbar, carried stacks, menu slots, recipe book and item tooltip icons, and the item icons of the advancement, beacon, stats and trading screens. `native_item_bar` still controls the bar separately; this hook does not suppress vanilla decorations. Bundles need "hooks": 89.
|
||||
|
||||
API 90 adds using an item over time, as vanilla's `Item.getUseDuration`, `getUseAnimation` and `finishUsingItem` and NeoForge's `LivingEntityUseItemEvent.Start` and `.Finish`. `native_use_duration` answers a stack's use time: a mod's own item with no use of vanilla's that answers above 0 is used over time on a right click, until it is let go or the time runs out, on single player, LAN and in-frame alike. `native_use_animation` picks the first person pose of a use (eat, drink, bow, trident, brush), and `native_can_eat` decides whether a food can be eaten now. `native_living_entity_use_item_start` may change a use's duration or refuse it, on the server and where the window's client predicts the use, and on the server `native_living_entity_use_item_finish` may replace what the hand holds once the time ran out (consumables, potions and mods' items). Both take the using entity, None for a player, so mobs can fire them later, and the hand in use. Change the world only where this game runs the world, not on a server's client. Bundles that use them need `"hooks": 90`.
|
||||
API 90 adds using an item over time, as vanilla's `Item.getUseDuration`, `getUseAnimation` and `finishUsingItem` and NeoForge's `LivingEntityUseItemEvent.Start` and `.Finish`. `native_use_duration` answers a stack's use time: a mod's own item with no use of vanilla's that answers above 0 is used over time on a right click, until it is let go or the time runs out, on single player and LAN alike. `native_use_animation` picks the first person pose of a use (eat, drink, bow, trident, brush), and `native_can_eat` decides whether a food can be eaten now. `native_living_entity_use_item_start` may change a use's duration or refuse it, on the server and where the window's client predicts the use, and on the server `native_living_entity_use_item_finish` may replace what the hand holds once the time ran out (consumables, potions and mods' items). Both take the using entity, None for a player, so mobs can fire them later, and the hand in use. Change the world only where this game runs the world, not on a server's client. Bundles that use them need `"hooks": 90`.
|
||||
|
||||
API 91 adds items and block state properties for drawing mod block entities: in `native_block_entity_submit`, `native_draw_item(draw,
|
||||
stack, display, owner_yaw, seed)` draws an item stack under `draw`'s pose as a campfire's or a shelf's
|
||||
@@ -830,7 +829,7 @@ goat's ram. It does not fire for a sulfur cube with a block that a hit with a ca
|
||||
ender dragon's wings, Punch arrows, explosions, wind charges, a mace smash's area, a hoglin's throw), nor yet for the ones
|
||||
Horizon makes another way: a mob's melee push from its attack knockback or a Knockback weapon, an armor stand's and the
|
||||
ender dragon's. It has no `self`: the game is in the middle of the hit. It runs on the thread that ticks the world (the
|
||||
server thread; the window's on the deprecated in-frame path), so state a mod shares with its render thread hooks needs
|
||||
server thread), so state a mod shares with its render thread hooks needs
|
||||
synchronising.
|
||||
|
||||
Bundles that use it need `"hooks": 125`.
|
||||
@@ -859,7 +858,7 @@ API 136 adds a projectile's impact, which a mod can cancel, through `native_proj
|
||||
|
||||
API 137 adds an entity getting on or off a vehicle, which a mod can cancel, through `native_entity_mount` (NeoForge's `EntityMountEvent`): the rider's id and type (`minecraft:player`, `minecraft:zombie`), the vehicle's id and type (`minecraft:oak_boat`, `minecraft:horse`), whether it is mounting, and a `canceled` flag. Set `canceled` and the rider does not board, or keeps riding; a `/ride` that is canceled fails. It is told for a player boarding a boat, minecart, cushion or mount (pig, strider, horse and its kin, llama, nautilus, happy ghast) and getting off by their own input, `/ride`, a stab, a buck, a teleport or spectator mode, and for the mobs Horizon seats on a vehicle (jockeys, raiders on a ravager, `/ride`) and lets off one with `/ride` or a stab. It is not told for a rider whose vehicle is gone or dies, nor for riders loaded with a save. On the single player server thread and on a dedicated server. Bundles that use it need "hooks": 137.
|
||||
|
||||
API 138 adds `native_interaction_key(button, key_mapping, hand, swing_hand, canceled)` fires while playing when an interaction key is about to act on the crosshair: button 0 the attack key (`key.attack`; when an attack is allowed, and each tick a held key keeps digging a block), 1 the use key (`key.use`; once for each hand tried, main first, before the hand's target and item), 2 pick block (`key.pickItem`; only with a block or entity aimed at). Set `canceled` to skip the action; a cancelled use ends the click, so the other hand gets no turn. A cancelled attack or use still swings `hand` unless the mod clears `swing_hand` (true on entry). A single player's window and a server's player (any window that plays on a server); the deprecated in-frame path is not covered. Bundles need "hooks": 138.
|
||||
API 138 adds `native_interaction_key(button, key_mapping, hand, swing_hand, canceled)` fires while playing when an interaction key is about to act on the crosshair: button 0 the attack key (`key.attack`; when an attack is allowed, and each tick a held key keeps digging a block), 1 the use key (`key.use`; once for each hand tried, main first, before the hand's target and item), 2 pick block (`key.pickItem`; only with a block or entity aimed at). Set `canceled` to skip the action; a cancelled use ends the click, so the other hand gets no turn. A cancelled attack or use still swings `hand` unless the mod clears `swing_hand` (true on entry). A single player's window and a server's player (any window that plays on a server). Bundles need "hooks": 138.
|
||||
|
||||
API 139 adds `native_gui_layer_pre(layer, batch, canceled)` and `native_gui_layer_post(layer, batch)` fire around each vanilla HUD layer Horizon draws, in its paint order, so a mod places its own drawing below a layer (in `pre`) or above it (in `post`), NeoForge's `RegisterGuiLayersEvent` registerBelow/registerAbove, and hides a layer by setting `canceled` in `pre` (`RenderGuiLayerEvent.Pre`). Layer ids: `minecraft:crosshair`, `hotbar`, `effects`, `boss_overlay`, `vehicle_health` (riding a mount), `contextual_info_bar` (the jump bar, locator bar or experience bar with the level number), `player_health` (health, armor, food and air rows, one pass), `selected_item_name`, `sleep_overlay`, `overlay_message` (action bar), `title`, `scoreboard_sidebar`, `subtitle_overlay`, `chat` (the unfocused lines) and `tab_list`. F1 hides the layers it hides in vanilla, and then nothing fires for them. The camera overlays (drawn by the screen-effects pass) and the debug screen are not layers. A hidden layer still runs its game logic (the health rows' blink, the bubble pop sound) and `post` still fires. Bundles need "hooks": 139.
|
||||
|
||||
@@ -1673,8 +1672,8 @@ NeoForge mod's own `Slot` overrides them. `may_place` and `max_stack` come in wi
|
||||
of its `results` ([mods.md](mods.md#menu-types)), true elsewhere, and 99): change them for your own menu types.
|
||||
`max_stack` is kept to 1 to 99, and the item's own stack size still caps it. It runs inside a click, a drag or a
|
||||
shift-click (several times for one), with no level or player at hand: answer from the stack, and fast. It is asked
|
||||
only for a server's players (single player's server thread, LAN guests, a dedicated server), never in-frame
|
||||
(`--in-frame`), and vanilla's menus never ask it. As in vanilla (`AbstractContainerMenu.moveItemStackTo`), a
|
||||
only for a server's players (single player's server thread, LAN guests, a dedicated server), and vanilla's menus
|
||||
never ask it. As in vanilla (`AbstractContainerMenu.moveItemStackTo`), a
|
||||
shift-click tops up a slot (not a `results` one) that already holds the same item without asking `may_place`, only `max_stack`: to keep a
|
||||
slot from filling that way, lower `max_stack`.
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ owner's body and dimension. The script owns the clock: each `tick` calls `tick_s
|
||||
while login and portal arrival pump connection-only passes. The client acknowledges
|
||||
terrain and teleports without predicting movement, matching the command oracle's stationary
|
||||
player. Script and save errors return through server shutdown; output keeps the existing
|
||||
styled messages and `CommandResultCallback` schema. `--in-frame` is not needed.
|
||||
styled messages and `CommandResultCallback` schema.
|
||||
|
||||
`HORIZON_DISPENSE_FIXTURE` and `HORIZON_SCULK_FIXTURE` are component probes. They construct
|
||||
a server `Game` on a server thread and retain the oracles' synthetic levels, seeded random
|
||||
@@ -114,7 +114,7 @@ coverage alongside the components:
|
||||
python3 tools/test_command_exec_parity.py
|
||||
python3 tools/test_dispenser_parity.py
|
||||
python3 tools/test_sculk_parity.py
|
||||
python3 tools/lan_e2e.py --server-thread --check machines --check fireball --check sculk_container
|
||||
python3 tools/lan_e2e.py --check machines --check fireball --check sculk_container
|
||||
```
|
||||
|
||||
The LAN checks open and fill machines through the guest's inventory packets, observe a
|
||||
@@ -131,8 +131,7 @@ Besides the Vineflower sources in `.local/minecraft/<version>/sources/`, `tools/
|
||||
the same path as normal single player. `python3 tools/test_friends.py --client
|
||||
target/fast/horizon-client --relay target/fast/horizon-relay --baseline` covers
|
||||
plain LAN, direct invitations, a local relay and ended invitations in that mode.
|
||||
`lan_e2e.py` still accepts `--in-frame`, which is deprecated (never use it, it will be removed
|
||||
soon), and `--server-thread` for existing callers. Run Linux games under `xvfb-run -a` when no display is available. Each
|
||||
Run Linux games under `xvfb-run -a` when no display is available. Each
|
||||
process gets scratch game, launcher, XDG data and config homes; telemetry, audio
|
||||
and launcher self-update are disabled.
|
||||
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@ type where it is known (`self`, a parameter, a `let`, a struct field, a trait to
|
||||
|
||||
| Kind | Means | Example it found |
|
||||
|---|---|---|
|
||||
| `seam` | A vanilla method ported on two paths (the in-frame game, the server-client side in `game/remote*.rs`, the server in `game/lan*.rs` and `src/lan/`), where one path reaches the port of a callee and the other doesn't. Single player runs on the server thread, so this is where most player-visible bugs sit; a gap on the old in-frame path sorts last. | `remote_use` ported `Minecraft.startUseItem` without `LocalPlayer.itemUsed`: the held block never dipped after a placement on a server |
|
||||
| `seam` | A vanilla method ported on more than one path (the shared game code the server runs for its players, the server-client side in `game/remote*.rs`, the server's network side in `game/lan*.rs` and `src/lan/`), where one path reaches the port of a callee and another doesn't. Single player runs on the server thread, so this is where most player-visible bugs sit. | `remote_use` ported `Minecraft.startUseItem` without `LocalPlayer.itemUsed`: the held block never dipped after a placement on a server |
|
||||
| `client` | A vanilla method with an effect only the client's copy of the world shows (`addParticle`, `playLocalSound`, `itemUsed`, an `isClientSide()` branch), whose port nothing in `game/remote*.rs` reaches. | `AbstractHorse.handleEating`'s happy-villager particle, drawn only on the server's level |
|
||||
| `drift` | A port that never names a sound, particle, item, block, effect or game event its vanilla method uses, or lacks one of its rare numbers. Names count however Rust spells them (`Sound::AnvilLand`, `"block.anvil.land"`). | `throw_ender_eye`, the port of `EnderEyeItem.use`, names neither the `used_ender_eye` trigger nor the `projectile_shoot` game event (a lead, not yet checked) |
|
||||
| `class` | A whole class nothing ports while most of its package is ported. Renderers, sound instances and goals are reached by registration, so no call graph shows them. | `ShulkerBulletRenderer`, `ElytraOnPlayerSoundInstance` |
|
||||
|
||||
@@ -26,23 +26,18 @@ the other dimensions' levels as `Parked` values and swaps one into place (`enter
|
||||
The packets of a guest are read in its own level's pass (`Hub::tick_level`), so its actions and
|
||||
commands run with its level in place.
|
||||
|
||||
The window's own game (`--in-frame`, `--no-server-thread`, `HORIZON_SERVER_THREAD=0`, and the command
|
||||
harness) keeps one level, which follows its player (`enter_dimension`). LAN guests of a world run that way
|
||||
stay in the host's dimension. **That mode is deprecated: never use it, it will be removed soon.** It gets no
|
||||
multi-level work and no compatibility.
|
||||
|
||||
## One code path for both
|
||||
|
||||
The dedicated server and the server thread are the same code. Each `Game` has an explicit
|
||||
`ExecutionRole`: `Client`, `IntegratedServer` or `DedicatedServer`, selected before construction in
|
||||
`Options.execution_role`. Startup, world loading and opening other dimensions use that role. Only
|
||||
clients mesh chunks and show loading progress; a client can still run its simulation in-frame, which is deprecated and will be removed.
|
||||
clients mesh chunks and show loading progress; a client never runs a simulation of its own.
|
||||
Connecting or disconnecting changes `remote`, independently of the execution role.
|
||||
|
||||
The server thread has a hub without a host player. The window joins it through memory as one
|
||||
more player. It still sets `options.dedicated` for gameplay checks outside the role migration's
|
||||
startup/world-loading scope. Everything above serves both servers, and `server_dimension_players`
|
||||
(vanilla clients on Horizon's dedicated server) and `lan_e2e --server-thread` (Horizon clients on the
|
||||
(vanilla clients on Horizon's dedicated server) and `lan_e2e` (Horizon clients on the
|
||||
server thread) check it from the two sides.
|
||||
|
||||
## Cost of an extra level
|
||||
@@ -73,7 +68,7 @@ the Nether, or whose host saves and quits, joins again in the Nether where it wa
|
||||
|
||||
## What's checked
|
||||
|
||||
`python3 tools/lan_e2e.py --server-thread --check portals --check levels`:
|
||||
`python3 tools/lan_e2e.py --check portals --check levels`:
|
||||
|
||||
- `portals`: the guest goes to the Nether through a portal while the host stays. The host then goes
|
||||
to the End while the guest is in the Nether, so three levels run at once, and both come back.
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
"status": "now",
|
||||
"area": "gameplay",
|
||||
"plain": "Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.",
|
||||
"dev": "Since #1514 single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic (#3873), the client's menu when the server replaces the player (#3841), splashes when falling in water (#3621). docs/server-levels.md.",
|
||||
"dev": "Since #1514 single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client. The open `[server-thread]` issues list the gaps. In review: breeding's advancement and statistic (#3873), the client's menu when the server replaces the player (#3841), splashes when falling in water (#3621). docs/server-levels.md.",
|
||||
"links": [
|
||||
"#1514",
|
||||
"#3873",
|
||||
@@ -478,9 +478,10 @@
|
||||
"title": "One single-player path: remove the in-frame world",
|
||||
"status": "next",
|
||||
"area": "gameplay",
|
||||
"plain": "Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode goes once everything passes without it.",
|
||||
"dev": "`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) still ticks the world inside the window's `Game`, and most harnesses test that path. Delete it once `lan_e2e --server-thread` and the parity harnesses pass on the server thread, which also halves the bug surface. See also 'Single player as a server' and docs/server-levels.md.",
|
||||
"plain": "Single player has one way to run, the server thread, so every fix works everywhere and there are fewer bugs. The older in-frame mode is removed.",
|
||||
"dev": "`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) ticked the world inside the window's `Game`. #6473 removes it with the code only it reached (the window's world loader and player tick, its portal travel and loading screen, the in-frame LAN host); the harnesses run on the server thread. See also 'Single player as a server' and docs/server-levels.md.",
|
||||
"links": [
|
||||
"#6473",
|
||||
"docs/server-levels.md"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -119,7 +119,7 @@ issue = "#3914"
|
||||
plain = """Single player now runs a small server in the background, as vanilla does. We are fixing what still \
|
||||
behaves differently there; every fix also works for LAN and server players."""
|
||||
dev = """Since #1514 single player runs on the server thread (`game/integrated.rs`) and the window joins it as a \
|
||||
client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: breeding's \
|
||||
client. The open `[server-thread]` issues list the gaps. In review: breeding's \
|
||||
advancement and statistic (#3873), the client's menu when the server replaces the player (#3841), splashes when \
|
||||
falling in water (#3621). docs/server-levels.md."""
|
||||
links = ["#1514", "#3873", "#3841", "#3621", "docs/server-levels.md"]
|
||||
@@ -414,13 +414,13 @@ id = "one-single-player-path"
|
||||
title = "One single-player path: remove the in-frame world"
|
||||
status = "next"
|
||||
area = "gameplay"
|
||||
plain = """Single player will have one way to run, the server thread, so every fix works everywhere and there are fewer \
|
||||
bugs. The older in-frame mode goes once everything passes without it."""
|
||||
dev = """`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) still ticks the world inside the \
|
||||
window's `Game`, and most harnesses test that path. Delete it once `lan_e2e --server-thread` and the parity \
|
||||
harnesses pass on the server thread, which also halves the bug surface. See also 'Single player as a server' \
|
||||
and docs/server-levels.md."""
|
||||
links = ["docs/server-levels.md"]
|
||||
plain = """Single player has one way to run, the server thread, so every fix works everywhere and there are fewer \
|
||||
bugs. The older in-frame mode is removed."""
|
||||
dev = """`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) ticked the world inside the \
|
||||
window's `Game`. #6473 removes it with the code only it reached (the window's world loader and player tick, its \
|
||||
portal travel and loading screen, the in-frame LAN host); the harnesses run on the server thread. See also \
|
||||
'Single player as a server' and docs/server-levels.md."""
|
||||
links = ["#6473", "docs/server-levels.md"]
|
||||
|
||||
[[item]]
|
||||
id = "mod-languages"
|
||||
|
||||
@@ -42,7 +42,6 @@ def main():
|
||||
parser.add_argument("--ctl", type=Path, default=ROOT / "target/fast/horizon-ctl")
|
||||
parser.add_argument("--resources", type=Path, default=ROOT / ".local/horizon-client/26.3")
|
||||
parser.add_argument("--output", type=Path)
|
||||
parser.add_argument("--in-frame", action="store_true", help="DEPRECATED, never use: unsupported, will be removed soon")
|
||||
args = parser.parse_args()
|
||||
output = args.output or Path(tempfile.mkdtemp(prefix="bundle-tooltip-"))
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
@@ -68,8 +67,7 @@ def main():
|
||||
results = []
|
||||
try:
|
||||
ctl("launch", "--game", args.game.resolve(), "--world", output / "world",
|
||||
"--creative", "--offscreen", "--wait", "--resources", args.resources.resolve(),
|
||||
*(["--in-frame"] if args.in_frame else []), timeout=120)
|
||||
"--creative", "--offscreen", "--wait", "--resources", args.resources.resolve(), timeout=120)
|
||||
state = ctl("state")
|
||||
px, _, pz = state["player"]["block_pos"]
|
||||
x, z = (px // 16) * 16 + 8, (pz // 16) * 16 + 8
|
||||
@@ -105,7 +103,7 @@ def main():
|
||||
ctl("quit")
|
||||
except (RuntimeError, subprocess.TimeoutExpired, json.JSONDecodeError):
|
||||
pass
|
||||
report = {"mode": "in-frame" if args.in_frame else "server-thread", "results": results}
|
||||
report = {"results": results}
|
||||
(output / "results.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
print(json.dumps(report, indent=2))
|
||||
assert len(results) == 2 and all(r["pass"] for r in results), "Missing bundle component tooltip"
|
||||
|
||||
@@ -6,10 +6,9 @@ HORIZON_DISCORD_IPC_DIR) that answers the handshake with READY and records every
|
||||
The game goes title -> world -> Nether -> quit through horizon-ctl, and the activities must say
|
||||
"In the menus", "Singleplayer" in the Overworld, then the Nether, and be cleared on quit:
|
||||
|
||||
single single player on its server thread (the default)
|
||||
in-frame single player ticked in the window's frame (--in-frame)
|
||||
single single player on its server thread
|
||||
lan a host opens its world to LAN and a guest joins: the guest shows "Multiplayer",
|
||||
the host "Hosting a world" with a party of 2 of 8 (lan-in-frame: the host in-frame)
|
||||
the host "Hosting a world" with a party of 2 of 8
|
||||
setting off in horizon-extras.txt: nothing reaches Discord; the Horizon Settings switch
|
||||
turns it on (the activity appears) and off (it is cleared)
|
||||
late Discord starts after the game: found within the 15 s retry
|
||||
@@ -160,8 +159,8 @@ class Game:
|
||||
shutil.rmtree(self.home, ignore_errors=True)
|
||||
|
||||
|
||||
def single(bins, args=()):
|
||||
game = Game(bins, 'single', args=args)
|
||||
def single(bins):
|
||||
game = Game(bins, 'single')
|
||||
d = game.discord
|
||||
try:
|
||||
d.wait('title screen', shows('In the menus'))
|
||||
@@ -182,8 +181,8 @@ def single(bins, args=()):
|
||||
game.close()
|
||||
|
||||
|
||||
def lan(bins, host_args=()):
|
||||
host = Game(bins, 'host', args=host_args)
|
||||
def lan(bins):
|
||||
host = Game(bins, 'host')
|
||||
guest = None
|
||||
try:
|
||||
host.ctl_run('open', 'Hosted', '5', 'creative')
|
||||
@@ -246,9 +245,7 @@ def late(bins):
|
||||
game.close()
|
||||
|
||||
|
||||
CHECKS = {'single': single, 'in-frame': lambda b: single(b, ['--in-frame']), 'lan': lan,
|
||||
'lan-in-frame': lambda b: lan(b, ['--in-frame']), 'setting': setting,
|
||||
'late': late}
|
||||
CHECKS = {'single': single, 'lan': lan, 'setting': setting, 'late': late}
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -26,10 +26,6 @@ def run_game(command, **kwargs):
|
||||
"""Run a headless game fixture with the same isolation as real-client tests."""
|
||||
with tempfile.TemporaryDirectory(prefix='hz-fixture-') as root:
|
||||
kwargs['env'] = game_environment(kwargs.pop('env', os.environ), root)
|
||||
if any(key in kwargs['env'] for key in
|
||||
('HORIZON_DISPENSE_FIXTURE', 'HORIZON_SCULK_FIXTURE')):
|
||||
# These compare seeded rule/callback traces, not the client/server boundary.
|
||||
command = [*command, '--in-frame']
|
||||
return subprocess.run(command, **kwargs)
|
||||
|
||||
|
||||
|
||||
+59
-135
@@ -1,8 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""LAN multiplayer end to end: a host opens its world to LAN and a guest joins, both real games.
|
||||
|
||||
The host uses its integrated server thread. --in-frame is DEPRECATED (never use it, it will be removed
|
||||
soon); --server-thread remains accepted. Two Horizon clients run under horizon-ctl in scratch folders. The host
|
||||
The host's world runs on its integrated server thread. Two Horizon clients run under horizon-ctl in scratch folders. The host
|
||||
creates a survival world and opens it to LAN with commands allowed; the guest connects to it by
|
||||
address. The script then checks, from both sides, what a guest's game used to get wrong (#366):
|
||||
|
||||
@@ -79,12 +78,12 @@ address. The script then checks, from both sides, what a guest's game used to ge
|
||||
events; each player's /give tells them of the Diamonds! advancement before the give's
|
||||
own message, and never echoes their command back to them as `[Name: ...]`;
|
||||
- effects: a beacon's power reaches every player in range, the host and the guest;
|
||||
- distance: (--server-thread) the server follows the host's render distance, past vanilla's 32 too
|
||||
- distance: the server follows the host's render distance, past vanilla's 32 too
|
||||
(IntegratedServer.tickServer), and the guest's chunks follow the server's (#804)
|
||||
- portals: (--server-thread) the server holds a level per dimension: the guest goes to the Nether
|
||||
- portals: the server holds a level per dimension: the guest goes to the Nether
|
||||
through a portal while the host stays, then the host goes to the End while the guest is in
|
||||
the Nether, and both come back.
|
||||
- levels: (--server-thread) the host and the guest each in a level of their own: `execute in`
|
||||
- levels: the host and the guest each in a level of their own: `execute in`
|
||||
teleports into another level, and `execute in` and `at` change and test blocks there;
|
||||
mobs fall in two levels at once, with ids unique across them; the guest rejoins in the
|
||||
Nether, dies there and respawns in the Overworld, then in the Nether at a `spawnpoint`
|
||||
@@ -123,7 +122,7 @@ address. The script then checks, from both sides, what a guest's game used to ge
|
||||
gets up with Escape, and once the host sleeps too the night ends and both wake.
|
||||
- bed-occupied: the bed the host sleeps in is occupied and the guest cannot lie down in it; it is
|
||||
free again after Escape, death, the bed broken and the sleeper leaving the game.
|
||||
- sleep-reload: (--server-thread) a villager asleep in its bed is still asleep there, its bed
|
||||
- sleep-reload: a villager asleep in its bed is still asleep there, its bed
|
||||
occupied, after the host saves, quits and reopens; the guest's bed is saved free.
|
||||
- predict: with the host frozen, the oak door the guest opens opens on the guest's screen at once (its
|
||||
prediction) and creaks there at once, stays open once the server answers, and each side hears
|
||||
@@ -220,7 +219,7 @@ address. The script then checks, from both sides, what a guest's game used to ge
|
||||
- riding: the host and the guest each saddle and ride a horse (walk, jump), a pig and a strider
|
||||
(with their sticks), a camel and a donkey, sit on a llama and fly a happy ghast, and
|
||||
get off by sneaking; the other side sees the animal move.
|
||||
- portal_rider: (--server-thread) a survival guest rides a horse into a Nether portal: the horse goes
|
||||
- portal_rider: a survival guest rides a horse into a Nether portal: the horse goes
|
||||
through at once and carries it, so it arrives in the Nether still riding the same horse.
|
||||
- riding_teleport: the host and the guest each /tp through chat while riding a horse; after 60
|
||||
ticks each stays dismounted at the destination and both sides see the horse left
|
||||
@@ -247,7 +246,7 @@ address. The script then checks, from both sides, what a guest's game used to ge
|
||||
server's golden hearts, 8 then 5 (#1306).
|
||||
- breath: the host and the guest each stay underwater for 15 s in a Respiration III helmet: their
|
||||
own bubbles follow the server's air, which the enchantment makes last (#1304).
|
||||
- lava: (--server-thread) the host and the guest each stand in a Nether lava current with Fire
|
||||
- lava: the host and the guest each stand in a Nether lava current with Fire
|
||||
Resistance, which pushes them at the Nether's strength (#1309).
|
||||
- flight: a player in creative double-taps jump and flies, stays up, and stops flying in survival:
|
||||
the guest, and on the server thread the host's window too, take what may fly from the
|
||||
@@ -343,7 +342,7 @@ both sides go to --out (host-*.png, guest-*.png).
|
||||
python3 tools/lan_e2e.py # build with the fast profile, run every check
|
||||
python3 tools/lan_e2e.py --bin-dir target/fast # prebuilt binaries
|
||||
python3 tools/lan_e2e.py --check chest --check tab
|
||||
python3 tools/lan_e2e.py --server-thread --check portals # the host's world on its server thread
|
||||
python3 tools/lan_e2e.py --check portals
|
||||
|
||||
Needs the prepared resources in .local/horizon-client. On Linux the games run on vscreen (the
|
||||
desktop), or headless (offscreen, no pause) where there is none; on macOS both games render offscreen. Games started here have no audio (HORIZON_NO_AUDIO=1).
|
||||
@@ -370,8 +369,6 @@ from integration_env import game_environment, require_server_reports # noqa: E4
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GUEST_NAME = 'Guest'
|
||||
# Match normal single player; --in-frame is deprecated.
|
||||
SERVER_THREAD = True
|
||||
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'menu-transfer', 'crafting', 'spawned', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'deaths',
|
||||
'flow-push', 'bubble-column', 'arrows', 'kills', 'looting', 'leave', 'hurt', 'looks', 'leads', 'respawn', 'containers', 'names', 'feedback', 'titles', 'chat', 'styles',
|
||||
'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing',
|
||||
@@ -521,15 +518,10 @@ def check_armour(host, guest, out):
|
||||
face_each_other(host, guest)
|
||||
host.shot(out, 'armour')
|
||||
guest.shot(out, 'armour')
|
||||
if SERVER_THREAD:
|
||||
# The host's window is a client of its server thread: what it draws is what it was sent.
|
||||
seen = [e for e in remote(host).get('player_equipment', [])]
|
||||
assert all(any(item in json.dumps(e) for e in seen) for item in
|
||||
('diamond_chestplate', 'diamond_leggings')), f'the host draws the guest in {seen}'
|
||||
else:
|
||||
seen = lan_guest(host).get('model_armor')
|
||||
assert seen == [None, 'minecraft:diamond_chestplate', 'minecraft:diamond_leggings', None], \
|
||||
f'the host draws the guest in {seen}'
|
||||
# The host's window is a client of its server thread: what it draws is what it was sent.
|
||||
seen = [e for e in remote(host).get('player_equipment', [])]
|
||||
assert all(any(item in json.dumps(e) for e in seen) for item in
|
||||
('diamond_chestplate', 'diamond_leggings')), f'the host draws the guest in {seen}'
|
||||
host_gear = [e for e in remote(guest).get('player_equipment', [])]
|
||||
assert any('golden_chestplate' in json.dumps(e) for e in host_gear), f'the guest sees the host in {host_gear}'
|
||||
return 'each in their own'
|
||||
@@ -780,15 +772,13 @@ def nether_portal(game, x, y, z):
|
||||
|
||||
|
||||
def lan_guest(host):
|
||||
"""The guest's entry in the host's `lan status` (on the server thread the owner is listed too)."""
|
||||
"""The guest's entry in the host's `lan status` (the owner is listed too)."""
|
||||
guests = host.raw('lan', 'status')['guests']
|
||||
return next(g for g in guests if g['name'] == GUEST_NAME)
|
||||
|
||||
def check_distance(host, guest, out):
|
||||
"""IntegratedServer.tickServer: the server takes the host's render distance as its view distance
|
||||
(past 32, which Horizon's options allow) and sends it to every player (SetChunkCacheRadius)."""
|
||||
if not SERVER_THREAD:
|
||||
return 'skipped (needs --server-thread)'
|
||||
seen = {}
|
||||
try:
|
||||
for distance in (48, 6):
|
||||
@@ -807,8 +797,6 @@ def check_distance(host, guest, out):
|
||||
|
||||
|
||||
def check_portals(host, guest, out):
|
||||
if not SERVER_THREAD:
|
||||
return 'skipped (needs --server-thread)'
|
||||
x, y, z = (int(v // 1) for v in pos_of(guest))
|
||||
hx, hy, hz = (int(v // 1) for v in pos_of(host))
|
||||
try:
|
||||
@@ -886,10 +874,8 @@ NETHER, END, OVERWORLD = 'minecraft:the_nether', 'minecraft:the_end', 'minecraft
|
||||
|
||||
|
||||
def check_levels(host, guest, out):
|
||||
"""(--server-thread) Each player in its own level (MinecraftServer.levels), the host and the guest
|
||||
"""Each player in its own level (MinecraftServer.levels), the host and the guest
|
||||
apart: the steps run in order, each reported, and the check fails if any did."""
|
||||
if not SERVER_THREAD:
|
||||
return 'skipped (needs --server-thread)'
|
||||
start = {g.name: pos_of(g) for g in (host, guest)}
|
||||
steps, failed = {}, []
|
||||
guest('command', command='gamemode creative')
|
||||
@@ -1626,7 +1612,7 @@ def check_respawn(host, guest, out):
|
||||
for g in (host, guest):
|
||||
back_to_game(g)
|
||||
results = {}
|
||||
sides = [('host', host, guest)] + ([('guest', guest, host)] if SERVER_THREAD else [])
|
||||
sides = [('host', host, guest), ('guest', guest, host)]
|
||||
for name, dying, watching in sides:
|
||||
face_each_other(host, guest)
|
||||
dying('command', command='kill @s')
|
||||
@@ -1670,9 +1656,7 @@ def check_hurt(host, guest, out):
|
||||
attacker.wait(20)
|
||||
host('command', command='kill @e[type=minecraft:pig]')
|
||||
# A hurt player flashes red for the other one too (#2827).
|
||||
# An in-frame host draws its guests as models of its own, not entities: only the screenshot.
|
||||
watched = ((('host hurt', host, guest),) if not SERVER_THREAD else
|
||||
(('host hurt', host, guest), ('guest hurt', guest, host)))
|
||||
watched = (('host hurt', host, guest), ('guest hurt', guest, host))
|
||||
for name, hurt, watcher in watched:
|
||||
hurt.wait(30)
|
||||
# Instant damage hurts on the next effect tick.
|
||||
@@ -1684,11 +1668,6 @@ def check_hurt(host, guest, out):
|
||||
watcher.wait(1)
|
||||
seen[name] = {'watcher': other['hurt_time']}
|
||||
watcher.shot(out, 'hurt-' + name.replace(' ', '-') + '-seen')
|
||||
if not SERVER_THREAD:
|
||||
guest.wait(30)
|
||||
guest('command', command='effect give @s minecraft:instant_damage 1 0')
|
||||
guest.wait(2)
|
||||
host.shot(out, 'hurt-guest-hurt-seen')
|
||||
# LivingEntity.hurtTime: 10 when hurt, one less each tick; the reads come a few ticks later.
|
||||
for name, sides in seen.items():
|
||||
assert all(t > 0 for t in sides.values()), f'{name}: the hurt flash {seen}'
|
||||
@@ -2112,7 +2091,7 @@ def check_grindstone(host, guest, out):
|
||||
|
||||
def check_anvil(host, guest, out):
|
||||
# AnvilMenu.onTake emits one level event to everyone, including the user.
|
||||
# Exercise both the in-frame host and a remote user (both remote with --server-thread).
|
||||
# Exercise both the host and a remote user (both are clients of the server).
|
||||
return {actor.name: check_anvil_user(actor, (host, guest), out) for actor in (guest, host)}
|
||||
|
||||
|
||||
@@ -3084,7 +3063,7 @@ def check_recipes(host, guest, out):
|
||||
guest.wait(5)
|
||||
assert any('oak_log' in str(item) for item in grid.values()), f'nothing placed in the grid: {grid}'
|
||||
# #2019: the alternatives popup belongs to one screen. Check both the
|
||||
# in-frame host (or --server-thread host) and the remote guest, with both
|
||||
# host and the remote guest, with both
|
||||
# inventory-close keys and a different recipe-book type on reopening.
|
||||
for game in (host, guest):
|
||||
game('command', command='recipe give @s minecraft:oak_planks')
|
||||
@@ -3155,7 +3134,7 @@ def check_chat(host, guest, out):
|
||||
line = f'chat check {int(time.time())}'
|
||||
host.raw('chat', line)
|
||||
seen = {g.name: g.raw('wait-for', 'chat', line, '--timeout', 10, '--since', 0).get('ok')
|
||||
for g in ((guest, host) if SERVER_THREAD else (guest,))}
|
||||
for g in (guest, host)}
|
||||
guest.shot(out, 'chat')
|
||||
return seen
|
||||
|
||||
@@ -3655,10 +3634,10 @@ def check_sleep(host, guest, out):
|
||||
asleep = {'guest': guest('state').get('sleeping'), 'host': host('state').get('sleeping'),
|
||||
'server': lan_guest(host).get('sleeping')}
|
||||
guest.shot(out, 'sleep-guest')
|
||||
# The host draws the guest lying in its bed (#1045): in frame its model, on the server thread its
|
||||
# client's (SLEEPING_POS), and a client pairing with the sleeper later too.
|
||||
# The host draws the guest lying in its bed (#1045): its client's (SLEEPING_POS), and a client
|
||||
# pairing with the sleeper later too.
|
||||
host.wait(5)
|
||||
lying = {'host': lan_guest(host).get('model_lying') if not SERVER_THREAD else bool(remote(host)['lying_players'])}
|
||||
lying = {'host': bool(remote(host)['lying_players'])}
|
||||
host.shot(out, 'sleep-host-sees-guest')
|
||||
# Alone in bed: still night 120 ticks later (everyone must sleep).
|
||||
guest.wait(120)
|
||||
@@ -3835,8 +3814,6 @@ def check_sleep_reload(host, guest, out):
|
||||
"""LivingEntity's sleeping_pos is saved: a villager asleep in its bed when the host saves and quits
|
||||
is still asleep in it, its bed still occupied, when the world opens again; the bed the guest slept
|
||||
in is saved free (ServerPlayer.disconnect gets every player up before the last save)."""
|
||||
if not SERVER_THREAD:
|
||||
return {'skipped': 'the reopen is the server thread\'s'}
|
||||
guest('command', command='gamemode survival')
|
||||
host('command', command='time set midnight')
|
||||
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
|
||||
@@ -4135,9 +4112,7 @@ def check_swings(host, guest, out):
|
||||
host('command', command=f'setblock {hx} {hy} {hz + 1} minecraft:stone')
|
||||
host.wait(10)
|
||||
def swinging(game):
|
||||
# On its server thread the host's window is a client of its server too.
|
||||
if game is host and not SERVER_THREAD:
|
||||
return lan_guest(host).get('model_swinging')
|
||||
# The host's window is a client of its server too.
|
||||
return any(e.get('swinging') for e in remote(game).get('player_equipment', []))
|
||||
# Most samples: a swing restarts only past its first half (SwingState.startIfAble), so a sample can
|
||||
# fall between two.
|
||||
@@ -4253,9 +4228,7 @@ def check_eating(host, guest, out):
|
||||
face_each_other(host, guest)
|
||||
|
||||
def using(game):
|
||||
# On its server thread the host's window is a client of its server too.
|
||||
if game is host and not SERVER_THREAD:
|
||||
return lan_guest(host).get('model_using') or 0
|
||||
# The host's window is a client of its server too.
|
||||
return max((e.get('using') or 0 for e in remote(game).get('player_equipment', [])), default=0)
|
||||
seen = {}
|
||||
for name, eater, watcher in (('host eats', host, guest), ('guest eats', guest, host)):
|
||||
@@ -4526,7 +4499,7 @@ def check_tag_query(host, guest, out):
|
||||
def check_fireworks(host, guest, out):
|
||||
"""Event 17 must arrive before removal, with the item's colored, trailing explosion (#1478).
|
||||
|
||||
FireworkRocketEntity.explode / handleEntityEvent; run with and without --server-thread.
|
||||
FireworkRocketEntity.explode / handleEntityEvent.
|
||||
The flash proves a configured explosion reached both clients; red pixels in later screenshots
|
||||
check that its sparks keep rendering after the tracked rocket disappears.
|
||||
"""
|
||||
@@ -4947,9 +4920,6 @@ def check_particles(host, guest, out):
|
||||
host('command', command='gamemode survival')
|
||||
guest('command', command='gamemode survival')
|
||||
host('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 16} {y - 1} {z + 8} minecraft:stone')
|
||||
# Without the server thread a guest's command reaches no host player (as `@a` doesn't).
|
||||
if not SERVER_THREAD:
|
||||
result['guest all'].pop('other dust')
|
||||
wrong = {k: v for k, v in result.items() if not k.endswith('@s') and not all(n > 0 for n in v.values())}
|
||||
wrong.update({k: v for k, v in result.items() if k.endswith('@s') and not (v['own'] >= 30 and v['other'] == 0)})
|
||||
assert not wrong, f'particles: {wrong}\n{result}'
|
||||
@@ -5094,7 +5064,7 @@ def check_flight(host, guest, out):
|
||||
game.wait(1)
|
||||
game.wait(10)
|
||||
|
||||
players = [('guest', guest)] + ([('host', host)] if SERVER_THREAD else [])
|
||||
players = [('guest', guest), ('host', host)]
|
||||
seen = {}
|
||||
# The rule is seven simulation ticks, independent of wall time. A slow
|
||||
# server clock leaves enough time to deliver the key events even on busy CI.
|
||||
@@ -5258,10 +5228,7 @@ def check_contacts(host, guest, out):
|
||||
|
||||
|
||||
def server_player(host, game):
|
||||
"""The player's entry on the server: the guest's in the host's `lan status`, on the server
|
||||
thread the host's too; the host's own world otherwise."""
|
||||
if game is host and not SERVER_THREAD:
|
||||
return own(host)
|
||||
"""The player's entry on the server: the host's `lan status` lists the guest and the host."""
|
||||
name = GUEST_NAME if game is not host else host.player
|
||||
return next(g for g in host.raw('lan', 'status')['guests'] if g['name'] == name)
|
||||
|
||||
@@ -5513,10 +5480,8 @@ def check_breath(host, guest, out):
|
||||
|
||||
|
||||
def check_lava(host, guest, out):
|
||||
"""(--server-thread) Standing in a settled Nether lava current with Fire Resistance: the
|
||||
"""Standing in a settled Nether lava current with Fire Resistance: the
|
||||
Nether's FAST_LAVA pushes each player about three times as hard as the Overworld's (#1309)."""
|
||||
if not SERVER_THREAD:
|
||||
return 'skipped (needs --server-thread)'
|
||||
seen = {}
|
||||
back = {g.name: pos_of(g) for g in (host, guest)}
|
||||
try:
|
||||
@@ -6417,9 +6382,7 @@ def check_light(host, guest, out):
|
||||
assert '.' in row, 'the guest has every chunk 24 chunks east'
|
||||
untracked = (gx + row.index('.')) >> 4
|
||||
near = untracked * 16 + 1
|
||||
# On its server thread the host's own game is a client too, sent the same light updates.
|
||||
if not SERVER_THREAD:
|
||||
assert host.raw('light', near, y, gz, near, y, gz)['sky'] != '.', 'the host lacks the chunk past the guest'
|
||||
# The host's own game is a client too, sent the same light updates.
|
||||
host('command', command=f'setblock {near} {y} {gz} minecraft:glowstone')
|
||||
try:
|
||||
lo, hi = (untracked * 16 - 16, y - 3, gz - 8), (untracked * 16 - 1, y + 3, gz + 8)
|
||||
@@ -6518,7 +6481,7 @@ def check_deflect(host, guest, out):
|
||||
host('command', command='tick rate 20')
|
||||
host.wait(60)
|
||||
ghast = [m for m in host.raw('mobs', 60).get('near', []) if m['kind'].endswith('ghast') and m['health'] > 0]
|
||||
hurt = [h for pl in host.raw('hurtlog')['players'] if pl['name'] == (host.player if SERVER_THREAD else 'host') for h in pl['hurts']
|
||||
hurt = [h for pl in host.raw('hurtlog')['players'] if pl['name'] == host.player for h in pl['hurts']
|
||||
if 'fireball' in h['source'] or 'explosion' in h['source']]
|
||||
finally:
|
||||
host('command', command='tick unfreeze')
|
||||
@@ -6649,35 +6612,24 @@ def check_worn(host, guest, out):
|
||||
guest('command', command='effect give @s minecraft:instant_health 1 5')
|
||||
guest.wait(30)
|
||||
before = remote(guest)['health']
|
||||
if not SERVER_THREAD:
|
||||
host.raw('hurtlog')
|
||||
# A mob's blow (no attacker: no difficulty scaling); armour counts against it.
|
||||
guest('command', command='damage @s 10 minecraft:mob_attack')
|
||||
if SERVER_THREAD:
|
||||
# The server's hurt log isn't on a control socket here: the health the guest is
|
||||
# sent, read as soon as it drops (natural regeneration heals it again after).
|
||||
health = before
|
||||
for _ in range(40):
|
||||
guest.wait(1)
|
||||
health = remote(guest)['health']
|
||||
if health < before:
|
||||
break
|
||||
taken['worn' if worn else 'bare'] = [round(before - health, 2)]
|
||||
continue
|
||||
guest.wait(10)
|
||||
log = [h for p in host.raw('hurtlog')['players'] if p['name'] == GUEST_NAME for h in p['hurts']]
|
||||
taken['worn' if worn else 'bare'] = [(h['amount'], h['armor'], round(h['taken'], 2)) for h in log]
|
||||
# The server's hurt log isn't on a control socket here: the health the guest is
|
||||
# sent, read as soon as it drops (natural regeneration heals it again after).
|
||||
health = before
|
||||
for _ in range(40):
|
||||
guest.wait(1)
|
||||
health = remote(guest)['health']
|
||||
if health < before:
|
||||
break
|
||||
taken['worn' if worn else 'bare'] = [round(before - health, 2)]
|
||||
finally:
|
||||
guest('command', command='clear @s')
|
||||
guest('command', command='effect give @s minecraft:instant_health 1 5')
|
||||
if SERVER_THREAD:
|
||||
# Natural regeneration may heal up to a point before the health arrives.
|
||||
assert len(taken['bare']) == 1 and 9.0 <= taken['bare'][0] <= 10.0, f'10 damage on no armour: {taken}'
|
||||
assert len(taken['worn']) == 1 and 5.0 <= taken['worn'][0] <= 6.0, \
|
||||
f'10 damage through iron armour (15) should take 6: {taken}'
|
||||
return taken
|
||||
assert taken['bare'] == [(10.0, 0.0, 10.0)], f'10 damage on no armour: {taken}'
|
||||
assert taken['worn'] == [(10.0, 15.0, 6.0)], f'10 damage through iron armour (15) should take 6: {taken}'
|
||||
# Natural regeneration may heal up to a point before the health arrives.
|
||||
assert len(taken['bare']) == 1 and 9.0 <= taken['bare'][0] <= 10.0, f'10 damage on no armour: {taken}'
|
||||
assert len(taken['worn']) == 1 and 5.0 <= taken['worn'][0] <= 6.0, \
|
||||
f'10 damage through iron armour (15) should take 6: {taken}'
|
||||
return taken
|
||||
|
||||
|
||||
@@ -6730,8 +6682,6 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour,
|
||||
|
||||
def run(ctl, out, resources, checks):
|
||||
host, guest = Game('host', ctl, resources), Game('guest', ctl, resources)
|
||||
# Either way explicitly: the game's default is the server thread.
|
||||
host.d.env['HORIZON_SERVER_THREAD'] = '1' if SERVER_THREAD else '0'
|
||||
# A headless run (HORIZON_E2E_HEADLESS, or a Linux without vscreen) has no focused window to draw
|
||||
# in: the games render offscreen and never pause on the focus loss, or the world would stand still
|
||||
# (a host paused before its world loads waits at "Connecting to the server..." for ever).
|
||||
@@ -6803,16 +6753,9 @@ def main():
|
||||
parser.add_argument('--bin-dir', help='prebuilt horizon-client and horizon-ctl (default: build with the fast profile)')
|
||||
parser.add_argument('--check', action='append', choices=CHECKS, help='run only these checks (repeatable)')
|
||||
parser.add_argument('--out', default=str(ROOT / '.local/lan-e2e'), help='screenshots and game logs go here')
|
||||
mode = parser.add_mutually_exclusive_group()
|
||||
mode.add_argument('--server-thread', dest='server_thread', action='store_true', default=True,
|
||||
help='host on its server thread (default; retained for existing callers)')
|
||||
mode.add_argument('--in-frame', dest='server_thread', action='store_false',
|
||||
help='DEPRECATED, never use: hosts the world in the window; unsupported, will be removed soon')
|
||||
# The host's world always runs on its server thread; the flag stays for existing callers.
|
||||
parser.add_argument('--server-thread', action='store_true', help=argparse.SUPPRESS)
|
||||
args = parser.parse_args()
|
||||
global SERVER_THREAD
|
||||
SERVER_THREAD = args.server_thread
|
||||
if not SERVER_THREAD:
|
||||
print('DEPRECATED: --in-frame is unsupported and will be removed soon. Do not use it.', file=sys.stderr)
|
||||
if args.bin_dir:
|
||||
bins = Path(args.bin_dir).resolve()
|
||||
else:
|
||||
@@ -6831,10 +6774,8 @@ def main():
|
||||
sys.exit(str(error))
|
||||
out = Path(args.out).resolve()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
print(f'LAN host mode: {"server-thread" if SERVER_THREAD else "in-frame"}')
|
||||
results = run(bins / 'horizon-ctl', out, resources, args.check or CHECKS)
|
||||
(out / 'results.json').write_text(json.dumps({'mode': 'server-thread' if SERVER_THREAD else 'in-frame',
|
||||
'checks': results}, indent=2) + '\n')
|
||||
(out / 'results.json').write_text(json.dumps({'checks': results}, indent=2) + '\n')
|
||||
print(json.dumps(results, indent=1))
|
||||
sys.exit(0 if results and all(r['pass'] for r in results.values()) else 1)
|
||||
|
||||
@@ -7117,8 +7058,6 @@ CHECK_FNS.update(elytra=check_elytra)
|
||||
|
||||
def server_using(host, game):
|
||||
"""The item the server has the player using ({slot, ticks}), or None."""
|
||||
if game is host and not SERVER_THREAD:
|
||||
return host('state').get('using')
|
||||
return server_player(host, game).get('using')
|
||||
|
||||
|
||||
@@ -7502,9 +7441,7 @@ def check_fishing(host, guest, out):
|
||||
for name, s in seen.items():
|
||||
other = guest if name == 'host' else host
|
||||
assert s['own'] == [0], f'{name}: {seen}'
|
||||
# The in-frame host draws only its own hook.
|
||||
if other is guest or SERVER_THREAD:
|
||||
assert len(s['other']) == 1 and s['other'][0] != 0, f'{name}: {seen}'
|
||||
assert len(s['other']) == 1 and s['other'][0] != 0, f'{name}: {seen}'
|
||||
# FishingHook.retrieve costs 2 on the ground, but a resting hook's onGround
|
||||
# alternates every tick on the server (Entity.move with no vertical motion
|
||||
# clears it, the next tick's gravity sets it again): 2 or nothing.
|
||||
@@ -7793,7 +7730,7 @@ CHECK_FNS.update(digging=check_digging)
|
||||
def check_instant_digging(host, guest, out):
|
||||
"""#1908: an instant survival START has no STOP. ServerPlayerGameMode.tick
|
||||
forgets a dig once its block is air; it must neither hit air nor break a replacement.
|
||||
Exercise the in-frame host and network guest (both are clients with --server-thread).
|
||||
Exercise the host and the network guest (both are clients of the server).
|
||||
"""
|
||||
result = {}
|
||||
for label, player in (('host', host), ('guest', guest)):
|
||||
@@ -8682,7 +8619,7 @@ CHECK_FNS.update({'cure-discount': check_cure_discount})
|
||||
|
||||
def check_spectator_travel(host, guest, out):
|
||||
"""Player.tick's spectator noPhysics survives a replacement body after dimension travel (#1299)."""
|
||||
players = (host, guest) if SERVER_THREAD else (host,)
|
||||
players = (host, guest)
|
||||
seen = {}
|
||||
for game in players:
|
||||
home = pos_of(game)
|
||||
@@ -8889,13 +8826,11 @@ CHECK_FNS.update(riding_teleport=check_riding_teleport)
|
||||
|
||||
|
||||
def check_portal_rider(host, guest, out):
|
||||
"""#1486 (--server-thread): a survival guest rides a horse into a Nether portal. The horse goes
|
||||
"""#1486: a survival guest rides a horse into a Nether portal. The horse goes
|
||||
through at once (NetherPortalBlock.getPortalTransitionTime is 0 for an entity that isn't a
|
||||
player; a passenger has no portal process of its own, Entity.canUsePortal) and carries its rider
|
||||
(Entity.teleportCrossDimension): the guest leaves well before a player's own 80 ticks and
|
||||
arrives in the Nether still on the same horse."""
|
||||
if not SERVER_THREAD:
|
||||
return 'skipped (needs --server-thread)'
|
||||
x, y, z = apart(host, guest)['guest']
|
||||
horse_at = (x + 1.0, y, z + 2.5)
|
||||
# The portal's plane crosses the horse's box (z + 1.8 to z + 3.2), the rider's too.
|
||||
@@ -9208,9 +9143,7 @@ CHECK_FNS.update(reopen_player=check_reopen_player)
|
||||
def check_recipe_targets(host, guest, out):
|
||||
"""RecipeCommand gives and takes the recipes of each target's own ServerRecipeBook: a
|
||||
`/recipe give|take <other player>` changes that player's book and reaches their client,
|
||||
not the book of the player who typed it. An in-frame host isn't one of the players a guest's
|
||||
command can name (`command_players` lists the guests), so the guest gives the host a recipe
|
||||
only when the host is a client of its server thread."""
|
||||
not the book of the player who typed it: the guest gives the host a recipe too."""
|
||||
host_name = host.raw('lan', 'status').get('host', 'Player')
|
||||
books = {}
|
||||
for game in (host, guest):
|
||||
@@ -9237,18 +9170,16 @@ def check_recipe_targets(host, guest, out):
|
||||
host('command', command=f'recipe give {GUEST_NAME} minecraft:stick')
|
||||
host.wait(20)
|
||||
books['host gives guest'] = known()
|
||||
if SERVER_THREAD:
|
||||
guest('command', command=f'recipe give {host_name} minecraft:stick')
|
||||
guest.wait(20)
|
||||
guest('command', command=f'recipe give {host_name} minecraft:stick')
|
||||
guest.wait(20)
|
||||
books['guest gives host'] = known()
|
||||
host('command', command=f'recipe take {GUEST_NAME} minecraft:stick')
|
||||
host.wait(20)
|
||||
books['host takes from guest'] = known()
|
||||
host.shot(out, 'recipe-targets-host')
|
||||
guest.shot(out, 'recipe-targets-guest')
|
||||
by_guest = int(SERVER_THREAD)
|
||||
expected = {'empty': (0, 0), 'host gives guest': (0, 2), 'guest gives host': (by_guest, 2),
|
||||
'host takes from guest': (by_guest, 1)}
|
||||
expected = {'empty': (0, 0), 'host gives guest': (0, 2), 'guest gives host': (1, 2),
|
||||
'host takes from guest': (1, 1)}
|
||||
for step, (in_host, in_guest) in expected.items():
|
||||
assert books[step] == {host.name: in_host, guest.name: in_guest}, (step, books)
|
||||
finally:
|
||||
@@ -10064,7 +9995,6 @@ def check_guardian_beams(host, guest, out):
|
||||
|
||||
Guardian.defineSynchedData/onSyncedDataUpdated/aiStep and
|
||||
GuardianRenderer.extractRenderState: charge, hit, then target loss.
|
||||
Run with both --server-thread and the in-frame host.
|
||||
"""
|
||||
x, y, z = map(math.floor, pos_of(host))
|
||||
host('command', command='difficulty normal')
|
||||
@@ -10096,8 +10026,8 @@ def check_guardian_beams(host, guest, out):
|
||||
|
||||
states = charging()
|
||||
assert states[0]['target'] == 'Player', states
|
||||
# The host reads the level that ticks the guardian (its own in-frame, the
|
||||
# server's on the server thread), where a targeted player is `Player`.
|
||||
# The host reads the level that ticks the guardian (the server's), where a
|
||||
# targeted player is `Player`.
|
||||
if observer is host:
|
||||
assert states[1]['target'] == 'Player', states
|
||||
else:
|
||||
@@ -10227,7 +10157,6 @@ CHECK_FNS.update(fangs=check_fangs)
|
||||
def check_sonic(host, guest, out):
|
||||
"""A real warden's ray reaches connected clients once, with normal damage.
|
||||
|
||||
Run with and without --server-thread to cover the window's local renderer too.
|
||||
The creative observer sees the same ray nearby and none outside sendParticles' range.
|
||||
"""
|
||||
x, y, z = (int(v // 1) for v in pos_of(host))
|
||||
@@ -10526,8 +10455,6 @@ def check_forced_levels(host, guest, out):
|
||||
Also keep a resident, unforced furnace outside the forced ticket's neighbour ring: scheduling
|
||||
a playerless integrated-server level must not enable the fixtures' tick-everywhere fallback.
|
||||
"""
|
||||
if not SERVER_THREAD:
|
||||
return 'skipped (needs --server-thread)'
|
||||
start = pos_of(guest)
|
||||
results = {}
|
||||
guest('command', command='gamemode creative')
|
||||
@@ -11050,8 +10977,7 @@ def check_trident_pickup(host, guest, out):
|
||||
face_each_other(host, guest)
|
||||
for name, case in seen.items():
|
||||
assert case['tridents'] == 1, f'{name} did not pick the trident up: {seen}'
|
||||
if SERVER_THREAD:
|
||||
assert all(n == 1 for n in case['flown'].values()), f"{name}'s trident did not fly to them: {seen}"
|
||||
assert all(n == 1 for n in case['flown'].values()), f"{name}'s trident did not fly to them: {seen}"
|
||||
return seen
|
||||
|
||||
|
||||
@@ -11084,7 +11010,7 @@ def check_mount_other_level(host, guest, out):
|
||||
of the Overworld's mobs; the inventory of a tamed horse, camel, llama, trader llama, nautilus,
|
||||
donkey and mule, opened with shift + right click or with the inventory key while riding, and
|
||||
again by shift + right click after getting off, must stay open through those ticks instead of
|
||||
closing at once. The other way round too (in_nether, --server-thread): the player and the mount
|
||||
closing at once. The other way round too (in_nether): the player and the mount
|
||||
in the Nether while the other player keeps the Overworld ticking. Each case is reported on its
|
||||
own (LAN_E2E_MOUNTS=camel,llama picks mounts, LAN_E2E_LEVELS=the_end,in_nether cases).
|
||||
|
||||
@@ -11194,8 +11120,6 @@ def check_mount_other_level(host, guest, out):
|
||||
# same check.
|
||||
if picked and 'in_nether' not in picked:
|
||||
pass
|
||||
elif not SERVER_THREAD:
|
||||
result['in_nether'] = 'skipped (needs --server-thread)'
|
||||
else:
|
||||
result['in_nether'] = {}
|
||||
for label, player, spot in (('host', host, (0, 100, 0)), ('guest', guest, (12, 100, 0))):
|
||||
|
||||
+10
-13
@@ -15,9 +15,9 @@ no port.
|
||||
Both sides' call graphs then show where a port exists but is not wired like vanilla, the kind of bug a
|
||||
method-by-method reading misses:
|
||||
|
||||
- seam: a vanilla method ported on both paths (the in-frame game and the server-client side,
|
||||
game/remote*.rs, or the server, game/lan*.rs), one of which reaches the port of a callee and the other
|
||||
doesn't;
|
||||
- seam: a vanilla method ported on more than one path (the shared game code, the server-client side,
|
||||
game/remote*.rs, or the server's network side, game/lan*.rs), one of which reaches the port of a callee
|
||||
and another doesn't;
|
||||
- callee: a ported vanilla method whose ported callee no Rust counterpart reaches;
|
||||
- client: a vanilla effect only the client's copy draws (`addParticle`, `playLocalSound`,
|
||||
`itemUsed`, a `isClientSide()` branch) whose port nothing on the server-client side (game/remote*.rs)
|
||||
@@ -1716,14 +1716,14 @@ def build(sources, jars, jobs):
|
||||
return seen
|
||||
|
||||
def side(fid):
|
||||
"""Which path a port runs on: the server-client side (game/remote*.rs), the server
|
||||
(game/lan*.rs, src/lan/), or the in-frame game."""
|
||||
"""Which path a port runs on: the server-client side (game/remote*.rs), the server's network
|
||||
side (game/lan*.rs, src/lan/), or the shared game code the server runs for its players."""
|
||||
f = fns[fid]["file"]
|
||||
if re.search(r"/game/remote[^/]*\.rs$", f):
|
||||
return "client"
|
||||
if re.search(r"/game/lan[^/]*\.rs$|/src/lan/", f):
|
||||
return "server"
|
||||
return "frame"
|
||||
return "game"
|
||||
|
||||
def callee_port_names(c):
|
||||
"""Names of the fns that port vanilla method c: those whose doc opens with it, else a fn with the
|
||||
@@ -1785,11 +1785,8 @@ def build(sources, jars, jobs):
|
||||
for i in miss:
|
||||
peers = [h for h in hit if side(clusters[h][0]) != side(clusters[i][0]) and ccover[h] & ccover[i]]
|
||||
if peers:
|
||||
# Singleplayer runs as a client of the integrated server, so a gap on the
|
||||
# in-frame path rarely shows; it sorts after the others.
|
||||
gaps.append({"kind": "seam", "vanilla": k, "callee": callee, "fn": clusters[i][0],
|
||||
"cluster": clusters[i], "sibling": clusters[peers[0]][0],
|
||||
"old_path": side(clusters[i][0]) == "frame"})
|
||||
"cluster": clusters[i], "sibling": clusters[peers[0]][0]})
|
||||
elif not hit and len(targets) <= 3 and substantial(callee):
|
||||
gaps.append({"kind": "callee", "vanilla": k, "callee": callee, "fn": fids[0]})
|
||||
if m["client"] and not any(reached_from_remote(fid) for fid in fids):
|
||||
@@ -2102,7 +2099,7 @@ def gap_text(led, g):
|
||||
rest = [led["fns"][f]["name"] for f in g.get("cluster", [])[1:]]
|
||||
with_rest = f" (with {', '.join(rest)})" if rest else ""
|
||||
return (f"seam: {fn_ref(led, g['fn'])}{with_rest} ports {name(g['vanilla'])} but never reaches the port of "
|
||||
f"{name(g['callee'])}; {fn_ref(led, g['sibling'])} does" + (" (old in-frame path)" if g.get("old_path") else ""))
|
||||
f"{name(g['callee'])}; {fn_ref(led, g['sibling'])} does")
|
||||
if g["kind"] == "callee":
|
||||
return f"callee: {name(g['vanilla'])} calls {name(g['callee'])}; {fn_ref(led, g['fn'])} doesn't reach its port"
|
||||
if g["kind"] == "client":
|
||||
@@ -2130,7 +2127,7 @@ GAP_ORDER = {"map": -1, "seam": 0, "client": 1, "drift": 2, "class": 3, "callee"
|
||||
|
||||
def gap_rank(g):
|
||||
"""Best leads first: by kind, the live path before the old one, then the classes with the most ported neighbours."""
|
||||
return (GAP_ORDER.get(g["kind"], 9), g.get("old_path", False), -g.get("package_ported", 0), -len(g.get("ids", ())),
|
||||
return (GAP_ORDER.get(g["kind"], 9), -g.get("package_ported", 0), -len(g.get("ids", ())),
|
||||
g.get("vanilla") or g.get("vanilla_class") or g.get("file", ""))
|
||||
|
||||
|
||||
@@ -2705,7 +2702,7 @@ def cmd_report(led):
|
||||
lines.append(f"| {p} | {c['total']} | " + " | ".join(str(c[k]) for k in COVERAGE_COLS) + f" | {found_pct(c):.0f} |")
|
||||
counts = Counter(g["kind"] for g in led["gaps"])
|
||||
lines += ["", "## Gaps", "", ", ".join(f"{k}: {v}" for k, v in counts.most_common()), ""]
|
||||
for kind, title in (("seam", "Ported on both paths (in-frame and server-client), wired on only one"),
|
||||
for kind, title in (("seam", "Ported on more than one path, wired on only one"),
|
||||
("client", "Client-only effects the server-client path never reaches"),
|
||||
("drift", "Ports that never name a sound, particle, item or constant vanilla uses"),
|
||||
("class", "Whole classes nothing ports, next to ported neighbours"),
|
||||
|
||||
@@ -29,7 +29,7 @@ ClientPacketListener.handleSetSpawn port crates/horizon-client/src/game/remote.r
|
||||
ClientPacketListener.handleAddTransientBlockPacket missing minor: the transient block shown while a falling block lands is not drawn; the block update still arrives
|
||||
ClientPacketListener.findTotem missing player: the totem of undying pop-up over the screen is not shown when death protection triggers; Horizon has no item activation
|
||||
ClientPacketListener.handleSetExperience port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetExperience arm
|
||||
ClientPacketListener.determineLevelLoadingReason port crates/horizon-client/src/game.rs::Game::loading_reason gap: player: only the in-frame game picks a reason; a dedicated server join or dimension change shows no level loading screen
|
||||
ClientPacketListener.determineLevelLoadingReason port crates/horizon-client-render/src/level_loading.rs::reason_for_respawn the reason of a Respawn packet: death, a Nether or an End crossing
|
||||
ClientPacketListener.handleOpenScreen port crates/horizon-client/src/game/remote.rs::Game::remote_open_screen
|
||||
ClientPacketListener.handleSetCursorItem port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetCursorItem arm; gap: minor: sets the carried stack even while the creative screen is open
|
||||
ClientPacketListener.handleSetPlayerInventory port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetPlayerInventory arm: slots 0-35, armour 36-39, off hand 40
|
||||
|
||||
|
@@ -22,8 +22,8 @@ ChunkHolder.updateFutures inline crates/horizon-client/src/game.rs::Game::start_
|
||||
ChunkLevel.getStatusAroundFullChunk inline crates/horizon-client-render/src/streaming.rs::Ranges::margins fixed margins around the view instead of a status per distance
|
||||
ChunkLevel.byStatus skip:other per status ticket levels; Horizon streams by distance (Ranges), not by level
|
||||
ChunkLevel.fullStatus port crates/horizon-client/src/ticket_levels.rs::Levels::loaded_ticking 31 entity ticking, 32 block ticking, 33 full as predicates on Levels
|
||||
ChunkLoadCounter.track inline crates/horizon-client/src/game.rs::Game::level_load_progress gap: minor: counts only the player's 7x7 chunks, not the initial forced or ticketed chunks
|
||||
ChunkLoadCounter.pendingChunks inline crates/horizon-client/src/game.rs::Game::level_load_progress counts lit chunks in the 7x7 around the player
|
||||
ChunkLoadCounter.track inline crates/horizon-client/src/game/integrated.rs::Game::publish_load_view gap: minor: counts only the spawn's 7x7 chunks, not the initial forced or ticketed chunks
|
||||
ChunkLoadCounter.pendingChunks inline crates/horizon-client/src/game/integrated.rs::Game::publish_load_view counts lit chunks in the 7x7 around the spawn
|
||||
ChunkMap.<init> inline crates/horizon-client-render/src/streaming.rs::Streaming::new worker pool and generator; light engine and POI storage are set up elsewhere
|
||||
ChunkMap.isChunkTracked inline crates/horizon-client/src/lan/hub.rs::Hub::broadcast_block_changes sent_chunks.contains: in the view and already sent
|
||||
ChunkMap.getChunkQueueLevel inline crates/horizon-client-render/src/streaming.rs::Ranges::priority job priority is a distance from the centre, not a ticket level
|
||||
@@ -49,7 +49,7 @@ ChunkMap.scheduleGenerationTask port crates/horizon-client-render/src/streaming.
|
||||
ChunkMap.runGenerationTask port crates/horizon-client-render/src/streaming.rs::Streaming::submit queues the job by distance
|
||||
ChunkMap.onChunkReadyToSend inline crates/horizon-client/src/lan/hub.rs::Hub::next_batch queued columns are sent once lit
|
||||
ChunkMap.prepareAccessibleChunk inline crates/horizon-client-render/src/streaming.rs::Streaming::update a column finishes once its 3x3 is decorated
|
||||
ChunkMap.allChunksWithAtLeastStatus inline crates/horizon-client/src/game.rs::Game::level_load_progress only the loading screen asks: lit chunks around the player
|
||||
ChunkMap.allChunksWithAtLeastStatus inline crates/horizon-client/src/game/integrated.rs::Game::publish_load_view only the loading screen asks: lit chunks around the spawn
|
||||
ChunkMap.saveChunkIfNeeded inline crates/horizon-client/src/game.rs::Game::save_level the modified set stands for the unsaved flag; autosave every 6000 ticks
|
||||
ChunkMap.isExistingChunkFull inline crates/horizon-client-render/src/streaming.rs::Streaming::save_checked_out protos exist only for columns not final, so one cannot overwrite a final chunk
|
||||
ChunkMap.markChunkPendingToSend port crates/horizon-client/src/lan/hub.rs::requeue chunks entering the view join the guest's queue, closest first
|
||||
@@ -138,7 +138,7 @@ GenerationChunkHolder.getLatestStatus port crates/horizon-client-render/src/stre
|
||||
LoadingChunkTracker.getLevel port crates/horizon-client/src/ticket_levels.rs::Levels::get the loading half of the pair
|
||||
LoadingChunkTracker.setLevel inline crates/horizon-client/src/ticket_levels.rs::ChunkTickets::update levels are rebuilt when sources change
|
||||
PlayerSpawnFinder.scheduleNext port crates/horizon-client/src/world_spawn.rs::PlayerSpawnFinder::next_candidate coprime stride order, then the suggestion
|
||||
PlayerSpawnFinder.scheduleCandidate port crates/horizon-client/src/game/initial_spawn.rs::Game::search_spawn gap: minor: the in-frame path waits for the candidate's chunk, but a server tick (LAN, dedicated) passes over candidates in unloaded chunks, so a new guest can stand on another spot of the radius
|
||||
PlayerSpawnFinder.scheduleCandidate port crates/horizon-client/src/game/initial_spawn.rs::Game::search_spawn gap: minor: a server tick passes over candidates in unloaded chunks instead of waiting for the candidate's chunk, so a new player can stand on another spot of the radius
|
||||
SectionTracker.checkNeighborsAfterUpdate inline crates/horizon-client/src/game/raids.rs::sections_to_village its only subclass is the POI village distance: Chebyshev distance in sections, 7 when farther than 6
|
||||
SectionTracker.getComputedLevel inline crates/horizon-client/src/game/raids.rs::sections_to_village
|
||||
SectionTracker.computeLevelFromNeighbor inline crates/horizon-client/src/game/raids.rs::sections_to_village
|
||||
|
@@ -25,7 +25,7 @@ AbstractVillager.getSlot missing minor: commands cannot address a villager inve
|
||||
AbstractVillager.addOffersFromItemListings port crates/horizon-client/src/merchant.rs::Trades::offers_from_set the allow_duplicates path: a picked trade stays in the pool
|
||||
AbstractVillager.addOffersFromItemListingsWithoutDuplicates port crates/horizon-client/src/merchant.rs::Trades::offers_from_set the no-duplicates path: a picked trade leaves the pool
|
||||
AbstractVillager.getRopeHoldPosition skip:ui lead rope drawing point only; the lead is drawn by leash_geometry in game.rs
|
||||
AbstractVillager.stillValid port crates/horizon-client/src/game/lan_trading.rs::Game::lan_merchant_tick closes when the trader is gone or dead, or the guest passes the interaction range plus 4 (7 blocks survival, 9 creative); the in-frame twin is game/trading.rs::Game::merchant_tick, and lan_e2e merchant-close covers the host and guest screens
|
||||
AbstractVillager.stillValid port crates/horizon-client/src/game/lan_trading.rs::Game::lan_merchant_tick closes when the trader is gone or dead, or the guest passes the interaction range plus 4 (7 blocks survival, 9 creative); lan_e2e merchant-close covers the host and guest screens
|
||||
Villager.ageBoundaryReached missing player: a villager that grows up keeps the baby brain (the brain is built once with its baby flag), so it never goes to work
|
||||
Villager.startTrading port crates/horizon-client/src/game/trading.rs::Game::interact_trader opens the trade screen for the player
|
||||
Villager.resetSpecialPrices port crates/horizon-client/src/merchant.rs::update_special_prices recomputes special prices from scratch, which drops the old diff
|
||||
|
||||
|
@@ -16,14 +16,14 @@ AbstractBoat.isUnderwater inline crates/horizon-client/src/game/boats.rs::Game::
|
||||
AbstractBoat.floatBoat inline crates/horizon-client/src/game/boats.rs::Game::tick_boat buoyancy, friction and the first-water-touch snap are in tick_boat
|
||||
AbstractBoat.controlBoat inline crates/horizon-client/src/game/boats.rs::Game::tick_boat steering, acceleration and paddle flags in the controlled branch of tick_boat
|
||||
AbstractBoat.getDismountLocationForPassenger port crates/horizon-client/src/game/boats.rs::Game::boat_dismount_target gap: minor: no dismount pose check; the floor and water tests are there
|
||||
AbstractBoat.clampRotation inline crates/horizon-client/src/game/boats.rs::Game::tick_riding the rider's yaw is clamped to 105 degrees off the boat; mobs get the head clamp in boat_passengers.rs seat_boat_mobs
|
||||
AbstractBoat.clampRotation inline crates/horizon-client/src/game/remote_vehicles.rs::Game::remote_ride the rider's yaw is clamped to 105 degrees off the boat; mobs get the head clamp in boat_passengers.rs seat_boat_mobs
|
||||
AbstractBoat.calculatePassengerBodyYRot inline crates/horizon-client/src/game/boat_passengers.rs::Game::seat_boat_mobs animal turned 90 or 270 degrees when the boat is full
|
||||
AbstractBoat.onPassengerTurned inline crates/horizon-client/src/game/boats.rs::Game::tick_riding the rider's turn is clamped each tick
|
||||
AbstractBoat.remove inline crates/horizon-client/src/game/boats.rs::Game::hurt_boat destroyed boats eject mobs and drop the boat; gap: minor: no leash drop, since boats cannot be leashed here
|
||||
AbstractBoat.checkFallDamage missing minor: boats keep no fall distance; boats take no fall damage either way
|
||||
AbstractBoat.getPaddleState port crates/horizon-client/src/game/boats.rs::Boat::rowing_time paddle flag, set only while a rider drives
|
||||
AbstractBoat.getControllingPassenger inline crates/horizon-client/src/game/boats.rs::Game::tick_riding the riding player is the controller; mobs never steer
|
||||
AbstractBoat.setInput inline crates/horizon-client/src/game/boats.rs::Game::tick_riding input flags are copied from the keys each tick
|
||||
AbstractBoat.getControllingPassenger inline crates/horizon-client/src/game/boat_passengers.rs::Game::boat_contacts a riding player is the controller; mobs never steer
|
||||
AbstractBoat.setInput inline crates/horizon-client/src/game/lan_vehicles.rs::Game::guest_paddle_boat the paddle packet's flags are the controller's input
|
||||
AbstractBoat.isUnderWater port crates/horizon-client/src/game/boats.rs::Boat::under_water
|
||||
AbstractBoat.modifyPassengerFluidInteractionBox inline crates/horizon-client/src/motion/fluids.rs::MotionWorld::interaction_box the ridden boat (RiddenBoat) is set on clients in remote.rs and for served players in lan_actions.rs; the passenger box keeps only the part above the boat's top
|
||||
AbstractChestBoat.remove inline crates/horizon-client/src/game/boats.rs::Game::hurt_boat a destroyed chest boat spills its 27 slots
|
||||
|
||||
|
@@ -117,7 +117,7 @@ MushroomBlock.randomTick port crates/horizon-world/src/level/tick.rs::Sim::mushr
|
||||
MushroomBlock.canSpread inline crates/horizon-world/src/level/tick.rs::Sim::mushroom_random_tick at most four of the same mushroom in the 9x3x9 box, counted in the tick
|
||||
MushroomBlock.isValidBonemealTarget port crates/horizon-world/src/level/bonemeal.rs::Sim::bonemeal_valid mushroom arm
|
||||
NetherPortalBlock.updateShape port crates/horizon-world/src/level/shapes.rs::Sim::update_shape_class portal breaks when the frame is incomplete
|
||||
NetherPortalBlock.getPortalTransitionTime port crates/horizon-client/src/game/nether_portal.rs::Game::tick_nether_portal gap: minor: creative and default delay rules by invulnerability; the delay counter is in the same fn
|
||||
NetherPortalBlock.getPortalTransitionTime port crates/horizon-client/src/game/server_travel.rs::Game::handle_server_portal creative and default delay game rules by invulnerability; the delay counter is in the same fn
|
||||
NetherPortalBlock.getExitPortal port crates/horizon-client/src/game/nether_portal.rs::Game::nether_exit_portal gap: minor: the Nether exit only; the End variant is Game::end_portal_exit
|
||||
NetherPortalBlock.getDimensionTransitionFromExit port crates/horizon-client/src/game/nether_portal.rs::Game::portal_plan
|
||||
NetherPortalBlock.animateTick port crates/horizon-client/src/animate_tick.rs::block_animate_tick portal ambient sound and particles
|
||||
|
||||
|
@@ -143,8 +143,8 @@ PortalShape.getDistanceUntilEdgeAboveFrame port crates/horizon-world/src/level/p
|
||||
PortalShape.calculateHeight inline crates/horizon-world/src/level/portal.rs::Sim::find_portal_shape height rows loop and the 3 to 21 check are inline
|
||||
PortalShape.hasTopFrame inline crates/horizon-world/src/level/portal.rs::Sim::find_portal_shape the top frame row check is inline
|
||||
PortalShape.getDistanceUntilTop inline crates/horizon-world/src/level/portal.rs::Sim::find_portal_shape rows loop counts portal blocks as it goes, inline
|
||||
TeleportTransition.playPortalSound inline crates/horizon-client/src/game/nether_portal.rs::Game::arrive_through_nether_portal level event 1032 is sent on arrival
|
||||
TeleportTransition.withRotation inline crates/horizon-client/src/game/nether_portal.rs::Game::arrive_through_nether_portal the turned yaw and motion are applied on arrival
|
||||
TeleportTransition.withPosition inline crates/horizon-client/src/game/nether_portal.rs::Game::arrive_through_nether_portal the exit position is applied on arrival
|
||||
TeleportTransition.playPortalSound inline crates/horizon-client/src/lan/hub.rs::Hub::land level event 1032 is sent on arrival
|
||||
TeleportTransition.withRotation inline crates/horizon-client/src/lan/hub.rs::Hub::change_level the turned yaw is applied with the level change
|
||||
TeleportTransition.withPosition inline crates/horizon-client/src/lan/hub.rs::Hub::land the exit position is applied on arrival
|
||||
TeleportTransition.transitionAsPassenger port crates/horizon-client/src/game/server_travel.rs::Game::carry_riders_through_portals riders travel with the vehicle and ride it again on arrival
|
||||
DefaultRedstoneWireEvaluator.calculateTargetStrength inline crates/horizon-world/src/level/redstone.rs::Sim::wire_update_power the 15 shortcut and the max with the incoming signal are inline
|
||||
|
@@ -27,7 +27,7 @@ LegacyRandomSource.LegacyPositionalRandomFactory.fromHashOf port crates/horizon-
|
||||
NoiseBasedChunkGenerator.createFluidPicker port crates/horizon-world/src/worldgen/aquifer.rs::GlobalFluidPicker::new lava below -54 or sea level, else the default fluid
|
||||
NoiseBasedChunkGenerator.createNoiseChunk inline crates/horizon-world/src/worldgen/generator.rs::Pipeline::generate_with_context beardifier field, density volume and aquifer built inline
|
||||
NoiseBasedChunkGenerator.chunkVolume inline crates/horizon-world/src/worldgen/generator.rs::Pipeline::generate_with_context DensityVolume 16 x height x 16
|
||||
NoiseBasedChunkGenerator.addDebugScreenInfo port crates/horizon-world/src/worldgen/generator.rs::GeneratorWorker::debug_density_line F3 Density line of the noise settings debug_functions, shown for the window of a server thread or an in-frame world
|
||||
NoiseBasedChunkGenerator.addDebugScreenInfo port crates/horizon-world/src/worldgen/generator.rs::GeneratorWorker::debug_density_line F3 Density line of the noise settings debug_functions, shown for the window of a server thread
|
||||
NoiseBasedChunkGenerator.iterateNoiseColumn port crates/horizon-world/src/worldgen/generator.rs::Pipeline::noise_column base_height and base_column are built on it
|
||||
NoiseBasedChunkGenerator.buildSurface port crates/horizon-world/src/worldgen/material_stage.rs::build_surface
|
||||
NoiseBasedChunkGenerator.generateCarvers port crates/horizon-world/src/worldgen/carver/generation.rs::generate_carving_mask the mask is applied by apply_carving_mask; no old-chunk filter
|
||||
|
||||
|
@@ -3,8 +3,7 @@
|
||||
|
||||
Build horizon-client and horizon-ctl with the fast profile first, then run:
|
||||
python3 tools/singleplayer_end_cargo_audit.py --case gateway
|
||||
Runs the in-frame singleplayer (and the server-thread one with --mode server):
|
||||
a cow and a seven-diamond stack stand in a gateway in the End, and both are
|
||||
Runs singleplayer on its server thread: a cow and a seven-diamond stack stand in a gateway in the End, and both are
|
||||
expected to leave it and land at the gateway's exit, the stack with its seven
|
||||
diamonds.
|
||||
"""
|
||||
@@ -47,7 +46,7 @@ def numbers(text):
|
||||
return [float(v) for v in re.findall(r'-?\d+(?:\.\d+)?', text.split(':', 1)[-1])]
|
||||
|
||||
|
||||
def run(bin_dir, resources, in_frame):
|
||||
def run(bin_dir, resources):
|
||||
with tempfile.TemporaryDirectory(prefix='hz-end-gateway-') as scratch:
|
||||
work = Path(scratch)
|
||||
env = lab.game_env({k: v for k, v in os.environ.items() if k not in (
|
||||
@@ -58,8 +57,6 @@ def run(bin_dir, resources, in_frame):
|
||||
args = [str(bin_dir / 'horizon-client'), '--resources', str(resources),
|
||||
'--world', 'cargo', '--creative', '--seed', '4242', '--offscreen',
|
||||
'--render-distance', '4', '--control', '--control-socket', str(socket)]
|
||||
if in_frame:
|
||||
args.append('--in-frame')
|
||||
with (work / 'game.log').open('w') as log:
|
||||
process = subprocess.Popen(args, cwd=home, env=env, stdout=log, stderr=log)
|
||||
try:
|
||||
@@ -129,8 +126,7 @@ def run(bin_dir, resources, in_frame):
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
process.wait(timeout=30)
|
||||
print(f'PASS end gateway: {"in-frame" if in_frame else "server-thread"}, '
|
||||
f'the cow and the stack left the gateway with their data')
|
||||
print('PASS end gateway: the cow and the stack left the gateway with their data')
|
||||
|
||||
|
||||
def main():
|
||||
@@ -138,11 +134,8 @@ def main():
|
||||
parser.add_argument('--case', choices=['gateway'], default='gateway')
|
||||
parser.add_argument('--bin-dir', type=Path, default=lab.cargo_target() / 'fast')
|
||||
parser.add_argument('--resources', type=Path, default=lab.RESOURCES)
|
||||
parser.add_argument('--mode', choices=['server', 'in-frame', 'both'], default='in-frame')
|
||||
args = parser.parse_args()
|
||||
modes = {'server': (False,), 'in-frame': (True,), 'both': (False, True)}
|
||||
for in_frame in modes[args.mode]:
|
||||
run(args.bin_dir.resolve(), args.resources.resolve(), in_frame)
|
||||
run(args.bin_dir.resolve(), args.resources.resolve())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
Build horizon-client and horizon-ctl with the fast profile first, then run:
|
||||
python3 tools/singleplayer_forced_chunks_audit.py --case force-save
|
||||
Runs both default server-thread and in-frame singleplayer, using scratch homes.
|
||||
Runs singleplayer on its server thread, using scratch homes.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
@@ -39,7 +39,7 @@ def query(cli, dimension, expected):
|
||||
raise AssertionError(f'{dimension}: expected {expected}, got {reply}')
|
||||
|
||||
|
||||
def run(bin_dir, resources, in_frame):
|
||||
def run(bin_dir, resources):
|
||||
with tempfile.TemporaryDirectory(prefix='hz-force-save-') as scratch:
|
||||
work = Path(scratch)
|
||||
previous = None
|
||||
@@ -55,8 +55,6 @@ def run(bin_dir, resources, in_frame):
|
||||
args = [str(bin_dir / 'horizon-client'), '--resources', str(resources),
|
||||
'--world', 'forced', '--creative', '--seed', '4242', '--offscreen',
|
||||
'--render-distance', '4', '--control', '--control-socket', str(socket)]
|
||||
if in_frame:
|
||||
args.append('--in-frame')
|
||||
with (work / f'{stage}.log').open('w') as log:
|
||||
process = subprocess.Popen(args, cwd=home, env=env, stdout=log, stderr=log)
|
||||
try:
|
||||
@@ -94,7 +92,7 @@ def run(bin_dir, resources, in_frame):
|
||||
process.terminate()
|
||||
process.wait(timeout=30)
|
||||
previous = home / 'saves'
|
||||
print(f'PASS force-save: {"in-frame" if in_frame else "server-thread"}, three dimensions, add/remove/restart')
|
||||
print('PASS force-save: three dimensions, add/remove/restart')
|
||||
|
||||
|
||||
def main():
|
||||
@@ -102,8 +100,7 @@ def main():
|
||||
parser.add_argument('--case', choices=['force-save'], default='force-save')
|
||||
parser.add_argument('--bin-dir', type=Path, default=Path(__file__).resolve().parents[1] / 'target/fast')
|
||||
args = parser.parse_args()
|
||||
for in_frame in (False, True):
|
||||
run(args.bin_dir.resolve(), lab.RESOURCES.resolve(), in_frame)
|
||||
run(args.bin_dir.resolve(), lab.RESOURCES.resolve())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -33,7 +33,6 @@ def run(mode, bins, resources):
|
||||
if previous:shutil.copytree(previous,home/'saves')
|
||||
sock=work/f'{stage}.sock';cli=Cli(bins/'horizon-ctl',sock,home,env)
|
||||
args=[str(bins/'horizon-client'),'--resources',str(resources),'--world','VehicleProof','--creative','--offscreen','--render-distance','2','--control','--control-socket',str(sock)]
|
||||
if mode=='in-frame':args+=['--in-frame']
|
||||
with (work/f'{stage}.log').open('w') as out:
|
||||
p=subprocess.Popen(args,cwd=home,env=env,stdout=out,stderr=out)
|
||||
try:
|
||||
@@ -67,7 +66,5 @@ if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--bin-dir', type=Path, required=True)
|
||||
parser.add_argument('--resources', type=Path, default=lab.RESOURCES)
|
||||
parser.add_argument('--mode', choices=['server', 'in-frame', 'both'], default='both')
|
||||
args = parser.parse_args()
|
||||
for mode in (('server', 'in-frame') if args.mode == 'both' else (args.mode,)):
|
||||
run(mode, args.bin_dir.resolve(), args.resources.resolve())
|
||||
run('server', args.bin_dir.resolve(), args.resources.resolve())
|
||||
@@ -93,7 +93,7 @@ def main():
|
||||
rust_dir.mkdir()
|
||||
# The oracle's Services resolve no profile (/fetchprofile fails on the next tick): the game gets no services
|
||||
# either, a discovery address that refuses at once, rather than Mojang's over the network.
|
||||
env = lab.game_env(HORIZON_SERVER_THREAD=1, HORIZON_PLAYER_NAME="Player", HORIZON_COMMAND_SCRIPT=script_path,
|
||||
env = lab.game_env(HORIZON_PLAYER_NAME="Player", HORIZON_COMMAND_SCRIPT=script_path,
|
||||
HORIZON_COMMAND_OUTPUT=build / 'rust.jsonl', HORIZON_API_DISCOVERY='http://127.0.0.1:9/')
|
||||
run([binary, '--resources', lab.RESOURCES, '--world', 'harness', '--seed', SEED, '--radius', 4], 'rust', game=True, cwd=rust_dir, env=env)
|
||||
|
||||
|
||||
@@ -287,12 +287,12 @@ def species_cases(layer, base_cases):
|
||||
yield {**base_cases[index], **extra}
|
||||
if name == 'bat':
|
||||
# Entity clocks, rather than injected render clocks: flight, rest, transitions and
|
||||
# unrelated flag bits. Exercise the in-frame tick and server-client visual update.
|
||||
# unrelated flag bits. Exercise the server's tick and the client's visual update.
|
||||
for flags in ([0] * 4, [1] * 4, [0, 0, 1, 1, 0, 0], [1, 1, 0, 0, 1, 1], [2, 3, 2, 2]):
|
||||
for in_frame in (False, True):
|
||||
for server_tick in (False, True):
|
||||
for partial in (0.0, 0.5):
|
||||
yield {**base_cases[0], 'ageInTicks': len(flags) + partial,
|
||||
'@batTicks': flags, '@batInFrame': in_frame}
|
||||
'@batTicks': flags, '@batServerTick': server_tick}
|
||||
|
||||
|
||||
def compare_vertices(v, r, args, stats):
|
||||
|
||||
+12
-26
@@ -15,9 +15,7 @@ is off: nothing leaves the machine.
|
||||
|
||||
python3 tools/test_friends.py --client target/fast/horizon-client --relay target/fast/horizon-relay
|
||||
|
||||
The host runs on its integrated server thread by default (the window is a
|
||||
client of it). `--in-frame` is DEPRECATED (never use it, it will be removed soon);
|
||||
`--server-thread` is accepted for compatibility.
|
||||
The host runs on its integrated server thread (the window is a client of it).
|
||||
|
||||
`--role host|guest` runs one side only (two machines, e.g. over a tailnet):
|
||||
the host prints the code, the guest takes it with `--code` (`--relay-url`
|
||||
@@ -87,12 +85,8 @@ class Game:
|
||||
# Extra words before and after the client (`--xvfb`, `--offscreen`).
|
||||
prefix = []
|
||||
extra = []
|
||||
# The host's world on its server thread (`--server-thread`), else in the window.
|
||||
server_thread = True
|
||||
|
||||
def __init__(self, client, resources, work, name, env):
|
||||
# Either way explicitly, whatever the game's default.
|
||||
env = dict(env, HORIZON_SERVER_THREAD='1' if Game.server_thread and name == 'host' else '0')
|
||||
self.home = work / name
|
||||
self.home.mkdir()
|
||||
self.endpoint = self.home / 'c.sock'
|
||||
@@ -287,16 +281,15 @@ def full(args, work):
|
||||
assert "Couldn't reach your friend's game" in reason, reason
|
||||
print(f'after stopping: "{reason}"')
|
||||
guest.stop()
|
||||
if Game.server_thread:
|
||||
# Leaving the world ends its code; coming back shares a new one.
|
||||
host('friends host')
|
||||
host.wait('friends status', lambda v: v['code'], 30, 'invite code')
|
||||
host('disconnect')
|
||||
host.wait('screen', lambda v: v['screen'] == 'title', 60, 'the title screen')
|
||||
assert host('friends status')['code'] is None, 'the code outlived its world'
|
||||
assert host_world(host) != code
|
||||
host('friends stop')
|
||||
print('leaving the world ends its code')
|
||||
# Leaving the world ends its code; coming back shares a new one.
|
||||
host('friends host')
|
||||
host.wait('friends status', lambda v: v['code'], 30, 'invite code')
|
||||
host('disconnect')
|
||||
host.wait('screen', lambda v: v['screen'] == 'title', 60, 'the title screen')
|
||||
assert host('friends status')['code'] is None, 'the code outlived its world'
|
||||
assert host_world(host) != code
|
||||
host('friends stop')
|
||||
print('leaving the world ends its code')
|
||||
# (b) A self-hosted relay, run locally: the short code, relayed.
|
||||
relay, relay_log, relay_url = start_relay(args, work)
|
||||
set_relay(host, relay_url)
|
||||
@@ -384,18 +377,12 @@ def main():
|
||||
parser.add_argument('--hold', type=float, default=0, help='guest: stay this many seconds before leaving')
|
||||
parser.add_argument('--baseline', action='store_true', help='first run the same checks over plain LAN')
|
||||
parser.add_argument('--offscreen', action='store_true', help='run the games without a window')
|
||||
mode = parser.add_mutually_exclusive_group()
|
||||
mode.add_argument('--server-thread', dest='server_thread', action='store_true', default=True,
|
||||
help='host on its server thread (default; retained for existing callers)')
|
||||
mode.add_argument('--in-frame', dest='server_thread', action='store_false',
|
||||
help='DEPRECATED, never use: unsupported, will be removed soon')
|
||||
# The host always runs on its server thread; the flag stays for existing callers.
|
||||
parser.add_argument('--server-thread', action='store_true', help=argparse.SUPPRESS)
|
||||
parser.add_argument('--xvfb', action='store_true', help='run the games under xvfb-run (headless Linux)')
|
||||
args = parser.parse_args()
|
||||
Game.prefix = ['xvfb-run', '-a'] if args.xvfb else []
|
||||
Game.extra = ['--offscreen'] if args.offscreen else []
|
||||
Game.server_thread = args.server_thread
|
||||
if not args.server_thread:
|
||||
print('DEPRECATED: --in-frame is unsupported and will be removed soon. Do not use it.', file=sys.stderr)
|
||||
args.client, args.resources = args.client.resolve(), args.resources.resolve()
|
||||
try:
|
||||
require_server_reports(args.resources)
|
||||
@@ -404,7 +391,6 @@ def main():
|
||||
# Short: Unix socket paths are limited to about 100 bytes.
|
||||
with tempfile.TemporaryDirectory(prefix='hzf-', dir='/tmp') as directory:
|
||||
work = Path(directory)
|
||||
print(f'Friends host mode: {"server-thread" if Game.server_thread else "in-frame"}')
|
||||
if args.role:
|
||||
return one_side(args, work)
|
||||
assert args.relay, 'give --relay (the relay binary, run locally for the relay half)'
|
||||
|
||||
@@ -90,16 +90,12 @@ def main():
|
||||
# Use the runner's current display (xvfb on Linux), with fresh isolated homes.
|
||||
lan.game_env = lambda _screen, home: lab.game_env(
|
||||
HORIZON_HOME=home, HORIZON_TELEMETRY_URL='off')
|
||||
reports = {}
|
||||
for threaded in (True, False):
|
||||
lan.SERVER_THREAD = threaded
|
||||
mode = 'server-thread' if threaded else 'in-frame'
|
||||
out = lab.LOCAL / 'remote-fireball-parity' / mode
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
# lan.Game launches each client with lab.game_env and private scratch homes.
|
||||
reports[mode] = lan.run(ctl, out, lab.RESOURCES.resolve(), ['remote-fireball', 'remote-thrown'])
|
||||
lab.write_json(lab.LOCAL / 'remote-fireball-parity' / 'report.json', reports)
|
||||
assert all(r['pass'] for mode in reports.values() for r in mode.values()), reports
|
||||
out = lab.LOCAL / 'remote-fireball-parity'
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
# lan.Game launches each client with lab.game_env and private scratch homes.
|
||||
report = lan.run(ctl, out, lab.RESOURCES.resolve(), ['remote-fireball', 'remote-thrown'])
|
||||
lab.write_json(out / 'report.json', report)
|
||||
assert all(r['pass'] for r in report.values()), report
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -19,15 +19,11 @@ def main():
|
||||
# CI supplies the display; each game still gets fresh scratch homes.
|
||||
lan.game_env = lambda _screen, home: lab.game_env(
|
||||
HORIZON_HOME=home, HORIZON_TELEMETRY_URL='off')
|
||||
reports = {}
|
||||
for threaded in (True, False):
|
||||
lan.SERVER_THREAD = threaded
|
||||
mode = 'server-thread' if threaded else 'in-frame'
|
||||
out = lab.LOCAL / 'remote-fireworks-parity' / mode
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
reports[mode] = lan.run(ctl, out, lab.RESOURCES.resolve(), ['fireworks'])
|
||||
lab.write_json(lab.LOCAL / 'remote-fireworks-parity' / 'report.json', reports)
|
||||
assert all(r['pass'] for mode in reports.values() for r in mode.values()), reports
|
||||
out = lab.LOCAL / 'remote-fireworks-parity'
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
report = lan.run(ctl, out, lab.RESOURCES.resolve(), ['fireworks'])
|
||||
lab.write_json(out / 'report.json', report)
|
||||
assert all(r['pass'] for r in report.values()), report
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user