Merge pull request #10384 from voidsipp/fix/10044-forced-respawn

Validate forced Overworld respawn points
This commit is contained in:
rdbt authored and GitHub committed 2026-11-07 12:41:14 +00:00
commit 13d03ec87f
4 files changed
+153 -51

No files matched your search

+91 -16
View File
@@ -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];
+40 -16
View File
@@ -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 {
+17 -1
View File
@@ -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)