mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
Merge pull request #10384 from voidsipp/fix/10044-forced-respawn
Validate forced Overworld respawn points
This commit is contained in:
4 files changed
+153
-51
No files matched your search
@@ -3488,22 +3488,13 @@ impl Game<'_> {
|
||||
if cancel {
|
||||
return;
|
||||
}
|
||||
if level == Dim::Overworld {
|
||||
g.spawn = Some(Vec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 0.5,
|
||||
));
|
||||
g.respawn_config = None;
|
||||
} else {
|
||||
g.spawn = None;
|
||||
g.respawn_config = Some(super::respawn_anchor::RespawnConfig {
|
||||
dim: level,
|
||||
pos,
|
||||
yaw,
|
||||
forced: true,
|
||||
});
|
||||
}
|
||||
g.spawn = None;
|
||||
g.respawn_config = Some(super::respawn_anchor::RespawnConfig {
|
||||
dim: level,
|
||||
pos,
|
||||
yaw,
|
||||
forced: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
let dim = Text::literal(self.dim.id());
|
||||
@@ -4761,3 +4752,87 @@ mod query_feedback_tests {
|
||||
assert!(lines[0].plain().contains("1234"), "{:?}", lines[0].plain());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod forced_respawn_tests {
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn overworld_spawnpoint_is_validated_at_both_player_blocks() {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.player = Some(
|
||||
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(5.5, 64.0, 5.5))
|
||||
.unwrap(),
|
||||
);
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.level.world_spawn = Some([0, 80, 0]);
|
||||
let registry = game.pack.assets.registry;
|
||||
let air = registry.default_state("minecraft:air").unwrap();
|
||||
// Liquid is a block property: aquatic plants such as kelp still permit
|
||||
// a forced point, while a bubble column does not, at feet or head.
|
||||
for (block, allowed) in [
|
||||
("air", true),
|
||||
("water", false),
|
||||
("lava", false),
|
||||
("bubble_column", false),
|
||||
("kelp", true),
|
||||
("stone", false),
|
||||
] {
|
||||
let state = registry
|
||||
.default_state(&format!("minecraft:{block}"))
|
||||
.unwrap();
|
||||
for height in [0, 1] {
|
||||
game.world.set(5, 64, 5, air).unwrap();
|
||||
game.world.set(5, 65, 5, air).unwrap();
|
||||
game.world.set(5, 64 + height, 5, state).unwrap();
|
||||
assert!(game.run_command("spawnpoint @s 5 64 5"));
|
||||
let config = game
|
||||
.respawn_config
|
||||
.expect("Overworld commands must retain a forced configuration");
|
||||
assert!(config.forced);
|
||||
assert_eq!(config.pos, [5, 64, 5]);
|
||||
let actual = game.resolve_server_respawn(config);
|
||||
assert_eq!(actual.is_some(), allowed, "{block} at height {height}");
|
||||
assert_eq!(game.respawn_config.is_some(), allowed);
|
||||
assert_eq!(game.level.world_spawn, Some([0, 80, 0]));
|
||||
if let Some((at, yaw)) = actual {
|
||||
assert_eq!(at, Vec3::new(5.5, 64.1, 5.5));
|
||||
assert_eq!(yaw, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,9 @@ use horizon_core::{
|
||||
geometry::{Aabb, Axis, Vec3, VoxelShape},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::{collision_context::CollisionContext, registry::BlockStateId};
|
||||
use horizon_world::{
|
||||
collision_context::CollisionContext, level::data::state_flags, registry::BlockStateId,
|
||||
};
|
||||
|
||||
/// `ServerPlayer.RespawnConfig`: a respawn point other than the world spawn
|
||||
/// that the game resolves through its block (a respawn anchor).
|
||||
@@ -69,14 +71,6 @@ const HORIZONTAL: [[i32; 3]; 8] = [
|
||||
[-1, 0, 1],
|
||||
[1, 0, 1],
|
||||
];
|
||||
/// Blocks vanilla defines with `.liquid()`: water, lava and the bubble column.
|
||||
fn is_liquid_block_name(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"minecraft:water" | "minecraft:lava" | "minecraft:bubble_column"
|
||||
)
|
||||
}
|
||||
|
||||
fn respawn_offsets() -> impl Iterator<Item = [i32; 3]> {
|
||||
HORIZONTAL
|
||||
.into_iter()
|
||||
@@ -418,10 +412,11 @@ impl Game<'_> {
|
||||
fn possible_to_respawn_in(&self, state: BlockStateId) -> bool {
|
||||
let f = self.res.facts().get(state);
|
||||
let name = &*f.name;
|
||||
let liquid = self.res.behaviours().facts(state).flags & state_flags::LIQUID != 0;
|
||||
name.ends_with("_banner")
|
||||
|| f.sign_form().is_some()
|
||||
|| name.ends_with("_pressure_plate")
|
||||
|| !(f.legacy_solid || is_liquid_block_name(name))
|
||||
|| !(f.legacy_solid || liquid)
|
||||
}
|
||||
/// `RespawnAnchorBlock.findStandUpPosition`: a safe spot around the
|
||||
/// anchor, else any spot.
|
||||
@@ -547,14 +542,6 @@ fn wrap_degrees(degrees: f64) -> f64 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bubble_column_counts_as_liquid_for_respawn() {
|
||||
assert!(is_liquid_block_name("minecraft:bubble_column"));
|
||||
assert!(is_liquid_block_name("minecraft:water"));
|
||||
assert!(is_liquid_block_name("minecraft:lava"));
|
||||
assert!(!is_liquid_block_name("minecraft:kelp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waking_up_next_to_a_bed_looks_toward_the_bed_head() {
|
||||
let head = [10, 64, 5];
|
||||
|
||||
@@ -3686,11 +3686,11 @@ pub struct Level {
|
||||
pub previous_game_mode: Option<i32>,
|
||||
pub time: i64,
|
||||
pub day_time: i64,
|
||||
/// Where the player respawns when it has no bed or anchor: its forced
|
||||
/// Overworld point (`spawnpoint`), or the world spawn.
|
||||
/// The world spawn or a legacy bare Overworld spawn column. Personal
|
||||
/// points use `respawn_elsewhere` so their blocks are checked on death.
|
||||
pub spawn: Option<[f64; 3]>,
|
||||
/// A respawn point resolved through its block when the player respawns
|
||||
/// (a bed, a respawn anchor, a forced point outside the Overworld):
|
||||
/// (a bed, a respawn anchor, a forced point in any dimension):
|
||||
/// dimension, block, yaw and `forced`.
|
||||
pub respawn_elsewhere: Option<(String, [i32; 3], f32, bool)>,
|
||||
pub position: Option<[f64; 3]>,
|
||||
@@ -4033,18 +4033,13 @@ impl Level {
|
||||
let dimension =
|
||||
text(respawn.get("dimension")).unwrap_or_else(|| "minecraft:overworld".into());
|
||||
let forced = number(respawn.get("forced")).is_some_and(|v| v != 0);
|
||||
// A forced point in the Overworld (`spawnpoint`) is where the
|
||||
// player respawns; anything else (a bed, an anchor) is resolved
|
||||
// through its block then.
|
||||
if dimension == "minecraft:overworld" && forced {
|
||||
self.spawn = Some([f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])]);
|
||||
} else {
|
||||
let yaw = match respawn.get("yaw") {
|
||||
Some(Tag::Float(b)) => f32::from_bits(*b),
|
||||
_ => 0.0,
|
||||
};
|
||||
self.respawn_elsewhere = Some((dimension, [pos[0], pos[1], pos[2]], yaw, forced));
|
||||
}
|
||||
// Forced points, including Overworld ones, are validated through
|
||||
// their blocks on death, just like beds and respawn anchors.
|
||||
let yaw = match respawn.get("yaw") {
|
||||
Some(Tag::Float(b)) => f32::from_bits(*b),
|
||||
_ => 0.0,
|
||||
};
|
||||
self.respawn_elsewhere = Some((dimension, [pos[0], pos[1], pos[2]], yaw, forced));
|
||||
}
|
||||
let mut inventory = Vec::new();
|
||||
let mut inventory_components = HashMap::new();
|
||||
@@ -7146,7 +7141,11 @@ mod tests {
|
||||
assert_eq!(back.dimension, "minecraft:the_nether");
|
||||
assert_eq!(back.world_spawn, Some([8, 64, 8]));
|
||||
assert_eq!(back.world_spawn_angle, [45.0, -10.0]);
|
||||
assert_eq!(back.spawn, Some([20.0, 70.0, 20.0]));
|
||||
assert_eq!(back.spawn, Some([8.0, 64.0, 8.0]));
|
||||
assert_eq!(
|
||||
back.respawn_elsewhere,
|
||||
Some(("minecraft:overworld".into(), [20, 70, 20], 0.0, true))
|
||||
);
|
||||
assert!(back.difficulty_locked);
|
||||
assert!(back.weather.raining);
|
||||
assert_eq!(back.weather.rain_time, 500);
|
||||
@@ -7411,6 +7410,31 @@ mod tests {
|
||||
assert_eq!(loaded.active_effects, effects);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_overworld_spawnpoint_reloads_as_a_configuration() {
|
||||
// A personal point coinciding with the world spawn must still keep
|
||||
// its forced flag and yaw, rather than turning into a world fallback.
|
||||
for pos in [[5, 64, 5], [0, 80, 0]] {
|
||||
let point = ("minecraft:overworld".to_owned(), pos, 37.0, true);
|
||||
let level = Level {
|
||||
respawn_elsewhere: Some(point.clone()),
|
||||
..Level::default()
|
||||
};
|
||||
let mut loaded = Level {
|
||||
spawn: Some([0.0, 80.0, 0.0]),
|
||||
world_spawn: Some([0, 80, 0]),
|
||||
..Level::default()
|
||||
};
|
||||
loaded.read_player(&level.player_tag());
|
||||
assert_eq!(loaded.respawn_elsewhere, Some(point));
|
||||
assert_eq!(loaded.spawn, Some([0.0, 80.0, 0.0]));
|
||||
assert_eq!(
|
||||
loaded.player_tag().get("respawn"),
|
||||
level.player_tag().get("respawn")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creative_flight_status_persists_in_save_and_resets_for_survival() {
|
||||
let mut level = Level {
|
||||
|
||||
@@ -940,6 +940,22 @@ def placement_cases(facts, count, seed):
|
||||
yield case
|
||||
|
||||
|
||||
def forced_respawn_cases(facts):
|
||||
"""Non-bed forced points: Block.isPossibleToRespawnInThis at feet and head.
|
||||
|
||||
Plants containing water are not liquid blocks; bubble columns are. Keep both
|
||||
cases even in a small run so fluid-containing is not substituted for liquid.
|
||||
"""
|
||||
states = d.States(facts)
|
||||
for name in ('air', 'water', 'lava', 'bubble_column', 'kelp', 'seagrass', 'stone'):
|
||||
for height in (0, 1):
|
||||
cells = [[0, 0, 0, states.get('air')], [0, 1, 0, states.get('air')]]
|
||||
cells[height][3] = states.get(name)
|
||||
yield {**h.environment(random.Random(10044)), 'pos': [0, 0, 0],
|
||||
'cells': cells, 'items': [], 'mode': 'dispense',
|
||||
'respawn_at': {'forced': True, 'consume': False, 'yaw': 37.0}}
|
||||
|
||||
|
||||
def d_plain(item, count):
|
||||
return d.plain(item, count)
|
||||
|
||||
@@ -972,7 +988,7 @@ def main():
|
||||
all_cases = [*cases(facts, args.count, args.seed, not args.no_transformers),
|
||||
*wake_cases(facts, max(args.count // 10, 1), args.seed),
|
||||
*used_stat_cases(facts, max(args.count // 10, 1), args.seed),
|
||||
*placement_cases(facts, max(args.count // 10, 1), args.seed)]
|
||||
*placement_cases(facts, max(args.count // 10, 1), args.seed), *forced_respawn_cases(facts)]
|
||||
(work / 'cases.jsonl').write_text(''.join(json.dumps(case) + '\n' for case in all_cases))
|
||||
classes = work / 'classes'
|
||||
classes.mkdir(exist_ok=True)
|
||||
|
||||
Reference in new issue
Block a user