mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
106
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b618d8157 | ||
|
|
d500607a7d | ||
|
|
394eacab4a | ||
|
|
124dd7c78d | ||
|
|
a6e6613c31 | ||
|
|
0666ea7566 | ||
|
|
57cf8c462e | ||
|
|
01d352c76e | ||
|
|
fe96eb32ef | ||
|
|
06a5980529 | ||
|
|
48788e2b3b | ||
|
|
e912d8fdee | ||
|
|
f6f327e30e | ||
|
|
6820dda93a | ||
|
|
4b000dd767 | ||
|
|
af2e6f86e0 | ||
|
|
8ddd57b30e | ||
|
|
f18fc1c1f4 | ||
|
|
40ebcd5c16 | ||
|
|
db2e8a5e67 | ||
|
|
c5f1b3771b | ||
|
|
2c8b7ee172 | ||
|
|
95c234cc1e | ||
|
|
a11e54e7b8 | ||
|
|
52cc53a4f9 | ||
|
|
1a01f5ef59 | ||
|
|
a717d6d583 | ||
|
|
1ffe686350 | ||
|
|
217216e263 | ||
|
|
825f8f8845 | ||
|
|
0771f59bca | ||
|
|
c455bb6ec7 | ||
|
|
8537e29c92 | ||
|
|
4812518327 | ||
|
|
7daca92c4a | ||
|
|
8d93530931 | ||
|
|
d27a7cbce3 | ||
|
|
c566e69c6c | ||
|
|
461a8757e3 | ||
|
|
7e459e5fb1 | ||
|
|
84185641bc | ||
|
|
e932a2da61 | ||
|
|
edd11b6103 | ||
|
|
6534db4f6d | ||
|
|
69689b3fdd | ||
|
|
c6dfae6dd7 | ||
|
|
04cd6ece23 | ||
|
|
9218dc3e7d | ||
|
|
5b66bd0ec0 | ||
|
|
f75896ec45 | ||
|
|
ae72e1f6ca | ||
|
|
164cfd6b11 | ||
|
|
7d0e5aa953 | ||
|
|
09a9735630 | ||
|
|
a80973335c | ||
|
|
ace27c11a9 | ||
|
|
c91ca07115 | ||
|
|
c001f56d0f | ||
|
|
b36a63fb39 | ||
|
|
f86c946a3c | ||
|
|
bd59c0bab2 | ||
|
|
d3cb2f5c29 | ||
|
|
5485e189f8 | ||
|
|
c48c2f2167 | ||
|
|
bdac04d6ca | ||
|
|
5f269772d9 | ||
|
|
ee90b3b34b | ||
|
|
a72f06cfab | ||
|
|
fab9c37413 | ||
|
|
ac7e491f3b | ||
|
|
cb1c3499bf | ||
|
|
4a094bee90 | ||
|
|
85bfbc7d46 | ||
|
|
f983223354 | ||
|
|
3d39bbdaf1 | ||
|
|
2aca6fe06c | ||
|
|
81b3803017 | ||
|
|
c8f484e040 | ||
|
|
d4a1ebaa33 | ||
|
|
2ac85c50eb | ||
|
|
43f2497f1f | ||
|
|
380d71d8a8 | ||
|
|
0543cf0345 | ||
|
|
2ee238e5f1 | ||
|
|
6bb648db1e | ||
|
|
e1d825312f | ||
|
|
5ea61ed3d8 | ||
|
|
231b309634 | ||
|
|
80df20837b | ||
|
|
b901415c80 | ||
|
|
da877fe1db | ||
|
|
6143e258ab | ||
|
|
f9ca0a76cf | ||
|
|
1232745064 | ||
|
|
3382b666ce | ||
|
|
92c073de61 | ||
|
|
139de824aa | ||
|
|
ed01a93df6 | ||
|
|
1a56e74324 | ||
|
|
25b84dfc2e | ||
|
|
a2098c2ffa | ||
|
|
d98dc8edc9 | ||
|
|
bf05176692 | ||
|
|
a3489d33b4 | ||
|
|
9c16d40db1 | ||
|
|
730184c64f |
No files matched your search
@@ -494,7 +494,11 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
|
||||
_ => string("minecraft:air"),
|
||||
},
|
||||
);
|
||||
f.put("facing", Tag::Int(int(tag.get("facing")).unwrap_or(0)));
|
||||
// Direction.LEGACY_ID_CODEC: the 3D data value as a byte.
|
||||
f.put(
|
||||
"facing",
|
||||
Tag::Byte(int(tag.get("facing")).unwrap_or(0) as i8),
|
||||
);
|
||||
f.float("progress", 0.0);
|
||||
f.flag("extending", false);
|
||||
f.flag("source", false);
|
||||
@@ -873,6 +877,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `PistonMovingBlockEntity.saveAdditional`: `facing` through
|
||||
/// `Direction.LEGACY_ID_CODEC` (a byte), `progress` a float.
|
||||
#[test]
|
||||
fn moving_piston_fields_match_vanilla() {
|
||||
let out = canon(
|
||||
"{id:\"minecraft:piston\",x:1,y:2,z:3,blockState:{Name:\"minecraft:stone\"},\
|
||||
facing:5,progress:0.5f,extending:1b,source:0b}",
|
||||
);
|
||||
assert_eq!(out.get("facing"), Some(&Tag::Byte(5)));
|
||||
assert_eq!(out.get("progress"), Some(&f32_tag(0.5)));
|
||||
assert_eq!(out.get("extending"), Some(&Tag::Byte(1)));
|
||||
assert_eq!(out.get("source"), Some(&Tag::Byte(0)));
|
||||
let fresh = canon("{id:\"minecraft:piston\",x:1,y:2,z:3}");
|
||||
assert_eq!(fresh.get("facing"), Some(&Tag::Byte(0)));
|
||||
assert_eq!(fresh.get("blockState"), Some(&string("minecraft:air")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loot_table_replaces_items_and_zero_seed_is_omitted() {
|
||||
let out = canon(
|
||||
|
||||
@@ -269,6 +269,13 @@ mod tests {
|
||||
assert_eq!(en.get("horizon.options.controller"), Some("Controller"));
|
||||
let ru = Language::load(&[&empty], "ru_ru");
|
||||
assert_eq!(ru.get("horizon.options.controller"), Some("Геймпад"));
|
||||
// Every bundled string carries both languages: a key added to one file
|
||||
// and forgotten in the other shows as a key name on screen.
|
||||
for key in en.map().keys() {
|
||||
assert!(ru.get(key).is_some(), "{key} has no ru_ru value");
|
||||
}
|
||||
assert_eq!(en.get("horizon.pages.mods"), Some("Mods"));
|
||||
assert_eq!(ru.get("horizon.pages.mods"), Some("Моды"));
|
||||
let uk = Language::load(&[&empty], "uk_ua");
|
||||
assert_eq!(
|
||||
uk.get("horizon.options.on_screen_keyboard"),
|
||||
|
||||
@@ -1563,3 +1563,49 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `DamageSource.getLocalizedDeathMessage`: the cause's item with a custom
|
||||
/// name gets the `<id>.item` message, with the item's name as a third
|
||||
/// argument.
|
||||
pub fn death_message_args(
|
||||
key: &str,
|
||||
victim: &Text,
|
||||
cause: &Text,
|
||||
item: Option<&Text>,
|
||||
) -> (String, Vec<Text>) {
|
||||
match item {
|
||||
Some(item) => (
|
||||
format!("{key}.item"),
|
||||
vec![victim.clone(), cause.clone(), item.clone()],
|
||||
),
|
||||
None => (key.to_owned(), vec![victim.clone(), cause.clone()]),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod death_message_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_death_message_names_the_causes_item_when_it_has_a_custom_name() {
|
||||
let victim = Text::literal("Steve");
|
||||
let cause = Text::literal("Zombie");
|
||||
// DamageSource.getLocalizedDeathMessage: the item variant, with the
|
||||
// item's name as the third argument.
|
||||
let (key, args) = death_message_args(
|
||||
"death.attack.arrow",
|
||||
&victim,
|
||||
&cause,
|
||||
Some(&Text::literal("My Sword")),
|
||||
);
|
||||
assert_eq!(key, "death.attack.arrow.item");
|
||||
assert_eq!(args.len(), 3);
|
||||
assert_eq!(args[0].plain(), "Steve");
|
||||
assert_eq!(args[1].plain(), "Zombie");
|
||||
assert_eq!(args[2].plain(), "My Sword");
|
||||
// Without a custom name the message keeps its two arguments.
|
||||
let (key, args) = death_message_args("death.attack.arrow", &victim, &cause, None);
|
||||
assert_eq!(key, "death.attack.arrow");
|
||||
assert_eq!(args.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -50,20 +50,30 @@ pub fn tooltip(
|
||||
Category::Neutral,
|
||||
)];
|
||||
}
|
||||
def.effects
|
||||
.iter()
|
||||
effect_lines(def.effects.iter().copied(), scale, translate)
|
||||
}
|
||||
/// `PotionContents.addPotionTooltip`'s line per effect: its name, potency and
|
||||
/// scaled duration, for (effect id, duration, amplifier) entries.
|
||||
pub fn effect_lines<'a>(
|
||||
effects: impl IntoIterator<Item = (&'a str, i32, i32)>,
|
||||
scale: f32,
|
||||
translate: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Vec<(String, Category)> {
|
||||
effects
|
||||
.into_iter()
|
||||
.map(|(e, duration, amp)| {
|
||||
let e = e.trim_start_matches("minecraft:");
|
||||
let mut line =
|
||||
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| (*e).to_owned());
|
||||
if *amp > 0 {
|
||||
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| e.to_owned());
|
||||
if amp > 0 {
|
||||
line.push(' ');
|
||||
line.push_str(
|
||||
&translate(&format!("potion.potency.{amp}"))
|
||||
.unwrap_or_else(|| crate::enchant::level_name(*amp as u32 + 1)),
|
||||
.unwrap_or_else(|| crate::enchant::level_name(amp as u32 + 1)),
|
||||
);
|
||||
}
|
||||
let instant = effect(e).is_some_and(|d| d.instant);
|
||||
let scaled = (*duration as f32 * scale) as i32;
|
||||
let scaled = (duration as f32 * scale) as i32;
|
||||
if !instant && scaled > 20 {
|
||||
line = format!("{line} ({})", format_duration(scaled));
|
||||
}
|
||||
|
||||
@@ -513,6 +513,56 @@ impl MeshTables {
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
|
||||
Mesher::tint(info, 0, &mut tints, [x & 15, y & 15, z & 15])
|
||||
}
|
||||
/// `BlockColors` for each of the state's tint indices at `pos`, as the
|
||||
/// chunk mesher tints them (grass blocks included): `None` for an
|
||||
/// untinted (-1) layer. For block models drawn outside the chunk mesh.
|
||||
pub fn block_tints(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
biomes: &Biomes,
|
||||
zoom: BiomeZoom,
|
||||
blend_radius: i32,
|
||||
) -> impl Fn(i32) -> Option<u32> + use<> {
|
||||
let sources = self.info.get(state.0 as usize).map(|info| {
|
||||
let [x, y, z] = pos;
|
||||
let origin = [x >> 4, z >> 4, y & !15];
|
||||
let mut tints = Tints::new(region, biomes, zoom, blend_radius, origin);
|
||||
let local = [x & 15, y & 15, z & 15];
|
||||
let colors = [0, 1].map(|i| Mesher::tint(info, i, &mut tints, local));
|
||||
(colors, i32::from(info.tint_count))
|
||||
});
|
||||
move |index| {
|
||||
let (colors, count) = sources?;
|
||||
if index < 0 || index >= count {
|
||||
return None;
|
||||
}
|
||||
colors[(index as usize).min(1)]
|
||||
}
|
||||
}
|
||||
/// `ModelBlockRenderer.tesselateFlat`'s light coords (`LightCoordsUtil`
|
||||
/// packing, the quad's own emission included) for `quad` of `state`
|
||||
/// drawn as the only block at `pos`, as `MovingBlockRenderState` gives
|
||||
/// it: `light` is the world's stored light (sky << 4 | block) at a
|
||||
/// position. For block models drawn outside the chunk mesh.
|
||||
pub fn flat_light(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
pos: [i32; 3],
|
||||
quad: &horizon_assets::bake::ModelQuad,
|
||||
light: impl Fn([i32; 3]) -> u8,
|
||||
) -> u32 {
|
||||
let own = self.info.get(state.0 as usize);
|
||||
let full = own.is_some_and(|i| i.full);
|
||||
let at = lighting::flat_position(pos, &quad.positions, quad.facing, quad.cull, full);
|
||||
let packed = light(at);
|
||||
let coords = match own {
|
||||
Some(own) => lighting::packed_coords(own, packed),
|
||||
None => u32::from(packed & 15) << 4 | u32::from(packed >> 4) << 20,
|
||||
};
|
||||
lighting::with_emission(coords, quad.light_emission)
|
||||
}
|
||||
/// `BlockState.getOffset` for blocks with a random model offset.
|
||||
pub fn offset(&self, state: BlockStateId, x: i32, z: i32) -> Option<[f64; 3]> {
|
||||
offset(self.info.get(state.0 as usize)?, x, z)
|
||||
|
||||
@@ -39,10 +39,13 @@ fn state<'a>(pad: &Padded, info: &'a [Info], p: [i32; 3]) -> &'a Info {
|
||||
/// `LightCoordsUtil.getLightCoords`: light at `p` as seen through `state`
|
||||
/// (block level << 4 | sky level << 20).
|
||||
pub(super) fn coords(pad: &Padded, state: &Info, p: [i32; 3]) -> u32 {
|
||||
packed_coords(state, pad.light[pi(p[0], p[1], p[2])])
|
||||
}
|
||||
/// `coords` of a stored light byte (sky level << 4 | block level).
|
||||
pub(super) fn packed_coords(state: &Info, packed: u8) -> u32 {
|
||||
if state.emissive {
|
||||
return FULL_BRIGHT;
|
||||
}
|
||||
let packed = pad.light[pi(p[0], p[1], p[2])];
|
||||
let block = u32::from((packed & 15).max(state.emission));
|
||||
block << 4 | u32::from(packed >> 4) << 20
|
||||
}
|
||||
@@ -274,8 +277,28 @@ pub(super) fn flat(
|
||||
positions: &[[f32; 3]; 4],
|
||||
face: D,
|
||||
) -> u32 {
|
||||
let cubic = shape(positions, face, own.full).cubic;
|
||||
coords(pad, own, if cubic { step(pos, face) } else { pos })
|
||||
coords(
|
||||
pad,
|
||||
own,
|
||||
flat_position(pos, positions, face, None, own.full),
|
||||
)
|
||||
}
|
||||
/// Where `tesselateFlat` reads the light of a quad of a block at `pos`: the
|
||||
/// neighbour on its cull face, else `prepareQuadFlat`'s choice (the neighbour
|
||||
/// when the quad is cubic, else the block itself). `full` is the block's
|
||||
/// `isCollisionShapeFullBlock`.
|
||||
pub(super) fn flat_position(
|
||||
pos: [i32; 3],
|
||||
positions: &[[f32; 3]; 4],
|
||||
face: D,
|
||||
cull: Option<D>,
|
||||
full: bool,
|
||||
) -> [i32; 3] {
|
||||
match cull {
|
||||
Some(d) => step(pos, d),
|
||||
None if shape(positions, face, full).cubic => step(pos, face),
|
||||
None => pos,
|
||||
}
|
||||
}
|
||||
/// `prepareQuadAmbientOcclusion`: per-vertex AO brightness (before `ARGB.gray`)
|
||||
/// and smooth light coords, by vertex index.
|
||||
@@ -398,6 +421,39 @@ mod tests {
|
||||
assert_eq!(max_coords(packed(3, 9), packed(7, 2)), packed(7, 9));
|
||||
}
|
||||
#[test]
|
||||
fn flat_light_reads_the_neighbour_a_face_looks_at() {
|
||||
let pos = [4, 64, -2];
|
||||
let full_top = [
|
||||
[0.0, 1.0, 0.0],
|
||||
[0.0, 1.0, 1.0],
|
||||
[1.0, 1.0, 1.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
];
|
||||
// A cull face samples its neighbour, whatever the quad's shape.
|
||||
let inset = full_top.map(|[x, _, z]| [x, 0.75, z]);
|
||||
assert_eq!(
|
||||
flat_position(pos, &inset, D::Up, Some(D::Up), false),
|
||||
[4, 65, -2]
|
||||
);
|
||||
// A flush quad without a cull face samples its neighbour too: a block
|
||||
// drawn at a cell that is now opaque (a moving block, #3002) still
|
||||
// takes the light around it.
|
||||
assert_eq!(
|
||||
flat_position(pos, &full_top, D::Up, None, false),
|
||||
[4, 65, -2]
|
||||
);
|
||||
let west = [
|
||||
[0.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0],
|
||||
[0.0, 1.0, 1.0],
|
||||
[0.0, 1.0, 0.0],
|
||||
];
|
||||
assert_eq!(flat_position(pos, &west, D::West, None, false), [3, 64, -2]);
|
||||
// An inset quad (a piston head's arm) takes the block's own light.
|
||||
assert_eq!(flat_position(pos, &inset, D::Up, None, false), pos);
|
||||
assert_eq!(flat_position(pos, &inset, D::Up, None, true), [4, 65, -2]);
|
||||
}
|
||||
#[test]
|
||||
fn quad_shape_classifies_partial_and_cubic_faces() {
|
||||
let full_top = [
|
||||
[0.0, 1.0, 0.0],
|
||||
|
||||
@@ -54,3 +54,40 @@ fn terrain_particles_use_the_particle_sprite_and_vanilla_tint_sources() {
|
||||
assert_eq!(tint("minecraft:pink_petals"), None);
|
||||
assert_eq!(tint("minecraft:stone"), None);
|
||||
}
|
||||
|
||||
/// Blocks drawn outside the chunk mesh (moved by a piston) take the chunk
|
||||
/// mesher's per-layer tints: a grass block's top and side overlay are tinted
|
||||
/// as in the world, not left gray like its particles.
|
||||
#[test]
|
||||
fn block_tints_match_the_chunk_mesh_for_grass_blocks() {
|
||||
let root =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
|
||||
if !root.join("horizon-resources.json").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility).unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
let state = |name| assets.registry.default_state(name).unwrap();
|
||||
let center = Chunk::empty(0, 0);
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(¢er);
|
||||
let tints = |name| {
|
||||
tables.block_tints(
|
||||
state(name),
|
||||
[5, 70, 9],
|
||||
®ion,
|
||||
&biomes,
|
||||
BiomeZoom::new(0),
|
||||
2,
|
||||
)
|
||||
};
|
||||
let grass = tints("minecraft:grass_block");
|
||||
assert!(grass(0).is_some());
|
||||
assert_eq!(grass(-1), None);
|
||||
assert_eq!(tints("minecraft:spruce_leaves")(0), Some(0x61_9961));
|
||||
assert_eq!(tints("minecraft:stone")(0), None);
|
||||
}
|
||||
@@ -84,6 +84,27 @@ pub(crate) const ACCESSIBILITY_OPTIONS: &[Opt] = &[
|
||||
Opt::HighContrastBlockOutlines,
|
||||
];
|
||||
|
||||
/// `ControlsScreen.options`: the controls added by Java Edition 26.3, in
|
||||
/// their vanilla order. The macOS-only Control-click switch is omitted on
|
||||
/// other platforms just as `InputQuirks.EMULATE_RIGHT_CLICK_WITH_CTRL_KEY`
|
||||
/// does in Java.
|
||||
pub(crate) fn vanilla_control_options() -> Vec<Opt> {
|
||||
let mut options = vec![
|
||||
Opt::ToggleCrouch,
|
||||
Opt::ToggleSprint,
|
||||
Opt::ToggleAttack,
|
||||
Opt::ToggleUse,
|
||||
Opt::AutoJump,
|
||||
Opt::SprintWindow,
|
||||
Opt::QuitShortcuts,
|
||||
];
|
||||
if cfg!(target_os = "macos") {
|
||||
options.push(Opt::CtrlClickEmulatesRightClick);
|
||||
}
|
||||
options.push(Opt::OperatorItemsTab);
|
||||
options
|
||||
}
|
||||
|
||||
/// `MouseSettingsScreen.options` in Java Edition 26.3, in vanilla order.
|
||||
pub(crate) const MOUSE_OPTIONS: &[Opt] = &[
|
||||
Opt::Sensitivity,
|
||||
@@ -120,6 +141,11 @@ impl Opt {
|
||||
| Opt::ChunkFade
|
||||
| Opt::ImprovedTransparency
|
||||
| Opt::ChunkBuilder
|
||||
// Saved, but the creative tabs, the quit keys and the mouse
|
||||
// don't read them yet.
|
||||
| Opt::OperatorItemsTab
|
||||
| Opt::QuitShortcuts
|
||||
| Opt::CtrlClickEmulatesRightClick
|
||||
)
|
||||
}
|
||||
fn file_key(self) -> Option<&'static str> {
|
||||
@@ -144,6 +170,9 @@ impl Opt {
|
||||
Opt::RotateWithMinecarts => "rotateWithMinecart",
|
||||
Opt::HighContrastBlockOutlines => "highContrastBlockOutline",
|
||||
Opt::MusicFrequency => "musicFrequency",
|
||||
Opt::OperatorItemsTab => "operatorItemsTab",
|
||||
Opt::QuitShortcuts => "quitShortcuts",
|
||||
Opt::CtrlClickEmulatesRightClick => "ctrlClickEmulatesRightClick",
|
||||
Opt::ModelPart(i) => MODEL_PART_KEYS[i],
|
||||
Opt::AllowServerListing => "allowServerListing",
|
||||
Opt::RealmsNotifications => "realmsNotifications",
|
||||
@@ -194,7 +223,13 @@ impl Opt {
|
||||
Opt::MenuBlur => "options.accessibility.menu_background_blurriness",
|
||||
Opt::ToggleCrouch => "key.sneak",
|
||||
Opt::ToggleSprint => "key.sprint",
|
||||
Opt::ToggleAttack => "key.attack",
|
||||
Opt::ToggleUse => "key.use",
|
||||
Opt::AutoJump => "options.autoJump",
|
||||
Opt::SprintWindow => "options.sprintWindow",
|
||||
Opt::QuitShortcuts => "options.quitShortcuts",
|
||||
Opt::CtrlClickEmulatesRightClick => "options.ctrlClickEmulatesRightClick",
|
||||
Opt::OperatorItemsTab => "options.operatorItemsTab",
|
||||
Opt::ChatVisibility => "options.chat.visibility",
|
||||
Opt::ChatColors => "options.chat.color",
|
||||
Opt::ChatLinks => "options.chat.links",
|
||||
@@ -217,7 +252,7 @@ impl Opt {
|
||||
Opt::NotificationDisplayTime => "options.notifications.display_time",
|
||||
Opt::Narrator => "options.narrator",
|
||||
Opt::TextBackground => "options.accessibility.text_background",
|
||||
Opt::ChatDelay => "Chat Delay",
|
||||
Opt::ChatDelay => "horizon.options.chat_delay",
|
||||
Opt::DistortionEffects => "options.screenEffectScale",
|
||||
Opt::FovEffects => "options.fovEffectScale",
|
||||
Opt::DarknessPulsing => "options.darknessEffectScale",
|
||||
@@ -308,6 +343,7 @@ impl Opt {
|
||||
Opt::PadLookSensitivity => Kind::Slider(s.controller.look_sensitivity),
|
||||
Opt::PadMoveDeadzone => unit(s.controller.move_deadzone, 0.0, 0.5),
|
||||
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
|
||||
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
|
||||
_ => Kind::Cycle,
|
||||
}
|
||||
}
|
||||
@@ -356,6 +392,14 @@ impl Opt {
|
||||
}
|
||||
}
|
||||
Opt::PanoramaSpeed => percent(c, s.file.double("panoramaScrollSpeed") as f32),
|
||||
Opt::SprintWindow => {
|
||||
let value = if s.sprint_window == 0 {
|
||||
tr("options.off")
|
||||
} else {
|
||||
Text::tr("options.value", &[&s.sprint_window])
|
||||
};
|
||||
generic(c, value)
|
||||
}
|
||||
Opt::Fov => match s.fov as i32 {
|
||||
70 => generic(c, tr("options.fov.min")),
|
||||
110 => generic(c, tr("options.fov.max")),
|
||||
@@ -573,7 +617,20 @@ impl Opt {
|
||||
} else {
|
||||
"options.key.hold"
|
||||
}),
|
||||
Opt::ToggleAttack => tr(if s.toggle_attack {
|
||||
"options.key.toggle"
|
||||
} else {
|
||||
"options.key.hold"
|
||||
}),
|
||||
Opt::ToggleUse => tr(if s.toggle_use {
|
||||
"options.key.toggle"
|
||||
} else {
|
||||
"options.key.hold"
|
||||
}),
|
||||
Opt::AutoJump => on_off(s.auto_jump),
|
||||
Opt::QuitShortcuts => on_off(s.quit_shortcuts),
|
||||
Opt::CtrlClickEmulatesRightClick => on_off(s.ctrl_click_emulates_right_click),
|
||||
Opt::OperatorItemsTab => on_off(s.operator_items_tab),
|
||||
Opt::ChatVisibility => tr([
|
||||
"options.chat.visibility.full",
|
||||
"options.chat.visibility.system",
|
||||
@@ -603,25 +660,25 @@ impl Opt {
|
||||
} else {
|
||||
"options.key.hold"
|
||||
}),
|
||||
Opt::PadLightbar => Text::literal(match s.controller.lightbar {
|
||||
gamepad::Lightbar::Health => "Health",
|
||||
gamepad::Lightbar::Dimension => "Dimension",
|
||||
gamepad::Lightbar::Off => "Off",
|
||||
Opt::PadLightbar => tr(match s.controller.lightbar {
|
||||
gamepad::Lightbar::Health => "horizon.options.lightbar.health",
|
||||
gamepad::Lightbar::Dimension => "horizon.options.lightbar.dimension",
|
||||
gamepad::Lightbar::Off => "options.off",
|
||||
}),
|
||||
Opt::PadGlyphs => Text::literal(match s.controller.glyphs {
|
||||
GlyphStyle::Auto => "Auto",
|
||||
GlyphStyle::Xbox => "Xbox",
|
||||
GlyphStyle::PlayStation => "PlayStation",
|
||||
GlyphStyle::Nintendo => "Nintendo",
|
||||
Opt::PadGlyphs => tr(match s.controller.glyphs {
|
||||
GlyphStyle::Auto => "horizon.options.glyphs.auto",
|
||||
GlyphStyle::Xbox => "horizon.options.glyphs.xbox",
|
||||
GlyphStyle::PlayStation => "horizon.options.glyphs.playstation",
|
||||
GlyphStyle::Nintendo => "horizon.options.glyphs.nintendo",
|
||||
}),
|
||||
Opt::PadMenuNav => Text::literal(match s.controller.menu_nav {
|
||||
gamepad::MenuNav::Focus => "Focus",
|
||||
gamepad::MenuNav::Pointer => "Pointer",
|
||||
Opt::PadMenuNav => tr(match s.controller.menu_nav {
|
||||
gamepad::MenuNav::Focus => "horizon.options.menu_focus",
|
||||
gamepad::MenuNav::Pointer => "horizon.options.menu_pointer",
|
||||
}),
|
||||
Opt::PadKeyboard => Text::literal(match s.controller.keyboard {
|
||||
gamepad::TextKeyboard::Auto => "Auto",
|
||||
gamepad::TextKeyboard::Horizon => "Horizon",
|
||||
gamepad::TextKeyboard::Steam => "Steam",
|
||||
Opt::PadKeyboard => tr(match s.controller.keyboard {
|
||||
gamepad::TextKeyboard::Auto => "horizon.options.keyboard.auto",
|
||||
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
|
||||
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
|
||||
}),
|
||||
_ => Text::empty(),
|
||||
}
|
||||
@@ -674,6 +731,7 @@ impl Opt {
|
||||
Opt::PadLookSensitivity => s.controller.look_sensitivity = v,
|
||||
Opt::PadMoveDeadzone => s.controller.move_deadzone = step(0.0, 50.0) / 100.0,
|
||||
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
|
||||
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
|
||||
_ => {}
|
||||
}
|
||||
if self.affects_preset() {
|
||||
@@ -770,7 +828,14 @@ impl Opt {
|
||||
Opt::ChunkBuilder => s.prioritize_chunk_updates = next(s.prioritize_chunk_updates, 3),
|
||||
Opt::ToggleCrouch => s.toggle_crouch = !s.toggle_crouch,
|
||||
Opt::ToggleSprint => s.toggle_sprint = !s.toggle_sprint,
|
||||
Opt::ToggleAttack => s.toggle_attack = !s.toggle_attack,
|
||||
Opt::ToggleUse => s.toggle_use = !s.toggle_use,
|
||||
Opt::AutoJump => s.auto_jump = !s.auto_jump,
|
||||
Opt::QuitShortcuts => s.quit_shortcuts = !s.quit_shortcuts,
|
||||
Opt::CtrlClickEmulatesRightClick => {
|
||||
s.ctrl_click_emulates_right_click = !s.ctrl_click_emulates_right_click
|
||||
}
|
||||
Opt::OperatorItemsTab => s.operator_items_tab = !s.operator_items_tab,
|
||||
Opt::ChatVisibility => s.chat_visibility = next(s.chat_visibility, 3),
|
||||
Opt::ChatColors => s.chat_colors = !s.chat_colors,
|
||||
Opt::ChatLinks => s.chat_links = !s.chat_links,
|
||||
@@ -1178,22 +1243,14 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
|
||||
/// What a button opening one of Horizon's pages says on hover.
|
||||
fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
|
||||
Some(match page {
|
||||
Page::Horizon => "Horizon's own settings: graphics, controllers, extras and mods.",
|
||||
Page::Graphics => "Graphics, simply: each setting says what it costs.",
|
||||
Page::GraphicsAdvanced => {
|
||||
"Every graphics setting, Horizon's included, with what each costs."
|
||||
}
|
||||
Page::Controller => "Controllers: their buttons, sticks, vibration and menu navigation.",
|
||||
Page::Extras => {
|
||||
"Optional additions, all off by default: the game stays vanilla until you turn one on."
|
||||
}
|
||||
Page::Agent => "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
|
||||
Page::ShaderPacks => {
|
||||
"Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time."
|
||||
}
|
||||
Page::Mods => {
|
||||
"Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on."
|
||||
}
|
||||
Page::Horizon => "horizon.pages.settings.tooltip",
|
||||
Page::Graphics => "horizon.pages.graphics.tooltip",
|
||||
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
|
||||
Page::Controller => "horizon.pages.controller.tooltip",
|
||||
Page::Extras => "horizon.pages.extras.tooltip",
|
||||
Page::Agent => "horizon.pages.agent.tooltip",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
|
||||
Page::Mods => "horizon.pages.mods.tooltip",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -1322,16 +1379,16 @@ impl OptionsUi {
|
||||
Page::Language => "options.language.title",
|
||||
Page::Font => "options.font.title",
|
||||
Page::Packs => "resourcePack.title",
|
||||
Page::Mods => "Mods",
|
||||
Page::Mods => "horizon.pages.mods",
|
||||
Page::NativeMod(_) => "Mod Settings",
|
||||
Page::ShaderPacks => "Shader Packs",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs",
|
||||
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
|
||||
Page::Controller => "Controller Settings",
|
||||
Page::PadBinds => "Controller Binds",
|
||||
Page::Extras => "Horizon Extras",
|
||||
Page::Controller => "horizon.pages.controller",
|
||||
Page::PadBinds => "horizon.pages.controller_binds",
|
||||
Page::Extras => "horizon.pages.extras",
|
||||
Page::Credits => "credits_and_attribution.screen.title",
|
||||
Page::Horizon => "Horizon Settings",
|
||||
Page::Agent => "AI Agent",
|
||||
Page::Horizon => "horizon.pages.settings",
|
||||
Page::Agent => "horizon.pages.agent",
|
||||
}
|
||||
}
|
||||
fn scrollbar_x(&self, width: f32) -> f32 {
|
||||
@@ -1469,21 +1526,23 @@ impl OptionsUi {
|
||||
Page::Horizon => {
|
||||
// Two to a row, as vanilla's OptionsList.addSmall lays out
|
||||
// buttons; each opens a page built from vanilla's widgets.
|
||||
let open = |label: &str, page: Page| {
|
||||
button(0.0, 0.0, 150.0, Text::literal(label), Act::Push(page))
|
||||
};
|
||||
let open =
|
||||
|key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
|
||||
vec![
|
||||
Row::Buttons(
|
||||
open("Graphics...", Page::Graphics),
|
||||
Some(open("Advanced Graphics...", Page::GraphicsAdvanced)),
|
||||
open("horizon.pages.open.graphics", Page::Graphics),
|
||||
Some(open(
|
||||
"horizon.pages.open.graphics_advanced",
|
||||
Page::GraphicsAdvanced,
|
||||
)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open("Shader Packs...", Page::ShaderPacks),
|
||||
Some(open("Controller Settings...", Page::Controller)),
|
||||
open("horizon.pages.open.shader_packs", Page::ShaderPacks),
|
||||
Some(open("horizon.pages.open.controller", Page::Controller)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open("Extras...", Page::Extras),
|
||||
Some(open("Mods...", Page::Mods)),
|
||||
open("horizon.pages.open.extras", Page::Extras),
|
||||
Some(open("horizon.pages.open.mods", Page::Mods)),
|
||||
),
|
||||
// No AI hides the AI Agent page and everything AI.
|
||||
Row::Buttons(
|
||||
@@ -1491,7 +1550,7 @@ impl OptionsUi {
|
||||
Some(if s.extras.no_ai {
|
||||
self.extra_button(s, crate::extras::Item::NoAi)
|
||||
} else {
|
||||
open("AI Agent...", Page::Agent)
|
||||
open("horizon.pages.open.agent", Page::Agent)
|
||||
}),
|
||||
),
|
||||
]
|
||||
@@ -1510,7 +1569,7 @@ impl OptionsUi {
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Copy Connection Details"),
|
||||
tr("horizon.agent.copy_details"),
|
||||
Act::CopyAgentDetails,
|
||||
)),
|
||||
)];
|
||||
@@ -1524,14 +1583,14 @@ impl OptionsUi {
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Horizon Graphics..."),
|
||||
tr("horizon.pages.open.horizon_graphics"),
|
||||
Act::Push(Page::Graphics),
|
||||
),
|
||||
Some(button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Advanced Graphics..."),
|
||||
tr("horizon.pages.open.graphics_advanced"),
|
||||
Act::Push(Page::GraphicsAdvanced),
|
||||
)),
|
||||
)];
|
||||
@@ -1615,18 +1674,15 @@ impl OptionsUi {
|
||||
Act::Push(Page::KeyBinds),
|
||||
)),
|
||||
)];
|
||||
rows.extend(pairs(&[
|
||||
Opt::ToggleCrouch,
|
||||
Opt::ToggleSprint,
|
||||
Opt::AutoJump,
|
||||
]));
|
||||
let controls = vanilla_control_options();
|
||||
rows.extend(pairs(&controls));
|
||||
// Horizon's controller page, after vanilla's rows.
|
||||
rows.push(Row::Buttons(
|
||||
button(
|
||||
0.0,
|
||||
0.0,
|
||||
310.0,
|
||||
Text::literal("Controller Settings..."),
|
||||
tr("horizon.pages.open.controller"),
|
||||
Act::Push(Page::Controller),
|
||||
),
|
||||
None,
|
||||
@@ -1639,7 +1695,7 @@ impl OptionsUi {
|
||||
0.0,
|
||||
0.0,
|
||||
310.0,
|
||||
Text::literal("Controller Binds..."),
|
||||
tr("horizon.pages.open.controller_binds"),
|
||||
Act::Push(Page::PadBinds),
|
||||
),
|
||||
None,
|
||||
@@ -1697,9 +1753,7 @@ impl OptionsUi {
|
||||
Page::Font => pairs(&[Opt::ForceUnicode, Opt::JapaneseGlyphs]),
|
||||
Page::Mods => {
|
||||
if s.mods.installed.is_empty() {
|
||||
return vec![Row::Header(
|
||||
"No mods: put .hmod files in the horizon-mods folder",
|
||||
)];
|
||||
return vec![Row::Header("horizon.mods.empty")];
|
||||
}
|
||||
s.mods
|
||||
.installed
|
||||
@@ -1709,9 +1763,9 @@ impl OptionsUi {
|
||||
let mut label = Text::literal(format!("{}: ", installed.title))
|
||||
.append(on_off(s.mods.is_on(i)));
|
||||
if installed.problem.is_some() {
|
||||
label = label.append(" (Stopped)");
|
||||
label = label.append(tr("horizon.mods.stopped"));
|
||||
} else if installed.dev {
|
||||
label = label.append(" (Dev)");
|
||||
label = label.append(tr("horizon.mods.dev"));
|
||||
}
|
||||
let dead = installed.dev || installed.id.is_empty();
|
||||
// A running mod with a settings page (capability
|
||||
@@ -1722,7 +1776,7 @@ impl OptionsUi {
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
Text::literal("Settings"),
|
||||
tr("horizon.mods.settings"),
|
||||
Act::ModSettings(i),
|
||||
);
|
||||
Row::Buttons(
|
||||
@@ -1739,17 +1793,17 @@ impl OptionsUi {
|
||||
Page::NativeMod(i) => self.native_rows(s, i),
|
||||
Page::ShaderPacks => {
|
||||
if s.shader_packs.is_empty() {
|
||||
return vec![Row::Header(
|
||||
"No shader packs: put them in the shaderpacks folder",
|
||||
)];
|
||||
return vec![Row::Header("horizon.shader_packs.empty")];
|
||||
}
|
||||
s.shader_packs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, pack)| {
|
||||
let kind = match pack.kind {
|
||||
crate::shader_pack_list::Kind::Horizon => " (Horizon)",
|
||||
crate::shader_pack_list::Kind::Classic => "",
|
||||
crate::shader_pack_list::Kind::Horizon => {
|
||||
lang::get("horizon.shader_packs.kind_horizon")
|
||||
}
|
||||
crate::shader_pack_list::Kind::Classic => String::new(),
|
||||
};
|
||||
let label = Text::literal(format!("{}{kind}: ", pack.title))
|
||||
.append(on_off(s.horizon.shader_pack == pack.file));
|
||||
@@ -1809,8 +1863,8 @@ impl OptionsUi {
|
||||
// Vanilla's Telemetry Data and Credits slots: Horizon's
|
||||
// settings and its mods, as a mod adds its button here.
|
||||
// Credits & Attribution (WinScreen's credits) follows them.
|
||||
("Horizon Settings...", Some(Page::Horizon)),
|
||||
("Mods...", Some(Page::Mods)),
|
||||
("horizon.pages.open.horizon_settings", Some(Page::Horizon)),
|
||||
("horizon.pages.open.mods", Some(Page::Mods)),
|
||||
("options.credits_and_attribution", Some(Page::Credits)),
|
||||
];
|
||||
let rows = entries.len().div_ceil(2) as f32;
|
||||
@@ -2243,18 +2297,12 @@ impl OptionsUi {
|
||||
content = y + self.scroll - top + 2.0;
|
||||
out[list_start..].iter_mut().for_each(|w| w.list = true);
|
||||
let folder = match page {
|
||||
Page::Mods => Some(("Open Mod Folder", Act::OpenModFolder)),
|
||||
Page::ShaderPacks => Some(("Open Pack Folder", Act::OpenShaderPackFolder)),
|
||||
Page::Mods => Some(("horizon.mods.open_folder", Act::OpenModFolder)),
|
||||
Page::ShaderPacks => Some(("pack.openFolder", Act::OpenShaderPackFolder)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((label, act)) = folder {
|
||||
out.push(button(
|
||||
cx - 154.0,
|
||||
h - 27.0,
|
||||
150.0,
|
||||
Text::literal(label),
|
||||
act,
|
||||
));
|
||||
out.push(button(cx - 154.0, h - 27.0, 150.0, tr(label), act));
|
||||
out.push(button(cx + 4.0, h - 27.0, 150.0, tr("gui.done"), Act::Done));
|
||||
} else {
|
||||
out.push(done(h - 27.0));
|
||||
@@ -2367,7 +2415,7 @@ impl OptionsUi {
|
||||
} = &wd.kind
|
||||
&& let Some(text) = horizon_page_tooltip(*page)
|
||||
{
|
||||
tooltip = Some(atlas.font.split(&Text::literal(text), 170.0));
|
||||
tooltip = Some(atlas.font.split(&tr(text), 170.0));
|
||||
}
|
||||
let key = match &wd.kind {
|
||||
WKind::Button {
|
||||
@@ -2394,10 +2442,7 @@ impl OptionsUi {
|
||||
} = &wd.kind
|
||||
&& !opt.available()
|
||||
{
|
||||
tooltip = Some(atlas.font.split(
|
||||
&Text::literal("This feature is not yet available in Horizon."),
|
||||
170.0,
|
||||
));
|
||||
tooltip = Some(atlas.font.split(&tr("horizon.options.unavailable"), 170.0));
|
||||
}
|
||||
if let WKind::Button {
|
||||
act: Act::ToggleMod(i),
|
||||
@@ -2538,9 +2583,7 @@ impl OptionsUi {
|
||||
if search.value.is_empty() && !search.focused {
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&Text::literal("Search settings...")
|
||||
.with_color(text::GRAY)
|
||||
.italic(),
|
||||
&tr("horizon.options.search").with_color(text::GRAY).italic(),
|
||||
search.text_x(),
|
||||
search.text_y(),
|
||||
u32::MAX,
|
||||
@@ -3223,6 +3266,44 @@ mod tests {
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controls_match_java_26_3_option_order() {
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Controls);
|
||||
let options =
|
||||
ui.list_rows(&Settings::default())
|
||||
.into_iter()
|
||||
.fold(Vec::new(), |mut options, row| {
|
||||
match row {
|
||||
Row::Big(opt) => options.push(opt),
|
||||
Row::Pair(a, b) => {
|
||||
options.push(a);
|
||||
options.extend(b);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
options
|
||||
});
|
||||
assert_eq!(options, vanilla_control_options());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprint_window_and_new_control_switches_round_trip() {
|
||||
let mut settings = Settings::default();
|
||||
set_slider(Opt::SprintWindow, &mut settings, 0.5);
|
||||
settings.operator_items_tab = true;
|
||||
settings.quit_shortcuts = false;
|
||||
settings.ctrl_click_emulates_right_click = true;
|
||||
settings.toggle_attack = true;
|
||||
settings.toggle_use = true;
|
||||
let loaded = Settings::parse(&settings.serialize());
|
||||
assert_eq!(loaded.sprint_window, 5);
|
||||
assert!(loaded.operator_items_tab);
|
||||
assert!(!loaded.quit_shortcuts);
|
||||
assert!(loaded.ctrl_click_emulates_right_click);
|
||||
assert!(loaded.toggle_attack && loaded.toggle_use);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_distance_slider_round_trips_through_extended_options_range() {
|
||||
let mut s = Settings::default();
|
||||
@@ -3314,8 +3395,15 @@ mod tests {
|
||||
ui.show(Page::Mods);
|
||||
let empty = ui.layout(&s, size.0, size.1).0;
|
||||
assert!(empty.iter().any(|w| {
|
||||
matches!(&w.kind, WKind::Text { text, .. } if text.plain().contains(".hmod"))
|
||||
matches!(&w.kind, WKind::Text { text, .. } if text.plain() == "horizon.mods.empty")
|
||||
}));
|
||||
// The empty page points at the bundled key; the English line behind it
|
||||
// is what a player reads, so both halves are checked here.
|
||||
let en = crate::lang::Language::load(&[], "en_us");
|
||||
assert!(
|
||||
en.get("horizon.mods.empty")
|
||||
.is_some_and(|line| line.contains(".hmod"))
|
||||
);
|
||||
let open_folder = empty
|
||||
.iter()
|
||||
.find(|w| {
|
||||
|
||||
@@ -174,6 +174,12 @@ pub struct Settings {
|
||||
pub toggle_use: bool,
|
||||
/// Taps of the sprint key counted as double taps (sprintWindow).
|
||||
pub sprint_window: i32,
|
||||
/// Show the operator items tab in a creative inventory.
|
||||
pub operator_items_tab: bool,
|
||||
/// Allow platform quit shortcuts to close the game window.
|
||||
pub quit_shortcuts: bool,
|
||||
/// On macOS, turn Control-click into a right click.
|
||||
pub ctrl_click_emulates_right_click: bool,
|
||||
/// Horizon's own graphics settings (`horizon-graphics.txt`, not options.txt).
|
||||
pub horizon: crate::graphics::Graphics,
|
||||
/// Controller settings (`horizon-controller.txt`, Horizon's own).
|
||||
@@ -256,6 +262,9 @@ impl Default for Settings {
|
||||
toggle_attack: false,
|
||||
toggle_use: false,
|
||||
sprint_window: 7,
|
||||
operator_items_tab: false,
|
||||
quit_shortcuts: true,
|
||||
ctrl_click_emulates_right_click: false,
|
||||
horizon: Default::default(),
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
@@ -418,6 +427,9 @@ impl Settings {
|
||||
toggle_attack: f.bool("toggleAttack"),
|
||||
toggle_use: f.bool("toggleUse"),
|
||||
sprint_window: f.int("sprintWindow"),
|
||||
operator_items_tab: f.bool("operatorItemsTab"),
|
||||
quit_shortcuts: f.bool("quitShortcuts"),
|
||||
ctrl_click_emulates_right_click: f.bool("ctrlClickEmulatesRightClick"),
|
||||
horizon: Default::default(),
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
@@ -518,6 +530,13 @@ impl Settings {
|
||||
put_b(&mut f, "toggleAttack", self.toggle_attack);
|
||||
put_b(&mut f, "toggleUse", self.toggle_use);
|
||||
put_i(&mut f, "sprintWindow", self.sprint_window);
|
||||
put_b(&mut f, "operatorItemsTab", self.operator_items_tab);
|
||||
put_b(&mut f, "quitShortcuts", self.quit_shortcuts);
|
||||
put_b(
|
||||
&mut f,
|
||||
"ctrlClickEmulatesRightClick",
|
||||
self.ctrl_click_emulates_right_click,
|
||||
);
|
||||
put_b(&mut f, "autoJump", self.auto_jump);
|
||||
put_s(&mut f, "lang", &self.language);
|
||||
put_b(&mut f, "forceUnicodeFont", self.force_unicode);
|
||||
@@ -664,7 +683,13 @@ pub enum Opt {
|
||||
MenuBlur,
|
||||
ToggleCrouch,
|
||||
ToggleSprint,
|
||||
ToggleAttack,
|
||||
ToggleUse,
|
||||
AutoJump,
|
||||
SprintWindow,
|
||||
QuitShortcuts,
|
||||
CtrlClickEmulatesRightClick,
|
||||
OperatorItemsTab,
|
||||
ChatVisibility,
|
||||
ChatColors,
|
||||
ChatLinks,
|
||||
|
||||
@@ -404,8 +404,14 @@ impl GenerationScan {
|
||||
wanted.push((input.ranges.priority(x, z, input.loading), x, z));
|
||||
}
|
||||
}
|
||||
// Forced chunks and their generation neighbourhood.
|
||||
let reach = margin - 1;
|
||||
// Forced chunks and their generation neighbourhood: the ring that
|
||||
// is decorated (or finished, without decoration) and the one its
|
||||
// 3x3 needs.
|
||||
let reach = if input.decorates {
|
||||
FORCED_TERRAIN_RING
|
||||
} else {
|
||||
FORCED_FEATURES_RING
|
||||
};
|
||||
for &(fx, fz) in input.ranges.forced {
|
||||
for dz in -reach..=reach {
|
||||
for dx in -reach..=reach {
|
||||
@@ -1047,10 +1053,10 @@ impl Streaming {
|
||||
&& i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2) > r4 * r4
|
||||
&& !ranges.in_extra(x, z, 4)
|
||||
&& !ranges.in_band(x, z, 4)
|
||||
&& !ranges
|
||||
.forced
|
||||
.iter()
|
||||
.any(|f| (x - f.0).abs() <= 3 && (z - f.1).abs() <= 3)
|
||||
&& !ranges.forced.iter().any(|f| {
|
||||
(x - f.0).abs() <= FORCED_TERRAIN_RING
|
||||
&& (z - f.1).abs() <= FORCED_TERRAIN_RING
|
||||
})
|
||||
})
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
@@ -1570,6 +1576,14 @@ pub fn in_view_distance(center: (i32, i32), radius: i32, x: i32, z: i32) -> bool
|
||||
let (dx, dz) = (gap(x - center.0), gap(z - center.1));
|
||||
dx * dx + dz * dz < i64::from(radius) * i64::from(radius)
|
||||
}
|
||||
/// The rings around a `/forceload`ed chunk, in chunks. Vanilla's FORCED
|
||||
/// ticket is level 31 (`ChunkMap.FORCED_TICKET_LEVEL`), the level rises one
|
||||
/// per ring (`ChunkTracker.computeLevelFromNeighbor`), and the level picks
|
||||
/// the column's status (`ChunkLevel.generationStatus`): full (33) two rings
|
||||
/// out, features (34) at three, terrain (35) at four.
|
||||
const FORCED_FULL_RING: i32 = 2;
|
||||
const FORCED_FEATURES_RING: i32 = 3;
|
||||
const FORCED_TERRAIN_RING: i32 = 4;
|
||||
/// The areas kept loaded: circles around the player and `extra`, and forced chunks.
|
||||
struct Ranges<'a> {
|
||||
center: (i32, i32),
|
||||
@@ -1825,7 +1839,16 @@ impl Ranges<'_> {
|
||||
if dx * dx + dz * dz <= r * r {
|
||||
return true;
|
||||
}
|
||||
let reach = (margin - 1).max(0);
|
||||
// A forced chunk holds a FORCED ticket at level 31
|
||||
// (`ChunkMap.FORCED_TICKET_LEVEL`), and the level rises one per ring
|
||||
// (`ChunkTracker.computeLevelFromNeighbor`): the margin is the
|
||||
// vanilla status ring the caller asks for.
|
||||
let reach = match margin {
|
||||
1 => FORCED_FULL_RING,
|
||||
2 => FORCED_FEATURES_RING,
|
||||
3 => FORCED_TERRAIN_RING,
|
||||
_ => margin + 1,
|
||||
};
|
||||
self.forced
|
||||
.iter()
|
||||
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
|
||||
@@ -2063,9 +2086,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
let forced_reach = if input.decorates {
|
||||
FORCED_TERRAIN_RING
|
||||
} else {
|
||||
FORCED_FEATURES_RING
|
||||
};
|
||||
for &(fx, fz) in input.ranges.forced {
|
||||
for dz in -(margin - 1)..=(margin - 1) {
|
||||
for dx in -(margin - 1)..=(margin - 1) {
|
||||
for dz in -forced_reach..=forced_reach {
|
||||
for dx in -forced_reach..=forced_reach {
|
||||
let (x, z) = (fx + dx, fz + dz);
|
||||
if missing(x, z) {
|
||||
wanted.push((input.ranges.distance(x, z), x, z));
|
||||
@@ -2467,6 +2495,57 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `/forceload`ed chunk holds a FORCED ticket at level 31
|
||||
/// (`ChunkMap.FORCED_TICKET_LEVEL`): the level rises one per ring
|
||||
/// (`ChunkTracker.computeLevelFromNeighbor`) and picks the column's
|
||||
/// status (`ChunkLevel.generationStatus`). So vanilla finishes the two
|
||||
/// rings around it (33), gives the third its features (34) and the
|
||||
/// fourth its terrain (35), and the rings past that are left alone.
|
||||
#[test]
|
||||
fn forced_chunks_reach_vanillas_rings() {
|
||||
let forced: HashSet<(i32, i32)> = [(40, -40)].into();
|
||||
let r = Ranges {
|
||||
// The player's own area, far away.
|
||||
center: (-400, 300),
|
||||
radius: 2,
|
||||
extra: &[],
|
||||
forced: &forced,
|
||||
cover: None,
|
||||
band: None,
|
||||
};
|
||||
let level = |x: i32, z: i32| 31 + (x - 40).abs().max((z + 40).abs());
|
||||
for x in 40 - 6..=40 + 6 {
|
||||
for z in -40 - 6..=-40 + 6 {
|
||||
// The full ring: the column becomes a level chunk.
|
||||
assert_eq!(r.in_range(x, z, 1), level(x, z) <= 33, "({x}, {z})");
|
||||
// The features ring, and the terrain ring.
|
||||
assert_eq!(r.in_range(x, z, 2), level(x, z) <= 34, "({x}, {z})");
|
||||
assert_eq!(r.in_range(x, z, 3), level(x, z) <= 35, "({x}, {z})");
|
||||
assert_eq!(r.requested(x, z, true), level(x, z) <= 35, "({x}, {z})");
|
||||
assert_eq!(r.chunk_kept(x, z, true), level(x, z) <= 35, "({x}, {z})");
|
||||
assert_eq!(r.unloaded(x, z, true), level(x, z) > 35, "({x}, {z})");
|
||||
}
|
||||
}
|
||||
// Generation reaches the terrain ring: the features ring is
|
||||
// decorated from it.
|
||||
let mut fixture = GenerationFixture::new(2, true);
|
||||
fixture.center = (-400, 300);
|
||||
fixture.forced = forced;
|
||||
let candidates = GenerationScan::default()
|
||||
.candidates(fixture.input(), usize::MAX)
|
||||
.expect("a scan");
|
||||
for (dx, dz) in [(-4, 0), (4, 0), (0, -4), (0, 4), (3, 3)] {
|
||||
assert!(
|
||||
candidates
|
||||
.iter()
|
||||
.any(|&(_, x, z)| (x, z) == (40 + dx, -40 + dz)),
|
||||
"the terrain ring at ({}, {}) is not generated",
|
||||
40 + dx,
|
||||
-40 + dz
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -11,5 +11,57 @@
|
||||
"horizon.options.look_deadzone": "Look Deadzone",
|
||||
"horizon.options.button_glyphs": "Button Glyphs",
|
||||
"horizon.options.menu_navigation": "Menu Navigation",
|
||||
"horizon.options.on_screen_keyboard": "On-Screen Keyboard"
|
||||
"horizon.options.on_screen_keyboard": "On-Screen Keyboard",
|
||||
"horizon.options.chat_delay": "Chat Delay",
|
||||
"horizon.options.lightbar.health": "Health",
|
||||
"horizon.options.lightbar.dimension": "Dimension",
|
||||
"horizon.options.glyphs.auto": "Auto",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Focus",
|
||||
"horizon.options.menu_pointer": "Pointer",
|
||||
"horizon.options.keyboard.auto": "Auto",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "This feature is not yet available in Horizon.",
|
||||
"horizon.options.search": "Search settings...",
|
||||
"horizon.pages.mods": "Mods",
|
||||
"horizon.pages.shader_packs": "Shader Packs",
|
||||
"horizon.pages.controller": "Controller Settings",
|
||||
"horizon.pages.controller_binds": "Controller Binds",
|
||||
"horizon.pages.extras": "Horizon Extras",
|
||||
"horizon.pages.settings": "Horizon Settings",
|
||||
"horizon.pages.agent": "AI Agent",
|
||||
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
|
||||
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Every graphics setting, Horizon's included, with what each costs.",
|
||||
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
|
||||
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
|
||||
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
|
||||
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
|
||||
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
|
||||
"horizon.pages.open.graphics": "Graphics...",
|
||||
"horizon.pages.open.graphics_advanced": "Advanced Graphics...",
|
||||
"horizon.pages.open.shader_packs": "Shader Packs...",
|
||||
"horizon.pages.open.controller": "Controller Settings...",
|
||||
"horizon.pages.open.extras": "Extras...",
|
||||
"horizon.pages.open.mods": "Mods...",
|
||||
"horizon.pages.open.agent": "AI Agent...",
|
||||
"horizon.pages.open.horizon_graphics": "Horizon Graphics...",
|
||||
"horizon.pages.open.controller_binds": "Controller Binds...",
|
||||
"horizon.pages.open.horizon_settings": "Horizon Settings...",
|
||||
"horizon.mods.empty": "No mods: put .hmod files in the horizon-mods folder",
|
||||
"horizon.mods.stopped": " (Stopped)",
|
||||
"horizon.mods.dev": " (Dev)",
|
||||
"horizon.mods.settings": "Settings",
|
||||
"horizon.mods.open_folder": "Open Mod Folder",
|
||||
"horizon.shader_packs.empty": "No shader packs: put them in the shaderpacks folder",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.agent.copy_details": "Copy Connection Details",
|
||||
"horizon.multiplayer.join": "Join",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Cancel Reconnecting",
|
||||
"horizon.multiplayer.join_friend": "Join a Friend",
|
||||
"horizon.multiplayer.invite_code": "Invite Code",
|
||||
"horizon.multiplayer.relay": "Relay (optional)"
|
||||
}
|
||||
@@ -11,5 +11,57 @@
|
||||
"horizon.options.look_deadzone": "Мёртвая зона обзора",
|
||||
"horizon.options.button_glyphs": "Символы кнопок",
|
||||
"horizon.options.menu_navigation": "Навигация по меню",
|
||||
"horizon.options.on_screen_keyboard": "Экранная клавиатура"
|
||||
"horizon.options.on_screen_keyboard": "Экранная клавиатура",
|
||||
"horizon.options.chat_delay": "Задержка чата",
|
||||
"horizon.options.lightbar.health": "Здоровье",
|
||||
"horizon.options.lightbar.dimension": "Измерение",
|
||||
"horizon.options.glyphs.auto": "Авто",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Фокус",
|
||||
"horizon.options.menu_pointer": "Указатель",
|
||||
"horizon.options.keyboard.auto": "Авто",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "Эта возможность в Horizon пока недоступна.",
|
||||
"horizon.options.search": "Поиск настроек...",
|
||||
"horizon.pages.mods": "Моды",
|
||||
"horizon.pages.shader_packs": "Шейдерпаки",
|
||||
"horizon.pages.controller": "Настройки контроллера",
|
||||
"horizon.pages.controller_binds": "Назначения кнопок контроллера",
|
||||
"horizon.pages.extras": "Дополнения Horizon",
|
||||
"horizon.pages.settings": "Настройки Horizon",
|
||||
"horizon.pages.agent": "ИИ-агент",
|
||||
"horizon.pages.settings.tooltip": "Собственные настройки Horizon: графика, контроллеры, дополнения и моды.",
|
||||
"horizon.pages.graphics.tooltip": "Графика без лишнего: у каждой настройки написано, чего она стоит.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Все графические настройки, включая собственные настройки Horizon, и их цена.",
|
||||
"horizon.pages.controller.tooltip": "Контроллеры: кнопки, стики, вибрация и навигация по меню.",
|
||||
"horizon.pages.extras.tooltip": "Дополнительные возможности, все выключены по умолчанию: игра остаётся ванильной, пока вы не включите что-то.",
|
||||
"horizon.pages.agent.tooltip": "Пусть играет ваш ИИ-агент: horizon-ctl и MCP-клиенты на этом компьютере.",
|
||||
"horizon.pages.shader_packs.tooltip": "Шейдерпаки из папки shaderpacks: собственные паки Horizon и паки в формате shaders/. Одновременно работает один.",
|
||||
"horizon.pages.mods.tooltip": "Моды для Horizon из папки horizon-mods. Каждый работает в песочнице и выключен, пока вы его не включите.",
|
||||
"horizon.pages.open.graphics": "Графика...",
|
||||
"horizon.pages.open.graphics_advanced": "Продвинутая графика...",
|
||||
"horizon.pages.open.shader_packs": "Шейдерпаки...",
|
||||
"horizon.pages.open.controller": "Настройки контроллера...",
|
||||
"horizon.pages.open.extras": "Дополнения...",
|
||||
"horizon.pages.open.mods": "Моды...",
|
||||
"horizon.pages.open.agent": "ИИ-агент...",
|
||||
"horizon.pages.open.horizon_graphics": "Графика Horizon...",
|
||||
"horizon.pages.open.controller_binds": "Назначения кнопок...",
|
||||
"horizon.pages.open.horizon_settings": "Настройки Horizon...",
|
||||
"horizon.mods.empty": "Модов нет: положите файлы .hmod в папку horizon-mods",
|
||||
"horizon.mods.stopped": " (остановлен)",
|
||||
"horizon.mods.dev": " (разработка)",
|
||||
"horizon.mods.settings": "Настройки",
|
||||
"horizon.mods.open_folder": "Открыть папку модов",
|
||||
"horizon.shader_packs.empty": "Шейдерпаков нет: положите их в папку shaderpacks",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.agent.copy_details": "Скопировать данные подключения",
|
||||
"horizon.multiplayer.join": "Подключиться",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Отменить переподключение",
|
||||
"horizon.multiplayer.join_friend": "Играть с другом",
|
||||
"horizon.multiplayer.invite_code": "Код приглашения",
|
||||
"horizon.multiplayer.relay": "Ретранслятор (необязательно)"
|
||||
}
|
||||
@@ -150,7 +150,7 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
|
||||
mob.extra.crouching = flag("isCrouching");
|
||||
mob.extra.passenger = flag("isPassenger");
|
||||
if flag("isHoldingBow") {
|
||||
mob.extra.main_hand = Some("minecraft:bow".into());
|
||||
mob.extra.set_main_hand(Some("minecraft:bow".into()));
|
||||
}
|
||||
}
|
||||
Species::IronGolem => {
|
||||
@@ -298,7 +298,7 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
|
||||
}
|
||||
}
|
||||
Species::Witch if flag("isHoldingItem") => {
|
||||
mob.extra.main_hand = Some("minecraft:potion".into());
|
||||
mob.extra.set_main_hand(Some("minecraft:potion".into()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -386,7 +386,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
.cloned()
|
||||
.unwrap_or_else(|| kind.model.clone());
|
||||
let mut mob = Mob::new(7, kind, Vec3::new(0.0, 64.0, 0.0), 0.0);
|
||||
mob.extra.main_hand = None;
|
||||
mob.extra.set_main_hand(None);
|
||||
let case = &v["case"];
|
||||
let f = |k: &str| case[k].as_f64().unwrap_or(0.0) as f32;
|
||||
if baby {
|
||||
|
||||
@@ -134,7 +134,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let item: Option<Arc<str>> = Some(Arc::from(s.item.as_str()));
|
||||
let index = match *slot {
|
||||
"mainhand" => {
|
||||
m.extra.main_hand = item;
|
||||
m.extra.set_main_hand(item);
|
||||
Some(gear::MAIN)
|
||||
}
|
||||
"offhand" => {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! End gateway travel for the entities that step into one:
|
||||
//! `EndGatewayBlock.entityInside` (the gateway takes the entity in and starts
|
||||
//! its 40 tick cooldown), `Entity.setAsInsidePortal` and `Entity.handlePortal`
|
||||
//! (the entity goes through on the next tick), and
|
||||
//! `TheEndGatewayBlockEntity.getPortalPosition` (where it lands).
|
||||
use crate::nether_portal::{ENTITY_DELAY, PortalState};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
|
||||
/// The End gateway travel of one entity.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Traveller {
|
||||
/// The entity's portal process and cooldown (`Entity.portalProcess`).
|
||||
state: PortalState,
|
||||
}
|
||||
|
||||
impl Traveller {
|
||||
/// `Entity.setAsInsidePortal`: the entity is in the gateway and goes
|
||||
/// through it on the next tick.
|
||||
pub fn enter(&mut self, gateway: BlockPos) {
|
||||
self.state.set_inside(gateway, ENTITY_DELAY);
|
||||
}
|
||||
|
||||
/// `Entity.handlePortal`: the entity goes through the gateway it entered
|
||||
/// (the End gateway has no transition time), or stays where it is.
|
||||
pub fn travel(&mut self, allowed: bool) -> Option<BlockPos> {
|
||||
self.state.handle(allowed, 0, ENTITY_DELAY)
|
||||
}
|
||||
|
||||
/// Whether the entity's travel is over: it has no portal process left and
|
||||
/// its portal cooldown has run out.
|
||||
pub fn is_done(&self) -> bool {
|
||||
self.state.cooldown == 0 && self.state.process.is_none()
|
||||
}
|
||||
|
||||
/// The entity's portal cooldown, the ticks it stays out of a portal.
|
||||
pub fn cooldown(&self) -> i32 {
|
||||
self.state.cooldown
|
||||
}
|
||||
}
|
||||
|
||||
/// The End gateway block an entity's box swept through
|
||||
/// (`Entity.checkInsideBlocks`, which calls `EndGatewayBlock.entityInside`).
|
||||
pub fn swept_gateway(
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
width: f32,
|
||||
height: f32,
|
||||
mut is_gateway: impl FnMut(BlockPos) -> bool,
|
||||
) -> Option<BlockPos> {
|
||||
let half = f64::from(width) / 2.0;
|
||||
let bounds = horizon_core::geometry::Aabb::new(
|
||||
Vec3::new(to.x - half, to.y, to.z - half),
|
||||
Vec3::new(to.x + half, to.y + f64::from(height), to.z + half),
|
||||
)
|
||||
.deflate(Vec3::new(1.0e-5, 1.0e-5, 1.0e-5));
|
||||
let mut found = None;
|
||||
let _ =
|
||||
horizon_core::geometry::sweep_intersected_blocks(from, to, bounds, 1_000_000, |p, _| {
|
||||
if is_gateway(p) {
|
||||
found = Some(p);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
});
|
||||
found
|
||||
}
|
||||
|
||||
/// The bottom centre of a block (`Vec3.atBottomCenterOf`): where a traveller
|
||||
/// lands at a gateway's exit.
|
||||
pub fn bottom_center(p: [i32; 3]) -> Vec3 {
|
||||
Vec3::new(
|
||||
f64::from(p[0]) + 0.5,
|
||||
f64::from(p[1]),
|
||||
f64::from(p[2]) + 0.5,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The gateway of the issue: a block at [702, 100, 0] in the End.
|
||||
const GATEWAY: [i32; 3] = [702, 100, 0];
|
||||
|
||||
fn at(x: f64, y: f64, z: f64) -> Vec3 {
|
||||
Vec3::new(x, y, z)
|
||||
}
|
||||
|
||||
fn is_gateway(p: BlockPos) -> bool {
|
||||
[p.x, p.y, p.z] == GATEWAY
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_and_a_mob_in_a_gateway_are_found_in_it() {
|
||||
// A dropped item, 0.25 wide, standing in the gateway block.
|
||||
let item = at(702.5, 100.5, 0.5);
|
||||
assert_eq!(
|
||||
swept_gateway(item, item, 0.25, 0.25, is_gateway),
|
||||
Some(BlockPos::new(702, 100, 0))
|
||||
);
|
||||
// A cow, 0.9 wide and 1.4 tall, in the same block.
|
||||
let cow = at(702.5, 100.0, 0.5);
|
||||
assert_eq!(
|
||||
swept_gateway(cow, cow, 0.9, 1.4, is_gateway),
|
||||
Some(BlockPos::new(702, 100, 0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entity_out_of_the_gateway_is_not_in_it() {
|
||||
// Two blocks above the gateway: nothing to travel through.
|
||||
let above = at(702.5, 102.5, 0.5);
|
||||
assert_eq!(swept_gateway(above, above, 0.25, 0.25, is_gateway), None);
|
||||
// Beside the gateway, outside its block.
|
||||
let beside = at(704.5, 100.5, 0.5);
|
||||
assert_eq!(swept_gateway(beside, beside, 0.9, 1.4, is_gateway), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entity_falling_through_the_gateway_goes_through_it() {
|
||||
// A cow falling past the gateway block passes through it
|
||||
// (Entity.checkInsideBlocks visits the blocks along the way).
|
||||
let from = at(702.5, 103.0, 0.5);
|
||||
let to = at(702.5, 99.0, 0.5);
|
||||
assert_eq!(
|
||||
swept_gateway(from, to, 0.9, 1.4, is_gateway),
|
||||
Some(BlockPos::new(702, 100, 0))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn an_entity_goes_through_the_gateway_on_the_tick_after_it_entered() {
|
||||
let gateway = BlockPos::new(702, 100, 0);
|
||||
let mut traveller = Traveller::default();
|
||||
// Nothing happens while the entity is outside the gateway.
|
||||
assert_eq!(traveller.travel(true), None);
|
||||
// It enters the gateway, and goes through on the next tick.
|
||||
traveller.enter(gateway);
|
||||
assert_eq!(traveller.travel(true), Some(gateway));
|
||||
// Its portal cooldown keeps it from going back at once.
|
||||
assert_eq!(traveller.cooldown(), ENTITY_DELAY);
|
||||
assert!(!traveller.is_done());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entity_that_may_not_use_portals_stays_in_the_gateway() {
|
||||
let gateway = BlockPos::new(702, 100, 0);
|
||||
let mut traveller = Traveller::default();
|
||||
traveller.enter(gateway);
|
||||
// A mob that cannot use a portal does not go through.
|
||||
assert_eq!(traveller.travel(false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_traveller_lands_at_the_bottom_centre_of_the_exit() {
|
||||
// TheEndGatewayBlockEntity.getPortalPosition: the exit's bottom
|
||||
// centre, the same spot the player arrives at.
|
||||
assert_eq!(bottom_center([8, 100, 0]), at(8.5, 100.0, 0.5));
|
||||
}
|
||||
}
|
||||
@@ -759,6 +759,17 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
if slot == "mainhand"
|
||||
&& let Some(stack) = m.extra.main_hand_stack()
|
||||
{
|
||||
let mut stack = stack.clone();
|
||||
if let Some(g) = crate::mobs::gear::gear(m) {
|
||||
stack.data_mut().enchantments = g.slots[index].clone();
|
||||
stack.data_mut().trim = g.trims[index].clone();
|
||||
}
|
||||
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
|
||||
continue;
|
||||
}
|
||||
// Enchanted or trimmed gear is saved with its components.
|
||||
if let (Some(id), Some(g)) = (item, crate::mobs::gear::gear(m))
|
||||
&& (!g.slots[index].is_empty() || g.trims[index].is_some())
|
||||
@@ -782,10 +793,11 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
}
|
||||
match item {
|
||||
Some(id) => {
|
||||
let keep = matches!(
|
||||
equipment.get(&slot.into()).and_then(|e| e.get("id")),
|
||||
Some(Tag::String(s)) if s.to_string_lossy() == **id
|
||||
);
|
||||
let keep = slot != "mainhand"
|
||||
&& matches!(
|
||||
equipment.get(&slot.into()).and_then(|e| e.get("id")),
|
||||
Some(Tag::String(s)) if s.to_string_lossy() == **id
|
||||
);
|
||||
if slot == "head"
|
||||
&& &**id == crate::mobs::raider::BANNER
|
||||
&& crate::mobs::raider::is_raider(m.species())
|
||||
@@ -989,10 +1001,8 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
|
||||
// What it holds (equipment.mainhand / offhand), when saved.
|
||||
if let Some(eq) = tag.get("equipment") {
|
||||
m.extra.main_hand = eq
|
||||
.get("mainhand")
|
||||
.and_then(|i| text(i.get("id")))
|
||||
.map(Into::into);
|
||||
m.extra
|
||||
.set_main_hand_stack(eq.get("mainhand").and_then(crate::save::item_from_nbt));
|
||||
m.extra.off_hand = eq
|
||||
.get("offhand")
|
||||
.and_then(|i| text(i.get("id")))
|
||||
@@ -1019,7 +1029,7 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
} else {
|
||||
// Registration hands out default weapons (finalizeSpawn does in
|
||||
// vanilla); a loaded mob holds only what its file lists.
|
||||
m.extra.main_hand = None;
|
||||
m.extra.set_main_hand(None);
|
||||
}
|
||||
m.death_time = number(tag.get("DeathTime")).unwrap_or(0) as i32;
|
||||
// Mob.readAdditionalSaveData overrides what the constructor set (copper
|
||||
@@ -3133,3 +3143,51 @@ mod neutral_anger_tests {
|
||||
assert_eq!(crate::mobs::anger_left(3000, 4000), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod held_item_name_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn replacing_a_named_item_does_not_restore_its_old_name_on_save() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<_, _> = data
|
||||
.types
|
||||
.into_iter()
|
||||
.map(|(id, kind)| (id, Arc::new(kind)))
|
||||
.collect();
|
||||
let mut mob = Mob::new(1, Arc::clone(&types["minecraft:zombie"]), Vec3::ZERO, 0.0);
|
||||
let mut sword = crate::items::ItemStack::new("minecraft:diamond_sword", 1);
|
||||
sword.data_mut().custom_name = Some("Old Sword".into());
|
||||
mob.extra.set_main_hand_stack(Some(sword));
|
||||
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None);
|
||||
let mut loaded = mob_from_nbt(&raw, &types, 2).unwrap();
|
||||
assert_eq!(
|
||||
loaded.extra.main_hand_stack().unwrap().custom_name(),
|
||||
Some("Old Sword")
|
||||
);
|
||||
loaded
|
||||
.extra
|
||||
.set_main_hand(Some("minecraft:diamond_sword".into()));
|
||||
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], Some(&raw));
|
||||
let restored = mob_from_nbt(&saved, &types, 3).unwrap();
|
||||
assert_eq!(
|
||||
restored.extra.main_hand.as_deref(),
|
||||
Some("minecraft:diamond_sword")
|
||||
);
|
||||
assert!(
|
||||
restored
|
||||
.extra
|
||||
.main_hand_stack()
|
||||
.unwrap()
|
||||
.custom_name()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1817,6 +1817,9 @@ struct Game<'a> {
|
||||
cart_models: minecarts::CartModels,
|
||||
/// The boat the player rides.
|
||||
riding: Option<u32>,
|
||||
/// The vehicle the player's file names, waiting for it to be in the world
|
||||
/// (`ServerPlayer.loadAndSpawnParentVehicle`).
|
||||
pending_vehicle: Option<[i32; 4]>,
|
||||
/// The ender inventory (27 slots) and the ender chest showing it.
|
||||
ender_items: Vec<Option<ItemStack>>,
|
||||
ender_open: Option<(i32, i32, i32)>,
|
||||
@@ -3078,6 +3081,10 @@ impl Game<'_> {
|
||||
self.portals.sections.remove(&key);
|
||||
self.block_ticks
|
||||
.remove_chunk(horizon_core::position::ChunkPos::new(key.0, key.1));
|
||||
// Its moving pistons were saved with it and load back with it.
|
||||
self.block_ticks
|
||||
.moving_pistons
|
||||
.retain(|(p, _)| (p.x >> 4, p.z >> 4) != key);
|
||||
}
|
||||
self.tick_chunks.recycle_added(added);
|
||||
}
|
||||
@@ -3289,10 +3296,20 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.map(|(p, piston)| ([p.x, p.y, p.z], piston.shape_state()))
|
||||
.collect();
|
||||
self.world.moving_piston_fallback = previous
|
||||
let mut fallback: Vec<_> = previous
|
||||
.into_iter()
|
||||
.filter(|(pos, _)| !current.iter().any(|(next, _)| next == pos))
|
||||
.collect();
|
||||
// A finished piston stays drawn until the chunk mesh shows the block
|
||||
// it placed, however many ticks that takes.
|
||||
let older = std::mem::take(&mut self.world.moving_piston_fallback);
|
||||
for (pos, piston) in older {
|
||||
let replaced = current.iter().chain(&fallback).any(|(p, _)| *p == pos);
|
||||
if !replaced && self.section_mesh_pending(pos) {
|
||||
fallback.push((pos, piston));
|
||||
}
|
||||
}
|
||||
self.world.moving_piston_fallback = fallback;
|
||||
self.world.moving_pistons = current;
|
||||
}
|
||||
/// `ServerLevel.tick`'s scheduled block then fluid ticks, one at a time:
|
||||
@@ -5309,6 +5326,7 @@ impl Game<'_> {
|
||||
let riding = self.riding.is_some();
|
||||
let entity_collisions = self.player_entity_collisions();
|
||||
let spin_targets = self.spin_targets();
|
||||
let sprint_window = self.settings.sprint_window;
|
||||
self.sync_player_body();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
// A dedicated server has no local player: the world still runs.
|
||||
@@ -5361,7 +5379,7 @@ impl Game<'_> {
|
||||
collision_binding: self.collision,
|
||||
resources: self.motion,
|
||||
}
|
||||
.controlled_simulation_tick(keys)?;
|
||||
.controlled_simulation_tick_with_sprint_window(keys, sprint_window)?;
|
||||
// Player.jumpFromGround: the jump statistic.
|
||||
if grounded && keys.jump && !player.flying && player.velocity.y > 0.2 {
|
||||
self.stats.custom("jump", 1);
|
||||
@@ -5579,7 +5597,26 @@ impl Game<'_> {
|
||||
// Inventory.dropAll: every stack thrown in a random direction.
|
||||
let feet = [player.position.x, player.position.y, player.position.z];
|
||||
let at = [feet[0], feet[1] + 1.32, feet[2]];
|
||||
// ServerPlayer.die: tellNeutralMobsThatIDied — every NeutralMob
|
||||
// in the dying player's block cell grown 32 sideways and 10 up
|
||||
// and down stops being angry at the player, when the gamerule
|
||||
// lets it and the mob's anger is at this player.
|
||||
let forgiving = self
|
||||
.ui
|
||||
.rules
|
||||
.bool("forgive_dead_players")
|
||||
.then(|| horizon_client::mobs::ai::forgiveness_area(feet));
|
||||
let killer = self.combat_killer_kind();
|
||||
if let Some(area) = forgiving {
|
||||
for m in &mut self.mobs {
|
||||
if horizon_client::mobs::ai::is_neutral(m.species())
|
||||
&& horizon_client::mobs::ai::angry_at_player(m)
|
||||
&& area.intersects(m.bounds())
|
||||
{
|
||||
horizon_client::mobs::ai::stop_being_angry(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Player.die: the dimension and block of the death.
|
||||
self.level.last_death =
|
||||
Some((self.dim.id().to_owned(), feet.map(|v| v.floor() as i32)));
|
||||
@@ -5674,11 +5711,16 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
if running {
|
||||
horizon_client::spans::time("entity_portals", || self.handle_entity_portals());
|
||||
horizon_client::spans::time("entity_portals", || {
|
||||
self.handle_entity_portals();
|
||||
self.handle_end_gateway_portals();
|
||||
});
|
||||
horizon_client::spans::time("tick_items", || self.tick_items());
|
||||
horizon_client::spans::time("touch_arrows", || self.touch_arrows());
|
||||
}
|
||||
horizon_client::spans::time("spawn_loaded_entities", || self.spawn_loaded_entities());
|
||||
// The ride the player's file names, once the vehicle is in the world.
|
||||
self.restore_root_vehicle();
|
||||
if running {
|
||||
horizon_client::spans::time("tick_mobs", || self.tick_mobs())?;
|
||||
horizon_client::spans::time("recheck_golem_openers", || self.recheck_golem_openers());
|
||||
@@ -5697,6 +5739,11 @@ impl Game<'_> {
|
||||
horizon_client::spans::time("tick_portal", || self.tick_nether_portal())?;
|
||||
if running {
|
||||
horizon_client::spans::time("tick_end", || self.tick_end())?;
|
||||
horizon_client::spans::time("end_gateways", || {
|
||||
self.find_end_gateway_portals();
|
||||
self.resolve_end_gateway_arrivals()
|
||||
})?;
|
||||
self.keep_gateway_destinations();
|
||||
}
|
||||
if running {
|
||||
horizon_client::spans::time("tick_falling", || self.tick_falling())?;
|
||||
@@ -9348,6 +9395,8 @@ impl Game<'_> {
|
||||
self.world = world;
|
||||
self.inventory = inventory;
|
||||
self.save = save;
|
||||
// The ride the player's file names, once the vehicle is in the world.
|
||||
self.pending_vehicle = level.root_vehicle;
|
||||
self.level = level;
|
||||
// ServerRecipeBook.loadUntrusted: recipes the manager does not have are dropped.
|
||||
let index = &self.res.stations().recipe_index;
|
||||
@@ -9356,7 +9405,8 @@ impl Game<'_> {
|
||||
book.to_be_displayed.retain(|r| index.contains(r));
|
||||
self.dim = dim;
|
||||
self.world_root = save_root.clone();
|
||||
self.world_flat = horizon_client::save::WorldGen::read(&save_root).is_some_and(|w| w.flat);
|
||||
self.world_flat = horizon_client::save::WorldGen::read(&save_root)
|
||||
.is_some_and(|w| w.preset == horizon_client::save::WorldPreset::Flat);
|
||||
self.block_ticks = Ticks::new(self.level.seed as i32);
|
||||
self.tick_chunks.clear();
|
||||
self.clear_entities();
|
||||
@@ -11718,6 +11768,8 @@ impl Game<'_> {
|
||||
self.level.game_rules = self.ui.rules.by_name();
|
||||
self.level.selected_slot = self.selected as i32;
|
||||
let mode = self.mode_id();
|
||||
// The vehicle the player rides (`ServerPlayer.saveParentVehicle`).
|
||||
let vehicle = self.root_vehicle_uuid(self.riding);
|
||||
let level = &mut self.level;
|
||||
level.creative = self.creative;
|
||||
level.game_mode = mode;
|
||||
@@ -11734,6 +11786,7 @@ impl Game<'_> {
|
||||
level.xp_level = self.experience.level;
|
||||
level.xp_progress = self.experience.progress;
|
||||
level.xp_total = self.experience.total;
|
||||
level.root_vehicle = vehicle.or(self.pending_vehicle);
|
||||
if let Some(p) = &self.player {
|
||||
level.position = Some([p.position.x, p.position.y, p.position.z]);
|
||||
level.health = p.health.health();
|
||||
@@ -14393,6 +14446,7 @@ impl<'a> Game<'a> {
|
||||
trim_slots: Vec::new(),
|
||||
cart_models: prepared.cart_models,
|
||||
riding: None,
|
||||
pending_vehicle: None,
|
||||
ender_items: vec![None; 27],
|
||||
ender_open: None,
|
||||
pending: Default::default(),
|
||||
|
||||
@@ -566,7 +566,7 @@ impl Game<'_> {
|
||||
"hardcore": options.hardcore,
|
||||
"difficulty": horizon_client::title::DIFFICULTIES[options.difficulty()],
|
||||
"allow_commands": options.commands(*creative),
|
||||
"superflat": options.flat,
|
||||
"world_type": options.world_type.id(),
|
||||
"generate_structures": options.structures,
|
||||
"bonus_chest": options.bonus_chest(),
|
||||
}),
|
||||
@@ -579,7 +579,7 @@ impl Game<'_> {
|
||||
} => json!({
|
||||
"mode": "customize-flat",
|
||||
"fields": { "name": name, "seed": seed },
|
||||
"superflat": options.flat,
|
||||
"superflat": options.world_type == horizon_client::save::WorldPreset::Flat,
|
||||
"biome": flat.settings().biome,
|
||||
"layers": flat.settings().layers.iter().map(|layer| json!({
|
||||
"block": layer.block,
|
||||
|
||||
@@ -219,7 +219,7 @@ impl Game<'_> {
|
||||
let holds = m.extra.main_hand.is_some();
|
||||
if !holds && !item.is_empty() {
|
||||
let m = &mut self.mobs[index];
|
||||
m.extra.main_hand = Some(item.into());
|
||||
m.extra.set_main_hand(Some(item.into()));
|
||||
m.extra.tame = true;
|
||||
// `Allay.mobInteract` remembers LIKED_PLAYER by UUID.
|
||||
m.extra.owner = Some(acting);
|
||||
|
||||
@@ -304,6 +304,16 @@ impl Game<'_> {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// The display component of a custom-named held stack.
|
||||
fn mob_main_hand_name(&self, id: u32) -> Option<Text> {
|
||||
let m = self.mobs.iter().find(|m| m.id == id)?;
|
||||
let mut stack = m.extra.main_hand_stack()?.clone();
|
||||
stack.custom_name()?;
|
||||
if let Some(g) = horizon_client::mobs::gear::gear(m) {
|
||||
stack.data_mut().enchantments = g.slots[horizon_client::mobs::gear::MAIN].clone();
|
||||
}
|
||||
Some(self.stack_display_name(&stack))
|
||||
}
|
||||
/// `CombatTracker.getDeathMessage`.
|
||||
pub(super) fn combat_death_message(&mut self) -> Text {
|
||||
self.combat_death_message_for(Text::literal(PLAYER_NAME))
|
||||
@@ -357,8 +367,16 @@ impl Game<'_> {
|
||||
}
|
||||
// `getLocalizedDeathMessage`: the causing entity's name, or the
|
||||
// direct entity's when nothing caused the damage.
|
||||
if let Some((_, name)) = killer {
|
||||
return Text::translate(&key, &[me, name]);
|
||||
if let Some((id, name)) = killer {
|
||||
// DamageSource.getLocalizedDeathMessage: the cause's item with a
|
||||
// custom name gets the `<id>.item` message.
|
||||
let (key, args) = horizon_client::scoreboard::death_message_args(
|
||||
&key,
|
||||
&me,
|
||||
&name,
|
||||
self.mob_main_hand_name(id).as_ref(),
|
||||
);
|
||||
return Text::translate(&key, &args);
|
||||
}
|
||||
if let Some(name) = direct {
|
||||
return Text::translate(&key, &[me, name]);
|
||||
|
||||
@@ -235,9 +235,9 @@ impl Game<'_> {
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == *id) else {
|
||||
return false;
|
||||
};
|
||||
let item = stack.map(|s| s.item);
|
||||
let item = stack.as_ref().map(|s| s.item.clone());
|
||||
match *key {
|
||||
"mainhand" => m.extra.main_hand = item,
|
||||
"mainhand" => m.extra.set_main_hand_stack(stack),
|
||||
"offhand" => m.extra.off_hand = item,
|
||||
"head" | "body" => m.extra.head = item,
|
||||
"chest" => m.extra.chest = item,
|
||||
|
||||
@@ -391,15 +391,14 @@ impl Lists {
|
||||
}
|
||||
}
|
||||
|
||||
/// The world presets of 26.3's `worldgen/world_preset`.
|
||||
const WORLD_PRESETS: [&str; 7] = [
|
||||
"minecraft:normal",
|
||||
"minecraft:flat",
|
||||
"minecraft:large_biomes",
|
||||
"minecraft:amplified",
|
||||
"minecraft:single_biome_surface",
|
||||
"minecraft:debug_all_block_states",
|
||||
"minecraft:flat_all_dimensions",
|
||||
/// DedicatedServerProperties.LEGACY_PRESET_NAMES: the old `level-type`
|
||||
/// names, before the presets had their own ids.
|
||||
const LEGACY_PRESET_NAMES: [(&str, horizon_client::save::WorldPreset); 2] = [
|
||||
("default", horizon_client::save::WorldPreset::Normal),
|
||||
(
|
||||
"largebiomes",
|
||||
horizon_client::save::WorldPreset::LargeBiomes,
|
||||
),
|
||||
];
|
||||
|
||||
/// Identifier.tryParse: `namespace:path`, or a path in `minecraft`.
|
||||
@@ -416,11 +415,18 @@ impl Game<'_> {
|
||||
/// DedicatedServerProperties.createDimensions for a world that doesn't
|
||||
/// exist yet: `level-type`'s preset, a flat world's `generator-settings`
|
||||
/// and `generate-structures`, written before the world opens so that
|
||||
/// streaming reads them. Horizon generates the normal and flat presets.
|
||||
/// streaming reads them.
|
||||
fn write_world_gen(&self, dir: &std::path::Path, props: &ServerProperties) -> Result<()> {
|
||||
let level_type = &props.level_type;
|
||||
let preset = parse_identifier(level_type)
|
||||
.filter(|id| WORLD_PRESETS.contains(&id.as_str()))
|
||||
.as_deref()
|
||||
.and_then(horizon_client::save::WorldPreset::of_id)
|
||||
.or_else(|| {
|
||||
LEGACY_PRESET_NAMES
|
||||
.iter()
|
||||
.find(|(name, _)| *name == level_type)
|
||||
.map(|(_, preset)| *preset)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
log_from(
|
||||
"Worker-Main-1",
|
||||
@@ -429,18 +435,9 @@ impl Game<'_> {
|
||||
"Failed to parse level-type {level_type}, defaulting to minecraft:normal"
|
||||
),
|
||||
);
|
||||
"minecraft:normal".to_owned()
|
||||
horizon_client::save::WorldPreset::Normal
|
||||
});
|
||||
let flat = preset == "minecraft:flat";
|
||||
if !flat && preset != "minecraft:normal" {
|
||||
log_from(
|
||||
"Worker-Main-1",
|
||||
"WARN",
|
||||
&format!(
|
||||
"Horizon doesn't generate the {preset} world preset yet: using minecraft:normal"
|
||||
),
|
||||
);
|
||||
}
|
||||
let flat = preset == horizon_client::save::WorldPreset::Flat;
|
||||
let settings = if flat {
|
||||
let biomes = self.res.biomes();
|
||||
let (settings, errors) = horizon_client::flatgen::settings_from_json(
|
||||
@@ -456,7 +453,7 @@ impl Game<'_> {
|
||||
None
|
||||
};
|
||||
let options = horizon_client::save::WorldGen {
|
||||
flat,
|
||||
preset,
|
||||
structures: props.generate_structures,
|
||||
bonus_chest: false,
|
||||
};
|
||||
@@ -1457,10 +1454,13 @@ impl Game<'_> {
|
||||
fn stop_dedicated(&mut self) -> Result<()> {
|
||||
log("INFO", "Stopping server");
|
||||
log("INFO", "Saving players");
|
||||
if let Some(h) = self.lan.as_mut() {
|
||||
for g in &h.hub.guests {
|
||||
horizon_client::lan::playerdata::save(g, &self.world_root);
|
||||
if let Some(host) = self.lan.take() {
|
||||
for g in &host.hub.guests {
|
||||
// The vehicle the guest rides, for the file that names it.
|
||||
let vehicle = self.root_vehicle_uuid(g.vehicle);
|
||||
horizon_client::lan::playerdata::save(g, &self.world_root, vehicle);
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
if let Some(h) = self.lan.as_ref() {
|
||||
for g in &h.hub.guests {
|
||||
|
||||
@@ -296,7 +296,7 @@ pub(super) fn mob_set_slot(m: &mut Mob, slot: Slot, stack: ItemStack) {
|
||||
let item: Arc<str> = stack.item.clone();
|
||||
let x = &mut m.extra;
|
||||
match slot {
|
||||
Slot::MainHand => x.main_hand = Some(item),
|
||||
Slot::MainHand => x.set_main_hand(Some(item)),
|
||||
Slot::OffHand => x.off_hand = Some(item),
|
||||
Slot::Feet => x.feet = Some(item),
|
||||
Slot::Legs => x.legs = Some(item),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! sky. The dragon fight itself lives in game/dragon.rs: the podium is
|
||||
//! inactive until the dragon dies.
|
||||
use super::{Dim, Game};
|
||||
use horizon_client::nether_portal::ENTITY_DELAY;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
containers::{BlockEntity, EndGateway},
|
||||
@@ -16,10 +17,18 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::Vec3,
|
||||
position::BlockPos,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
/// A list of doubles, as an NBT list of them.
|
||||
fn doubles(values: [f64; 3]) -> Tag {
|
||||
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
|
||||
}
|
||||
|
||||
/// ServerLevel.END_SPAWN_POINT.
|
||||
pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
|
||||
/// EnderDragonFight gateway ring radius and height.
|
||||
@@ -38,6 +47,18 @@ pub(super) enum GatewayJob {
|
||||
Known { exit: [i32; 3], exact: bool },
|
||||
}
|
||||
|
||||
/// An entity travelling through an End gateway: its saved compound and the
|
||||
/// job that finds where it lands (`TheEndGatewayBlockEntity.getPortalPosition`).
|
||||
#[derive(Clone, Debug)]
|
||||
struct GatewayArrival {
|
||||
/// The entity's compound, as it waits for the exit's chunks.
|
||||
tag: Tag,
|
||||
/// The gateway's exit, or the search for one.
|
||||
job: GatewayJob,
|
||||
/// The spot to load before the job is resolved.
|
||||
target: Vec3,
|
||||
}
|
||||
|
||||
impl GatewayJob {
|
||||
/// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks.
|
||||
fn for_gateway(
|
||||
@@ -94,6 +115,11 @@ 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>,
|
||||
/// The entities that went through a gateway, waiting for its exit.
|
||||
arrivals: Vec<GatewayArrival>,
|
||||
/// The chunks kept loaded for those arrivals.
|
||||
areas: Vec<(i32, i32)>,
|
||||
/// The Overworld generator, for stronghold positions (ServerLevel.findNearestMapStructure).
|
||||
locator: std::sync::Mutex<Option<Arc<VanillaTerrain>>>,
|
||||
/// Thrown eyes of ender in flight.
|
||||
@@ -947,6 +973,17 @@ impl Game<'_> {
|
||||
self.save_gateway_exits()
|
||||
}
|
||||
|
||||
/// The exit a gateway already leads to: its block entity, or the side file
|
||||
/// that kept it for a gateway whose chunk is not loaded.
|
||||
fn known_gateway_exit(&mut self, gateway: [i32; 3]) -> Option<([i32; 3], bool)> {
|
||||
let key = (gateway[0], gateway[1], gateway[2]);
|
||||
let saved = match self.world.block_entities.get(&key) {
|
||||
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)),
|
||||
_ => None,
|
||||
};
|
||||
saved.or_else(|| self.gateway_exits().get(&gateway).copied())
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
@@ -1043,13 +1080,7 @@ impl Game<'_> {
|
||||
/// The teleport position of a gateway job, creating the exit gateway (and
|
||||
/// an island when the chunk has no landing spot) for new destinations.
|
||||
pub(super) fn resolve_gateway(&mut self, job: GatewayJob, target: Vec3) -> Result<Vec3> {
|
||||
let bottom_center = |p: [i32; 3]| {
|
||||
Vec3::new(
|
||||
f64::from(p[0]) + 0.5,
|
||||
f64::from(p[1]),
|
||||
f64::from(p[2]) + 0.5,
|
||||
)
|
||||
};
|
||||
let bottom_center = horizon_client::end_gateway::bottom_center;
|
||||
let find_exit = |game: &Self, exit: [i32; 3]| {
|
||||
let p = game.tallest_block([exit[0], exit[1] + 2, exit[2]], 5, false);
|
||||
[p[0], p[1] + 1, p[2]]
|
||||
@@ -1059,6 +1090,14 @@ impl Game<'_> {
|
||||
find_exit(self, e)
|
||||
}))),
|
||||
GatewayJob::Find { gateway, exact } => {
|
||||
// The player and the entities may resolve the same gateway: the
|
||||
// first one to arrive finds the exit, the others use it.
|
||||
let known = self.known_gateway_exit(gateway);
|
||||
if let Some((exit, exact)) = known {
|
||||
return Ok(bottom_center(exit_target(exit, exact, |e| {
|
||||
find_exit(self, e)
|
||||
})));
|
||||
}
|
||||
// findExitPortalXZPosTentative over the loaded chunks.
|
||||
let dir = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2]))
|
||||
.normalize_or_zero();
|
||||
@@ -1420,6 +1459,194 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- End gateway travel for the entities ----
|
||||
|
||||
/// `EndGatewayBlock.entityInside` for the items and mobs in the End: the
|
||||
/// gateway blocks their box swept through. A gateway that is cooling down
|
||||
/// (`TheEndGatewayBlockEntity.triggerCooldown`) takes nobody in.
|
||||
pub(super) fn find_end_gateway_portals(&mut self) {
|
||||
if self.remote.is_some() || self.dim != Dim::End {
|
||||
return;
|
||||
}
|
||||
let items: Vec<(u32, Vec3, f32, f32)> = self
|
||||
.dropped
|
||||
.iter()
|
||||
.map(|e| (e.id, e.position, 0.25, 0.25))
|
||||
.collect();
|
||||
let mobs: Vec<(u32, Vec3, f32, f32)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| self.mob_can_use_portal(m))
|
||||
.map(|m| (m.id, m.position, m.width(), m.height()))
|
||||
.collect();
|
||||
let mut inside: Vec<(u32, BlockPos)> = Vec::new();
|
||||
for (id, to, width, height) in items.into_iter().chain(mobs) {
|
||||
let from = self.portals.before.get(&id).copied().unwrap_or(to);
|
||||
let found = horizon_client::end_gateway::swept_gateway(from, to, width, height, |p| {
|
||||
&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name
|
||||
== "minecraft:end_gateway"
|
||||
});
|
||||
if let Some(pos) = found {
|
||||
inside.push((id, pos));
|
||||
}
|
||||
}
|
||||
for (id, pos) in inside {
|
||||
// One entity at a time: the gateway cools down for 40 ticks.
|
||||
if !self.gateway_enter([pos.x, pos.y, pos.z]) {
|
||||
continue;
|
||||
}
|
||||
// An entity that just came through any portal stays out of this one.
|
||||
if self.restart_portal_cooldown(id) {
|
||||
continue;
|
||||
}
|
||||
self.end.gateways.entry(id).or_default().enter(pos);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.handlePortal` for the entities in an End gateway: they go
|
||||
/// through to the gateway's exit, where they wait for its chunks.
|
||||
pub(super) fn handle_end_gateway_portals(&mut self) {
|
||||
if self.remote.is_some() || self.dim != Dim::End {
|
||||
return;
|
||||
}
|
||||
let ids: Vec<(u32, bool)> = self
|
||||
.dropped
|
||||
.iter()
|
||||
.map(|e| (e.id, true))
|
||||
.chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m))))
|
||||
.collect();
|
||||
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
|
||||
self.end.gateways.retain(|id, _| present.contains(id));
|
||||
let mut leaving: Vec<(u32, BlockPos)> = Vec::new();
|
||||
for (id, allowed) in ids {
|
||||
let Some(state) = self.end.gateways.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
// The End gateway has no transition time (Portal.getPortalTransitionTime).
|
||||
if let Some(entry) = state.travel(allowed) {
|
||||
leaving.push((id, entry));
|
||||
}
|
||||
if state.is_done() {
|
||||
self.end.gateways.remove(&id);
|
||||
}
|
||||
}
|
||||
for (id, entry) in leaving {
|
||||
self.send_through_gateway(id, entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.teleportToPortalDestination` for an entity that goes through an
|
||||
/// End gateway: it is saved and waits for the exit's chunks.
|
||||
fn send_through_gateway(&mut self, id: u32, gateway: BlockPos) {
|
||||
let tag = if let Some(i) = self.dropped.iter().position(|e| e.id == id) {
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag = horizon_client::entity_nbt::item_entity_to_nbt(
|
||||
&self.dropped[i],
|
||||
uuid,
|
||||
self.persist_raw(id),
|
||||
);
|
||||
self.dropped.remove(i);
|
||||
self.forget_entity(id);
|
||||
tag
|
||||
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
|
||||
let mut tag = self.mob_tree_nbt(i);
|
||||
// Entity.removeAfterChangingDimensions drops the leash.
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.remove(&"leash".into());
|
||||
}
|
||||
// The mobs riding it travel with it (their tags are in its compound).
|
||||
let mut gone = vec![id];
|
||||
let mut k = 0;
|
||||
while k < gone.len() {
|
||||
let v = gone[k];
|
||||
gone.extend(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.extra.mount == Some(v))
|
||||
.map(|m| m.id),
|
||||
);
|
||||
k += 1;
|
||||
}
|
||||
self.mobs.retain(|m| !gone.contains(&m.id));
|
||||
for g in gone {
|
||||
self.forget_entity(g);
|
||||
}
|
||||
tag
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
|
||||
self.end.arrivals.push(GatewayArrival { tag, job, target });
|
||||
}
|
||||
|
||||
/// The entities that reached a gateway's exit: they land where the
|
||||
/// gateway leads, once its chunks have loaded.
|
||||
pub(super) fn resolve_end_gateway_arrivals(&mut self) -> Result<()> {
|
||||
if self.remote.is_some() || self.dim != Dim::End {
|
||||
return Ok(());
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < self.end.arrivals.len() {
|
||||
let (cx, cz) = (
|
||||
(self.end.arrivals[i].target.x.floor() as i32) >> 4,
|
||||
(self.end.arrivals[i].target.z.floor() as i32) >> 4,
|
||||
);
|
||||
if !self.world.chunks.contains_key(&(cx, cz)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let (job, target) = (self.end.arrivals[i].job, self.end.arrivals[i].target);
|
||||
let at = match self.resolve_gateway(job, target) {
|
||||
Ok(at) => at,
|
||||
Err(e) => {
|
||||
// The traveller waits for the exit: the failure is retried.
|
||||
log::warn!("End gateway: {e}");
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut tag = self.end.arrivals.remove(i).tag;
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
|
||||
c.insert("Motion".into(), doubles([0.0, 0.0, 0.0]));
|
||||
// setPortalCooldown before the teleport.
|
||||
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
|
||||
}
|
||||
let key = ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
|
||||
match self
|
||||
.world
|
||||
.loaded_entities
|
||||
.iter_mut()
|
||||
.find(|(k, _)| *k == key)
|
||||
{
|
||||
Some((_, tags)) => tags.push(tag),
|
||||
None => self.world.loaded_entities.push((key, vec![tag])),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The chunks the entities waiting to arrive need, kept loaded like the
|
||||
/// areas a server keeps around its players.
|
||||
pub(super) fn keep_gateway_destinations(&mut self) {
|
||||
let areas = &self.end.areas;
|
||||
self.streaming
|
||||
.extra
|
||||
.retain(|&(c, r)| !(r == 1 && areas.contains(&c)));
|
||||
let mut areas = Vec::new();
|
||||
for a in &self.end.arrivals {
|
||||
let c = (
|
||||
(a.target.x.floor() as i32) >> 4,
|
||||
(a.target.z.floor() as i32) >> 4,
|
||||
);
|
||||
if !areas.contains(&c) {
|
||||
areas.push(c);
|
||||
self.streaming.extra.push((c, 1));
|
||||
}
|
||||
}
|
||||
self.end.areas = areas;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -178,7 +178,8 @@ pub(super) fn baby_index(kind: &str) -> Option<u8> {
|
||||
| "minecraft:axolotl"
|
||||
| "minecraft:dolphin"
|
||||
| "minecraft:squid"
|
||||
| "minecraft:glow_squid" => Some(16),
|
||||
| "minecraft:glow_squid"
|
||||
| "minecraft:sulfur_cube" => Some(16),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -603,10 +604,11 @@ fn mob_meta(
|
||||
}
|
||||
}
|
||||
// AbstractCubeMob.ID_SIZE.
|
||||
"minecraft:slime" | "minecraft:magma_cube" => {
|
||||
"minecraft:slime" | "minecraft:magma_cube" | "minecraft:sulfur_cube" => {
|
||||
if e.size > 1 {
|
||||
meta.push((18, serializer::INT, Value::Int(e.size)));
|
||||
}
|
||||
defaults.push((18, serializer::INT, Value::Int(1)));
|
||||
}
|
||||
// Creeper.DATA_SWELL_DIR, DATA_IS_POWERED and DATA_IS_IGNITED: the
|
||||
// guest swells the creeper from them. The direction always goes in,
|
||||
@@ -1245,15 +1247,53 @@ impl Game<'_> {
|
||||
/// Save a guest when they leave or the world closes: an integrated
|
||||
/// server's owner with the world (`save_game`), anyone else in their file.
|
||||
pub(super) fn save_guest(&mut self, host: &LanHost, guest: &horizon_client::lan::guest::Guest) {
|
||||
// The vehicle the guest rides, for the file that names it.
|
||||
let vehicle = self.root_vehicle_uuid(guest.vehicle);
|
||||
if host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) {
|
||||
if guest.placed {
|
||||
playerdata::store(guest, &mut self.level);
|
||||
playerdata::store(guest, &mut self.level, vehicle);
|
||||
}
|
||||
} else {
|
||||
playerdata::save(guest, &self.world_root);
|
||||
playerdata::save(guest, &self.world_root, vehicle);
|
||||
}
|
||||
self.save_guest_records(host, guest);
|
||||
}
|
||||
/// `ServerPlayer.loadAndSpawnParentVehicle`: a guest whose file names the
|
||||
/// vehicle it rode rides it again, once the vehicle is in the world.
|
||||
fn restore_guest_vehicles(&mut self, host: &mut LanHost) {
|
||||
let waiting: Vec<(usize, [i32; 4])> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, g)| Some((i, g.saved_vehicle?)))
|
||||
.filter(|(i, _)| {
|
||||
let g = &host.hub.guests[*i];
|
||||
let chunk = (
|
||||
(g.position[0].floor() as i32).div_euclid(16),
|
||||
(g.position[2].floor() as i32).div_euclid(16),
|
||||
);
|
||||
g.placed
|
||||
&& g.vehicle.is_none()
|
||||
&& g.dimension == self.dim.id()
|
||||
&& g.sent_chunks.contains(&chunk)
|
||||
})
|
||||
.collect();
|
||||
for (i, uuid) in waiting {
|
||||
let raw = host.hub.guests[i].saved.clone();
|
||||
let Some(id) = self.restore_saved_vehicle(uuid, raw.as_ref()) else {
|
||||
continue;
|
||||
};
|
||||
let g = &mut host.hub.guests[i];
|
||||
if let Some(horizon_world::nbt::Tag::Compound(p)) = &mut g.saved {
|
||||
p.remove(&"RootVehicle".into());
|
||||
}
|
||||
g.vehicle = Some(id);
|
||||
g.saved_vehicle = None;
|
||||
// Pair the stationary restored entities before their passenger packet.
|
||||
g.moved = true;
|
||||
}
|
||||
}
|
||||
/// The file name of a player's advancements and statistics: an
|
||||
/// integrated server's owner keeps single player's.
|
||||
fn records_uuid(&self, host: &LanHost, uuid: u128) -> Option<[i32; 4]> {
|
||||
@@ -1324,14 +1364,28 @@ impl Game<'_> {
|
||||
}
|
||||
/// The integrated server's owner into the world's player, for `Level::write`.
|
||||
pub(super) fn store_owner(&mut self) {
|
||||
let Some(uuid) = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.and_then(|h| h.owner.as_ref())
|
||||
.map(|o| o.uuid)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let riding = self.lan.as_ref().and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.uuid == uuid && g.placed)
|
||||
.and_then(|g| g.vehicle)
|
||||
});
|
||||
// The vehicle the owner rides, by the UUID it is saved with.
|
||||
let vehicle = self.root_vehicle_uuid(riding);
|
||||
let Some(host) = &self.lan else {
|
||||
return;
|
||||
};
|
||||
let Some(uuid) = host.owner.as_ref().map(|o| o.uuid) else {
|
||||
return;
|
||||
};
|
||||
if let Some(g) = host.hub.guests.iter().find(|g| g.uuid == uuid && g.placed) {
|
||||
playerdata::store(g, &mut self.level);
|
||||
playerdata::store(g, &mut self.level, vehicle);
|
||||
self.save_guest_records(host, g);
|
||||
}
|
||||
}
|
||||
@@ -1514,6 +1568,8 @@ impl Game<'_> {
|
||||
m.rider = None;
|
||||
}
|
||||
}
|
||||
// A guest whose file names the vehicle it rode rides it again.
|
||||
self.restore_guest_vehicles(&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();
|
||||
@@ -2162,11 +2218,8 @@ impl Game<'_> {
|
||||
.motion
|
||||
.as_ref()
|
||||
.map_or(0, |p| p.health.hurt_animation_time);
|
||||
m.extra.main_hand = g
|
||||
.inventory
|
||||
.get(g.selected)
|
||||
.and_then(|s| s.as_ref())
|
||||
.map(|s| s.item.clone());
|
||||
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
|
||||
|
||||
@@ -675,9 +675,7 @@ impl Game<'_> {
|
||||
let new_mode = self.mode_id();
|
||||
self.mode = mode;
|
||||
self.creative = creative;
|
||||
self.selected = std::mem::replace(&mut g.selected, selected);
|
||||
// `g.selected` now holds the host's value: swap the inventories back.
|
||||
std::mem::swap(&mut self.selected, &mut g.selected);
|
||||
g.selected = std::mem::replace(&mut self.selected, selected);
|
||||
std::mem::swap(&mut self.inventory, &mut g.inventory);
|
||||
std::mem::swap(&mut self.armor, &mut g.armor);
|
||||
std::mem::swap(&mut self.offhand, &mut g.offhand);
|
||||
|
||||
@@ -1240,7 +1240,7 @@ impl Game<'_> {
|
||||
diffuse: bool,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
self.block_model_vertices_tinted(state, seed, m, light, diffuse, 0xFF_FFFF, out);
|
||||
self.tinted_block_model_vertices(state, seed, m, |_| light, diffuse, |_| None, out);
|
||||
}
|
||||
/// `block_model_vertices` with `tint` (RGB) on the faces that have a tint index.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1253,6 +1253,23 @@ impl Game<'_> {
|
||||
diffuse: bool,
|
||||
tint: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let tint = |index: i32| (index >= 0).then_some(tint);
|
||||
self.tinted_block_model_vertices(state, seed, m, |_| light, diffuse, tint, out);
|
||||
}
|
||||
/// `block_model_vertices` with each face multiplied by `tint` of its
|
||||
/// tint index (RGB, `None` for untinted faces) and lit by `light` of
|
||||
/// each quad.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn tinted_block_model_vertices(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
seed: i64,
|
||||
m: glam::Mat4,
|
||||
light: impl Fn(&horizon_assets::bake::ModelQuad) -> u32,
|
||||
diffuse: bool,
|
||||
tint: impl Fn(i32) -> Option<u32>,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let Ok(models) = self.pack.render.model_catalog.collect_seeded(state, seed) else {
|
||||
return;
|
||||
@@ -1274,17 +1291,17 @@ impl Game<'_> {
|
||||
D::North | D::South => 204,
|
||||
D::West | D::East => 153,
|
||||
};
|
||||
let color = if diffuse {
|
||||
let mut color = if diffuse {
|
||||
u32::MAX
|
||||
} else {
|
||||
0xFF00_0000 | shade << 16 | shade << 8 | shade
|
||||
};
|
||||
let color = if face.quad.tint_index >= 0 {
|
||||
let tint = horizon_client::mobs::render::abgr(tint);
|
||||
horizon_client::hud_layout::multiply(color, tint)
|
||||
} else {
|
||||
color
|
||||
};
|
||||
if let Some(t) = tint(face.quad.tint_index) {
|
||||
color = horizon_client::hud_layout::multiply(
|
||||
color,
|
||||
horizon_client::mobs::render::abgr(t),
|
||||
);
|
||||
}
|
||||
let normal = if diffuse {
|
||||
use horizon_client::item_render::{direction_vector, transform_normal};
|
||||
let n = transform_normal(m, direction_vector(face.quad.facing));
|
||||
@@ -1293,6 +1310,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
[0; 4]
|
||||
};
|
||||
let light = light(&face.quad);
|
||||
let light = if diffuse {
|
||||
let emission = u32::from(face.quad.light_emission) * 16;
|
||||
(light & 0xFF) | (light >> 8 & 0xFF).max(emission) << 8
|
||||
|
||||
@@ -182,13 +182,13 @@ impl Game<'_> {
|
||||
0
|
||||
};
|
||||
if level > 0 {
|
||||
m.extra.main_hand = Some("minecraft:crossbow".into());
|
||||
m.extra.set_main_hand(Some("minecraft:crossbow".into()));
|
||||
gear::set(m, MAIN, Vec::new());
|
||||
gear::upgrade(m, MAIN, "minecraft:quick_charge", level);
|
||||
}
|
||||
}
|
||||
RaiderType::Vindicator => {
|
||||
m.extra.main_hand = Some("minecraft:iron_axe".into());
|
||||
m.extra.set_main_hand(Some("minecraft:iron_axe".into()));
|
||||
gear::set(m, MAIN, Vec::new());
|
||||
if enchant {
|
||||
let level = if wave > raid::num_groups(2) { 2 } else { 1 };
|
||||
|
||||
@@ -14,30 +14,58 @@ impl Game<'_> {
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let partial = alpha as f32;
|
||||
let mut states = self.world.moving_pistons.clone();
|
||||
states.extend(self.world.moving_piston_fallback.iter().copied());
|
||||
for (p, piston) in states {
|
||||
let live = self
|
||||
.world
|
||||
.moving_pistons
|
||||
.iter()
|
||||
.map(|&(p, s)| (p, s, false));
|
||||
// A piston that just finished bridges the frames until the chunk mesh
|
||||
// shows the block it placed; drawn over that mesh, it z-fights (#3002).
|
||||
let finished = self
|
||||
.world
|
||||
.moving_piston_fallback
|
||||
.iter()
|
||||
.filter(|(p, _)| self.section_mesh_pending(*p))
|
||||
.map(|&(p, s)| (p, s, true));
|
||||
let states: Vec<_> = live.chain(finished).collect();
|
||||
for (p, piston, placed) in states {
|
||||
let Some((block, base)) = self.moving_piston_blocks(piston, partial) else {
|
||||
continue;
|
||||
};
|
||||
let step = piston.movement_direction().normal();
|
||||
let from = horizon_core::position::BlockPos::new(
|
||||
p[0] - step[0],
|
||||
p[1] - step[1],
|
||||
p[2] - step[2],
|
||||
);
|
||||
let at = horizon_core::position::BlockPos::new(p[0], p[1], p[2]);
|
||||
// `createMovingBlock`'s block position (seed, tint and light): the
|
||||
// cell the block moves from. A finished one stands in for the
|
||||
// chunk mesh's block at `p`, so it is drawn as that block.
|
||||
let from = if placed {
|
||||
at
|
||||
} else {
|
||||
horizon_core::position::BlockPos::new(
|
||||
p[0] - step[0],
|
||||
p[1] - step[1],
|
||||
p[2] - step[2],
|
||||
)
|
||||
};
|
||||
let offset = piston.interpolated_offset(partial);
|
||||
let light = self.world.light(p[0], p[1], p[2]);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
let corner = glam::DVec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2]));
|
||||
let moved = corner + glam::Vec3::from_array(offset).as_dvec3();
|
||||
self.moving_block_vertices(block, from, moved - camera, packed, out);
|
||||
self.moving_block_vertices(block, from, moved - camera, out);
|
||||
if let Some(base) = base {
|
||||
let at = horizon_core::position::BlockPos::new(p[0], p[1], p[2]);
|
||||
self.moving_block_vertices(base, at, corner - camera, packed, out);
|
||||
self.moving_block_vertices(base, at, corner - camera, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether the chunk mesh doesn't show the blocks at `p` yet (edited,
|
||||
/// meshing, or waiting for its upload).
|
||||
pub(super) fn section_mesh_pending(&self, p: [i32; 3]) -> bool {
|
||||
let Some(y) = p[1].checked_sub(horizon_client::chunk::MIN_Y) else {
|
||||
return false;
|
||||
};
|
||||
let section = (p[0] >> 4, y.div_euclid(16), p[2] >> 4);
|
||||
self.world.dirty_sections.contains(§ion)
|
||||
|| self.streaming.is_meshing(section)
|
||||
|| self.pending_uploads.iter().any(|(key, _)| *key == section)
|
||||
}
|
||||
/// The moved block and, for a retracting piston, its extended base.
|
||||
fn moving_piston_blocks(
|
||||
&self,
|
||||
@@ -81,32 +109,51 @@ impl Game<'_> {
|
||||
Some((piston.moved_state, None))
|
||||
}
|
||||
/// One `MovingBlockRenderState`: the baked model at `at` (camera-relative),
|
||||
/// seeded by its block position and tinted like a falling block.
|
||||
/// seeded by its block position `seed`, tinted per face with the block
|
||||
/// colours there and lit as `ModelBlockRenderer.tesselateFlat` lights a
|
||||
/// lone block at `seed`: each face from the cell it looks at. Not from
|
||||
/// the moving piston's own cell, which is dark once the block it placed
|
||||
/// stands there (#3002).
|
||||
fn moving_block_vertices(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
seed: horizon_core::position::BlockPos,
|
||||
at: glam::DVec3,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let tint = self
|
||||
.particle_sprite(state, [seed.x, seed.y, seed.z])
|
||||
.map(|(_, tint)| tint);
|
||||
let start = out.len();
|
||||
self.block_model_vertices(
|
||||
let chunks = &self.world.chunks;
|
||||
let (cx, cz) = (seed.x >> 4, seed.z >> 4);
|
||||
let region: [[Option<&horizon_client::chunk::Chunk>; 3]; 3] = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| {
|
||||
chunks
|
||||
.get(&(cx + dx as i32 - 1, cz + dz as i32 - 1))
|
||||
.map(|c| &**c)
|
||||
})
|
||||
});
|
||||
let tint = self.pack.context.tables.block_tints(
|
||||
state,
|
||||
[seed.x, seed.y, seed.z],
|
||||
®ion,
|
||||
self.res.biomes(),
|
||||
self.streaming.biome_zoom,
|
||||
self.streaming.biome_blend,
|
||||
);
|
||||
let tables = &self.pack.context.tables;
|
||||
let light = |quad: &horizon_assets::bake::ModelQuad| {
|
||||
let coords = tables.flat_light(state, [seed.x, seed.y, seed.z], quad, |p| {
|
||||
self.world.light(p[0], p[1], p[2])
|
||||
});
|
||||
// Light coords (block << 4 | sky << 20) as the vertex packs them.
|
||||
(coords >> 16 & 0xF0) | ((coords & 0xF0) << 8)
|
||||
};
|
||||
self.tinted_block_model_vertices(
|
||||
state,
|
||||
horizon_core::random::position_seed(seed),
|
||||
glam::Mat4::from_translation(at.as_vec3()),
|
||||
light,
|
||||
false,
|
||||
tint,
|
||||
out,
|
||||
);
|
||||
if let Some(tint) = tint {
|
||||
let tint = horizon_client::mobs::render::abgr(tint);
|
||||
for vertex in &mut out[start..] {
|
||||
vertex.color = horizon_client::hud_layout::multiply(vertex.color, tint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
//! portal) until the player is in that dimension and its exit is loaded.
|
||||
use super::{Dim, Game, Screen};
|
||||
use horizon_client::Result;
|
||||
use horizon_client::nether_portal::PortalState;
|
||||
use horizon_client::nether_portal::{ENTITY_DELAY, PortalState};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Axis, Vec3},
|
||||
position::BlockPos,
|
||||
@@ -29,8 +29,6 @@ use std::collections::HashMap;
|
||||
|
||||
/// `Player.getDimensionChangingDelay`.
|
||||
pub(super) const PLAYER_DELAY: i32 = 10;
|
||||
/// `Entity.getDimensionChangingDelay`.
|
||||
const ENTITY_DELAY: i32 = 300;
|
||||
|
||||
/// Where in its portal a traveller stood, and where its exit is to be found.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -89,7 +87,7 @@ pub(super) struct Portals {
|
||||
/// Items and mobs, by entity id.
|
||||
entities: HashMap<u32, PortalState>,
|
||||
/// Positions before this tick's moves, by entity id.
|
||||
before: HashMap<u32, Vec3>,
|
||||
pub(super) before: HashMap<u32, Vec3>,
|
||||
arrivals: Vec<Arrival>,
|
||||
/// The vehicles server players ride that went through a portal this
|
||||
/// tick, in their level, with their entry: they carry their riders
|
||||
@@ -673,6 +671,17 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `Entity.setAsInsidePortal` on an entity that is still on its portal
|
||||
/// cooldown, whatever the portal: the cooldown restarts and it stays.
|
||||
pub(super) fn restart_portal_cooldown(&mut self, id: u32) -> bool {
|
||||
match self.portals.entities.get_mut(&id) {
|
||||
Some(state) if state.cooldown > 0 => {
|
||||
state.cooldown = ENTITY_DELAY;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// `Entity.setPortalCooldown` on an entity that has just spawned in a
|
||||
/// portal: it stands in the portal for 300 ticks without going through.
|
||||
pub(super) fn set_portal_cooldown(&mut self, id: u32) {
|
||||
@@ -686,7 +695,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `canUsePortal(false)` of a mob: alive, riding nothing, not asleep,
|
||||
/// not carrying the player, and not a boss that never travels.
|
||||
fn mob_can_use_portal(&self, m: &horizon_client::mobs::Mob) -> bool {
|
||||
pub(super) fn mob_can_use_portal(&self, m: &horizon_client::mobs::Mob) -> bool {
|
||||
use horizon_client::mobs::Species;
|
||||
m.alive()
|
||||
&& m.extra.mount.is_none()
|
||||
|
||||
@@ -159,7 +159,7 @@ impl Game<'_> {
|
||||
{
|
||||
if let Some(zombie) = self.spawn_mob("minecraft:zombie", at, false) {
|
||||
if let Some(z) = self.mobs.iter_mut().find(|m| m.id == zombie) {
|
||||
z.extra.main_hand = Some("minecraft:iron_spear".into());
|
||||
z.extra.set_main_hand(Some("minecraft:iron_spear".into()));
|
||||
}
|
||||
self.mount_mob(zombie, id);
|
||||
}
|
||||
@@ -189,7 +189,7 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
if let Some(h) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
h.extra.main_hand = Some("minecraft:iron_spear".into());
|
||||
h.extra.set_main_hand(Some("minecraft:iron_spear".into()));
|
||||
}
|
||||
let Some(camel) = self.spawn_mob("minecraft:camel_husk", at, false) else {
|
||||
return;
|
||||
@@ -257,7 +257,8 @@ impl Game<'_> {
|
||||
if let Some(piglin) = self.spawn_mob("minecraft:zombified_piglin", at, false) {
|
||||
let baby = self.world.random.next_float() < CHICKEN_JOCKEY_ODDS;
|
||||
if let Some(p) = self.mobs.iter_mut().find(|m| m.id == piglin) {
|
||||
p.extra.main_hand = Some("minecraft:warped_fungus_on_a_stick".into());
|
||||
p.extra
|
||||
.set_main_hand(Some("minecraft:warped_fungus_on_a_stick".into()));
|
||||
p.extra.age = if baby { i32::MIN / 2 } else { 0 };
|
||||
}
|
||||
if let Some(s) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
|
||||
@@ -9,8 +9,13 @@ use horizon_client::{
|
||||
mobs::Who,
|
||||
save::{ChunkExtras, random_uuid},
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, position::ChunkPos, random::RandomSource, ticks::ChunkTicks};
|
||||
use horizon_world::{chunk::BlockState, nbt::Tag, registry::BlockStateId};
|
||||
use horizon_core::{
|
||||
geometry::{Direction, Vec3},
|
||||
position::{BlockPos, ChunkPos},
|
||||
random::RandomSource,
|
||||
ticks::ChunkTicks,
|
||||
};
|
||||
use horizon_world::{chunk::BlockState, level::MovingPiston, nbt::Tag, registry::BlockStateId};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
@@ -66,6 +71,10 @@ fn painting_center(p: &horizon_client::entities::Painting) -> Vec3 {
|
||||
(lo[2] + hi[2]) / 2.0,
|
||||
)
|
||||
}
|
||||
fn block_entity_pos(tag: &Tag) -> [i32; 3] {
|
||||
[tag.get("x"), tag.get("y"), tag.get("z")]
|
||||
.map(|v| horizon_client::save::number(v).unwrap_or(0) as i32)
|
||||
}
|
||||
fn chunk_of(p: Vec3) -> (i32, i32) {
|
||||
((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4)
|
||||
}
|
||||
@@ -86,6 +95,19 @@ impl Game<'_> {
|
||||
extras.fluid_ticks = t.pack(time);
|
||||
}
|
||||
extras.last_update = time;
|
||||
// PistonMovingBlockEntity.saveAdditional: a block caught mid-move is
|
||||
// saved with its chunk (#3310).
|
||||
for (p, piston) in &self.block_ticks.moving_pistons {
|
||||
if (p.x >> 4, p.z >> 4) != (chunk.x, chunk.z) {
|
||||
continue;
|
||||
}
|
||||
extras
|
||||
.block_entities
|
||||
.retain(|tag| block_entity_pos(tag) != [p.x, p.y, p.z]);
|
||||
extras
|
||||
.block_entities
|
||||
.push(self.moving_piston_to_nbt(*p, piston));
|
||||
}
|
||||
// TheEndGatewayBlockEntity saves its Age (the server copy's).
|
||||
for tag in &mut extras.block_entities {
|
||||
let pos = [tag.get("x"), tag.get("y"), tag.get("z")]
|
||||
@@ -142,6 +164,82 @@ impl Game<'_> {
|
||||
s.fluid_ticks.clear();
|
||||
}
|
||||
}
|
||||
// PistonMovingBlockEntity.loadAdditional: blocks saved mid-move carry
|
||||
// on moving; later saves take them from the level (#3310).
|
||||
let pistons: Vec<_> = saved.as_deref().map_or_else(Vec::new, |s| {
|
||||
s.block_entities
|
||||
.iter()
|
||||
.filter_map(|tag| self.moving_piston_from_nbt(tag))
|
||||
.collect()
|
||||
});
|
||||
if pistons.is_empty() {
|
||||
return;
|
||||
}
|
||||
for &(pos, piston) in &pistons {
|
||||
self.block_ticks.load_moving_piston(pos, piston);
|
||||
}
|
||||
if let Some(c) = self.world.chunks.get_mut(&key) {
|
||||
let c = Arc::make_mut(c);
|
||||
if let Some(s) = c.saved.as_mut() {
|
||||
Arc::make_mut(s).block_entities.retain(|tag| {
|
||||
let at = block_entity_pos(tag);
|
||||
!pistons.iter().any(|(p, _)| [p.x, p.y, p.z] == at)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `PistonMovingBlockEntity.saveAdditional`.
|
||||
fn moving_piston_to_nbt(&self, pos: BlockPos, piston: &MovingPiston) -> Tag {
|
||||
let flag = |b: bool| Tag::Byte(i8::from(b));
|
||||
Tag::compound([
|
||||
("id", Tag::String("minecraft:piston".into())),
|
||||
("x", Tag::Int(pos.x)),
|
||||
("y", Tag::Int(pos.y)),
|
||||
("z", Tag::Int(pos.z)),
|
||||
("blockState", self.state_to_nbt(piston.moved_state)),
|
||||
// Direction.LEGACY_ID_CODEC: the 3D data value as a byte.
|
||||
("facing", Tag::Byte(piston.direction as i8)),
|
||||
("progress", Tag::Float(piston.progress_o.to_bits())),
|
||||
("extending", flag(piston.extending)),
|
||||
("source", flag(piston.source)),
|
||||
])
|
||||
}
|
||||
/// `PistonMovingBlockEntity.loadAdditional`, for a `minecraft:piston`
|
||||
/// entity whose block is still a moving piston.
|
||||
fn moving_piston_from_nbt(&self, tag: &Tag) -> Option<(BlockPos, MovingPiston)> {
|
||||
use horizon_client::save::{float, number, text};
|
||||
if text(tag.get("id")).as_deref() != Some("minecraft:piston") {
|
||||
return None;
|
||||
}
|
||||
let [x, y, z] = block_entity_pos(tag);
|
||||
let here = self.world.block(x, y, z);
|
||||
if &*self.res.facts().get(here).name != "minecraft:moving_piston" {
|
||||
return None;
|
||||
}
|
||||
let moved_state = tag
|
||||
.get("blockState")
|
||||
.and_then(|t| self.state_from_nbt(t))
|
||||
.or_else(|| self.pack.assets.registry.default_state("minecraft:air"))?;
|
||||
let direction = usize::try_from(number(tag.get("facing")).unwrap_or(0))
|
||||
.ok()
|
||||
.and_then(|i| Direction::ALL.get(i).copied())
|
||||
.unwrap_or(Direction::Down);
|
||||
let progress = float(tag.get("progress")).unwrap_or(0.0) as f32;
|
||||
let flag = |k: &str| number(tag.get(k)).unwrap_or(0) != 0;
|
||||
Some((
|
||||
BlockPos::new(x, y, z),
|
||||
MovingPiston {
|
||||
moved_state,
|
||||
direction,
|
||||
extending: flag("extending"),
|
||||
source: flag("source"),
|
||||
progress,
|
||||
progress_o: progress,
|
||||
last_ticked: 0,
|
||||
death_ticks: 0,
|
||||
id: 0,
|
||||
},
|
||||
))
|
||||
}
|
||||
/// Spawn the entities of chunks that finished loading.
|
||||
pub(super) fn spawn_loaded_entities(&mut self) {
|
||||
@@ -151,6 +249,9 @@ impl Game<'_> {
|
||||
for tag in tags {
|
||||
let kind = entity_nbt::kind(&tag).unwrap_or_default();
|
||||
let uuid = entity_nbt::uuid(&tag);
|
||||
if uuid.is_some_and(|u| self.vehicle_by_uuid(u).is_some()) {
|
||||
continue;
|
||||
}
|
||||
if matches!(
|
||||
kind.as_str(),
|
||||
"minecraft:experience_orb"
|
||||
@@ -368,8 +469,11 @@ impl Game<'_> {
|
||||
}
|
||||
/// A saved mob and, under `Passengers`, the mobs riding it.
|
||||
pub(super) fn load_mob(&mut self, tag: Tag, mount: Option<u32>) -> Option<u32> {
|
||||
self.next_entity += 1;
|
||||
let uuid = entity_nbt::uuid(&tag);
|
||||
if let Some(id) = uuid.and_then(|u| self.vehicle_by_uuid(u)) {
|
||||
return Some(id);
|
||||
}
|
||||
self.next_entity += 1;
|
||||
// Structure mobs that generation left to finish: saves from before
|
||||
// they were finalized at placement, or a placement that could not.
|
||||
let mut tag = tag;
|
||||
@@ -538,6 +642,76 @@ impl Game<'_> {
|
||||
}
|
||||
i
|
||||
}
|
||||
/// `RootVehicle.Attach` names the immediate mount, not the root of its tree.
|
||||
pub(super) fn root_vehicle_uuid(&mut self, id: Option<u32>) -> Option<[i32; 4]> {
|
||||
Some(self.uuid_for(id?))
|
||||
}
|
||||
/// The vehicle with this UUID, among the ones in the world.
|
||||
pub(super) fn vehicle_by_uuid(&self, uuid: [i32; 4]) -> Option<u32> {
|
||||
let id = *self
|
||||
.persist
|
||||
.uuids
|
||||
.iter()
|
||||
.find(|(_, u)| **u == uuid)
|
||||
.map(|(id, _)| id)?;
|
||||
let there = self.mobs.iter().any(|m| m.id == id && m.alive())
|
||||
|| self.boats.iter().any(|b| b.id == id)
|
||||
|| self.carts.iter().any(|c| c.id == id);
|
||||
there.then_some(id)
|
||||
}
|
||||
/// Vanilla stores the root entity in the player's file, outside chunk entities.
|
||||
/// Keep the raw wrapper until attachment succeeds, including unsupported trees.
|
||||
pub(super) fn restore_saved_vehicle(
|
||||
&mut self,
|
||||
uuid: [i32; 4],
|
||||
raw: Option<&Tag>,
|
||||
) -> Option<u32> {
|
||||
if let Some(id) = self.vehicle_by_uuid(uuid) {
|
||||
return Some(id);
|
||||
}
|
||||
let tag = raw?.get("RootVehicle")?.get("Entity")?.clone();
|
||||
let root_uuid = entity_nbt::uuid(&tag)?;
|
||||
let kind = entity_nbt::kind(&tag)?;
|
||||
if !self.res.mob_types().contains_key(&kind)
|
||||
&& super::boats::any_boat_wood(&kind).is_none()
|
||||
&& super::minecarts::CartKind::from_id(&kind).is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if self.vehicle_by_uuid(root_uuid).is_none() {
|
||||
let at = entity_nbt::position(&tag)?;
|
||||
let key = (
|
||||
(at.x.floor() as i32).div_euclid(16),
|
||||
(at.z.floor() as i32).div_euclid(16),
|
||||
);
|
||||
self.world.loaded_entities.push((key, vec![tag]));
|
||||
self.spawn_loaded_entities();
|
||||
}
|
||||
self.vehicle_by_uuid(uuid)
|
||||
}
|
||||
/// `ServerPlayer.loadAndSpawnParentVehicle`: the ride the player's file
|
||||
/// names, once the vehicle is in the world.
|
||||
pub(super) fn restore_root_vehicle(&mut self) {
|
||||
if self.serving.is_some()
|
||||
|| self.options.dedicated.is_some()
|
||||
|| self.riding.is_some()
|
||||
|| self.player.is_none()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(uuid) = self.pending_vehicle else {
|
||||
return;
|
||||
};
|
||||
let raw = self.level.player_extra.clone();
|
||||
let Some(id) = self.restore_saved_vehicle(uuid, raw.as_ref()) else {
|
||||
return;
|
||||
};
|
||||
if let Some(Tag::Compound(p)) = &mut self.level.player_extra {
|
||||
p.remove(&"RootVehicle".into());
|
||||
}
|
||||
self.pending_vehicle = None;
|
||||
self.riding = Some(id);
|
||||
}
|
||||
/// A mob's entity tag with the mobs riding it under `Passengers`.
|
||||
pub(super) fn mob_tree_nbt(&mut self, i: usize) -> Tag {
|
||||
let id = self.mobs[i].id;
|
||||
|
||||
@@ -361,9 +361,7 @@ impl Game<'_> {
|
||||
p.pose == Pose::Swimming || (!p.fall_flying && p.pose == Pose::FallFlying);
|
||||
let in_water = p.fluids.was_touching_water;
|
||||
let (yaw, pitch) = (self.yaw, self.pitch);
|
||||
let main = self.inventory[self.selected]
|
||||
.as_ref()
|
||||
.map(|s| s.item.clone());
|
||||
let main = self.inventory[self.selected].clone();
|
||||
let off = self.offhand.as_ref().map(|s| s.item.clone());
|
||||
let riding = self.riding.is_some();
|
||||
let hurt = p.health.hurt_animation_time;
|
||||
@@ -427,7 +425,7 @@ impl Game<'_> {
|
||||
m.age = m.age.wrapping_add(1);
|
||||
m.anim.attack_anim_o = attack.0;
|
||||
m.anim.attack_anim = attack.1;
|
||||
m.extra.main_hand = main;
|
||||
m.extra.set_main_hand_stack(main);
|
||||
m.extra.off_hand = off;
|
||||
m.extra.crouching = crouching;
|
||||
m.extra.passenger = riding;
|
||||
|
||||
@@ -1370,10 +1370,21 @@ impl Game<'_> {
|
||||
}
|
||||
fn set_block(&mut self, pos: [i32; 3], state: i32) {
|
||||
let state = BlockStateId(state.max(0) as u32);
|
||||
let previous = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if self.world.loaded(pos[0], pos[2])
|
||||
&& self.world.set(pos[0], pos[1], pos[2], state).is_ok()
|
||||
{
|
||||
self.sync_block_entity((pos[0], pos[1], pos[2]), state);
|
||||
// LevelChunk.setBlockState removes the block entity of a block
|
||||
// that changes: the moving piston the client's moveBlocks made
|
||||
// stops when the server places what it moved (#3002).
|
||||
let facts = self.res.facts();
|
||||
let moving = |s: BlockStateId| &*facts.get(s).name == "minecraft:moving_piston";
|
||||
if moving(previous) && !moving(state) {
|
||||
let at = horizon_core::position::BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.block_ticks.moving_pistons.retain(|(p, _)| *p != at);
|
||||
self.sync_moving_pistons();
|
||||
}
|
||||
}
|
||||
// A block we just placed: play its place sound (the server skips the placer).
|
||||
if let Some((target, _)) = self.remote.as_ref().and_then(|r| r.placing)
|
||||
@@ -2336,7 +2347,7 @@ impl Game<'_> {
|
||||
for (slot, item, stack) in items {
|
||||
let e = &mut m.extra;
|
||||
match slot {
|
||||
0 => e.main_hand = item,
|
||||
0 => e.set_main_hand(item),
|
||||
1 => e.off_hand = item,
|
||||
2 => {
|
||||
e.feet = item;
|
||||
@@ -3820,6 +3831,7 @@ impl Game<'_> {
|
||||
self.tick_subtitles();
|
||||
let entity_collisions = self.player_entity_collisions();
|
||||
let pushed = self.remote_pushes();
|
||||
let sprint_window = self.settings.sprint_window;
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -3918,7 +3930,7 @@ impl Game<'_> {
|
||||
collision_binding: self.collision,
|
||||
resources: self.motion,
|
||||
}
|
||||
.controlled_simulation_tick(keys)?;
|
||||
.controlled_simulation_tick_with_sprint_window(keys, sprint_window)?;
|
||||
} else {
|
||||
// LivingEntity.baseTick counts hurtTime down for a dead or riding
|
||||
// player too (controlled_simulation_tick does it otherwise): left
|
||||
|
||||
@@ -138,7 +138,7 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
m.extra.color = (*c & 15) as u8;
|
||||
}
|
||||
// AbstractCubeMob.ID_SIZE: its size and box (refreshDimensions).
|
||||
(Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => {
|
||||
(Species::Slime | Species::MagmaCube | Species::SulfurCube, 18, Value::Int(size)) => {
|
||||
m.extra.size = (*size).clamp(1, 127);
|
||||
}
|
||||
// Enderman.DATA_CREEPY (after DATA_CARRY_STATE): the open mouth
|
||||
@@ -539,6 +539,21 @@ mod tests {
|
||||
assert!(!calf.is_baby());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cube_mobs_receive_size_from_metadata() {
|
||||
for kind in ["slime", "magma_cube", "sulfur_cube"] {
|
||||
let Some(mut m) = mob(&format!("minecraft:{kind}")) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(m.extra.size, 1);
|
||||
apply(&mut m, &[(18, s::INT, Value::Int(2))]);
|
||||
assert_eq!(m.extra.size, 2);
|
||||
if kind == "sulfur_cube" {
|
||||
assert!(!m.is_baby());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bat_flags_select_continuous_client_clocks_without_changing_motion() {
|
||||
use horizon_client::mobs::{MobTypes, anim, keyframes::Animation};
|
||||
|
||||
@@ -2000,6 +2000,16 @@ impl Game<'_> {
|
||||
{
|
||||
push(&mut lines, text, c);
|
||||
}
|
||||
// SuspiciousStewEffects.addToTooltip: the creative flag, which vanilla
|
||||
// sets for the creative screen's own slots, as `name_visible` above.
|
||||
for (text, c) in horizon_client::item_tooltip::stew_lines(
|
||||
stack,
|
||||
!nested && self.screen == Screen::Creative,
|
||||
&display,
|
||||
&translate,
|
||||
) {
|
||||
push(&mut lines, text, c);
|
||||
}
|
||||
// BlockItemStateProperties.addToTooltip.
|
||||
if let Some(text) = horizon_client::item_tooltip::honey_line(stack, &display, &translate) {
|
||||
push(&mut lines, text, gray);
|
||||
|
||||
@@ -218,7 +218,10 @@ fn carry_over(from: &Mob, to: &mut Mob) {
|
||||
to.name_visible = from.name_visible;
|
||||
to.invisible = from.invisible;
|
||||
to.extra.pick_up_loot = from.extra.pick_up_loot;
|
||||
to.extra.main_hand = from.extra.main_hand.clone();
|
||||
to.extra.set_main_hand(from.extra.main_hand.clone());
|
||||
if let Some(stack) = from.extra.main_hand_stack() {
|
||||
to.extra.set_main_hand_stack(Some(stack.clone()));
|
||||
}
|
||||
to.extra.off_hand = from.extra.off_hand.clone();
|
||||
to.extra.head = from.extra.head.clone();
|
||||
to.extra.chest = from.extra.chest.clone();
|
||||
|
||||
@@ -183,6 +183,18 @@ fn components_tag(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> Option<Tag>
|
||||
("mob_factor", Tag::Float(v[5].to_bits())),
|
||||
]),
|
||||
Component::Profile(p) => profile_tag(p),
|
||||
// SuspiciousStewEffects' NBT: each effect by name and its duration.
|
||||
Component::StewEffects(list) => Tag::List(
|
||||
list.iter()
|
||||
.filter_map(|(id, duration)| {
|
||||
let name = builtin.get("minecraft:mob_effect")?.name(*id)?;
|
||||
Some(Tag::compound([
|
||||
("id", Tag::String(name.into())),
|
||||
("duration", Tag::Int(*duration)),
|
||||
]))
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
// PotionContents' NBT: the potion by name and the custom colour.
|
||||
Component::Potion { potion, color, .. } => {
|
||||
let potion = potion.and_then(|id| builtin.get("minecraft:potion")?.name(id));
|
||||
@@ -523,7 +535,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// What a creative client sends (`stack_to_network`) comes back whole on
|
||||
/// the server: damage, both enchantment lists, repair cost, name, trim.
|
||||
/// the server: damage, both enchantment lists, repair cost, name, trim,
|
||||
/// potion and stew effects.
|
||||
#[test]
|
||||
fn a_creative_stack_keeps_its_components_across_the_wire() {
|
||||
let mut components = vec![String::new(); 60];
|
||||
@@ -539,6 +552,10 @@ mod tests {
|
||||
"minecraft:stored_enchantments",
|
||||
),
|
||||
(component::TRIM, "minecraft:trim"),
|
||||
(
|
||||
component::SUSPICIOUS_STEW_EFFECTS,
|
||||
"minecraft:suspicious_stew_effects",
|
||||
),
|
||||
] {
|
||||
components[id as usize] = name.to_owned();
|
||||
}
|
||||
@@ -551,6 +568,7 @@ mod tests {
|
||||
"minecraft:air",
|
||||
"minecraft:diamond_chestplate",
|
||||
"minecraft:splash_potion",
|
||||
"minecraft:suspicious_stew",
|
||||
])),
|
||||
),
|
||||
(
|
||||
@@ -561,6 +579,10 @@ mod tests {
|
||||
"minecraft:potion".to_owned(),
|
||||
Ids::from_names(names(&["minecraft:water", "minecraft:swiftness"])),
|
||||
),
|
||||
(
|
||||
"minecraft:mob_effect".to_owned(),
|
||||
Ids::from_names(names(&["minecraft:speed", "minecraft:fire_resistance"])),
|
||||
),
|
||||
]
|
||||
.into(),
|
||||
};
|
||||
@@ -642,5 +664,22 @@ mod tests {
|
||||
from_network(&wire, &builtin, &dynamic).potion(),
|
||||
Some("minecraft:swiftness")
|
||||
);
|
||||
// SuspiciousStewEffects, as a creative tab's stew has them: without
|
||||
// them the server's stew gives no effect when eaten (#3137).
|
||||
let mut stew = ItemStack::new("minecraft:suspicious_stew", 1);
|
||||
stew.data_mut()
|
||||
.stew_effects
|
||||
.push((Arc::from("minecraft:fire_resistance"), 60));
|
||||
let wire = crate::lan::guest::stack_to_network(
|
||||
|name| id("minecraft:item", name).unwrap_or(0),
|
||||
id,
|
||||
&Some(stew),
|
||||
)
|
||||
.unwrap();
|
||||
let back = from_network(&wire, &builtin, &dynamic);
|
||||
assert_eq!(
|
||||
back.data().map(|d| d.stew_effects.clone()),
|
||||
Some(vec![(Arc::from("minecraft:fire_resistance"), 60)])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -483,14 +483,26 @@ pub fn potion_modifier_lines(
|
||||
if !is_potion_item(&stack.item) {
|
||||
return Vec::new();
|
||||
}
|
||||
let applied: Vec<(String, u32)> = stack
|
||||
let effects = stack
|
||||
.potion()
|
||||
.and_then(crate::brewing::potion)
|
||||
.into_iter()
|
||||
.flat_map(|p| p.effects.iter())
|
||||
.filter_map(|(effect, _, amplifier)| {
|
||||
let (attribute, amount, operation) = effect_modifier(effect)?;
|
||||
let amount = amount * (*amplifier + 1) as f64;
|
||||
.map(|(effect, _, amplifier)| (*effect, *amplifier));
|
||||
effect_modifier_lines(effects, translate)
|
||||
}
|
||||
|
||||
/// `addPotionTooltip`'s attribute block for (effect id, amplifier) entries.
|
||||
fn effect_modifier_lines<'a>(
|
||||
effects: impl IntoIterator<Item = (&'a str, i32)>,
|
||||
translate: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Vec<(String, u32)> {
|
||||
let applied: Vec<(String, u32)> = effects
|
||||
.into_iter()
|
||||
.filter_map(|(effect, amplifier)| {
|
||||
let (attribute, amount, operation) =
|
||||
effect_modifier(effect.trim_start_matches("minecraft:"))?;
|
||||
let amount = amount * (amplifier + 1) as f64;
|
||||
let line = modifier_line(translate, attribute, amount, operation, false)?;
|
||||
let color = if amount > 0.0 {
|
||||
rgba(85, 85, 255, 255)
|
||||
@@ -514,6 +526,48 @@ pub fn potion_modifier_lines(
|
||||
lines
|
||||
}
|
||||
|
||||
/// `SuspiciousStewEffects.addToTooltip`: only with the creative tooltip flag,
|
||||
/// the stew's effects as `PotionContents.addPotionTooltip` lists them (each in
|
||||
/// its `MobEffectCategory` colour, "No Effects" for none), then their
|
||||
/// attribute modifiers.
|
||||
pub fn stew_lines(
|
||||
stack: &ItemStack,
|
||||
creative: bool,
|
||||
display: &TooltipDisplay,
|
||||
translate: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Vec<(String, u32)> {
|
||||
let effects = stack.data().map_or(&[][..], |d| &d.stew_effects[..]);
|
||||
// The component is the item's default (empty) on a suspicious stew.
|
||||
if !creative
|
||||
|| (effects.is_empty() && &*stack.item != "minecraft:suspicious_stew")
|
||||
|| !display.shows("minecraft:suspicious_stew_effects")
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
if effects.is_empty() {
|
||||
let none = translate("effect.none").unwrap_or_else(|| "No Effects".into());
|
||||
return vec![(none, rgba(170, 170, 170, 255))];
|
||||
}
|
||||
// `Entry.createEffectInstance`: amplifier 0.
|
||||
let entries = effects.iter().map(|(id, duration)| (&**id, *duration, 0));
|
||||
let mut lines: Vec<(String, u32)> = crate::brewing::effect_lines(entries, 1.0, translate)
|
||||
.into_iter()
|
||||
.map(|(line, category)| {
|
||||
// MobEffectCategory.getTooltipFormatting: harmful red, else blue.
|
||||
let color = match category {
|
||||
crate::brewing::Category::Harmful => rgba(255, 85, 85, 255),
|
||||
_ => rgba(85, 85, 255, 255),
|
||||
};
|
||||
(line, color)
|
||||
})
|
||||
.collect();
|
||||
lines.extend(effect_modifier_lines(
|
||||
effects.iter().map(|(id, _)| (&**id, 0)),
|
||||
translate,
|
||||
));
|
||||
lines
|
||||
}
|
||||
|
||||
/// `ItemStack.components.size()`: the item's default components with the
|
||||
/// stack's own changes applied.
|
||||
pub fn component_count(stack: &ItemStack, info: Option<&ItemInfo>) -> usize {
|
||||
@@ -804,6 +858,7 @@ pub fn lore_texts(stack: &ItemStack) -> Vec<crate::text::Text> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn lore_keeps_component_colours_under_dark_purple_italic() {
|
||||
@@ -870,6 +925,59 @@ mod tests {
|
||||
assert!(!display.shows("minecraft:attribute_modifiers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stew_effects_show_only_with_the_creative_flag() {
|
||||
let translate = |key: &str| {
|
||||
Some(
|
||||
match key {
|
||||
"effect.minecraft.fire_resistance" => "Fire Resistance",
|
||||
"effect.minecraft.weakness" => "Weakness",
|
||||
"effect.minecraft.saturation" => "Saturation",
|
||||
"effect.none" => "No Effects",
|
||||
"potion.whenDrank" => "When Applied:",
|
||||
"attribute.modifier.take.0" => "-%s %s",
|
||||
"attribute.name.attack_damage" => "Attack Damage",
|
||||
_ => return None,
|
||||
}
|
||||
.to_owned(),
|
||||
)
|
||||
};
|
||||
let (blue, red) = (rgba(85, 85, 255, 255), rgba(255, 85, 85, 255));
|
||||
let lines = |stack: &ItemStack, creative: bool| {
|
||||
stew_lines(
|
||||
stack,
|
||||
creative,
|
||||
&TooltipDisplay::for_stack(stack),
|
||||
&translate,
|
||||
)
|
||||
};
|
||||
let mut stew = ItemStack::new("minecraft:suspicious_stew", 1);
|
||||
// The item's default, an empty list.
|
||||
assert_eq!(
|
||||
lines(&stew, true),
|
||||
[("No Effects".to_owned(), rgba(170, 170, 170, 255))]
|
||||
);
|
||||
stew.data_mut().stew_effects = vec![
|
||||
(Arc::from("minecraft:fire_resistance"), 60),
|
||||
(Arc::from("minecraft:weakness"), 140),
|
||||
(Arc::from("minecraft:saturation"), 7),
|
||||
];
|
||||
assert!(lines(&stew, false).is_empty());
|
||||
assert_eq!(
|
||||
lines(&stew, true),
|
||||
[
|
||||
("Fire Resistance (00:03)".to_owned(), blue),
|
||||
("Weakness (00:07)".to_owned(), red),
|
||||
// 20 ticks or less: no duration.
|
||||
("Saturation".to_owned(), blue),
|
||||
(String::new(), u32::MAX),
|
||||
("When Applied:".to_owned(), rgba(170, 0, 170, 255)),
|
||||
("-4 Attack Damage".to_owned(), red),
|
||||
]
|
||||
);
|
||||
assert!(lines(&ItemStack::new("minecraft:bowl", 1), true).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ominous_bottle_lists_bad_omen_at_its_amplifier_for_100_minutes() {
|
||||
let lang = |key: &str| {
|
||||
|
||||
@@ -165,6 +165,9 @@ pub struct Guest {
|
||||
/// What the player's file says of the body (`Entity.load`: the fire
|
||||
/// and air ticks and the fall distance), for the first body it gets.
|
||||
pub loaded_body: Option<SavedBody>,
|
||||
/// The vehicle the player's file names (`ServerPlayer.saveParentVehicle`'s
|
||||
/// "RootVehicle"), for the ride to come back on the next login.
|
||||
pub saved_vehicle: Option<[i32; 4]>,
|
||||
/// The hurt and attack logs of the body it had before it respawned,
|
||||
/// until the new body takes them over (the control socket's `hurtlog`
|
||||
/// reads across a death).
|
||||
@@ -324,6 +327,7 @@ impl Guest {
|
||||
tracker: Tracker::default(),
|
||||
motion: None,
|
||||
loaded_body: None,
|
||||
saved_vehicle: None,
|
||||
logs_kept: None,
|
||||
inventory: vec![None; 36],
|
||||
armor: [None, None, None, None],
|
||||
@@ -737,6 +741,21 @@ fn network_stack(
|
||||
},
|
||||
));
|
||||
}
|
||||
// SuspiciousStewEffects: each effect by its `minecraft:mob_effect` id.
|
||||
if !d.stew_effects.is_empty() {
|
||||
let effects = d
|
||||
.stew_effects
|
||||
.iter()
|
||||
.filter_map(|(effect, duration)| {
|
||||
let name = horizon_world::datafix::dynamic::namespaced(effect);
|
||||
Some((synced_id("minecraft:mob_effect", &name)?, *duration))
|
||||
})
|
||||
.collect();
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::SUSPICIOUS_STEW_EFFECTS,
|
||||
horizon_protocol::item::Component::StewEffects(effects),
|
||||
));
|
||||
}
|
||||
// A loaded crossbow's ammunition.
|
||||
if !d.charged.is_empty() {
|
||||
let charged = d
|
||||
|
||||
@@ -98,6 +98,7 @@ pub fn apply(guest: &mut Guest, level: &Level) {
|
||||
air: short("Air", 300),
|
||||
fall_distance: level.fall_distance,
|
||||
});
|
||||
guest.saved_vehicle = level.root_vehicle;
|
||||
guest.recipe_book = level.recipe_book.clone();
|
||||
guest.dimension = match level.dimension.as_str() {
|
||||
d @ ("minecraft:the_nether" | "minecraft:the_end") => d.to_owned(),
|
||||
@@ -129,12 +130,12 @@ pub fn apply(guest: &mut Guest, level: &Level) {
|
||||
}
|
||||
|
||||
/// Save a guest (PlayerDataStorage.save).
|
||||
pub fn save(guest: &Guest, world: &Path) {
|
||||
pub fn save(guest: &Guest, world: &Path, vehicle: Option<[i32; 4]>) {
|
||||
let mut level = Level {
|
||||
player_uuid: Some(uuid_ints(guest.uuid)),
|
||||
..Level::default()
|
||||
};
|
||||
store(guest, &mut level);
|
||||
store(guest, &mut level, vehicle);
|
||||
let file = path(world, guest.uuid);
|
||||
if let Some(dir) = file.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
@@ -143,7 +144,7 @@ pub fn save(guest: &Guest, world: &Path) {
|
||||
}
|
||||
|
||||
/// Put a guest's state in `level`'s player fields.
|
||||
pub fn store(guest: &Guest, level: &mut Level) {
|
||||
pub fn store(guest: &Guest, level: &mut Level, vehicle: Option<[i32; 4]>) {
|
||||
level.position = Some(guest.position);
|
||||
level.dimension = guest.dimension.clone();
|
||||
level.rotation = [guest.yaw, guest.pitch];
|
||||
@@ -171,6 +172,8 @@ pub fn store(guest: &Guest, level: &mut Level) {
|
||||
level.seen_credits = guest.seen_credits;
|
||||
level.last_death = guest.last_death.clone();
|
||||
level.player_extra = guest.saved.clone();
|
||||
// The vehicle the player rides (`ServerPlayer.saveParentVehicle`).
|
||||
level.root_vehicle = vehicle.or(guest.saved_vehicle);
|
||||
// The live body's fire, air and fall distance (Entity.saveWithoutId),
|
||||
// or what the file had while the player has no body yet.
|
||||
let body = guest
|
||||
@@ -251,7 +254,7 @@ mod tests {
|
||||
fall_distance: 9.5,
|
||||
});
|
||||
let mut level = Level::default();
|
||||
store(&g, &mut level);
|
||||
store(&g, &mut level, None);
|
||||
let tag = level.player_tag();
|
||||
let mut read = Level::default();
|
||||
read.read_player(&tag);
|
||||
@@ -260,4 +263,65 @@ mod tests {
|
||||
apply(&mut back, &read);
|
||||
assert_eq!(back.loaded_body, g.loaded_body);
|
||||
}
|
||||
|
||||
/// The resources the prepared game data holds, for a body to breathe with.
|
||||
fn resources() -> Option<(crate::assets::Assets, crate::motion::MotionResources)> {
|
||||
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
});
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return None;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).ok()?;
|
||||
let resources = crate::motion::MotionResources::load(&root, &assets).ok()?;
|
||||
Some((assets, resources))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_air_comes_back() {
|
||||
// A player saved underwater (#1321): the file keeps the remaining air
|
||||
// (Entity.saveWithoutId's Air), and the body that comes back breathes
|
||||
// on it instead of starting with a full supply.
|
||||
let Some((_assets, resources)) = resources() else {
|
||||
eprintln!("skipped the air round trip: prepared resources unavailable");
|
||||
return;
|
||||
};
|
||||
let mut g = guest();
|
||||
let mut motion =
|
||||
crate::motion::PlayerMotion::new(&resources, horizon_core::geometry::Vec3::ZERO)
|
||||
.expect("a body");
|
||||
motion.air = 99;
|
||||
g.motion = Some(Box::new(motion));
|
||||
let mut level = Level::default();
|
||||
store(&g, &mut level, None);
|
||||
let tag = level.player_tag();
|
||||
let mut read = Level::default();
|
||||
read.read_player(&tag);
|
||||
read.player_extra = Some(tag);
|
||||
let mut back = guest();
|
||||
apply(&mut back, &read);
|
||||
assert_eq!(back.loaded_body.expect("a body").air, 99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_root_vehicle_comes_back() {
|
||||
// A player saved riding a horse (#1474): the file names the vehicle
|
||||
// (ServerPlayer.saveParentVehicle's RootVehicle), and the next login
|
||||
// finds it and rides it again.
|
||||
let vehicle = [1, 2, 3, 4];
|
||||
let g = guest();
|
||||
let mut level = Level::default();
|
||||
store(&g, &mut level, Some(vehicle));
|
||||
let tag = level.player_tag();
|
||||
let mut read = Level::default();
|
||||
read.read_player(&tag);
|
||||
read.player_extra = Some(tag);
|
||||
let mut back = guest();
|
||||
apply(&mut back, &read);
|
||||
assert_eq!(back.saved_vehicle, Some(vehicle));
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ pub mod dialog;
|
||||
pub mod dialog_screen;
|
||||
pub mod discord_presence;
|
||||
pub mod end_crystal;
|
||||
pub mod end_gateway;
|
||||
pub mod entities;
|
||||
pub mod entity_culling;
|
||||
pub mod entity_nbt;
|
||||
|
||||
@@ -325,6 +325,7 @@ pub struct Flags {
|
||||
pub water_sensitive: bool,
|
||||
pub animal: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MobType {
|
||||
pub id: String,
|
||||
@@ -808,6 +809,8 @@ pub struct Extra {
|
||||
pub charged: bool,
|
||||
/// Held item ids (main hand, off hand) and head item.
|
||||
pub main_hand: Option<Arc<str>>,
|
||||
/// Components of the current held stack; setters clear these on replacement.
|
||||
main_hand_stack: Option<crate::items::ItemStack>,
|
||||
pub off_hand: Option<Arc<str>>,
|
||||
pub head: Option<Arc<str>>,
|
||||
/// Worn armour below the head (humanoid mobs).
|
||||
@@ -939,6 +942,26 @@ pub struct Extra {
|
||||
/// A panda's flags, counters and amounts.
|
||||
pub panda: Option<Box<panda::State>>,
|
||||
}
|
||||
impl Extra {
|
||||
pub fn set_main_hand(&mut self, item: Option<Arc<str>>) {
|
||||
self.main_hand = item;
|
||||
self.main_hand_stack = None;
|
||||
}
|
||||
pub fn set_main_hand_stack(&mut self, stack: Option<crate::items::ItemStack>) {
|
||||
self.main_hand = stack.as_ref().map(|s| s.item.clone());
|
||||
self.main_hand_stack = stack;
|
||||
}
|
||||
pub fn main_hand_stack(&self) -> Option<&crate::items::ItemStack> {
|
||||
self.main_hand_stack
|
||||
.as_ref()
|
||||
.filter(|s| self.main_hand.as_ref() == Some(&s.item))
|
||||
}
|
||||
pub fn take_main_hand(&mut self) -> Option<Arc<str>> {
|
||||
self.main_hand_stack = None;
|
||||
self.main_hand.take()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Mob {
|
||||
pub id: u32,
|
||||
@@ -3327,3 +3350,26 @@ mod push_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod held_stack_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn replacing_even_the_same_item_clears_its_old_components() {
|
||||
let mut extra = Extra::default();
|
||||
let mut named = crate::items::ItemStack::new("minecraft:diamond_sword", 1);
|
||||
named.data_mut().custom_name = Some("Old Sword".into());
|
||||
extra.set_main_hand_stack(Some(named.clone()));
|
||||
assert_eq!(
|
||||
extra.main_hand_stack().unwrap().custom_name(),
|
||||
Some("Old Sword")
|
||||
);
|
||||
extra.set_main_hand(Some(named.item.clone()));
|
||||
assert!(extra.main_hand_stack().is_none());
|
||||
extra.set_main_hand_stack(Some(named));
|
||||
extra.take_main_hand();
|
||||
assert!(extra.main_hand.is_none());
|
||||
assert!(extra.main_hand_stack().is_none());
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ use super::{
|
||||
};
|
||||
use crate::blocks::Fluid;
|
||||
use horizon_core::{
|
||||
geometry::Vec3,
|
||||
geometry::{Aabb, Vec3},
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
|
||||
@@ -1205,7 +1205,11 @@ fn server_ai_step(
|
||||
let mut targets = std::mem::take(&mut m.ai.targets);
|
||||
let mut goals = std::mem::take(&mut m.ai.goals);
|
||||
{
|
||||
let mut cx = Cx { w, events };
|
||||
let mut cx = Cx {
|
||||
w,
|
||||
lr: level_random,
|
||||
events,
|
||||
};
|
||||
if id_based % 2 != 0 && tick_count > 1 {
|
||||
targets.tick_running(m, &mut cx, false);
|
||||
goals.tick_running(m, &mut cx, false);
|
||||
@@ -1246,7 +1250,11 @@ fn server_ai_step(
|
||||
super::animal_brain::tick(m, w, level_random, events);
|
||||
}
|
||||
{
|
||||
let mut cx = Cx { w, events };
|
||||
let mut cx = Cx {
|
||||
w,
|
||||
lr: level_random,
|
||||
events,
|
||||
};
|
||||
custom_server_ai_step(m, &mut cx);
|
||||
}
|
||||
if frozen {
|
||||
@@ -1827,6 +1835,54 @@ pub fn after_hurt(m: &mut Mob, source: &Damage) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// The `NeutralMob` classes of 26.3: they stop being angry when the player
|
||||
/// they are angry at dies nearby (`forgive_dead_players`).
|
||||
pub fn is_neutral(s: Species) -> bool {
|
||||
matches!(
|
||||
s,
|
||||
Species::Bee
|
||||
| Species::Enderman
|
||||
| Species::IronGolem
|
||||
| Species::PolarBear
|
||||
| Species::Wolf
|
||||
| Species::ZombifiedPiglin
|
||||
)
|
||||
}
|
||||
/// Whether the mob's persistent anger is at the player
|
||||
/// (`NeutralMob.getPersistentAngerTarget`), `playerDied`'s gate.
|
||||
pub fn angry_at_player(m: &Mob) -> bool {
|
||||
match m.species() {
|
||||
Species::Bee => super::bee::get(m).is_some_and(|b| b.angry_at == Some(Who::Player)),
|
||||
// The polar bear's and enderman's anger is their player target: the
|
||||
// bear marks it with flag2, the enderman holds it while stared at.
|
||||
Species::PolarBear => m.extra.flag2 && m.ai.target == Some(Who::Player),
|
||||
Species::Enderman => m.ai.target == Some(Who::Player),
|
||||
_ => m.extra.anger > 0 && m.ai.target == Some(Who::Player),
|
||||
}
|
||||
}
|
||||
/// `NeutralMob.stopBeingAngry`, for a mob `angry_at_player` let through: the
|
||||
/// last hurt by, the persistent target and the target all go, the end time
|
||||
/// is -1. Bees keep their anger in their own state, and the polar bear ends
|
||||
/// its own flags with it.
|
||||
pub fn stop_being_angry(m: &mut Mob) {
|
||||
match m.species() {
|
||||
Species::Bee => return super::bee::stop_being_angry(m),
|
||||
Species::PolarBear => return super::polar_bear::stop_being_angry(m),
|
||||
_ => {}
|
||||
}
|
||||
m.ai.last_hurt_by = None;
|
||||
m.extra.anger = 0;
|
||||
m.extra.anger_end = -1;
|
||||
m.extra.angry_at = None;
|
||||
m.ai.target = None;
|
||||
}
|
||||
/// `ServerPlayer.tellNeutralMobsThatIDied`'s area: the dying player's block
|
||||
/// cell grown 32 blocks sideways and 10 up and down.
|
||||
pub fn forgiveness_area(feet: [f64; 3]) -> Aabb {
|
||||
let (x, y, z) = (feet[0].floor(), feet[1].floor(), feet[2].floor());
|
||||
Aabb::new(Vec3::new(x, y, z), Vec3::new(x + 1.0, y + 1.0, z + 1.0))
|
||||
.inflate(Vec3::new(32.0, 10.0, 32.0))
|
||||
}
|
||||
/// Mob.doHurtTarget: attack damage (+ species extras) to the target.
|
||||
pub fn do_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) {
|
||||
if m.species() == Species::IronGolem {
|
||||
@@ -2287,3 +2343,113 @@ mod frog_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod forgiveness_tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn mob(species: Species) -> Mob {
|
||||
let mut t = (*crate::mobs::sensor_scan_tests::kind()).clone();
|
||||
t.species = species;
|
||||
Mob::new(1, Arc::new(t), Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
/// The dying player's forgiveness covers the six `NeutralMob` classes of
|
||||
/// 26.3 and no others: a panda, a spider and a trader llama are not one
|
||||
/// any more, piglins' anger is Brain-based and zombies are always
|
||||
/// hostile.
|
||||
#[test]
|
||||
fn the_neutral_classes_match_vanilla() {
|
||||
for s in [
|
||||
Species::Bee,
|
||||
Species::Enderman,
|
||||
Species::IronGolem,
|
||||
Species::PolarBear,
|
||||
Species::Wolf,
|
||||
Species::ZombifiedPiglin,
|
||||
] {
|
||||
assert!(is_neutral(s), "{s:?}");
|
||||
}
|
||||
for s in [
|
||||
Species::Panda,
|
||||
Species::Spider,
|
||||
Species::TraderLlama,
|
||||
Species::Piglin,
|
||||
Species::Zombie,
|
||||
Species::Cow,
|
||||
] {
|
||||
assert!(!is_neutral(s), "{s:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `NeutralMob.playerDied` only forgives a mob whose persistent anger is
|
||||
/// at the dying player; `stopBeingAngry` then clears the hurt by, the
|
||||
/// persistent target, the target and the timer fully.
|
||||
#[test]
|
||||
fn only_the_mobs_angry_at_the_player_are_forgiven() {
|
||||
let mut wolf = mob(Species::Wolf);
|
||||
wolf.extra.anger = 500;
|
||||
// A wolf angry at a skeleton keeps its anger.
|
||||
wolf.ai.target = Some(Who::Mob(7));
|
||||
assert!(!angry_at_player(&wolf));
|
||||
|
||||
wolf.ai.target = Some(Who::Player);
|
||||
wolf.ai.last_hurt_by = Some(Who::Player);
|
||||
assert!(angry_at_player(&wolf));
|
||||
stop_being_angry(&mut wolf);
|
||||
assert_eq!(wolf.extra.anger, 0);
|
||||
assert_eq!(wolf.extra.anger_end, -1);
|
||||
assert_eq!(wolf.extra.angry_at, None);
|
||||
assert_eq!(wolf.ai.target, None);
|
||||
assert_eq!(wolf.ai.last_hurt_by, None);
|
||||
}
|
||||
|
||||
/// The polar bear's anger is its player target behind flag2, and its own
|
||||
/// stop ends the flag with the rest.
|
||||
#[test]
|
||||
fn a_polar_bears_flag_ends_with_the_rest() {
|
||||
let mut bear = mob(Species::PolarBear);
|
||||
bear.extra.anger = 500;
|
||||
bear.extra.flag2 = true;
|
||||
bear.ai.target = Some(Who::Player);
|
||||
bear.ai.last_hurt_by = Some(Who::Player);
|
||||
assert!(angry_at_player(&bear));
|
||||
stop_being_angry(&mut bear);
|
||||
assert!(!bear.extra.flag2);
|
||||
assert_eq!(bear.extra.anger, 0);
|
||||
assert_eq!(bear.ai.target, None);
|
||||
assert_eq!(bear.ai.last_hurt_by, None);
|
||||
}
|
||||
|
||||
/// A bee's anger is its own state's target: the same call ends it there.
|
||||
#[test]
|
||||
fn a_bees_anger_ends_with_the_same_call() {
|
||||
let mut bee = mob(Species::Bee);
|
||||
bee.ai.last_hurt_by = Some(Who::Player);
|
||||
bee.ai.target = Some(Who::Player);
|
||||
stop_being_angry(&mut bee);
|
||||
assert_eq!(bee.ai.target, None);
|
||||
assert_eq!(bee.ai.last_hurt_by, None);
|
||||
let b = crate::mobs::bee::get(&bee).unwrap();
|
||||
assert_eq!(b.angry_at, None);
|
||||
assert_eq!(b.anger_end, -1);
|
||||
}
|
||||
|
||||
/// `tellNeutralMobsThatIDied`'s area: the block cell grown 32 sideways
|
||||
/// and 10 up and down, so a wolf 20 blocks out is in it, one 34 out or
|
||||
/// 12 up is not.
|
||||
#[test]
|
||||
fn the_area_reaches_twenty_blocks_out_but_not_twelve_up() {
|
||||
let area = forgiveness_area([0.0, 64.0, 0.0]);
|
||||
let mut wolf = mob(Species::Wolf);
|
||||
wolf.position = Vec3::new(20.0, 64.0, 0.0);
|
||||
assert!(area.intersects(wolf.bounds()));
|
||||
wolf.position = Vec3::new(34.0, 64.0, 0.0);
|
||||
assert!(!area.intersects(wolf.bounds()));
|
||||
wolf.position = Vec3::new(0.0, 76.0, 0.0);
|
||||
assert!(!area.intersects(wolf.bounds()));
|
||||
wolf.position = Vec3::new(0.0, 69.0, 0.0);
|
||||
assert!(area.intersects(wolf.bounds()));
|
||||
}
|
||||
}
|
||||
@@ -2396,6 +2396,7 @@ fn axolotl_action(m: &mut Mob, cx: &mut Bx, action: AxolotlAction, until: &mut i
|
||||
target,
|
||||
&mut super::goals::Cx {
|
||||
w: cx.w,
|
||||
lr: cx.lr,
|
||||
events: cx.events,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -103,8 +103,15 @@ pub(super) fn dangerous_block(name: &str) -> bool {
|
||||
| "minecraft:powder_snow"
|
||||
)
|
||||
}
|
||||
/// `LongJumpUtil.calculateJumpVectorForAngle` without the collision check.
|
||||
fn jump_vector(m: &Mob, target: Vec3, max_velocity: f32, angle: i32) -> Option<Vec3> {
|
||||
/// `LongJumpUtil.calculateJumpVectorForAngle`; with a world, the arc is
|
||||
/// checked for collisions (`checkCollision`).
|
||||
pub(super) fn jump_vector(
|
||||
m: &Mob,
|
||||
target: Vec3,
|
||||
max_velocity: f32,
|
||||
angle: i32,
|
||||
collide: Option<&MobWorld>,
|
||||
) -> Option<Vec3> {
|
||||
let pos = m.position;
|
||||
let plane = Vec3::new(target.x - pos.x, 0.0, target.z - pos.z)
|
||||
.normalize()
|
||||
@@ -130,8 +137,41 @@ fn jump_vector(m: &Mob, target: Vec3, max_velocity: f32, angle: i32) -> Option<V
|
||||
return None;
|
||||
}
|
||||
let (v0r, v0y) = (v0 * cos, v0 * sin);
|
||||
if let Some(w) = collide {
|
||||
// `ceil(r / v0r) * 2` points along the arc, each step clear of blocks.
|
||||
let samples = (r / v0r).ceil() as i32 * 2;
|
||||
let (sin_xz, cos_xz) = (jmath::sin(xz), jmath::cos(xz));
|
||||
let mut ri = 0.0;
|
||||
let mut previous: Option<Vec3> = None;
|
||||
for _ in 0..samples - 1 {
|
||||
ri += r / f64::from(samples);
|
||||
let yi = sin / cos * ri - jmath::pow(ri, 2.0) * g / (2.0 * v0sq * jmath::pow(cos, 2.0));
|
||||
let p = Vec3::new(pos.x + ri * cos_xz, pos.y + yi, pos.z + ri * sin_xz);
|
||||
if previous.is_some_and(|q| !clear_transition(m, w, q, p)) {
|
||||
return None;
|
||||
}
|
||||
previous = Some(p);
|
||||
}
|
||||
}
|
||||
Some(Vec3::new(v0r * jmath::cos(xz), v0y, v0r * jmath::sin(xz)).scale(f64::from(0.95_f32)))
|
||||
}
|
||||
/// `LongJumpUtil.isClearTransition`: the box fits at points no further apart
|
||||
/// than nine tenths of its smaller side.
|
||||
fn clear_transition(m: &Mob, w: &MobWorld, from: Vec3, to: Vec3) -> bool {
|
||||
let d = to.subtract(from);
|
||||
let min = f64::from(m.width().min(m.height()));
|
||||
let checks = (d.length() / min).ceil() as i32;
|
||||
let step = d.normalize().scale(min * f64::from(0.9_f32));
|
||||
let mut p = from;
|
||||
(0..checks).all(|i| {
|
||||
p = if i == checks - 1 {
|
||||
to
|
||||
} else {
|
||||
p.add_vector(step)
|
||||
};
|
||||
w.no_collision(m.bounds_at(p))
|
||||
})
|
||||
}
|
||||
/// `LongJump.calculateOptimalJumpVector`: the allowed angles shuffled, the
|
||||
/// first that reaches the target.
|
||||
pub(super) fn optimal_jump_vector(m: &mut Mob, target: Vec3) -> Option<Vec3> {
|
||||
@@ -143,7 +183,7 @@ pub(super) fn optimal_jump_vector(m: &mut Mob, target: Vec3) -> Option<Vec3> {
|
||||
let max = 0.058_333_334_f32 * m.attrs.follow_range as f32;
|
||||
let v = angles
|
||||
.iter()
|
||||
.find_map(|&a| jump_vector(m, target, max, a))?;
|
||||
.find_map(|&a| jump_vector(m, target, max, a, None))?;
|
||||
if m.has_effect("minecraft:jump_boost") {
|
||||
let amp = m
|
||||
.extra
|
||||
|
||||
@@ -270,7 +270,7 @@ pub fn clear_slot(m: &mut Mob, slot: &str) {
|
||||
let body = head_is_body(m.species());
|
||||
let index = match slot {
|
||||
"mainhand" => {
|
||||
m.extra.main_hand = None;
|
||||
m.extra.set_main_hand(None);
|
||||
gear::MAIN
|
||||
}
|
||||
"offhand" => {
|
||||
|
||||
@@ -836,6 +836,10 @@ pub fn spawn_egg_offspring(child: &mut Mob, species: Species, variant: u8, color
|
||||
if species == Species::Sheep {
|
||||
child.extra.color = color;
|
||||
}
|
||||
// `SulfurCube.getBreedOffspring`: `setSize(1, true)`.
|
||||
if species == Species::SulfurCube {
|
||||
sulfur_size(child, 1);
|
||||
}
|
||||
}
|
||||
/// `setBaby`: AgeableMob through its age, zombies, piglins and zoglins.
|
||||
pub fn set_baby(m: &mut Mob, baby: bool) {
|
||||
@@ -896,7 +900,7 @@ pub fn item(m: &Mob, slot: usize) -> Option<Arc<str>> {
|
||||
pub fn set_item(m: &mut Mob, slot: usize, id: &str) {
|
||||
let v = Some(Arc::from(id));
|
||||
match slot {
|
||||
MAINHAND => m.extra.main_hand = v,
|
||||
MAINHAND => m.extra.set_main_hand(v),
|
||||
OFFHAND => m.extra.off_hand = v,
|
||||
FEET => m.extra.feet = v,
|
||||
LEGS => m.extra.legs = v,
|
||||
|
||||
@@ -168,11 +168,12 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
let eaten = m.extra.ints[TICKS_SINCE_EATEN];
|
||||
if eaten > 600 {
|
||||
// ItemStack.finishUsingItem: the food is gone, a use remainder stays.
|
||||
m.extra.main_hand = w
|
||||
.items
|
||||
.and_then(|i| i.items.get(&item))
|
||||
.and_then(|i| i.use_remainder.as_deref())
|
||||
.map(Into::into);
|
||||
m.extra.set_main_hand(
|
||||
w.items
|
||||
.and_then(|i| i.items.get(&item))
|
||||
.and_then(|i| i.use_remainder.as_deref())
|
||||
.map(Into::into),
|
||||
);
|
||||
m.extra.ints[TICKS_SINCE_EATEN] = 0;
|
||||
} else if eaten > 560 && m.random.next_float() < 0.1 {
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
@@ -742,7 +743,8 @@ impl EatBerries {
|
||||
.unwrap_or(0);
|
||||
let mut count = 1 + m.random.next_int_bound(2).unwrap_or(0) + i32::from(age == 3);
|
||||
if m.extra.main_hand.is_none() {
|
||||
m.extra.main_hand = Some("minecraft:sweet_berries".into());
|
||||
m.extra
|
||||
.set_main_hand(Some("minecraft:sweet_berries".into()));
|
||||
count -= 1;
|
||||
}
|
||||
if count > 0 {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Frogs (`Frog`, `FrogAi`): the `Croak` and `ShootTongue` behaviours as
|
||||
//! goals, the poses they set and the client's animation states.
|
||||
//! Frogs (`Frog`, `FrogAi`): the `Croak`, `ShootTongue` and long jump
|
||||
//! behaviours as goals, the poses they set and the client's animation states.
|
||||
//! AnimState states: jump, croak, tongue, swim idle.
|
||||
//!
|
||||
//! The brain's idle `RunOne` picks a croak three times as often as a stroll
|
||||
@@ -13,7 +13,10 @@ use super::{
|
||||
},
|
||||
sync,
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use horizon_core::{
|
||||
geometry::Vec3,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
|
||||
const JUMP: usize = 0;
|
||||
const CROAK: usize = 1;
|
||||
@@ -30,6 +33,25 @@ const EATING_DISTANCE: f64 = 1.75;
|
||||
const DETECTION_DISTANCE: f64 = 10.0;
|
||||
const SCAN_RATE: i32 = 20;
|
||||
|
||||
/// `FrogAi.TIME_BETWEEN_LONG_JUMPS` and the brain memory
|
||||
/// `LONG_JUMP_COOLDOWN_TICKS` (saved with the others, as `finalize` sets it).
|
||||
const TIME_BETWEEN_LONG_JUMPS: (i32, i32) = (100, 140);
|
||||
const LONG_JUMP_COOLDOWN: &str = "long_jump_cooldown";
|
||||
/// `FrogAi`'s `LongJumpToPreferredBlock`: `MAX_LONG_JUMP_WIDTH`,
|
||||
/// `MAX_LONG_JUMP_HEIGHT`, `MAX_JUMP_VELOCITY_MULTIPLIER` and the chance to
|
||||
/// look for a lily pad or big dripleaf first.
|
||||
const JUMP_WIDTH: i32 = 4;
|
||||
const JUMP_HEIGHT: i32 = 2;
|
||||
const JUMP_VELOCITY_MULTIPLIER: f32 = 3.571_428_8;
|
||||
const PREFERRED_BLOCKS_CHANCE: f32 = 0.5;
|
||||
/// `LongJumpToRandomPos.ALLOWED_ANGLES`, `FIND_JUMP_TRIES` and
|
||||
/// `PREPARE_JUMP_DURATION`.
|
||||
const ALLOWED_ANGLES: [i32; 4] = [65, 70, 75, 80];
|
||||
const FIND_JUMP_TRIES: i32 = 20;
|
||||
const PREPARE_JUMP_DURATION: i32 = 40;
|
||||
/// `LongJumpMidJump`'s duration (`TIME_OUT_DURATION`).
|
||||
const MID_JUMP_DURATION: i32 = 100;
|
||||
|
||||
/// `ShootTongue.State`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Phase {
|
||||
@@ -56,6 +78,8 @@ pub struct State {
|
||||
eat_timer: i32,
|
||||
path_counter: i32,
|
||||
time: i32,
|
||||
/// `LivingEntity.discardFriction`, set through the long jump.
|
||||
pub discard_friction: bool,
|
||||
}
|
||||
|
||||
pub fn register(m: &mut Mob) {
|
||||
@@ -74,6 +98,7 @@ pub fn register(m: &mut Mob) {
|
||||
g.add(0, Float);
|
||||
g.add(1, Panic::new(1.25));
|
||||
g.add(2, ShootTongue);
|
||||
g.add(3, LongJump::default());
|
||||
g.add(4, Croak);
|
||||
g.add(5, Stroll::avoiding_water(1.0).interval(1));
|
||||
g.add(6, LookAtPlayer::new(6.0));
|
||||
@@ -119,6 +144,13 @@ fn can_eat(species: Species, size: i32) -> bool {
|
||||
|
||||
/// `FrogAttackablesSensor` (every 20 ticks) and the memory timers.
|
||||
pub fn server_ai_step(m: &mut Mob, w: &MobWorld) {
|
||||
// `CountDownCooldownTicks(LONG_JUMP_COOLDOWN_TICKS)`: one less each tick,
|
||||
// erased the tick after it reaches 0.
|
||||
match jump_cooldown(m) {
|
||||
Some(t) if t > 0 => set_jump_cooldown(m, Some(t - 1)),
|
||||
Some(_) => set_jump_cooldown(m, None),
|
||||
None => {}
|
||||
}
|
||||
let f = &mut m.extra.frog;
|
||||
f.unreachable.retain_mut(|(_, ttl)| {
|
||||
*ttl -= 1;
|
||||
@@ -310,3 +342,366 @@ impl Goal for ShootTongue {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `LONG_JUMP_COOLDOWN_TICKS`.
|
||||
fn jump_cooldown(m: &Mob) -> Option<i64> {
|
||||
m.extra
|
||||
.memories
|
||||
.iter()
|
||||
.find(|x| x.0 == LONG_JUMP_COOLDOWN)
|
||||
.map(|x| x.1)
|
||||
}
|
||||
fn set_jump_cooldown(m: &mut Mob, ticks: Option<i64>) {
|
||||
let memories = &mut m.extra.memories;
|
||||
let at = memories.iter().position(|x| x.0 == LONG_JUMP_COOLDOWN);
|
||||
match (at, ticks) {
|
||||
(Some(i), Some(t)) => memories[i].1 = t,
|
||||
(Some(i), None) => {
|
||||
memories.remove(i);
|
||||
}
|
||||
(None, Some(t)) => memories.push((LONG_JUMP_COOLDOWN, t, i64::MAX)),
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
/// `TIME_BETWEEN_LONG_JUMPS.sample` from the level's random, divided as the
|
||||
/// caller asks.
|
||||
fn sample_cooldown(m: &mut Mob, lr: &mut LegacyRandom, div: i32) {
|
||||
let (lo, hi) = TIME_BETWEEN_LONG_JUMPS;
|
||||
let t = lr.next_int_between_inclusive(lo, hi).unwrap_or(lo) / div;
|
||||
set_jump_cooldown(m, Some(i64::from(t)));
|
||||
}
|
||||
|
||||
/// `FrogAi.isAcceptableLandingSpot`: no fluid in the target, below or above
|
||||
/// it; a lily pad or big dripleaf (`frog_prefer_jump_to`) or a trapdoor; else
|
||||
/// `LongJumpToRandomPos.defaultAcceptableLandingSpot`: a solid block below
|
||||
/// and a path type the frog walks without a malus.
|
||||
fn acceptable_landing(m: &Mob, w: &MobWorld, [x, y, z]: [i32; 3]) -> bool {
|
||||
use super::path::PathType;
|
||||
if (y - 1..=y + 1).any(|y| w.fluid(x, y, z) != crate::blocks::Fluid::None) {
|
||||
return false;
|
||||
}
|
||||
let preferred = |y| w.has_tag("minecraft:frog_prefer_jump_to", w.block_name(x, y, z));
|
||||
if preferred(y) || preferred(y - 1) {
|
||||
return true;
|
||||
}
|
||||
let here = w.path_type_static(x, y, z);
|
||||
if here == PathType::Trapdoor
|
||||
|| w.facts.is_air(w.world.block(x, y, z))
|
||||
&& w.path_type_static(x, y - 1, z) == PathType::Trapdoor
|
||||
{
|
||||
return true;
|
||||
}
|
||||
w.facts.get(w.world.block(x, y - 1, z)).solid_render && m.malus(here) == 0.0
|
||||
}
|
||||
|
||||
/// `LongJumpToRandomPos.PossibleJump`: a block and its weight.
|
||||
type Candidate = ([i32; 3], i32);
|
||||
|
||||
/// `FrogAi`'s long jump activity: `LongJumpToPreferredBlock` looks for a spot
|
||||
/// it cannot walk to, faces it for 40 ticks and leaps; `LongJumpMidJump`
|
||||
/// holds the pose until it lands. One goal here, like the brain activity
|
||||
/// that keeps the idle croak and stroll from starting meanwhile.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct LongJump {
|
||||
candidates: Vec<Candidate>,
|
||||
not_preferred: Vec<Candidate>,
|
||||
wanting_preferred: bool,
|
||||
initial: Option<Vec3>,
|
||||
chosen: Option<Vec3>,
|
||||
/// The chosen spot, looked at while preparing.
|
||||
look: [f64; 3],
|
||||
tries: i32,
|
||||
/// Ticks since the jump was chosen.
|
||||
prepare: i32,
|
||||
/// `LONG_JUMP_MID_JUMP`, and the ticks `LongJumpMidJump` has run.
|
||||
mid_jump: bool,
|
||||
in_air: Option<i32>,
|
||||
}
|
||||
impl LongJump {
|
||||
/// `WeightedRandom.getRandomItem` with the level's random over the
|
||||
/// candidates, removed once drawn.
|
||||
fn draw(&mut self, lr: &mut LegacyRandom) -> Option<Candidate> {
|
||||
let total: i32 = self.candidates.iter().map(|c| c.1).sum();
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut pick = lr.next_int_bound(total).unwrap_or(0);
|
||||
let i = self.candidates.iter().position(|c| {
|
||||
pick -= c.1;
|
||||
pick < 0
|
||||
})?;
|
||||
Some(self.candidates.remove(i))
|
||||
}
|
||||
/// `LongJumpToPreferredBlock.getJumpCandidate`: while it wants them, the
|
||||
/// first drawn spot over a preferred block, else the first one passed over.
|
||||
fn candidate(&mut self, w: &MobWorld, lr: &mut LegacyRandom) -> Option<Candidate> {
|
||||
if !self.wanting_preferred {
|
||||
return self.draw(lr);
|
||||
}
|
||||
while !self.candidates.is_empty() {
|
||||
if let Some(c) = self.draw(lr) {
|
||||
let [x, y, z] = c.0;
|
||||
if w.has_tag("minecraft:frog_prefer_jump_to", w.block_name(x, y - 1, z)) {
|
||||
return Some(c);
|
||||
}
|
||||
self.not_preferred.push(c);
|
||||
}
|
||||
}
|
||||
(!self.not_preferred.is_empty()).then(|| self.not_preferred.remove(0))
|
||||
}
|
||||
/// `LongJumpToRandomPos.pickCandidate`: the first acceptable spot it can
|
||||
/// reach in an arc but not by an 8 block path.
|
||||
fn pick(&mut self, m: &mut Mob, w: &MobWorld, lr: &mut LegacyRandom) {
|
||||
while !self.candidates.is_empty() {
|
||||
let Some((target, _)) = self.candidate(w, lr) else {
|
||||
continue;
|
||||
};
|
||||
let here = [m.position.x.floor() as i32, m.position.z.floor() as i32];
|
||||
if here == [target[0], target[2]] || !acceptable_landing(m, w, target) {
|
||||
continue;
|
||||
}
|
||||
let centre = Vec3::new(
|
||||
f64::from(target[0]) + 0.5,
|
||||
f64::from(target[1]) + 0.5,
|
||||
f64::from(target[2]) + 0.5,
|
||||
);
|
||||
let Some(v) = optimal_jump_vector(m, w, centre) else {
|
||||
continue;
|
||||
};
|
||||
self.look = [centre.x, centre.y, centre.z];
|
||||
let walkable = m
|
||||
.create_path_raw(w, &[target], 0, 8.0)
|
||||
.is_some_and(|p| p.reached);
|
||||
if !walkable {
|
||||
self.chosen = Some(v);
|
||||
self.prepare = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `LongJumpMidJump.stop`, or the end of a search.
|
||||
fn end(&mut self, m: &mut Mob, cx: &mut Cx) {
|
||||
if self.mid_jump {
|
||||
if m.on_ground {
|
||||
m.velocity = m.velocity.multiply(Vec3::new(0.1, 1.0, 0.1));
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
cx.events
|
||||
.push(MobEvent::Sound("entity.frog.step", at, 2.0, 1.0));
|
||||
}
|
||||
set_pose(m, sync::STANDING);
|
||||
sample_cooldown(m, cx.lr, 1);
|
||||
}
|
||||
m.extra.frog.discard_friction = false;
|
||||
*self = Self::default();
|
||||
}
|
||||
}
|
||||
/// `LongJumpToRandomPos.start`'s candidates: every block of the box but the
|
||||
/// frog's own, weighted by the squared distance, in `betweenClosed` order.
|
||||
fn candidates([x, y, z]: [i32; 3]) -> Vec<Candidate> {
|
||||
let mut out = Vec::new();
|
||||
for dz in -JUMP_WIDTH..=JUMP_WIDTH {
|
||||
for dy in -JUMP_HEIGHT..=JUMP_HEIGHT {
|
||||
for dx in -JUMP_WIDTH..=JUMP_WIDTH {
|
||||
if (dx, dy, dz) != (0, 0, 0) {
|
||||
out.push(([x + dx, y + dy, z + dz], dx * dx + dy * dy + dz * dz));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `LongJumpToRandomPos.calculateOptimalJumpVector`: the angles shuffled, the
|
||||
/// first arc that reaches the target clear of blocks. `Collections.shuffle`
|
||||
/// without a random draws from an unseeded one, not the mob's or the level's.
|
||||
fn optimal_jump_vector(m: &Mob, w: &MobWorld, target: Vec3) -> Option<Vec3> {
|
||||
let mut random = crate::sound_engine::SoundEngine::unseeded();
|
||||
let mut angles = ALLOWED_ANGLES;
|
||||
for i in (2..=angles.len()).rev() {
|
||||
let j = random.next_int_bound(i as i32).unwrap_or(0) as usize;
|
||||
angles.swap(i - 1, j);
|
||||
}
|
||||
let max = (m.attrs.jump_strength * f64::from(JUMP_VELOCITY_MULTIPLIER)) as f32;
|
||||
angles
|
||||
.iter()
|
||||
.find_map(|&a| super::breeze::jump_vector(m, target, max, a, Some(w)))
|
||||
}
|
||||
impl Goal for LongJump {
|
||||
goal_clone!();
|
||||
fn flags(&self) -> u8 {
|
||||
MOVE | LOOK
|
||||
}
|
||||
fn interruptable(&self) -> bool {
|
||||
!self.mid_jump
|
||||
}
|
||||
fn every_tick(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
// The activity: no cooldown and out of water. FrogAi.initJumpActivity
|
||||
// also wants TEMPTING_PLAYER and BREED_TARGET absent, and the
|
||||
// LAY_SPAWN activity (IS_PREGNANT) goes before LONG_JUMP.
|
||||
// TODO: gate on them once frogs follow frog_food, breed and lay
|
||||
// frogspawn here; none of the three exists for frogs yet.
|
||||
if jump_cooldown(m).is_some() || m.fluids.in_water {
|
||||
return false;
|
||||
}
|
||||
// checkExtraStartConditions: on the ground, not in lava or on honey.
|
||||
let (x, y, z) = (
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
);
|
||||
let can =
|
||||
m.on_ground && !m.fluids.in_lava && cx.w.block_name(x, y, z) != "minecraft:honey_block";
|
||||
if !can {
|
||||
sample_cooldown(m, cx.lr, 2);
|
||||
}
|
||||
can
|
||||
}
|
||||
fn start(&mut self, m: &mut Mob, _cx: &mut Cx) {
|
||||
// LongJumpToRandomPos.start.
|
||||
let (x, y, z) = (
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
);
|
||||
*self = Self {
|
||||
candidates: candidates([x, y, z]),
|
||||
tries: FIND_JUMP_TRIES,
|
||||
initial: Some(m.position),
|
||||
..Self::default()
|
||||
};
|
||||
// LongJumpToPreferredBlock.start.
|
||||
self.wanting_preferred = m.random.next_float() < PREFERRED_BLOCKS_CHANCE;
|
||||
}
|
||||
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
if self.mid_jump {
|
||||
// LongJumpMidJump starts the tick after the leap and runs until
|
||||
// the frog is on the ground again, for 100 ticks at most; water
|
||||
// switches the brain to the swim activity, which stops it too.
|
||||
match self.in_air {
|
||||
None => {
|
||||
self.in_air = Some(0);
|
||||
m.extra.frog.discard_friction = true;
|
||||
set_pose(m, sync::LONG_JUMPING);
|
||||
}
|
||||
Some(t) => self.in_air = Some(t + 1),
|
||||
}
|
||||
return !m.on_ground
|
||||
&& !m.fluids.in_water
|
||||
&& self.in_air.is_some_and(|t| t <= MID_JUMP_DURATION);
|
||||
}
|
||||
let valid = self.initial == Some(m.position)
|
||||
&& self.tries > 0
|
||||
&& !m.fluids.in_water
|
||||
&& !(self.chosen.is_none() && self.candidates.is_empty());
|
||||
if !valid {
|
||||
sample_cooldown(m, cx.lr, 2);
|
||||
}
|
||||
valid
|
||||
}
|
||||
fn stop(&mut self, m: &mut Mob, cx: &mut Cx) {
|
||||
self.end(m, cx);
|
||||
}
|
||||
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
|
||||
if self.mid_jump {
|
||||
return;
|
||||
}
|
||||
let Some(jump) = self.chosen else {
|
||||
self.tries -= 1;
|
||||
self.pick(m, cx.w, cx.lr);
|
||||
return;
|
||||
};
|
||||
m.look_at(self.look);
|
||||
self.prepare += 1;
|
||||
if self.prepare < PREPARE_JUMP_DURATION {
|
||||
return;
|
||||
}
|
||||
// The leap: the body's facing, no friction, the arc's velocity
|
||||
// lengthened by jump boost.
|
||||
m.yaw = m.body_yaw;
|
||||
m.extra.frog.discard_friction = true;
|
||||
let len = jump.length();
|
||||
let boost = m
|
||||
.extra
|
||||
.effects
|
||||
.iter()
|
||||
.find(|e| e.0 == "minecraft:jump_boost")
|
||||
.map_or(0.0, |e| f64::from(0.1_f32 * (e.2 + 1) as f32));
|
||||
m.velocity = jump.scale((len + boost) / len);
|
||||
self.mid_jump = true;
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
cx.events
|
||||
.push(MobEvent::Sound("entity.frog.long_jump", at, 1.0, 1.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_core::random::LegacyRandom;
|
||||
|
||||
fn frog() -> Mob {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let k = std::sync::Arc::make_mut(&mut kind);
|
||||
k.id = "minecraft:frog".into();
|
||||
k.species = Species::Frog;
|
||||
Mob::new(1, kind, Vec3::new(0.5, 0.0, 0.5), 0.0)
|
||||
}
|
||||
|
||||
/// `LongJumpToRandomPos.start`: the 9 x 5 x 9 box without the frog's own
|
||||
/// block, x fastest, then y, then z, each weighted by its squared distance.
|
||||
#[test]
|
||||
fn the_candidates_cover_the_jump_box_in_vanilla_order() {
|
||||
let c = candidates([10, 64, -3]);
|
||||
assert_eq!(c.len(), 9 * 5 * 9 - 1);
|
||||
assert_eq!(c[0], ([6, 62, -7], 16 + 4 + 16));
|
||||
assert_eq!(c[1], ([7, 62, -7], 9 + 4 + 16));
|
||||
assert_eq!(c[9], ([6, 63, -7], 16 + 1 + 16));
|
||||
assert_eq!(c[45], ([6, 62, -6], 16 + 4 + 9));
|
||||
assert!(!c.iter().any(|(p, _)| *p == [10, 64, -3]));
|
||||
assert_eq!(c.last().unwrap().0, [14, 66, 1]);
|
||||
}
|
||||
|
||||
/// `WeightedRandom.getRandomItem`: `nextInt(total)` walked down the
|
||||
/// list, and the drawn candidate is removed.
|
||||
#[test]
|
||||
fn a_draw_takes_the_weighted_candidate_out() {
|
||||
let mut j = LongJump {
|
||||
candidates: vec![([0, 0, 0], 1), ([1, 0, 0], 4), ([2, 0, 0], 9)],
|
||||
..LongJump::default()
|
||||
};
|
||||
let mut r = LegacyRandom::new(7);
|
||||
let mut lr = r.clone();
|
||||
let pick = r.next_int_bound(14).unwrap();
|
||||
let want = if pick < 1 {
|
||||
0
|
||||
} else if pick < 5 {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
assert_eq!(j.draw(&mut lr).unwrap().0, [want, 0, 0]);
|
||||
assert_eq!(j.candidates.len(), 2);
|
||||
assert!(!j.candidates.iter().any(|c| c.0 == [want, 0, 0]));
|
||||
}
|
||||
|
||||
/// `CountDownCooldownTicks`: the memory falls by one a tick and is gone
|
||||
/// the tick after it reached 0; the sample is 100..=140, halved after a
|
||||
/// failed search, drawn from the level's random and not the frog's.
|
||||
#[test]
|
||||
fn the_long_jump_cooldown_counts_down_and_is_erased() {
|
||||
let mut m = frog();
|
||||
let own = m.random.state();
|
||||
sample_cooldown(&mut m, &mut LegacyRandom::new(3), 2);
|
||||
let t = jump_cooldown(&m).unwrap();
|
||||
assert!((50..=70).contains(&t));
|
||||
assert_eq!(m.random.state(), own);
|
||||
set_jump_cooldown(&mut m, Some(1));
|
||||
set_jump_cooldown(&mut m, Some(0));
|
||||
assert_eq!(jump_cooldown(&m), Some(0));
|
||||
set_jump_cooldown(&mut m, None);
|
||||
assert_eq!(jump_cooldown(&m), None);
|
||||
assert!(!m.extra.memories.iter().any(|x| x.0 == LONG_JUMP_COOLDOWN));
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use super::{Mob, MobEvent, MobWorld, Seen, Species, Who, world::TargetInfo};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
jmath,
|
||||
random::RandomSource,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -16,6 +16,9 @@ pub const TARGET: u8 = 8;
|
||||
/// What goals may read and emit during a tick.
|
||||
pub struct Cx<'a, 'w> {
|
||||
pub w: &'a MobWorld<'w>,
|
||||
/// The level's random (`level.getRandom()`), for the draws vanilla's
|
||||
/// behaviours make from it rather than from the mob's own.
|
||||
pub lr: &'a mut LegacyRandom,
|
||||
pub events: &'a mut Vec<MobEvent>,
|
||||
}
|
||||
pub trait Goal: std::fmt::Debug {
|
||||
|
||||
@@ -62,7 +62,7 @@ pub fn register(m: &mut Mob) {
|
||||
|
||||
/// `Illusioner.finalizeSpawn`: a bow in the main hand.
|
||||
pub fn finalize(m: &mut Mob) {
|
||||
m.extra.main_hand = Some("minecraft:bow".into());
|
||||
m.extra.set_main_hand(Some("minecraft:bow".into()));
|
||||
}
|
||||
|
||||
/// `IllusionerMirrorSpellGoal` and `IllusionerBlindnessSpellGoal`.
|
||||
|
||||
@@ -1045,8 +1045,8 @@ impl Mob {
|
||||
} else {
|
||||
y -= self.gravity();
|
||||
}
|
||||
// LivingEntity.shouldDiscardFriction (a breeze's long jump).
|
||||
if super::breeze::discards_friction(self) {
|
||||
// LivingEntity.shouldDiscardFriction (a breeze's or a frog's long jump).
|
||||
if super::breeze::discards_friction(self) || self.extra.frog.discard_friction {
|
||||
self.velocity = Vec3::new(movement.x, y, movement.z);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -783,6 +783,7 @@ impl B {
|
||||
m.swing();
|
||||
let mut gcx = Cx {
|
||||
w,
|
||||
lr: cx.lr,
|
||||
events: cx.events,
|
||||
};
|
||||
super::ai::do_hurt_target(m, target, &mut gcx);
|
||||
|
||||
@@ -232,7 +232,7 @@ impl Mob {
|
||||
}
|
||||
q
|
||||
}
|
||||
fn create_path_raw(
|
||||
pub(super) fn create_path_raw(
|
||||
&mut self,
|
||||
w: &MobWorld,
|
||||
targets: &[[i32; 3]],
|
||||
|
||||
@@ -183,7 +183,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld) {
|
||||
}
|
||||
}
|
||||
/// `NeutralMob.stopBeingAngry`.
|
||||
fn stop_being_angry(m: &mut Mob) {
|
||||
pub(super) fn stop_being_angry(m: &mut Mob) {
|
||||
m.ai.last_hurt_by = None;
|
||||
m.ai.target = None;
|
||||
m.extra.anger = 0;
|
||||
|
||||
@@ -207,24 +207,24 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) {
|
||||
let weapon = if difficulty.hard { 0.05 } else { 0.01 };
|
||||
if m.species() != S::Drowned && m.random.next_float() < weapon {
|
||||
let r = m.random.next_int_bound(6).unwrap_or(0);
|
||||
m.extra.main_hand = Some(
|
||||
m.extra.set_main_hand(Some(
|
||||
match r {
|
||||
0 => "minecraft:iron_sword",
|
||||
1 => "minecraft:iron_spear",
|
||||
_ => "minecraft:iron_shovel",
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
));
|
||||
}
|
||||
if m.species() == S::Drowned && m.random.next_float() > 0.9 {
|
||||
m.extra.main_hand = Some(
|
||||
m.extra.set_main_hand(Some(
|
||||
if m.random.next_int_bound(16).unwrap_or(1) < 10 {
|
||||
"minecraft:trident"
|
||||
} else {
|
||||
"minecraft:fishing_rod"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
));
|
||||
}
|
||||
if m.species() == S::ZombieVillager {
|
||||
m.extra.variant = villager_type(biome);
|
||||
@@ -238,28 +238,28 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) {
|
||||
let can_break_doors = m.random.next_float() < difficulty.special() * 0.1;
|
||||
super::doors::set_can_break_doors(m, can_break_doors);
|
||||
let spear = m.random.next_int_bound(20).unwrap_or(1) == 0;
|
||||
m.extra.main_hand = Some(
|
||||
m.extra.set_main_hand(Some(
|
||||
if spear {
|
||||
"minecraft:golden_spear"
|
||||
} else {
|
||||
"minecraft:golden_sword"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
));
|
||||
}
|
||||
// AbstractSkeleton.populateDefaultEquipmentSlots: armour, then a bow.
|
||||
S::Skeleton | S::Stray | S::Bogged | S::Parched => {
|
||||
populate_armor(m, difficulty);
|
||||
m.extra.main_hand = Some("minecraft:bow".into());
|
||||
m.extra.set_main_hand(Some("minecraft:bow".into()));
|
||||
m.extra.pick_up_loot = m.random.next_float() < 0.55 * difficulty.special();
|
||||
}
|
||||
// WitherSkeleton.populateDefaultEquipmentSlots: a stone sword only.
|
||||
S::WitherSkeleton => {
|
||||
m.extra.main_hand = Some("minecraft:stone_sword".into());
|
||||
m.extra.set_main_hand(Some("minecraft:stone_sword".into()));
|
||||
m.extra.pick_up_loot = m.random.next_float() < 0.55 * difficulty.special();
|
||||
}
|
||||
S::Pillager => {
|
||||
m.extra.main_hand = Some("minecraft:crossbow".into());
|
||||
m.extra.set_main_hand(Some("minecraft:crossbow".into()));
|
||||
m.extra.pick_up_loot = true;
|
||||
}
|
||||
S::Horse | S::Donkey | S::Mule | S::SkeletonHorse | S::ZombieHorse => {
|
||||
@@ -286,21 +286,21 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) {
|
||||
}
|
||||
}
|
||||
let crossbow = m.random.next_float() < 0.5;
|
||||
m.extra.main_hand = Some(
|
||||
m.extra.set_main_hand(Some(
|
||||
if crossbow {
|
||||
"minecraft:crossbow"
|
||||
} else {
|
||||
"minecraft:golden_sword"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
));
|
||||
}
|
||||
S::PiglinBrute => {
|
||||
m.extra.main_hand = Some("minecraft:golden_axe".into());
|
||||
m.extra.set_main_hand(Some("minecraft:golden_axe".into()));
|
||||
m.extra.pick_up_loot = true;
|
||||
}
|
||||
S::Illusioner => super::illusioner::finalize(m),
|
||||
S::Vindicator => m.extra.main_hand = Some("minecraft:iron_axe".into()),
|
||||
S::Vindicator => m.extra.set_main_hand(Some("minecraft:iron_axe".into())),
|
||||
S::Villager => {
|
||||
m.extra.variant = villager_type(biome);
|
||||
m.extra.pick_up_loot = true;
|
||||
|
||||
@@ -114,11 +114,11 @@ impl Goal for DrinkItem {
|
||||
m.extra.using_item > 0
|
||||
}
|
||||
fn start(&mut self, m: &mut Mob, _: &mut Cx) {
|
||||
m.extra.main_hand = Some(self.item.into());
|
||||
m.extra.set_main_hand(Some(self.item.into()));
|
||||
m.extra.using_item = 1;
|
||||
}
|
||||
fn stop(&mut self, m: &mut Mob, cx: &mut Cx) {
|
||||
m.extra.main_hand = None;
|
||||
m.extra.set_main_hand(None);
|
||||
m.extra.using_item = 0;
|
||||
let pitch = m.random.next_float() * 0.2 + 0.9;
|
||||
cx.events
|
||||
|
||||
@@ -75,7 +75,7 @@ pub fn held(m: &Mob) -> Option<&ItemStack> {
|
||||
/// `setItemSlot(MAINHAND, ..)`, keeping the rendered copy in step.
|
||||
pub fn set_held(m: &mut Mob, stack: Option<ItemStack>) {
|
||||
let stack = stack.filter(|s| s.count > 0);
|
||||
m.extra.main_hand = stack.as_ref().map(|s| s.item.clone());
|
||||
m.extra.set_main_hand_stack(stack.clone());
|
||||
m.extra.ints[1] = stack.as_ref().map_or(0, |s| s.count as i32);
|
||||
if m.extra.items.is_empty() {
|
||||
m.extra.items.push(None);
|
||||
|
||||
@@ -19,7 +19,7 @@ use std::f32::consts::PI;
|
||||
pub fn register(m: &mut Mob) {
|
||||
m.no_gravity = true;
|
||||
m.ai.mov.kind = super::control::MoveKind::Custom;
|
||||
m.extra.main_hand = Some("minecraft:iron_sword".into());
|
||||
m.extra.set_main_hand(Some("minecraft:iron_sword".into()));
|
||||
if m.extra.egg_time == 0 {
|
||||
m.extra.egg_time = -1;
|
||||
}
|
||||
|
||||
@@ -1721,7 +1721,7 @@ impl B {
|
||||
}
|
||||
t.begin(cx.lr, cx.now);
|
||||
bonemeal_target(m, *crop);
|
||||
m.extra.main_hand = Some("minecraft:bone_meal".into());
|
||||
m.extra.set_main_hand(Some("minecraft:bone_meal".into()));
|
||||
*next_work = cx.now;
|
||||
*worked = 0;
|
||||
*repick = false;
|
||||
@@ -3200,7 +3200,7 @@ impl B {
|
||||
t, last_session, ..
|
||||
} => {
|
||||
t.running = false;
|
||||
m.extra.main_hand = None;
|
||||
m.extra.set_main_hand(None);
|
||||
*last_session = i64::from(m.age);
|
||||
}
|
||||
B::WorkAtPoi { t, .. } => t.running = false,
|
||||
|
||||
@@ -454,7 +454,13 @@ pub fn do_push(m: &mut Mob, w: &MobWorld, who: Who, events: &mut Vec<MobEvent>)
|
||||
}
|
||||
/// `Warden.doHurtTarget`: the attack keyframes (entity event 4), the impact
|
||||
/// sound, a sonic boom cooldown, then `Mob.doHurtTarget`.
|
||||
pub(super) fn do_hurt_target(m: &mut Mob, target: Who, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
pub(super) fn do_hurt_target(
|
||||
m: &mut Mob,
|
||||
target: Who,
|
||||
w: &MobWorld,
|
||||
lr: &mut LegacyRandom,
|
||||
events: &mut Vec<MobEvent>,
|
||||
) {
|
||||
m.anim.states[ROAR].stop();
|
||||
m.anim.states[ATTACK].start(m.age as f32);
|
||||
events.push(MobEvent::EntityEvent {
|
||||
@@ -465,7 +471,7 @@ pub(super) fn do_hurt_target(m: &mut Mob, target: Who, w: &MobWorld, events: &mu
|
||||
let pitch = voice_pitch(m);
|
||||
sound(m, "entity.warden.attack_impact", 10.0, pitch, events);
|
||||
warden_brain::mem(m).sonic_boom_cooldown.set_ttl((), 40);
|
||||
let mut cx = warden_brain::cx(w, events);
|
||||
let mut cx = warden_brain::cx(w, lr, events);
|
||||
super::ai::do_hurt_target(m, target, &mut cx);
|
||||
}
|
||||
|
||||
|
||||
@@ -962,7 +962,7 @@ impl B {
|
||||
}
|
||||
mem(m).look_target.set(Tracker::Entity(target, true));
|
||||
m.swing();
|
||||
warden::do_hurt_target(m, target, w, cx.events);
|
||||
warden::do_hurt_target(m, target, w, cx.lr, cx.events);
|
||||
mem(m)
|
||||
.attack_cooling_down
|
||||
.set_ttl(true, MELEE_ATTACK_COOLDOWN);
|
||||
@@ -1188,8 +1188,12 @@ fn swim_condition(m: &Mob) -> bool {
|
||||
m.fluids.water > m.fluid_jump_threshold() || m.fluids.in_lava
|
||||
}
|
||||
/// The `Cx` the mob library's attacks take.
|
||||
pub(super) fn cx<'a, 'w>(w: &'a MobWorld<'w>, events: &'a mut Vec<MobEvent>) -> Cx<'a, 'w> {
|
||||
Cx { w, events }
|
||||
pub(super) fn cx<'a, 'w>(
|
||||
w: &'a MobWorld<'w>,
|
||||
lr: &'a mut LegacyRandom,
|
||||
events: &'a mut Vec<MobEvent>,
|
||||
) -> Cx<'a, 'w> {
|
||||
Cx { w, lr, events }
|
||||
}
|
||||
|
||||
// ---- Printing ---------------------------------------------------------------
|
||||
|
||||
@@ -268,7 +268,7 @@ fn drink(m: &mut Mob, cx_events: &mut Vec<MobEvent>, target_far: bool) {
|
||||
m.extra.using_item -= 1;
|
||||
if m.extra.using_item < 0 {
|
||||
m.extra.using_item = 0;
|
||||
if let Some(potion) = m.extra.main_hand.take() {
|
||||
if let Some(potion) = m.extra.take_main_hand() {
|
||||
match potion.as_ref() {
|
||||
"minecraft:water_breathing" => {
|
||||
m.add_effect("minecraft:water_breathing", 3600, 0)
|
||||
@@ -307,7 +307,7 @@ fn drink(m: &mut Mob, cx_events: &mut Vec<MobEvent>, target_far: bool) {
|
||||
None
|
||||
};
|
||||
if let Some(p) = potion {
|
||||
m.extra.main_hand = Some(p.into());
|
||||
m.extra.set_main_hand(Some(p.into()));
|
||||
// PotionItem use duration 32 ticks.
|
||||
m.extra.using_item = 32;
|
||||
m.extra.flag = true;
|
||||
|
||||
@@ -705,6 +705,14 @@ impl PlayerMotion {
|
||||
/// Vehicle, item-use and effect bindings remain separate; hunger, flight,
|
||||
/// water and collision conditions use this actor's actual state.
|
||||
pub fn sprint_input_tick(&mut self, keys: crate::sprint::Keys) -> Result<()> {
|
||||
self.sprint_input_tick_with_window(keys, 7)
|
||||
}
|
||||
/// The same phase with Java's `Options.sprintWindow` value.
|
||||
pub fn sprint_input_tick_with_window(
|
||||
&mut self,
|
||||
keys: crate::sprint::Keys,
|
||||
sprint_window: i32,
|
||||
) -> Result<()> {
|
||||
let conditions = crate::sprint::Conditions {
|
||||
sprinting: self.sprinting,
|
||||
enough_food: self.health.food.has_enough_food(),
|
||||
@@ -725,7 +733,9 @@ impl PlayerMotion {
|
||||
// The sprint setter edits attributes and flags, independent of local
|
||||
// keyboard history. Retain the input prefix even if that setter fails.
|
||||
let mut state = self.sprint_input;
|
||||
let result = state.tick(keys, conditions, 7, |value| self.set_sprinting(value));
|
||||
let result = state.tick(keys, conditions, sprint_window.clamp(0, 10), |value| {
|
||||
self.set_sprinting(value)
|
||||
});
|
||||
self.sprint_input = state;
|
||||
result
|
||||
}
|
||||
@@ -992,6 +1002,14 @@ impl MotionWorld<'_, '_, '_> {
|
||||
.food_tick(&self.resources.health, self.scene.health_rules)
|
||||
}
|
||||
pub fn controlled_simulation_tick(&mut self, keys: crate::sprint::Keys) -> Result<()> {
|
||||
self.controlled_simulation_tick_with_sprint_window(keys, 7)
|
||||
}
|
||||
/// Local simulation with the configured double-tap sprint window.
|
||||
pub fn controlled_simulation_tick_with_sprint_window(
|
||||
&mut self,
|
||||
keys: crate::sprint::Keys,
|
||||
sprint_window: i32,
|
||||
) -> Result<()> {
|
||||
// The base tick (fluids, swimming) precedes LocalPlayer.aiStep.
|
||||
self.player.health.advance_clock();
|
||||
self.fluid_base_tick()?;
|
||||
@@ -1004,7 +1022,8 @@ impl MotionWorld<'_, '_, '_> {
|
||||
self.player.auto_jump_time -= 1;
|
||||
keys.jump = true;
|
||||
}
|
||||
self.player.sprint_input_tick(keys)?;
|
||||
self.player
|
||||
.sprint_input_tick_with_window(keys, sprint_window)?;
|
||||
self.player.shift_key_down = keys.shift;
|
||||
let direction = keys.direction();
|
||||
self.player.move_vector = direction;
|
||||
|
||||
@@ -660,7 +660,7 @@ impl Screen {
|
||||
half(w) - 100.0,
|
||||
quarter(h) + 96.0 + 18.0,
|
||||
200.0,
|
||||
"Join",
|
||||
&crate::lang::get_or("horizon.multiplayer.join", "Join"),
|
||||
horizon_friends::invite::parse(code).is_some(),
|
||||
56,
|
||||
),
|
||||
@@ -687,7 +687,10 @@ impl Screen {
|
||||
half(w) - 100.0,
|
||||
half(h) + 54.0,
|
||||
200.0,
|
||||
"Cancel Reconnecting",
|
||||
&crate::lang::get_or(
|
||||
"horizon.multiplayer.cancel_reconnecting",
|
||||
"Cancel Reconnecting",
|
||||
),
|
||||
true,
|
||||
55,
|
||||
));
|
||||
@@ -1055,13 +1058,18 @@ impl Screen {
|
||||
}
|
||||
}
|
||||
Self::Friend { code, relay, focus } => {
|
||||
centered(batch, "Join a Friend", 20.0, u32::MAX);
|
||||
centered(
|
||||
batch,
|
||||
&crate::lang::get_or("horizon.multiplayer.join_friend", "Join a Friend"),
|
||||
20.0,
|
||||
u32::MAX,
|
||||
);
|
||||
Self::field(
|
||||
atlas,
|
||||
batch,
|
||||
caret,
|
||||
focused_at,
|
||||
"Invite Code",
|
||||
&crate::lang::get_or("horizon.multiplayer.invite_code", "Invite Code"),
|
||||
code,
|
||||
FRIEND_CODE,
|
||||
max,
|
||||
@@ -1073,7 +1081,7 @@ impl Screen {
|
||||
batch,
|
||||
caret,
|
||||
focused_at,
|
||||
"Relay (optional)",
|
||||
&crate::lang::get_or("horizon.multiplayer.relay", "Relay (optional)"),
|
||||
relay,
|
||||
FRIEND_RELAY,
|
||||
max,
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
//! (`Entity.setAsInsidePortal` / `handlePortal`) for nether portals.
|
||||
use horizon_core::position::BlockPos;
|
||||
|
||||
/// `Entity.getDimensionChangingDelay`: the portal cooldown of an entity that
|
||||
/// went through a portal.
|
||||
pub const ENTITY_DELAY: i32 = 300;
|
||||
|
||||
/// `PortalProcessor` for a nether portal.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Process {
|
||||
|
||||
@@ -2562,14 +2562,83 @@ const INT_RULES: [&str; 11] = [
|
||||
"respawn_radius",
|
||||
];
|
||||
|
||||
/// The world presets of 26.3's `worldgen/world_preset` that the create
|
||||
/// screen offers, in the order of `WorldPresetTags.NORMAL`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WorldPreset {
|
||||
Normal,
|
||||
Flat,
|
||||
LargeBiomes,
|
||||
Amplified,
|
||||
SingleBiomeSurface,
|
||||
}
|
||||
impl WorldPreset {
|
||||
/// The presets, in the order the world-type button cycles through them.
|
||||
pub const ALL: [Self; 5] = [
|
||||
Self::Normal,
|
||||
Self::Flat,
|
||||
Self::LargeBiomes,
|
||||
Self::Amplified,
|
||||
Self::SingleBiomeSurface,
|
||||
];
|
||||
/// The preset's registry id (`minecraft:normal`).
|
||||
pub const fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Normal => "minecraft:normal",
|
||||
Self::Flat => "minecraft:flat",
|
||||
Self::LargeBiomes => "minecraft:large_biomes",
|
||||
Self::Amplified => "minecraft:amplified",
|
||||
Self::SingleBiomeSurface => "minecraft:single_biome_surface",
|
||||
}
|
||||
}
|
||||
/// The preset an id names, if it is one of them.
|
||||
pub fn of_id(id: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|preset| preset.id() == id)
|
||||
}
|
||||
/// The language key the world-type button shows
|
||||
/// (`Identifier.toLanguageKey("generator")`).
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Normal => "generator.minecraft.normal",
|
||||
Self::Flat => "generator.minecraft.flat",
|
||||
Self::LargeBiomes => "generator.minecraft.large_biomes",
|
||||
Self::Amplified => "generator.minecraft.amplified",
|
||||
Self::SingleBiomeSurface => "generator.minecraft.single_biome_surface",
|
||||
}
|
||||
}
|
||||
/// The preset the world-type button cycles to next.
|
||||
pub fn next(self) -> Self {
|
||||
let index = Self::ALL
|
||||
.iter()
|
||||
.position(|preset| *preset == self)
|
||||
.unwrap_or(0);
|
||||
Self::ALL[(index + 1) % Self::ALL.len()]
|
||||
}
|
||||
/// The preset a saved overworld generator is: the noise settings name
|
||||
/// the preset, and the single-biome preset shares the overworld's noise
|
||||
/// settings, so its fixed biome source tells the two apart.
|
||||
pub fn of_generator(generator: &Tag) -> Self {
|
||||
if text(generator.get("type")).as_deref() == Some("minecraft:flat") {
|
||||
return Self::Flat;
|
||||
}
|
||||
let source = generator.get("biome_source");
|
||||
let fixed = text(source.and_then(|s| s.get("type"))).as_deref() == Some("minecraft:fixed");
|
||||
match text(generator.get("settings")).as_deref() {
|
||||
Some("minecraft:large_biomes") => Self::LargeBiomes,
|
||||
Some("minecraft:amplified") => Self::Amplified,
|
||||
Some("minecraft:overworld") if fixed => Self::SingleBiomeSurface,
|
||||
_ => Self::Normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// World and player state stored in `level.dat`, the saved-data files and
|
||||
/// the player file.
|
||||
/// The world options CreateWorldScreen sets (`WorldOptions` and the world
|
||||
/// preset) that `world_gen_settings` keeps.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct WorldGen {
|
||||
/// The `minecraft:flat` preset (Superflat) instead of `normal`.
|
||||
pub flat: bool,
|
||||
/// The world preset the world is created with.
|
||||
pub preset: WorldPreset,
|
||||
pub structures: bool,
|
||||
pub bonus_chest: bool,
|
||||
}
|
||||
@@ -2583,8 +2652,7 @@ impl WorldGen {
|
||||
.and_then(|d| d.get("minecraft:overworld"))
|
||||
.and_then(|o| o.get("generator"));
|
||||
Some(Self {
|
||||
flat: text(generator.and_then(|g| g.get("type")))
|
||||
.is_some_and(|t| t.trim_start_matches("minecraft:") == "flat"),
|
||||
preset: generator.map_or(WorldPreset::Normal, WorldPreset::of_generator),
|
||||
structures: flag("generate_structures", true),
|
||||
bonus_chest: flag("bonus_chest", false),
|
||||
})
|
||||
@@ -2617,7 +2685,7 @@ impl WorldGen {
|
||||
impl Default for WorldGen {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
flat: false,
|
||||
preset: WorldPreset::Normal,
|
||||
structures: true,
|
||||
bonus_chest: false,
|
||||
}
|
||||
@@ -2692,6 +2760,34 @@ pub fn write_world_gen_settings_with(
|
||||
("preset", string(p)),
|
||||
])
|
||||
};
|
||||
// The overworld generator of worldgen/world_preset/<preset>.json: the
|
||||
// noise settings and the biome source each preset uses.
|
||||
let overworld = match options.preset {
|
||||
WorldPreset::Flat => flat,
|
||||
WorldPreset::LargeBiomes => dimension(
|
||||
"minecraft:overworld",
|
||||
"minecraft:large_biomes",
|
||||
preset("minecraft:overworld"),
|
||||
),
|
||||
WorldPreset::Amplified => dimension(
|
||||
"minecraft:overworld",
|
||||
"minecraft:amplified",
|
||||
preset("minecraft:overworld"),
|
||||
),
|
||||
WorldPreset::SingleBiomeSurface => dimension(
|
||||
"minecraft:overworld",
|
||||
"minecraft:overworld",
|
||||
Tag::compound([
|
||||
("type", string("minecraft:fixed")),
|
||||
("biome", string("minecraft:plains")),
|
||||
]),
|
||||
),
|
||||
WorldPreset::Normal => dimension(
|
||||
"minecraft:overworld",
|
||||
"minecraft:overworld",
|
||||
preset("minecraft:overworld"),
|
||||
),
|
||||
};
|
||||
write_saved_data(
|
||||
dir,
|
||||
"world_gen_settings",
|
||||
@@ -2705,18 +2801,7 @@ pub fn write_world_gen_settings_with(
|
||||
(
|
||||
"dimensions",
|
||||
Tag::compound([
|
||||
(
|
||||
"minecraft:overworld",
|
||||
if options.flat {
|
||||
flat
|
||||
} else {
|
||||
dimension(
|
||||
"minecraft:overworld",
|
||||
"minecraft:overworld",
|
||||
preset("minecraft:overworld"),
|
||||
)
|
||||
},
|
||||
),
|
||||
("minecraft:overworld", overworld),
|
||||
(
|
||||
"minecraft:the_nether",
|
||||
dimension(
|
||||
@@ -2809,6 +2894,9 @@ pub struct Level {
|
||||
pub food_tick_timer: i32,
|
||||
/// Abilities.flying: whether creative/spectator flight is active.
|
||||
pub flying: bool,
|
||||
/// The vehicle the player rides, by UUID (`ServerPlayer.saveParentVehicle`'s
|
||||
/// "RootVehicle"), so the ride comes back on the next login.
|
||||
pub root_vehicle: Option<[i32; 4]>,
|
||||
/// The player compound as loaded, for fields Horizon does not model
|
||||
/// (ender chest, effects, abilities, recipe book, attributes, ...).
|
||||
pub player_extra: Option<Tag>,
|
||||
@@ -3091,6 +3179,12 @@ impl Level {
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
// ServerPlayer.saveParentVehicle: the vehicle the player rides, by
|
||||
// UUID. Horizon keeps the vehicle in the world, so the file names it
|
||||
// instead of holding a second copy of it.
|
||||
self.root_vehicle = p
|
||||
.get("RootVehicle")
|
||||
.and_then(|r| uuid_from_tag(r.get("Attach")));
|
||||
}
|
||||
/// The player compound: the loaded one with Horizon's state written over it.
|
||||
pub fn player_tag(&self) -> Tag {
|
||||
@@ -3299,6 +3393,18 @@ impl Level {
|
||||
map.entry(key.into()).or_insert(value);
|
||||
}
|
||||
}
|
||||
// ServerPlayer.saveParentVehicle: the vehicle the player rides, by
|
||||
// UUID. Horizon keeps the vehicle in the world, so the file names it
|
||||
// instead of holding a second copy of it.
|
||||
if let Some(vehicle) = self.root_vehicle
|
||||
&& p.get("RootVehicle").is_none()
|
||||
{
|
||||
put(
|
||||
&mut p,
|
||||
"RootVehicle",
|
||||
Tag::compound([("Attach", uuid_tag(vehicle))]),
|
||||
);
|
||||
}
|
||||
p
|
||||
}
|
||||
/// Write every file; a world without a player UUID gets a random one.
|
||||
@@ -5376,6 +5482,31 @@ mod tests {
|
||||
assert_eq!(flying_survival, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unconsumed_root_vehicle_keeps_the_complete_vanilla_tree() {
|
||||
let entity = Tag::compound([
|
||||
("id", Tag::String("minecraft:horse".into())),
|
||||
("UUID", uuid_tag([5, 6, 7, 8])),
|
||||
(
|
||||
"Passengers",
|
||||
Tag::List(vec![Tag::compound([
|
||||
("id", Tag::String("minecraft:pig".into())),
|
||||
("UUID", uuid_tag([1, 2, 3, 4])),
|
||||
])]),
|
||||
),
|
||||
]);
|
||||
let vehicle = Tag::compound([("Attach", uuid_tag([1, 2, 3, 4])), ("Entity", entity)]);
|
||||
let raw = Tag::compound([("RootVehicle", vehicle.clone())]);
|
||||
let mut level = Level::default();
|
||||
level.read_player(&raw);
|
||||
level.player_extra = Some(raw);
|
||||
assert_eq!(level.root_vehicle, Some([1, 2, 3, 4]));
|
||||
assert_eq!(level.player_tag().get("RootVehicle"), Some(&vehicle));
|
||||
// Even saving before the vehicle is restored must not destroy it.
|
||||
level.root_vehicle = None;
|
||||
assert_eq!(level.player_tag().get("RootVehicle"), Some(&vehicle));
|
||||
}
|
||||
|
||||
/// `RecipesUsed` is what a furnace owes in experience, so a save that drops it pays the
|
||||
/// player nothing after a reload.
|
||||
#[test]
|
||||
|
||||
@@ -447,7 +447,7 @@ impl Title {
|
||||
} = mode
|
||||
{
|
||||
if apply {
|
||||
options.flat = true;
|
||||
options.world_type = crate::save::WorldPreset::Flat;
|
||||
options.flat_settings = Some(flat.settings);
|
||||
}
|
||||
self.mode = Mode::Create {
|
||||
|
||||
@@ -30,8 +30,8 @@ pub struct WorldOptions {
|
||||
pub difficulty: usize,
|
||||
/// Allow Cheats as toggled; unset follows the game mode.
|
||||
pub commands: Option<bool>,
|
||||
/// The Superflat world preset instead of Default.
|
||||
pub flat: bool,
|
||||
/// The world preset the world is created with.
|
||||
pub world_type: crate::save::WorldPreset,
|
||||
/// CreateFlatWorldScreen's layers and biome, kept with the world type.
|
||||
pub flat_settings: Option<super::flat::FlatSettings>,
|
||||
pub structures: bool,
|
||||
@@ -51,7 +51,7 @@ impl Default for WorldOptions {
|
||||
hardcore: false,
|
||||
difficulty: 2,
|
||||
commands: None,
|
||||
flat: false,
|
||||
world_type: crate::save::WorldPreset::Normal,
|
||||
flat_settings: None,
|
||||
structures: true,
|
||||
bonus_chest: false,
|
||||
@@ -76,9 +76,8 @@ impl WorldOptions {
|
||||
.position(|d| *d == level.difficulty)
|
||||
.unwrap_or(2),
|
||||
commands: Some(commands),
|
||||
flat: generation.flat,
|
||||
flat_settings: generation
|
||||
.flat
|
||||
world_type: generation.preset,
|
||||
flat_settings: (generation.preset == crate::save::WorldPreset::Flat)
|
||||
.then(|| super::flat::FlatSettings::from_world(dir))
|
||||
.flatten(),
|
||||
structures: generation.structures,
|
||||
@@ -101,7 +100,7 @@ impl WorldOptions {
|
||||
}
|
||||
pub fn world_gen(&self) -> crate::save::WorldGen {
|
||||
crate::save::WorldGen {
|
||||
flat: self.flat,
|
||||
preset: self.world_type,
|
||||
structures: self.structures,
|
||||
bonus_chest: self.bonus_chest(),
|
||||
}
|
||||
@@ -126,8 +125,8 @@ impl WorldOptions {
|
||||
23 => self.difficulty = (self.difficulty + 1) % DIFFICULTIES.len(),
|
||||
24 => self.commands = Some(!self.commands(*creative)),
|
||||
25 => {
|
||||
self.flat = !self.flat;
|
||||
if self.flat {
|
||||
self.world_type = self.world_type.next();
|
||||
if self.world_type == crate::save::WorldPreset::Flat {
|
||||
// setWorldType starts the generator from the flat preset.
|
||||
self.flat_settings = Some(super::flat::FlatSettings::default());
|
||||
}
|
||||
@@ -262,14 +261,7 @@ pub(super) fn buttons(w: f32, h: f32, creative: bool, o: &WorldOptions) -> Vec<B
|
||||
gx,
|
||||
gy,
|
||||
150.0,
|
||||
labelled(
|
||||
"selectWorld.mapType",
|
||||
tr(if o.flat {
|
||||
"generator.minecraft.flat"
|
||||
} else {
|
||||
"generator.minecraft.normal"
|
||||
}),
|
||||
),
|
||||
labelled("selectWorld.mapType", tr(o.world_type.label())),
|
||||
true,
|
||||
25,
|
||||
),
|
||||
@@ -279,7 +271,7 @@ pub(super) fn buttons(w: f32, h: f32, creative: bool, o: &WorldOptions) -> Vec<B
|
||||
gy,
|
||||
150.0,
|
||||
tr("selectWorld.customizeType"),
|
||||
o.flat,
|
||||
o.world_type == crate::save::WorldPreset::Flat,
|
||||
26,
|
||||
),
|
||||
// SwitchGrid: 44-wide value-only buttons at the grid's right.
|
||||
|
||||
@@ -851,27 +851,49 @@ fn create_world_tabs_follow_vanilla_and_their_choices_reach_the_action() {
|
||||
};
|
||||
assert!(*creative && !options.hardcore && options.commands(true));
|
||||
// The World tab (second of three 124-wide tabs from x 54): its seed box
|
||||
// takes the focus, Superflat and the switches toggle.
|
||||
// takes the focus, the world type cycles and the switches toggle.
|
||||
let (action, sound) = f.title.click(54.0 + 124.0 + 10.0, 10.0, size);
|
||||
assert!(sound && action == Action::None);
|
||||
let world = labels(&f);
|
||||
assert_eq!((world[0].1, world[0].2), (85.0, 38.0));
|
||||
assert_eq!((world[2].1, world[2].2), (85.0 + 266.0, 38.0 + 73.0));
|
||||
f.title.click(world[0].1 + 10.0, world[0].2 + 10.0, size);
|
||||
// The world-type button cycles through vanilla's world presets.
|
||||
let world_type = |f: &Fixture| -> crate::save::WorldPreset {
|
||||
let Mode::Create { options, .. } = &f.title.mode else {
|
||||
panic!()
|
||||
};
|
||||
options.world_type
|
||||
};
|
||||
assert_eq!(world_type(&f), crate::save::WorldPreset::Normal);
|
||||
for expected in [
|
||||
crate::save::WorldPreset::Flat,
|
||||
crate::save::WorldPreset::LargeBiomes,
|
||||
crate::save::WorldPreset::Amplified,
|
||||
crate::save::WorldPreset::SingleBiomeSurface,
|
||||
crate::save::WorldPreset::Normal,
|
||||
] {
|
||||
f.title.click(world[0].1 + 10.0, world[0].2 + 10.0, size);
|
||||
assert_eq!(world_type(&f), expected);
|
||||
}
|
||||
// The amplified preset is the third one.
|
||||
for _ in 0..3 {
|
||||
f.title.click(world[0].1 + 10.0, world[0].2 + 10.0, size);
|
||||
}
|
||||
f.title.click(world[3].1 + 10.0, world[3].2 + 10.0, size);
|
||||
f.title.click(100.0, 38.0 + 41.0 + 10.0, size);
|
||||
let Mode::Create { focus, options, .. } = &f.title.mode else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(*focus, 1);
|
||||
assert!(options.flat && options.bonus_chest());
|
||||
assert_eq!(options.world_type, crate::save::WorldPreset::Amplified);
|
||||
assert!(options.bonus_chest());
|
||||
let Action::Create { options, .. } = f.title.create_action() else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
options.world_gen(),
|
||||
crate::save::WorldGen {
|
||||
flat: true,
|
||||
preset: crate::save::WorldPreset::Amplified,
|
||||
structures: true,
|
||||
bonus_chest: true
|
||||
}
|
||||
@@ -906,26 +928,86 @@ fn new_world_generation_settings_round_trip() {
|
||||
let f = Fixture::new();
|
||||
let dir = f.root.join("flat");
|
||||
let options = crate::save::WorldGen {
|
||||
flat: true,
|
||||
preset: crate::save::WorldPreset::Flat,
|
||||
structures: false,
|
||||
bonus_chest: true,
|
||||
};
|
||||
crate::save::write_world_gen_settings(&dir, 42, options).unwrap();
|
||||
assert_eq!(crate::save::WorldGen::read(&dir), Some(options));
|
||||
let settings = crate::save::world_gen_settings(&dir).unwrap();
|
||||
let generator = settings
|
||||
.get("dimensions")
|
||||
.and_then(|d| d.get("minecraft:overworld"))
|
||||
.and_then(|o| o.get("generator"));
|
||||
assert_eq!(
|
||||
crate::save::text(
|
||||
settings
|
||||
.get("dimensions")
|
||||
.and_then(|d| d.get("minecraft:overworld"))
|
||||
.and_then(|o| o.get("generator"))
|
||||
.and_then(|g| g.get("type"))
|
||||
)
|
||||
.as_deref(),
|
||||
crate::save::text(generator.and_then(|g| g.get("type"))).as_deref(),
|
||||
Some("minecraft:flat")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_world_preset_writes_its_own_generator() {
|
||||
let f = Fixture::new();
|
||||
let generator = |preset: crate::save::WorldPreset| {
|
||||
let dir = f.root.join(preset.id());
|
||||
let options = crate::save::WorldGen {
|
||||
preset,
|
||||
structures: true,
|
||||
bonus_chest: false,
|
||||
};
|
||||
crate::save::write_world_gen_settings(&dir, 42, options).unwrap();
|
||||
assert_eq!(crate::save::WorldGen::read(&dir), Some(options));
|
||||
let settings = crate::save::world_gen_settings(&dir).unwrap();
|
||||
let generator = settings
|
||||
.get("dimensions")
|
||||
.and_then(|d| d.get("minecraft:overworld"))
|
||||
.and_then(|o| o.get("generator"))
|
||||
.cloned()
|
||||
.unwrap();
|
||||
(
|
||||
crate::save::text(generator.get("type")),
|
||||
crate::save::text(generator.get("settings")),
|
||||
crate::save::text(generator.get("biome_source").and_then(|s| s.get("type"))),
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
generator(crate::save::WorldPreset::Normal),
|
||||
(
|
||||
Some("minecraft:noise".to_owned()),
|
||||
Some("minecraft:overworld".to_owned()),
|
||||
Some("minecraft:multi_noise".to_owned()),
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
generator(crate::save::WorldPreset::Amplified),
|
||||
(
|
||||
Some("minecraft:noise".to_owned()),
|
||||
Some("minecraft:amplified".to_owned()),
|
||||
Some("minecraft:multi_noise".to_owned()),
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
generator(crate::save::WorldPreset::LargeBiomes),
|
||||
(
|
||||
Some("minecraft:noise".to_owned()),
|
||||
Some("minecraft:large_biomes".to_owned()),
|
||||
Some("minecraft:multi_noise".to_owned()),
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
generator(crate::save::WorldPreset::SingleBiomeSurface),
|
||||
(
|
||||
Some("minecraft:noise".to_owned()),
|
||||
Some("minecraft:overworld".to_owned()),
|
||||
Some("minecraft:fixed".to_owned()),
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
generator(crate::save::WorldPreset::Flat).0,
|
||||
Some("minecraft:flat".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_fields_edit_at_their_cursor() {
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! A `/forceload`ed chunk reaches vanilla's rings (#2785): vanilla holds a
|
||||
//! FORCED ticket at level 31 (`ChunkMap.FORCED_TICKET_LEVEL`), the level
|
||||
//! rises one per ring (`ChunkTracker.computeLevelFromNeighbor`) and picks the
|
||||
//! column's status (`ChunkLevel.generationStatus`). The chunk itself and the
|
||||
//! two rings around it are level chunks with their light, the third ring has
|
||||
//! its features and the fourth only its terrain.
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
chunk::Chunk,
|
||||
level_loading::Status,
|
||||
light::LightTables,
|
||||
mesher::MeshTables,
|
||||
models,
|
||||
streaming::{ChunkGenerator, Proto, Streaming, WorkerContext},
|
||||
world::World,
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
fn resources() -> Option<std::path::PathBuf> {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
root.join("items.json").exists().then_some(root)
|
||||
}
|
||||
|
||||
/// A column under generation, and whether it has its features.
|
||||
struct Column {
|
||||
x: i32,
|
||||
z: i32,
|
||||
decorated: bool,
|
||||
}
|
||||
|
||||
/// A generator that makes every column: a proto, decorated on request, and
|
||||
/// finished into a level chunk.
|
||||
struct Plain;
|
||||
|
||||
impl ChunkGenerator for Plain {
|
||||
type Worker = ();
|
||||
fn worker(&self) {}
|
||||
fn proto(&self, _: &mut (), x: i32, z: i32) -> Proto {
|
||||
Box::new(Column {
|
||||
x,
|
||||
z,
|
||||
decorated: false,
|
||||
})
|
||||
}
|
||||
fn decorates(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// The centre gets its features, as vanilla's FEATURES step does; the
|
||||
/// neighbours are only written to.
|
||||
fn decorate(&self, _: &mut (), region: &mut [Proto; 9]) {
|
||||
region[4].downcast_mut::<Column>().unwrap().decorated = true;
|
||||
}
|
||||
fn is_decorated(&self, proto: &Proto) -> bool {
|
||||
proto.downcast_ref::<Column>().is_some_and(|c| c.decorated)
|
||||
}
|
||||
fn finish(&self, proto: Proto) -> Chunk {
|
||||
let column = *proto.downcast::<Column>().unwrap();
|
||||
Chunk::empty(column.x, column.z)
|
||||
}
|
||||
fn light_view(&self, proto: &Proto) -> Option<Chunk> {
|
||||
let column = proto.downcast_ref::<Column>()?;
|
||||
Some(Chunk::empty(column.x, column.z))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_chunks_reach_vanillas_rings() {
|
||||
let Some(root) = resources() else {
|
||||
return;
|
||||
};
|
||||
let assets = Arc::new(Assets::load(&root).unwrap());
|
||||
let render = Arc::new(assets.reload(&root).unwrap());
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let visibility = models::load_visibility(&root, assets.registry).unwrap();
|
||||
let context = Arc::new(WorkerContext {
|
||||
tables: Arc::new(MeshTables::new(&assets, &render, &facts, visibility).unwrap()),
|
||||
light: Arc::new(LightTables::load(&root, assets.registry, &facts).unwrap()),
|
||||
biomes: Arc::new(Biomes::load(&root).unwrap()),
|
||||
assets,
|
||||
render: render.clone(),
|
||||
});
|
||||
let mut streaming = Streaming::new(Arc::new(Plain), context, 2);
|
||||
// The player stands far away: only the forced chunk's own rings are
|
||||
// generated.
|
||||
streaming.center = (0, 0);
|
||||
streaming.radius = 2;
|
||||
streaming.forced.insert((40, -40));
|
||||
let mut world = World::new(BlockStateId(0), 0);
|
||||
let deadline = Instant::now() + Duration::from_secs(120);
|
||||
let mut quiet = 0;
|
||||
while quiet < 20 {
|
||||
assert!(Instant::now() < deadline, "streaming never settled");
|
||||
let before = world.chunks.len();
|
||||
let _ = streaming.update(&mut world);
|
||||
thread::sleep(Duration::from_millis(5));
|
||||
let idle = !streaming.busy() && before == world.chunks.len();
|
||||
quiet = if idle { quiet + 1 } else { 0 };
|
||||
}
|
||||
// The chunk itself and the two rings around it are level chunks with
|
||||
// their light.
|
||||
for (dx, dz) in (-2..=2).flat_map(|dx| (-2..=2).map(move |dz| (dx, dz))) {
|
||||
let (x, z) = (40 + dx, -40 + dz);
|
||||
assert!(
|
||||
world.chunks.contains_key(&(x, z)),
|
||||
"({x}, {z}) is not a level chunk"
|
||||
);
|
||||
assert!(streaming.is_lit(x, z), "({x}, {z}) has no light");
|
||||
}
|
||||
// The third ring has its features, the fourth only its terrain.
|
||||
for (dx, dz) in (-4i32..=4).flat_map(|dx| (-4i32..=4).map(move |dz| (dx, dz))) {
|
||||
let (x, z) = (40 + dx, -40 + dz);
|
||||
let ring = dx.abs().max(dz.abs());
|
||||
let status = streaming.latest_status(&world, x, z);
|
||||
if ring == 3 {
|
||||
assert_eq!(status, Some(Status::Features), "({x}, {z})");
|
||||
} else if ring == 4 {
|
||||
assert_eq!(status, Some(Status::Terrain), "({x}, {z})");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -326,7 +326,7 @@ fn recipe_settings_packets_update_only_the_players_book_and_survive_save() {
|
||||
);
|
||||
}
|
||||
let mut saved = horizon_client::save::Level::default();
|
||||
horizon_client::lan::playerdata::store(&s.hub.guests[0], &mut saved);
|
||||
horizon_client::lan::playerdata::store(&s.hub.guests[0], &mut saved, None);
|
||||
let mut reopened = horizon_client::save::Level::default();
|
||||
reopened.read_player(&saved.player_tag());
|
||||
assert_eq!(reopened.recipe_book.open, settings.open);
|
||||
|
||||
@@ -106,6 +106,50 @@ impl BiomeZoom {
|
||||
}
|
||||
selected
|
||||
}
|
||||
/// The jitter `quart_at_block` adds to each of the eight corners of the
|
||||
/// quart cell `base` (the block position minus 2, shifted right by 2), as
|
||||
/// `[x, y, z]` per corner in its corner order (x, then y, then z offset).
|
||||
/// With `select_corner` this gives `quart_at_block`'s choice for every
|
||||
/// block of the cell from one mix.
|
||||
pub fn corner_jitter(self, base: [i32; 3]) -> [[f64; 3]; 8] {
|
||||
let coordinate = base.map(i64::from);
|
||||
let seed = self.seed;
|
||||
const A: i64 = 6364136223846793005;
|
||||
const B: i64 = 1442695040888963407;
|
||||
let g = |m: i64| m.wrapping_mul(m.wrapping_mul(A).wrapping_add(B));
|
||||
let root = g(seed);
|
||||
std::array::from_fn(|corner| {
|
||||
let [x, y, z] = [(corner >> 2) & 1, (corner >> 1) & 1, corner & 1].map(|v| v as i64);
|
||||
let m0 = root.wrapping_add(coordinate[0].wrapping_add(x));
|
||||
let m1 = g(m0).wrapping_add(coordinate[1].wrapping_add(y));
|
||||
let m2 = g(m1).wrapping_add(coordinate[2].wrapping_add(z));
|
||||
let m3 = g(m2).wrapping_add(coordinate[0].wrapping_add(x));
|
||||
let m4 = g(m3).wrapping_add(coordinate[1].wrapping_add(y));
|
||||
let m5 = g(m4).wrapping_add(coordinate[2].wrapping_add(z));
|
||||
let m6 = g(m5).wrapping_add(seed);
|
||||
let m7 = g(m6).wrapping_add(seed);
|
||||
[fiddle(m5), fiddle(m6), fiddle(m7)]
|
||||
})
|
||||
}
|
||||
/// The corner (index into `corner_jitter`) `quart_at_block` picks for
|
||||
/// `block` inside the cell whose jitter is `jitter`.
|
||||
#[inline]
|
||||
pub fn select_corner(block: BlockPos, jitter: &[[f64; 3]; 8]) -> usize {
|
||||
let fractions = [block.x, block.y, block.z].map(|v| f64::from(v.wrapping_sub(2) & 3) / 4.);
|
||||
let mut selected = 0;
|
||||
let mut best = f64::INFINITY;
|
||||
for (corner, jitter) in jitter.iter().enumerate() {
|
||||
let dx = fractions[0] - ((corner >> 2) & 1) as f64 + jitter[0];
|
||||
let dy = fractions[1] - ((corner >> 1) & 1) as f64 + jitter[1];
|
||||
let dz = fractions[2] - (corner & 1) as f64 + jitter[2];
|
||||
let distance = dz * dz + dy * dy + dx * dx;
|
||||
if best > distance {
|
||||
best = distance;
|
||||
selected = corner;
|
||||
}
|
||||
}
|
||||
selected
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct noise-biome lookup bypasses the eight-corner zoom.
|
||||
@@ -224,4 +268,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_jitter_matches_quart_at_block() {
|
||||
for seed in [0i64, -1, i64::MIN, 0x0123_4567_89ab_cdef] {
|
||||
let zoom = BiomeZoom::new(seed);
|
||||
let check = |block: BlockPos| {
|
||||
let base = [block.x, block.y, block.z].map(|v| v.wrapping_sub(2) >> 2);
|
||||
let corner = BiomeZoom::select_corner(block, &zoom.corner_jitter(base));
|
||||
let offsets = [(corner >> 2) & 1, (corner >> 1) & 1, corner & 1];
|
||||
let quart: [i32; 3] =
|
||||
std::array::from_fn(|i| base[i].wrapping_add(offsets[i] as i32));
|
||||
assert_eq!(
|
||||
quart,
|
||||
zoom.quart_at_block(block),
|
||||
"seed {seed} at {block:?}"
|
||||
);
|
||||
};
|
||||
for x in -20..20 {
|
||||
for y in -20..20 {
|
||||
for z in -20..20 {
|
||||
check(BlockPos::new(x, y, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
for v in [i32::MIN, i32::MIN + 1, -1, 0, i32::MAX - 1, i32::MAX] {
|
||||
check(BlockPos::new(v, v, v));
|
||||
check(BlockPos::new(v, v.wrapping_neg(), 3));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@ description = "Port a Java modpack to Horizon: import, inventory, classification
|
||||
# Content path rules only (no runtime): what the game's content loader accepts.
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# Exact float parsing: the upgrade writes back the numbers it read (data pack
|
||||
# upgrade), and Java reads them exactly.
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
sha1 = "0.10"
|
||||
toml = "1.1.6"
|
||||
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||
|
||||
@@ -21,5 +21,6 @@ pub mod oracle;
|
||||
pub mod pipeline;
|
||||
pub mod processors;
|
||||
pub mod report;
|
||||
pub mod upgrade;
|
||||
pub mod worldgen;
|
||||
mod yung;
|
||||
@@ -25,22 +25,27 @@
|
||||
//! `<out>/check.json`.
|
||||
//! - `port <pack> <work> [--from <folder>] [--resources <prepared>]`: every
|
||||
//! step for the portable mods, as the launcher's "Import modpack" runs it.
|
||||
//! - `upgrade-datapack <pack> <out> | --world <world>`: a 26.2 data pack
|
||||
//! (zip or folder) with its world generation rewritten in 26.3's formats
|
||||
//! (`docs/datapack-upgrade.md`), into the folder `<out>`, or into
|
||||
//! `<world>/datapacks/<pack name>-26.3`.
|
||||
//!
|
||||
//! The work folder belongs to the user: jars and carried-over files stay
|
||||
//! there and are never uploaded or committed.
|
||||
use horizon_port_pack::{fetch, inventory::Record, pipeline};
|
||||
use horizon_port_pack::{fetch, inventory::Record, pipeline, upgrade};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::ExitCode,
|
||||
};
|
||||
|
||||
const USAGE: &str = "usage: horizon-port-pack import <pack> <work> | inventory <work> [--offline] | fetch <work> [--from <folder>] [<mod id>...] | carry <work> [--resources <prepared>] [<mod id>...] | report <work> | check <jar> <out> | port <pack> <work> [--from <folder>] [--resources <prepared>]";
|
||||
const USAGE: &str = "usage: horizon-port-pack import <pack> <work> | inventory <work> [--offline] | fetch <work> [--from <folder>] [<mod id>...] | carry <work> [--resources <prepared>] [<mod id>...] | report <work> | check <jar> <out> | port <pack> <work> [--from <folder>] [--resources <prepared>] | upgrade-datapack <pack> (<out> | --world <world>)";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let result = (|| {
|
||||
let from = option(&mut args, "--from")?;
|
||||
let resources = option(&mut args, "--resources")?;
|
||||
let world = option(&mut args, "--world")?;
|
||||
let offline = take_flag(&mut args, "--offline");
|
||||
let path = |i: usize| args.get(i).map(Path::new).ok_or(USAGE.to_owned());
|
||||
let rest = || args.get(2..).unwrap_or_default().to_vec();
|
||||
@@ -89,6 +94,22 @@ fn main() -> ExitCode {
|
||||
println!("{}", work.join("coverage.md").display());
|
||||
Ok(())
|
||||
}
|
||||
Some("upgrade-datapack") => {
|
||||
let pack = path(1)?;
|
||||
let out = match &world {
|
||||
Some(world) => {
|
||||
let name = pack.file_stem().unwrap_or_default().to_string_lossy();
|
||||
world.join("datapacks").join(format!("{name}-26.3"))
|
||||
}
|
||||
None => path(2)?.to_path_buf(),
|
||||
};
|
||||
let report = upgrade::upgrade_into(pack, &out)?;
|
||||
for line in report.lines() {
|
||||
println!("{line}");
|
||||
}
|
||||
println!("{}", out.display());
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(USAGE.to_owned()),
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
//! Density functions.
|
||||
use super::{PackFile, Report, contains_type, full_id, kind, noise::noise_holder, vanilla};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// Vanilla 26.2 density functions whose value does not depend on y, which
|
||||
/// a pack can name without defining them.
|
||||
const VANILLA_FLAT: [&str; 13] = [
|
||||
"minecraft:zero",
|
||||
"minecraft:shift_x",
|
||||
"minecraft:shift_z",
|
||||
"minecraft:overworld/continents",
|
||||
"minecraft:overworld/erosion",
|
||||
"minecraft:overworld/ridges",
|
||||
"minecraft:overworld/ridges_folded",
|
||||
"minecraft:overworld/offset",
|
||||
"minecraft:overworld/factor",
|
||||
"minecraft:overworld/jaggedness",
|
||||
"minecraft:overworld_large_biomes/continents",
|
||||
"minecraft:overworld_large_biomes/erosion",
|
||||
"minecraft:end/sloped_cheese",
|
||||
];
|
||||
|
||||
pub(super) struct Context<'a> {
|
||||
/// The pack's own density functions by id, in 26.2's form.
|
||||
functions: BTreeMap<String, &'a Value>,
|
||||
/// The interpolation cell (`size_horizontal * 4`, `size_vertical * 4`)
|
||||
/// of the noise settings being upgraded, which 26.3 writes on each
|
||||
/// `interpolated`.
|
||||
pub(super) cell: (i64, i64),
|
||||
/// The cells of the pack's noise settings that reach each of its named
|
||||
/// density functions through their routers.
|
||||
cells: BTreeMap<String, BTreeSet<(i64, i64)>>,
|
||||
/// The cell of every noise settings in the pack.
|
||||
all_cells: BTreeSet<(i64, i64)>,
|
||||
/// Named density functions the upgrade adds, by id.
|
||||
pub(super) generated: BTreeMap<String, Value>,
|
||||
/// Material rules the upgrade adds (one per noise settings), by id.
|
||||
pub(super) rules: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
impl<'a> Context<'a> {
|
||||
pub(super) fn new(files: &'a BTreeMap<String, Value>) -> Self {
|
||||
let mut functions = BTreeMap::new();
|
||||
let mut settings = Vec::new();
|
||||
for (path, v) in files {
|
||||
let Some(f) = PackFile::parse(path) else {
|
||||
continue;
|
||||
};
|
||||
match f.registry.as_str() {
|
||||
"worldgen/density_function" => {
|
||||
functions.insert(f.id(), v);
|
||||
}
|
||||
"worldgen/noise_settings" => settings.push(v),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut ctx = Self {
|
||||
functions,
|
||||
cell: (4, 8),
|
||||
cells: BTreeMap::new(),
|
||||
all_cells: BTreeSet::new(),
|
||||
generated: BTreeMap::new(),
|
||||
rules: BTreeMap::new(),
|
||||
};
|
||||
for s in settings {
|
||||
let cell = cell_of(s.get("noise").unwrap_or(&Value::Null));
|
||||
ctx.all_cells.insert(cell);
|
||||
let mut reached = BTreeSet::new();
|
||||
ctx.reach(s.get("noise_router").unwrap_or(&Value::Null), &mut reached);
|
||||
for id in reached {
|
||||
ctx.cells.entry(id).or_default().insert(cell);
|
||||
}
|
||||
}
|
||||
ctx
|
||||
}
|
||||
|
||||
/// Every pack density function `v` names, directly or through others.
|
||||
fn reach(&self, v: &Value, out: &mut BTreeSet<String>) {
|
||||
match v {
|
||||
Value::String(id) => {
|
||||
let id = full_id(id);
|
||||
if let Some(f) = self.functions.get(&id)
|
||||
&& out.insert(id)
|
||||
{
|
||||
self.reach(f, out);
|
||||
}
|
||||
}
|
||||
Value::Object(o) => o.values().for_each(|x| self.reach(x, out)),
|
||||
Value::Array(a) => a.iter().for_each(|x| self.reach(x, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A named density function file in 26.3's form, with the cell of the
|
||||
/// noise settings that use it: vanilla's overworld cell when none of the
|
||||
/// pack's do, the pack's own when it has one.
|
||||
pub(super) fn named_function(&mut self, id: &str, v: &Value, report: &mut Report) -> Value {
|
||||
let cells = self.cells.get(id).cloned().unwrap_or_default();
|
||||
self.cell = match (cells.len(), self.all_cells.len()) {
|
||||
(1, _) => *cells.iter().next().unwrap_or(&(4, 8)),
|
||||
(0, 1) => *self.all_cells.iter().next().unwrap_or(&(4, 8)),
|
||||
(0, _) => (4, 8),
|
||||
_ => {
|
||||
if contains_type(v, "interpolated") {
|
||||
report.lossy("density: an interpolated function shared by noise settings with different cells");
|
||||
}
|
||||
*cells.iter().next().unwrap_or(&(4, 8))
|
||||
}
|
||||
};
|
||||
self.density(v, report)
|
||||
}
|
||||
|
||||
/// One density function (a number, an id, or an object) in 26.3's form.
|
||||
pub(super) fn density(&mut self, v: &Value, report: &mut Report) -> Value {
|
||||
let Value::Object(o) = v else {
|
||||
return v.clone();
|
||||
};
|
||||
let Some(t) = kind(v) else {
|
||||
return v.clone();
|
||||
};
|
||||
let arg = |name: &str| o.get(name).cloned().unwrap_or(Value::Null);
|
||||
let unary = |this: &mut Self, report: &mut Report, to: &str| {
|
||||
let input = this.density(&arg("argument"), report);
|
||||
json!({"type": format!("minecraft:{to}"), "input": input})
|
||||
};
|
||||
match t {
|
||||
"abs" | "square" | "cube" | "half_negative" | "quarter_negative" | "squeeze"
|
||||
| "blend_density" => unary(self, report, t),
|
||||
"invert" => unary(self, report, "reciprocal"),
|
||||
"add" | "mul" | "min" | "max" => {
|
||||
let left = self.density(&arg("argument1"), report);
|
||||
let right = self.density(&arg("argument2"), report);
|
||||
json!({"type": format!("minecraft:{t}"), "left": left, "right": right})
|
||||
}
|
||||
"interpolated" => {
|
||||
let input = self.density(&arg("argument"), report);
|
||||
json!({"type": "minecraft:interpolated", "cell_size_xz": self.cell.0,
|
||||
"cell_size_y": self.cell.1, "input": input})
|
||||
}
|
||||
// 26.2's flat cache holds the value at y = 0 of each column
|
||||
// inside the chunk; 26.3's cache only memoises.
|
||||
"flat_cache" => {
|
||||
let raw = arg("argument");
|
||||
let input = self.density(&raw, report);
|
||||
let input = if self.depends_on_y(&raw, &mut BTreeSet::new()) {
|
||||
json!({"type": "minecraft:slice", "axis": "y", "coordinate": 0, "input": input})
|
||||
} else {
|
||||
input
|
||||
};
|
||||
json!({"type": "minecraft:cache", "input": input})
|
||||
}
|
||||
"cache_2d" | "cache_once" | "cache_all_in_cell" => {
|
||||
let raw = arg("argument");
|
||||
if t == "cache_2d" && self.depends_on_y(&raw, &mut BTreeSet::new()) {
|
||||
report.lossy("density: cache_2d around a function of y");
|
||||
}
|
||||
let input = self.density(&raw, report);
|
||||
json!({"type": "minecraft:cache", "input": input})
|
||||
}
|
||||
"y_clamped_gradient" => {
|
||||
let (from, to) = (arg("from_y"), arg("to_y"));
|
||||
if from == to {
|
||||
// A step at that y: clampedMap's NaN at the step itself
|
||||
// has no 26.3 form.
|
||||
report.lossy("density: y_clamped_gradient with from_y == to_y");
|
||||
let y = from.as_f64().unwrap_or_default();
|
||||
return json!({"type": "minecraft:range_choice", "input": "minecraft:y",
|
||||
"min_inclusive": -1_000_000.0, "max_exclusive": y,
|
||||
"when_in_range": arg("from_value"), "when_out_of_range": arg("to_value")});
|
||||
}
|
||||
json!({"type": "minecraft:gradient", "axis": "y", "from_coordinate": from,
|
||||
"to_coordinate": to, "from_value": arg("from_value"),
|
||||
"to_value": arg("to_value")})
|
||||
}
|
||||
"noise" => {
|
||||
let mut n = o.clone();
|
||||
n.insert("noise".into(), noise_holder(&arg("noise"), report));
|
||||
Value::Object(n)
|
||||
}
|
||||
"shifted_noise" => {
|
||||
let mut n = Map::new();
|
||||
n.insert("type".into(), vanilla("noise"));
|
||||
n.insert("noise".into(), noise_holder(&arg("noise"), report));
|
||||
n.insert("xz_scale".into(), arg("xz_scale"));
|
||||
n.insert("y_scale".into(), arg("y_scale"));
|
||||
n.insert("shift_x".into(), self.density(&arg("shift_x"), report));
|
||||
let shift_y = arg("shift_y");
|
||||
if !shift_y.as_f64().is_some_and(|y| y == 0.0) {
|
||||
n.insert("shift_y".into(), self.density(&shift_y, report));
|
||||
}
|
||||
n.insert("shift_z".into(), self.density(&arg("shift_z"), report));
|
||||
Value::Object(n)
|
||||
}
|
||||
"shift_a" | "shift_b" | "shift" => {
|
||||
json!({"type": format!("minecraft:{t}"), "noise": noise_holder(&arg("argument"), report)})
|
||||
}
|
||||
"end_islands" => json!({"type": "minecraft:end_outer_islands"}),
|
||||
"constant" => arg("argument"),
|
||||
"clamp" | "range_choice" | "find_top_surface" | "interval_select" => {
|
||||
let mut n = o.clone();
|
||||
for key in [
|
||||
"input",
|
||||
"when_in_range",
|
||||
"when_out_of_range",
|
||||
"density",
|
||||
"upper_bound",
|
||||
] {
|
||||
if let Some(x) = o.get(key) {
|
||||
n.insert(key.into(), self.density(x, report));
|
||||
}
|
||||
}
|
||||
if let Some(Value::Array(fs)) = o.get("functions") {
|
||||
let fs = fs.iter().map(|f| self.density(f, report)).collect();
|
||||
n.insert("functions".into(), Value::Array(fs));
|
||||
}
|
||||
Value::Object(n)
|
||||
}
|
||||
"spline" => {
|
||||
let spline = self.spline(&arg("spline"), report);
|
||||
json!({"type": "minecraft:spline", "spline": spline})
|
||||
}
|
||||
"old_blended_noise" | "blend_alpha" | "blend_offset" | "beardifier" => v.clone(),
|
||||
other => {
|
||||
report.unknown(format!("density function {other}"));
|
||||
v.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A cubic spline: a number, or a coordinate and points whose values are
|
||||
/// splines themselves.
|
||||
fn spline(&mut self, v: &Value, report: &mut Report) -> Value {
|
||||
let Value::Object(o) = v else {
|
||||
return v.clone();
|
||||
};
|
||||
let mut n = o.clone();
|
||||
if let Some(c) = o.get("coordinate") {
|
||||
n.insert("coordinate".into(), self.density(c, report));
|
||||
}
|
||||
if let Some(Value::Array(points)) = o.get("points") {
|
||||
let points = points
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let mut p = p.clone();
|
||||
if let Some(value) = p.get("value").cloned() {
|
||||
p["value"] = self.spline(&value, report);
|
||||
}
|
||||
p
|
||||
})
|
||||
.collect();
|
||||
n.insert("points".into(), Value::Array(points));
|
||||
}
|
||||
Value::Object(n)
|
||||
}
|
||||
|
||||
/// Whether a 26.2 density function's value can change with y, through
|
||||
/// the pack's own functions; unknown ids count as depending on y.
|
||||
fn depends_on_y(&self, v: &Value, visiting: &mut BTreeSet<String>) -> bool {
|
||||
match v {
|
||||
Value::Number(_) => false,
|
||||
Value::String(id) => {
|
||||
let id = if id.contains(':') {
|
||||
id.clone()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
};
|
||||
if id == "minecraft:y" {
|
||||
return true;
|
||||
}
|
||||
if let Some(f) = self.functions.get(&id) {
|
||||
return visiting.insert(id.clone()) && {
|
||||
let r = self.depends_on_y(f, visiting);
|
||||
visiting.remove(&id);
|
||||
r
|
||||
};
|
||||
}
|
||||
!VANILLA_FLAT.contains(&id.as_str())
|
||||
}
|
||||
Value::Object(o) => match kind(v) {
|
||||
Some("y_clamped_gradient" | "old_blended_noise" | "beardifier" | "shift") => true,
|
||||
Some("flat_cache" | "end_islands" | "find_top_surface" | "constant") => false,
|
||||
Some("blend_alpha" | "blend_offset" | "shift_a" | "shift_b") => false,
|
||||
Some("noise") => o.get("y_scale").and_then(Value::as_f64) != Some(0.0),
|
||||
Some("shifted_noise") => {
|
||||
o.get("y_scale").and_then(Value::as_f64) != Some(0.0)
|
||||
|| ["shift_x", "shift_y", "shift_z"]
|
||||
.iter()
|
||||
.any(|k| o.get(*k).is_some_and(|x| self.depends_on_y(x, visiting)))
|
||||
}
|
||||
Some("spline") => self.spline_depends_on_y(&o["spline"], visiting),
|
||||
_ => o
|
||||
.iter()
|
||||
.filter(|(k, _)| *k != "type")
|
||||
.any(|(_, x)| match x {
|
||||
Value::Array(a) => a.iter().any(|x| self.depends_on_y(x, visiting)),
|
||||
x => self.depends_on_y(x, visiting),
|
||||
}),
|
||||
},
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn spline_depends_on_y(&self, v: &Value, visiting: &mut BTreeSet<String>) -> bool {
|
||||
let Value::Object(o) = v else {
|
||||
return false;
|
||||
};
|
||||
o.get("coordinate")
|
||||
.is_some_and(|c| self.depends_on_y(c, visiting))
|
||||
|| o.get("points").and_then(Value::as_array).is_some_and(|ps| {
|
||||
ps.iter().any(|p| {
|
||||
p.get("value")
|
||||
.is_some_and(|x| self.spline_depends_on_y(x, visiting))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The interpolation cell of 26.2 `noise` settings.
|
||||
pub(super) fn cell_of(noise: &Value) -> (i64, i64) {
|
||||
let size = |k: &str, d: i64| noise.get(k).and_then(Value::as_i64).unwrap_or(d) * 4;
|
||||
(size("size_horizontal", 1), size("size_vertical", 2))
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Configured and placed features.
|
||||
use super::{Report, kind, states, vanilla};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
/// Feature types 26.3 dropped for compositions of other features; a pack
|
||||
/// using them needs vanilla's composition, which the upgrade does not
|
||||
/// write.
|
||||
const REPLACED_FEATURES: [&str; 12] = [
|
||||
"basalt_columns",
|
||||
"basalt_pillar",
|
||||
"coral_mushroom",
|
||||
"desert_well",
|
||||
"glowstone_blob",
|
||||
"kelp",
|
||||
"nether_forest_vegetation",
|
||||
"sea_pickle",
|
||||
"seagrass",
|
||||
"twisting_vines",
|
||||
"weeping_vines",
|
||||
"random_patch",
|
||||
];
|
||||
|
||||
/// 26.2 block state provider types and their 26.3 names.
|
||||
fn provider(t: &str) -> Option<&'static str> {
|
||||
Some(match t {
|
||||
"simple_state_provider" => "simple_state_provider",
|
||||
"weighted_state_provider" => "weighted",
|
||||
"rotated_block_provider" => "rotated",
|
||||
"randomized_int_state_provider" => "randomized_int",
|
||||
"rule_based_state_provider" => "rule_based",
|
||||
"noise_provider" => "noise",
|
||||
"dual_noise_provider" => "dual_noise",
|
||||
"noise_threshold_provider" => "noise_threshold",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// One placement modifier: `random_offset` became `offset` with one
|
||||
/// provider per axis (x and z both from `xz_spread`, drawn in that order).
|
||||
fn placement(m: &mut Value) {
|
||||
if kind(m) == Some("random_offset") {
|
||||
let xz = m.get("xz_spread").cloned().unwrap_or(json!(0));
|
||||
let y = m.get("y_spread").cloned().unwrap_or(json!(0));
|
||||
*m = json!({"type": "minecraft:offset", "x": xz.clone(), "y": y, "z": xz});
|
||||
}
|
||||
}
|
||||
|
||||
/// A configured feature in 26.3's form: `config` unwrapped, block state
|
||||
/// providers renamed, nested features and placements upgraded.
|
||||
pub(super) fn feature(v: &Value, report: &mut Report) -> Value {
|
||||
let Value::Object(o) = v else {
|
||||
return v.clone();
|
||||
};
|
||||
let Some(t) = kind(v) else {
|
||||
return v.clone();
|
||||
};
|
||||
if REPLACED_FEATURES.contains(&t) {
|
||||
report.unknown(format!("feature {t} (written as data in 26.3)"));
|
||||
}
|
||||
let mut n = Map::new();
|
||||
n.insert("type".into(), o["type"].clone());
|
||||
if let Some(Value::Object(config)) = o.get("config") {
|
||||
for (k, x) in config {
|
||||
n.insert(k.clone(), x.clone());
|
||||
}
|
||||
}
|
||||
let mut v = Value::Object(n);
|
||||
states(&mut v);
|
||||
nested(&mut v, report);
|
||||
v
|
||||
}
|
||||
|
||||
/// Inside a feature: block state providers, placed features and
|
||||
/// configured features held inline.
|
||||
fn nested(v: &mut Value, report: &mut Report) {
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
if let Some(t) = kind(&Value::Object(o.clone())) {
|
||||
if let Some(to) = provider(t) {
|
||||
if t == "simple_state_provider" {
|
||||
*v = o.get("state").cloned().unwrap_or_default();
|
||||
return;
|
||||
}
|
||||
o.insert("type".into(), vanilla(to));
|
||||
}
|
||||
}
|
||||
// An inline placed feature: {feature, placement}.
|
||||
if o.contains_key("placement") && o.contains_key("feature") {
|
||||
let placed = placed(&Value::Object(o.clone()), report);
|
||||
*v = placed;
|
||||
return;
|
||||
}
|
||||
for x in o.values_mut() {
|
||||
if x.get("config").is_some() && x.get("type").is_some() {
|
||||
*x = feature(x, report);
|
||||
} else {
|
||||
nested(x, report);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(|x| nested(x, report)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A placed feature: its feature (an id or inline) and its placement
|
||||
/// modifiers.
|
||||
pub(super) fn placed(v: &Value, report: &mut Report) -> Value {
|
||||
let mut v = v.clone();
|
||||
states(&mut v);
|
||||
if let Some(f) = v.get("feature").cloned()
|
||||
&& f.is_object()
|
||||
{
|
||||
v["feature"] = feature(&f, report);
|
||||
}
|
||||
if let Some(Value::Array(ms)) = v.get_mut("placement") {
|
||||
for m in ms.iter_mut() {
|
||||
placement(m);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
//! A 26.2 data pack's world generation, rewritten in 26.3's formats.
|
||||
//!
|
||||
//! 26.3 reworked the worldgen registries: configured carvers and features
|
||||
//! became `carver` and `feature` with their `config` unwrapped, noise
|
||||
//! parameters gained a base amplitude, density functions name their inputs
|
||||
//! `input`/`left`/`right`, the noise settings moved aquifers, ore veins and
|
||||
//! the surface rule out of the router into an `aquifers` object and a
|
||||
//! `material_rule`, and biomes carry their mob spawns as attributes. Each
|
||||
//! conversion here is the one vanilla's own data went through between 26.2
|
||||
//! and 26.3, written so that the 26.3 pack generates what the 26.2 pack did:
|
||||
//! the hard-coded parts of 26.2 (the beardifier added to the final density,
|
||||
//! the deep dark aquifer exclusion, the ore veinifier, the interpolation
|
||||
//! cell from `size_horizontal`/`size_vertical`) are spelled out as data.
|
||||
//! Whatever has no faithful 26.3 form is kept as it is and listed in the
|
||||
//! report.
|
||||
mod density;
|
||||
mod features;
|
||||
mod noise;
|
||||
mod registries;
|
||||
mod settings;
|
||||
|
||||
use density::Context;
|
||||
use noise::noise;
|
||||
pub use noise::parity_base_amplitude;
|
||||
use registries::{biome, carver, dimension_type, structure};
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::BTreeMap, fs, io::Read, path::Path};
|
||||
|
||||
/// 26.3's data pack format (`version.json`'s `pack_version.data_major`).
|
||||
pub const PACK_FORMAT: u64 = 121;
|
||||
|
||||
/// What the upgrade did and what it could not do faithfully.
|
||||
#[derive(Default, Debug)]
|
||||
pub struct Report {
|
||||
/// Files rewritten, per registry.
|
||||
pub upgraded: BTreeMap<String, usize>,
|
||||
/// Files written that the 26.2 pack did not have (named density
|
||||
/// functions the noise settings share).
|
||||
pub added: Vec<String>,
|
||||
/// Parts that changed meaning or were dropped, with how often.
|
||||
pub lossy: BTreeMap<String, usize>,
|
||||
/// Types the upgrade does not know, left as they are.
|
||||
pub unknown: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
fn lossy(&mut self, what: impl Into<String>) {
|
||||
*self.lossy.entry(what.into()).or_default() += 1;
|
||||
}
|
||||
fn unknown(&mut self, what: impl Into<String>) {
|
||||
*self.unknown.entry(what.into()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Every file of a pack, by its path inside the pack (`data/...`,
|
||||
/// `pack.mcmeta`), upgraded. Files outside world generation are kept as
|
||||
/// they are.
|
||||
pub fn upgrade_pack(files: &BTreeMap<String, Vec<u8>>) -> (BTreeMap<String, Vec<u8>>, Report) {
|
||||
let mut report = Report::default();
|
||||
let mut json: BTreeMap<String, Value> = BTreeMap::new();
|
||||
let mut out: BTreeMap<String, Vec<u8>> = BTreeMap::new();
|
||||
for (path, bytes) in files {
|
||||
let parsed = path
|
||||
.ends_with(".json")
|
||||
.then(|| serde_json::from_slice::<Value>(bytes).ok())
|
||||
.flatten();
|
||||
match parsed {
|
||||
Some(v) if path.starts_with("data/") => {
|
||||
json.insert(path.clone(), v);
|
||||
}
|
||||
_ => {
|
||||
out.insert(path.clone(), bytes.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ctx = Context::new(&json);
|
||||
let mut written: BTreeMap<String, Value> = BTreeMap::new();
|
||||
for (path, v) in &json {
|
||||
let Some(file) = PackFile::parse(path) else {
|
||||
written.insert(path.clone(), v.clone());
|
||||
continue;
|
||||
};
|
||||
let upgraded = match file.registry.as_str() {
|
||||
"worldgen/noise" => Some(noise(v, &mut report)),
|
||||
"worldgen/density_function" => Some(ctx.named_function(&file.id(), v, &mut report)),
|
||||
"worldgen/noise_settings" => Some(ctx.settings(&file.id(), v, &mut report)),
|
||||
"worldgen/configured_carver" => Some(carver(v, &mut report)),
|
||||
"worldgen/configured_feature" => Some(features::feature(v, &mut report)),
|
||||
"worldgen/placed_feature" => Some(features::placed(v, &mut report)),
|
||||
"worldgen/biome" => Some(biome(v, &mut report)),
|
||||
"worldgen/structure" => Some(structure(v)),
|
||||
"worldgen/world_preset" => Some(ctx.world_preset(v, &mut report)),
|
||||
"dimension" => Some(ctx.dimension(v, &mut report)),
|
||||
"dimension_type" => Some(dimension_type(v)),
|
||||
"worldgen/template_pool" | "worldgen/processor_list" => {
|
||||
let mut v = v.clone();
|
||||
states(&mut v);
|
||||
Some(v)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let path = match file.registry.as_str() {
|
||||
"worldgen/configured_carver" => file.with_registry("worldgen/carver"),
|
||||
"worldgen/configured_feature" => file.with_registry("worldgen/feature"),
|
||||
_ => path.clone(),
|
||||
};
|
||||
match upgraded {
|
||||
Some(u) => {
|
||||
*report.upgraded.entry(file.registry.clone()).or_default() += 1;
|
||||
written.insert(path, u);
|
||||
}
|
||||
None => {
|
||||
written.insert(path, v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, v) in std::mem::take(&mut ctx.generated) {
|
||||
let path = registry_path("worldgen/density_function", &id);
|
||||
report.added.push(path.clone());
|
||||
written.insert(path, v);
|
||||
}
|
||||
for (id, v) in std::mem::take(&mut ctx.rules) {
|
||||
let path = registry_path("worldgen/material_rule", &id);
|
||||
report.added.push(path.clone());
|
||||
written.insert(path, v);
|
||||
}
|
||||
for (path, v) in written {
|
||||
out.insert(path, pretty(&v));
|
||||
}
|
||||
if let Some(meta) = out
|
||||
.get("pack.mcmeta")
|
||||
.and_then(|b| serde_json::from_slice(b).ok())
|
||||
{
|
||||
out.insert("pack.mcmeta".into(), pretty(&mcmeta(meta)));
|
||||
}
|
||||
(out, report)
|
||||
}
|
||||
|
||||
fn pretty(v: &Value) -> Vec<u8> {
|
||||
let mut bytes = serde_json::to_vec_pretty(v).unwrap_or_default();
|
||||
bytes.push(b'\n');
|
||||
bytes
|
||||
}
|
||||
|
||||
/// `pack.mcmeta` for 26.3: the pack format range is 26.3's alone.
|
||||
fn mcmeta(mut meta: Value) -> Value {
|
||||
if let Some(pack) = meta.get_mut("pack").and_then(Value::as_object_mut) {
|
||||
pack.remove("supported_formats");
|
||||
pack.insert("pack_format".into(), json!(PACK_FORMAT));
|
||||
pack.insert("min_format".into(), json!(PACK_FORMAT));
|
||||
pack.insert("max_format".into(), json!(PACK_FORMAT));
|
||||
if let Some(Value::String(d)) = pack.get_mut("description") {
|
||||
d.push_str(" (upgraded to 26.3)");
|
||||
}
|
||||
}
|
||||
meta
|
||||
}
|
||||
|
||||
/// `data/<ns>/<registry...>/<path>.json`, split by the registries the
|
||||
/// upgrade knows (a registry name has one or two segments).
|
||||
struct PackFile {
|
||||
namespace: String,
|
||||
registry: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
impl PackFile {
|
||||
fn parse(path: &str) -> Option<Self> {
|
||||
let rest = path.strip_prefix("data/")?.strip_suffix(".json")?;
|
||||
let (namespace, rest) = rest.split_once('/')?;
|
||||
let (registry, path) = if let Some(r) = rest.strip_prefix("worldgen/") {
|
||||
let (kind, path) = r.split_once('/')?;
|
||||
(format!("worldgen/{kind}"), path)
|
||||
} else {
|
||||
rest.split_once('/').map(|(k, p)| (k.to_owned(), p))?
|
||||
};
|
||||
Some(Self {
|
||||
namespace: namespace.into(),
|
||||
registry,
|
||||
path: path.into(),
|
||||
})
|
||||
}
|
||||
fn id(&self) -> String {
|
||||
format!("{}:{}", self.namespace, self.path)
|
||||
}
|
||||
fn with_registry(&self, registry: &str) -> String {
|
||||
format!("data/{}/{registry}/{}.json", self.namespace, self.path)
|
||||
}
|
||||
}
|
||||
|
||||
fn registry_path(registry: &str, id: &str) -> String {
|
||||
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
format!("data/{ns}/{registry}/{path}.json")
|
||||
}
|
||||
|
||||
/// A type name without its `minecraft:` prefix, as the codecs read both.
|
||||
fn kind(v: &Value) -> Option<&str> {
|
||||
let t = v.get("type")?.as_str()?;
|
||||
Some(t.strip_prefix("minecraft:").unwrap_or(t))
|
||||
}
|
||||
|
||||
/// An id with its namespace (`minecraft:` when it has none).
|
||||
fn full_id(id: &str) -> String {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an object of type `t` appears anywhere in `v`.
|
||||
fn contains_type(v: &Value, t: &str) -> bool {
|
||||
match v {
|
||||
Value::Object(o) => kind(v) == Some(t) || o.values().any(|x| contains_type(x, t)),
|
||||
Value::Array(a) => a.iter().any(|x| contains_type(x, t)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn vanilla(name: &str) -> Value {
|
||||
Value::String(format!("minecraft:{name}"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Block states
|
||||
|
||||
/// 26.2 writes a block or fluid state as `{"Name": ..., "Properties":
|
||||
/// {...}}`; 26.3 reads `{"id": ..., "properties": {...}}` (or the id alone
|
||||
/// for the default state).
|
||||
fn states(v: &mut Value) {
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
let state = o.get("Name").is_some_and(Value::is_string)
|
||||
&& o.keys().all(|k| k == "Name" || k == "Properties");
|
||||
if state {
|
||||
let name = o.remove("Name").unwrap_or_default();
|
||||
match o.remove("Properties") {
|
||||
Some(Value::Object(p)) if !p.is_empty() => {
|
||||
o.insert("id".into(), name);
|
||||
o.insert("properties".into(), Value::Object(p));
|
||||
}
|
||||
_ => *v = name,
|
||||
}
|
||||
return;
|
||||
}
|
||||
o.values_mut().for_each(states);
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(states),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Files
|
||||
|
||||
/// A pack's files by their path inside it, from a zip or a folder. A zip
|
||||
/// whose `pack.mcmeta` sits in one top folder is read from that folder.
|
||||
pub fn read_pack(path: &Path) -> Result<BTreeMap<String, Vec<u8>>, String> {
|
||||
let at = |e: &dyn std::fmt::Display| format!("{}: {e}", path.display());
|
||||
let mut files = BTreeMap::new();
|
||||
if path.is_dir() {
|
||||
let mut stack = vec![path.to_path_buf()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in fs::read_dir(&dir).map_err(|e| at(&e))? {
|
||||
let p = entry.map_err(|e| at(&e))?.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
} else if let Ok(rel) = p.strip_prefix(path) {
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
files.insert(rel, fs::read(&p).map_err(|e| at(&e))?);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let file = fs::File::open(path).map_err(|e| at(&e))?;
|
||||
let mut zip = zip::ZipArchive::new(file).map_err(|e| at(&e))?;
|
||||
for i in 0..zip.len() {
|
||||
let mut entry = zip.by_index(i).map_err(|e| at(&e))?;
|
||||
if entry.is_dir() {
|
||||
continue;
|
||||
}
|
||||
// A pack from the internet may name entries `../x` or `/x`.
|
||||
let name = entry
|
||||
.enclosed_name()
|
||||
.filter(|name| is_inside(name))
|
||||
.ok_or_else(|| at(&format!("entry {:?} leaves the pack", entry.name())))?;
|
||||
let mut bytes = Vec::new();
|
||||
entry.read_to_end(&mut bytes).map_err(|e| at(&e))?;
|
||||
files.insert(name.to_string_lossy().replace('\\', "/"), bytes);
|
||||
}
|
||||
}
|
||||
if !files.contains_key("pack.mcmeta") {
|
||||
let top = files
|
||||
.keys()
|
||||
.find_map(|k| k.strip_suffix("/pack.mcmeta").filter(|t| !t.contains('/')))
|
||||
.map(|t| format!("{t}/"));
|
||||
if let Some(top) = top {
|
||||
files = files
|
||||
.into_iter()
|
||||
.filter_map(|(k, v)| k.strip_prefix(&top).map(|k| (k.to_owned(), v)))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
if !files.contains_key("pack.mcmeta") {
|
||||
return Err(format!("{}: no pack.mcmeta", path.display()));
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Upgrades the pack at `input` into the folder `out`, which must not
|
||||
/// exist yet (a world's `datapacks/<name>`).
|
||||
pub fn upgrade_into(input: &Path, out: &Path) -> Result<Report, String> {
|
||||
if out.exists() {
|
||||
return Err(format!("{} already exists", out.display()));
|
||||
}
|
||||
let files = read_pack(input)?;
|
||||
let (upgraded, report) = upgrade_pack(&files);
|
||||
for (path, bytes) in upgraded {
|
||||
if !is_inside(Path::new(&path)) {
|
||||
return Err(format!("{path}: leaves {}", out.display()));
|
||||
}
|
||||
let target = out.join(&path);
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
}
|
||||
fs::write(&target, bytes).map_err(|e| format!("{}: {e}", target.display()))?;
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Whether `path` is a plain relative path: no root, drive, `.` or `..`.
|
||||
fn is_inside(path: &Path) -> bool {
|
||||
path.components().next().is_some()
|
||||
&& path
|
||||
.components()
|
||||
.all(|c| matches!(c, std::path::Component::Normal(_)))
|
||||
}
|
||||
|
||||
impl Report {
|
||||
/// The report as lines for the terminal.
|
||||
pub fn lines(&self) -> Vec<String> {
|
||||
let mut lines: Vec<String> = self
|
||||
.upgraded
|
||||
.iter()
|
||||
.map(|(r, n)| format!("upgraded {n} {r}"))
|
||||
.collect();
|
||||
if !self.added.is_empty() {
|
||||
lines.push(format!(
|
||||
"added {} shared density functions",
|
||||
self.added.len()
|
||||
));
|
||||
}
|
||||
lines.extend(
|
||||
self.lossy
|
||||
.iter()
|
||||
.map(|(w, n)| format!("not faithful ({n}x): {w}")),
|
||||
);
|
||||
lines.extend(
|
||||
self.unknown
|
||||
.iter()
|
||||
.map(|(w, n)| format!("left as it is ({n}x): {w}")),
|
||||
);
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn up(path: &str, v: Value) -> (Value, Report) {
|
||||
let files = BTreeMap::from([(path.to_owned(), serde_json::to_vec(&v).unwrap())]);
|
||||
let (out, report) = upgrade_pack(&files);
|
||||
let (_, bytes) = out
|
||||
.into_iter()
|
||||
.find(|(p, _)| !p.contains("/upgraded/"))
|
||||
.unwrap();
|
||||
(serde_json::from_slice(&bytes).unwrap(), report)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_keeps_the_octaves_and_finds_the_parity_amplitude() {
|
||||
let (v, _) = up(
|
||||
"data/x/worldgen/noise/n.json",
|
||||
json!({"firstOctave": -7, "amplitudes": [1.0, 2.0, 1.0, 0.0, 0.0, 0.0]}),
|
||||
);
|
||||
// Vanilla 26.3's minecraft:ridge, which 26.2 wrote as above.
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({"base_amplitude": 0.9147152149950137, "base_octave": -7, "octave_count": 6,
|
||||
"amplitude_modifiers": [1.0, 2.0, 1.0, 0.0, 0.0, 0.0]})
|
||||
);
|
||||
let (v, _) = up(
|
||||
"data/x/worldgen/noise/n.json",
|
||||
json!({"firstOctave": -3, "amplitudes": [1.0]}),
|
||||
);
|
||||
// Vanilla 26.3's minecraft:aquifer_barrier.
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({"base_amplitude": 0.955388882960065, "base_octave": -3})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn density_arguments_and_removed_types() {
|
||||
let (v, _) = up(
|
||||
"data/x/worldgen/density_function/f.json",
|
||||
json!({"type": "minecraft:add", "argument1": {"type": "minecraft:constant", "argument": 2.0},
|
||||
"argument2": {"type": "minecraft:abs", "argument": {"type": "minecraft:y_clamped_gradient",
|
||||
"from_y": -64, "to_y": 320, "from_value": 1.5, "to_value": -1.5}}}),
|
||||
);
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({"type": "minecraft:add", "left": 2.0, "right": {"type": "minecraft:abs",
|
||||
"input": {"type": "minecraft:gradient", "axis": "y", "from_coordinate": -64,
|
||||
"to_coordinate": 320, "from_value": 1.5, "to_value": -1.5}}})
|
||||
);
|
||||
let (v, _) = up(
|
||||
"data/x/worldgen/density_function/f.json",
|
||||
json!({"type": "minecraft:shifted_noise", "noise": "minecraft:erosion",
|
||||
"shift_x": "minecraft:shift_x", "shift_y": 0.0, "shift_z": "minecraft:shift_z",
|
||||
"xz_scale": 0.25, "y_scale": 0.0}),
|
||||
);
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({"type": "minecraft:noise", "noise": "minecraft:erosion", "shift_x": "minecraft:shift_x",
|
||||
"shift_z": "minecraft:shift_z", "xz_scale": 0.25, "y_scale": 0.0})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_cache_slices_at_y_zero_only_what_depends_on_y() {
|
||||
let flat = |argument: Value| {
|
||||
up(
|
||||
"data/x/worldgen/density_function/f.json",
|
||||
json!({"type": "minecraft:flat_cache", "argument": argument}),
|
||||
)
|
||||
.0
|
||||
};
|
||||
let n = json!({"type": "minecraft:noise", "noise": "x:n", "xz_scale": 1.0, "y_scale": 0.0});
|
||||
assert_eq!(
|
||||
flat(n.clone()),
|
||||
json!({"type": "minecraft:cache", "input": n})
|
||||
);
|
||||
let y = json!({"type": "minecraft:mul", "argument1": "minecraft:y", "argument2": 2.0});
|
||||
assert_eq!(
|
||||
flat(y),
|
||||
json!({"type": "minecraft:cache", "input": {"type": "minecraft:slice", "axis": "y",
|
||||
"coordinate": 0, "input": {"type": "minecraft:mul", "left": "minecraft:y", "right": 2.0}}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolation_cell_comes_from_the_settings() {
|
||||
let settings = json!({"noise": {"min_y": 0, "height": 128, "size_horizontal": 2, "size_vertical": 1},
|
||||
"noise_router": {}, "surface_rule": {"type": "minecraft:sequence", "sequence": []},
|
||||
"default_block": {"Name": "minecraft:stone"}, "default_fluid": {"Name": "minecraft:water",
|
||||
"Properties": {"level": "0"}}, "aquifers_enabled": false, "ore_veins_enabled": false,
|
||||
"sea_level": 32, "disable_mob_generation": false, "legacy_random_source": false,
|
||||
"spawn_target": []});
|
||||
let files = BTreeMap::from([
|
||||
(
|
||||
"data/x/worldgen/noise_settings/s.json".to_owned(),
|
||||
serde_json::to_vec(&settings).unwrap(),
|
||||
),
|
||||
(
|
||||
"data/x/worldgen/density_function/f.json".to_owned(),
|
||||
serde_json::to_vec(&json!({"type": "minecraft:interpolated", "argument": 1.0}))
|
||||
.unwrap(),
|
||||
),
|
||||
]);
|
||||
let (out, _) = upgrade_pack(&files);
|
||||
let f: Value =
|
||||
serde_json::from_slice(&out["data/x/worldgen/density_function/f.json"]).unwrap();
|
||||
assert_eq!(
|
||||
f,
|
||||
json!({"type": "minecraft:interpolated", "cell_size_xz": 8, "cell_size_y": 4, "input": 1.0})
|
||||
);
|
||||
let s: Value =
|
||||
serde_json::from_slice(&out["data/x/worldgen/noise_settings/s.json"]).unwrap();
|
||||
assert_eq!(s["noise"], json!({"min_y": 0, "height": 128}));
|
||||
assert_eq!(
|
||||
s["default_fluid"],
|
||||
json!({"id": "minecraft:water", "properties": {"level": "0"}})
|
||||
);
|
||||
assert_eq!(s["default_block"], json!("minecraft:stone"));
|
||||
assert!(s.get("aquifers").is_none());
|
||||
assert_eq!(
|
||||
s["noise_router"]["final_density"]["right"],
|
||||
json!({"type": "minecraft:beardifier"})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_move_aquifers_veins_and_spawn_target() {
|
||||
let settings = json!({"noise": {"min_y": -64, "height": 384, "size_horizontal": 1, "size_vertical": 2},
|
||||
"noise_router": {"barrier": 1.0, "fluid_level_floodedness": 2.0, "fluid_level_spread": 3.0,
|
||||
"lava": 4.0, "temperature": "x:t", "vegetation": "x:v", "continents": "x:c",
|
||||
"erosion": "x:e", "depth": "x:d", "ridges": "x:r", "final_density": "x:f",
|
||||
"preliminary_surface_level": {"type": "minecraft:constant", "argument": 64.0},
|
||||
"vein_toggle": 0.5, "vein_ridged": -1.0, "vein_gap": 0.0},
|
||||
"surface_rule": {"type": "minecraft:block", "result_state": {"Name": "minecraft:dirt"}},
|
||||
"default_block": {"Name": "minecraft:stone"}, "default_fluid": {"Name": "minecraft:water"},
|
||||
"aquifers_enabled": true, "ore_veins_enabled": true, "sea_level": 63,
|
||||
"disable_mob_generation": false, "legacy_random_source": false,
|
||||
"spawn_target": [{"temperature": [-1, 1], "humidity": [-1, 1], "continentalness": [-0.11, 1],
|
||||
"erosion": [-1, 1], "weirdness": [-1, -0.16], "depth": 0, "offset": 0}]});
|
||||
let files = BTreeMap::from([(
|
||||
"data/x/worldgen/noise_settings/s.json".to_owned(),
|
||||
serde_json::to_vec(&settings).unwrap(),
|
||||
)]);
|
||||
let (out, _) = upgrade_pack(&files);
|
||||
let read = |p: &str| -> Value { serde_json::from_slice(&out[p]).unwrap() };
|
||||
let s = read("data/x/worldgen/noise_settings/s.json");
|
||||
assert_eq!(s["material_rule"], json!("x:s/upgraded/material_rule"));
|
||||
let rule = read("data/x/worldgen/material_rule/s/upgraded/material_rule.json");
|
||||
let aquifers = &s["aquifers"];
|
||||
assert_eq!(aquifers["barrier"], json!(1.0));
|
||||
assert_eq!(
|
||||
aquifers["surface_level"],
|
||||
json!("x:s/upgraded/preliminary_surface_level")
|
||||
);
|
||||
assert_eq!(aquifers["exclusion"]["left"]["right"], json!("x:e"));
|
||||
let rules = rule["sequence"].as_array().unwrap();
|
||||
assert_eq!(rules.len(), 3);
|
||||
assert_eq!(rules[0]["type"], json!("minecraft:ore_vein"));
|
||||
assert_eq!(
|
||||
rules[2],
|
||||
json!({"type": "minecraft:block", "result_state": "minecraft:dirt"})
|
||||
);
|
||||
assert_eq!(
|
||||
s["spawn_target"],
|
||||
json!([{"x:t": [-1, 1], "x:v": [-1, 1], "x:c": [-0.11, 1], "x:e": [-1, 1], "x:r": [-1, -0.16]}])
|
||||
);
|
||||
assert_eq!(
|
||||
s["noise_router"]["chunk_surface_level"],
|
||||
json!({"type": "minecraft:interpolated", "cell_size_xz": 16, "cell_size_y": 1,
|
||||
"input": "x:s/upgraded/preliminary_surface_level"})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn features_lose_their_config_and_placements_their_spread() {
|
||||
let (v, _) = up(
|
||||
"data/x/worldgen/configured_feature/f.json",
|
||||
json!({"type": "minecraft:disk", "config": {"state_provider": {"type":
|
||||
"minecraft:simple_state_provider", "state": {"Name": "minecraft:clay"}}, "radius": 2}}),
|
||||
);
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({"type": "minecraft:disk", "state_provider": "minecraft:clay", "radius": 2})
|
||||
);
|
||||
let (v, _) = up(
|
||||
"data/x/worldgen/placed_feature/p.json",
|
||||
json!({"feature": "x:f", "placement": [{"type": "minecraft:random_offset",
|
||||
"xz_spread": 0, "y_spread": -1}]}),
|
||||
);
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({"feature": "x:f", "placement": [{"type": "minecraft:offset", "x": 0, "y": -1, "z": 0}]})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn biome_spawns_become_attributes() {
|
||||
let (v, _) = up(
|
||||
"data/x/worldgen/biome/b.json",
|
||||
json!({"spawners": {"monster": [{"type": "minecraft:zombie", "minCount": 4, "maxCount": 4,
|
||||
"weight": 95}], "ambient": []}, "spawn_costs": {}, "creature_spawn_probability": 0.07,
|
||||
"attributes": {"minecraft:visual/sky_color": "#78a7ff"}}),
|
||||
);
|
||||
assert_eq!(
|
||||
v["attributes"]["minecraft:gameplay/natural_mob_spawns"],
|
||||
json!({"modifier": "overlay", "argument": {"spawn_costs": {}, "spawns_by_category": {
|
||||
"monster": [{"type": "minecraft:zombie", "count": 4, "weight": 95}], "ambient": []}}})
|
||||
);
|
||||
assert_eq!(
|
||||
v["attributes"]["minecraft:gameplay/creature_world_gen_spawn_probability"],
|
||||
json!(0.07)
|
||||
);
|
||||
assert!(v.get("spawners").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_outside_the_pack_are_refused() {
|
||||
use std::io::Write;
|
||||
let dir = std::env::temp_dir().join(format!("upgrade-escape-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
for (i, name) in ["../escape.txt", "data/../../escape.txt", "/escape.txt"]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let zip_path = dir.join(format!("p{i}.zip"));
|
||||
let mut zip = zip::ZipWriter::new(fs::File::create(&zip_path).unwrap());
|
||||
for (file, bytes) in [("pack.mcmeta", &b"{}"[..]), (name, b"x")] {
|
||||
zip.start_file(file, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(bytes).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
let out = dir.join(format!("out{i}/datapacks/p-26.3"));
|
||||
let err = upgrade_into(&zip_path, &out).unwrap_err();
|
||||
assert!(err.contains("leaves the pack"), "{name}: {err}");
|
||||
assert!(!out.exists(), "{name}");
|
||||
}
|
||||
assert!(!dir.join("escape.txt").exists());
|
||||
assert!(is_inside(Path::new("data/x/worldgen/biome/b.json")));
|
||||
assert!(!is_inside(Path::new("data/../x.json")));
|
||||
assert!(!is_inside(Path::new("")));
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn carvers_move_and_spell_out_their_hard_coded_shape() {
|
||||
let files = BTreeMap::from([(
|
||||
"data/x/worldgen/configured_carver/c.json".to_owned(),
|
||||
serde_json::to_vec(&json!({"type": "minecraft:canyon", "config": {"probability": 0.01,
|
||||
"yScale": 3.0, "lava_level": {"above_bottom": 8}, "shape": {"width_smoothness": 3}}}))
|
||||
.unwrap(),
|
||||
)]);
|
||||
let (out, _) = upgrade_pack(&files);
|
||||
let v: Value = serde_json::from_slice(&out["data/x/worldgen/carver/c.json"]).unwrap();
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({"type": "minecraft:canyon", "probability": 0.01,
|
||||
"shape": {"width_smoothness": 3, "y_scale": 3.0}})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Noise parameters: 26.2's `{firstOctave, amplitudes}` as 26.3's `NormalNoise` parameters.
|
||||
use super::Report;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
/// `{firstOctave, amplitudes}` to 26.3's `NormalNoise` parameters, as
|
||||
/// vanilla's `NormalNoise.createParity` builds them: the same octaves and
|
||||
/// the base amplitude that keeps 26.2's normalisation.
|
||||
pub(super) fn noise(v: &Value, report: &mut Report) -> Value {
|
||||
let (Some(first), Some(amps)) = (
|
||||
v.get("firstOctave").and_then(Value::as_i64),
|
||||
v.get("amplitudes").and_then(Value::as_array),
|
||||
) else {
|
||||
return v.clone();
|
||||
};
|
||||
let amps: Vec<f64> = amps.iter().filter_map(Value::as_f64).collect();
|
||||
if amps.is_empty() || amps.len() > 32 || amps.iter().any(|a| *a < 0.0) {
|
||||
report.lossy("noise: more than 32 octaves or negative amplitudes (left as they are)");
|
||||
return v.clone();
|
||||
}
|
||||
let mut o = Map::new();
|
||||
let base = parity_base_amplitude(first as i32, &s);
|
||||
if base != 1.0 {
|
||||
o.insert("base_amplitude".into(), json!(base));
|
||||
}
|
||||
o.insert("base_octave".into(), json!(first));
|
||||
if amps.len() != 1 {
|
||||
o.insert("octave_count".into(), json!(amps.len()));
|
||||
}
|
||||
if amps.iter().any(|a| *a != 1.0) {
|
||||
o.insert("amplitude_modifiers".into(), json!(amps));
|
||||
}
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
/// `NormalNoise.computeParityBaseAmplitude`.
|
||||
pub fn parity_base_amplitude(base_octave: i32, amps: &[f64]) -> f64 {
|
||||
let octaves = build_octaves(base_octave, 1.0, amps.len(), amps);
|
||||
let target: f64 = java_sum(octaves.iter().map(|a| a.abs()));
|
||||
let new = normalization_factor(target, &octaves);
|
||||
if new == 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
parity_normalization_factor(1.0, amps) / new
|
||||
}
|
||||
|
||||
/// `NormalNoise.buildOctaves` with normalisation on: the octaves' signed
|
||||
/// amplitudes (zero-modifier octaves left out).
|
||||
fn build_octaves(_base_octave: i32, base_amplitude: f64, count: usize, mods: &[f64]) -> Vec<f64> {
|
||||
let n = count as i32;
|
||||
let mut amplitude = base_amplitude * (0.5f64.powi(-(n - 1)) / (0.5f64.powi(-n) - 1.0));
|
||||
let mut out = Vec::new();
|
||||
for i in 0..count {
|
||||
let m = mods.get(i).copied().unwrap_or(1.0);
|
||||
if m != 0.0 {
|
||||
out.push(amplitude * m);
|
||||
}
|
||||
amplitude *= 0.5;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn normalization_factor(target: f64, octaves: &[f64]) -> f64 {
|
||||
let mut variance = 0.0;
|
||||
for a in octaves {
|
||||
let layer = 0.2702247831245211 * a.abs();
|
||||
variance += layer * layer;
|
||||
}
|
||||
let deviation = variance.sqrt();
|
||||
if deviation == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
target * 0.3333333333333333 / (deviation * 2f64.sqrt())
|
||||
}
|
||||
|
||||
fn parity_normalization_factor(base_amplitude: f64, mods: &[f64]) -> f64 {
|
||||
let (mut min, mut max) = (i32::MAX, i32::MIN);
|
||||
for (i, m) in mods.iter().enumerate() {
|
||||
if *m != 0.0 {
|
||||
min = min.min(i as i32);
|
||||
max = max.max(i as i32);
|
||||
}
|
||||
}
|
||||
let span = max.wrapping_sub(min);
|
||||
base_amplitude * 0.5 * 0.3333333333333333 / (0.1 * (1.0 + 1.0 / f64::from(span + 1)))
|
||||
}
|
||||
|
||||
/// `DoubleStream.sum()`: Kahan summation as `Collectors.sumWithCompensation`
|
||||
/// and `computeFinalSum` do it.
|
||||
fn java_sum(values: impl Iterator<Item = f64>) -> f64 {
|
||||
let (mut sum, mut c, mut simple) = (0.0f64, 0.0f64, 0.0f64);
|
||||
for v in values {
|
||||
simple += v;
|
||||
let tmp = v - c;
|
||||
let velvel = sum + tmp;
|
||||
c = (velvel - sum) - tmp;
|
||||
sum = velvel;
|
||||
}
|
||||
let tmp = sum - c;
|
||||
if tmp.is_nan() && simple.is_infinite() {
|
||||
simple
|
||||
} else {
|
||||
tmp
|
||||
}
|
||||
}
|
||||
|
||||
/// A noise holder: an id, or inline parameters to upgrade.
|
||||
pub(super) fn noise_holder(v: &Value, report: &mut Report) -> Value {
|
||||
match v {
|
||||
Value::Object(_) => noise(v, report),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Carvers, biomes, structures and dimension types.
|
||||
use super::{Report, kind, states, vanilla};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
/// A configured carver in 26.3's form. 26.2's cave carvers hard-coded their
|
||||
/// count, thickness and the room's vertical scale per type; 26.3 reads them.
|
||||
pub(super) fn carver(v: &Value, report: &mut Report) -> Value {
|
||||
let Some(t) = kind(v) else {
|
||||
return v.clone();
|
||||
};
|
||||
let mut c = v.get("config").cloned().unwrap_or_default();
|
||||
let Some(o) = c.as_object_mut() else {
|
||||
return v.clone();
|
||||
};
|
||||
o.remove("debug_settings");
|
||||
let lava = o.remove("lava_level");
|
||||
let replaceable = o.remove("replaceable");
|
||||
if lava.is_some_and(|l| l != json!({"above_bottom": 8}) && l != json!({"above_bottom": 10})) {
|
||||
report.lossy("carver: lava_level (26.3 takes it from the aquifer)");
|
||||
}
|
||||
if replaceable.is_some_and(|r| {
|
||||
r != json!("#minecraft:overworld_carver_replaceables")
|
||||
&& r != json!("#minecraft:nether_carver_replaceables")
|
||||
}) {
|
||||
report.lossy("carver: replaceable (26.3 carves any stone)");
|
||||
}
|
||||
let y_scale = o.remove("yScale");
|
||||
let mut n = Map::new();
|
||||
match t {
|
||||
"cave" | "nether_cave" => {
|
||||
let nether = t == "nether_cave";
|
||||
n.insert("type".into(), vanilla("cave"));
|
||||
n.insert(
|
||||
"count".into(),
|
||||
json!({"type": "minecraft:very_biased_to_bottom", "min_inclusive": 0,
|
||||
"max_inclusive": if nether { 9 } else { 14 }}),
|
||||
);
|
||||
n.insert(
|
||||
"thickness".into(),
|
||||
if nether {
|
||||
json!({"type": "minecraft:trapezoid", "min": 0.0, "max": 6.0, "plateau": 2.0})
|
||||
} else {
|
||||
json!({"type": "minecraft:trapezoid", "min": 0.0, "max": 3.0, "plateau": 1.0})
|
||||
},
|
||||
);
|
||||
if !nether {
|
||||
n.insert("weird_thickness_bias".into(), json!(true));
|
||||
} else {
|
||||
n.insert("start_vertical_radius_multiplier".into(), json!(5.0));
|
||||
}
|
||||
if let Some(s) = y_scale {
|
||||
n.insert("room_vertical_radius_multiplier".into(), s);
|
||||
}
|
||||
}
|
||||
"canyon" => {
|
||||
n.insert("type".into(), vanilla("canyon"));
|
||||
if let (Some(s), Some(shape)) =
|
||||
(y_scale, o.get_mut("shape").and_then(Value::as_object_mut))
|
||||
{
|
||||
shape.insert("y_scale".into(), s);
|
||||
}
|
||||
}
|
||||
other => {
|
||||
report.unknown(format!("carver {other}"));
|
||||
n.insert("type".into(), v["type"].clone());
|
||||
}
|
||||
}
|
||||
for (k, x) in o.iter() {
|
||||
n.insert(k.clone(), x.clone());
|
||||
}
|
||||
let mut v = Value::Object(n);
|
||||
states(&mut v);
|
||||
v
|
||||
}
|
||||
|
||||
/// `{type, minCount, maxCount, weight}` to `{type, count, weight}`.
|
||||
fn spawner(s: &Value) -> Value {
|
||||
let mut s = s.clone();
|
||||
if let Some(o) = s.as_object_mut() {
|
||||
let min = o.remove("minCount");
|
||||
let max = o.remove("maxCount");
|
||||
if let (Some(min), Some(max)) = (min, max) {
|
||||
let count = if min == max {
|
||||
min
|
||||
} else {
|
||||
json!({"type": "minecraft:uniform", "min_inclusive": min, "max_inclusive": max})
|
||||
};
|
||||
o.insert("count".into(), count);
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// A biome in 26.3's form: mob spawns become the
|
||||
/// `minecraft:gameplay/natural_mob_spawns` attribute (every category kept,
|
||||
/// empty ones too, so nothing comes from the dimension's defaults), and the
|
||||
/// creature spawn probability its own attribute.
|
||||
pub(super) fn biome(v: &Value, report: &mut Report) -> Value {
|
||||
let mut v = v.clone();
|
||||
states(&mut v);
|
||||
let Some(o) = v.as_object_mut() else {
|
||||
return v;
|
||||
};
|
||||
let spawners = o.remove("spawners");
|
||||
let costs = o.remove("spawn_costs");
|
||||
let probability = o.remove("creature_spawn_probability");
|
||||
let attributes = o
|
||||
.entry("attributes")
|
||||
.or_insert_with(|| json!({}))
|
||||
.as_object_mut();
|
||||
let Some(attributes) = attributes else {
|
||||
report.lossy("biome: attributes is not an object");
|
||||
return v;
|
||||
};
|
||||
if let Some(Value::Object(categories)) = spawners {
|
||||
let by_category: Map<String, Value> = categories
|
||||
.iter()
|
||||
.map(|(c, list)| {
|
||||
let list = list.as_array().into_iter().flatten().map(spawner).collect();
|
||||
(c.clone(), Value::Array(list))
|
||||
})
|
||||
.collect();
|
||||
attributes.insert(
|
||||
"minecraft:gameplay/natural_mob_spawns".into(),
|
||||
json!({"modifier": "overlay", "argument": {
|
||||
"spawn_costs": costs.unwrap_or(json!({})),
|
||||
"spawns_by_category": by_category}}),
|
||||
);
|
||||
}
|
||||
if let Some(p) = probability
|
||||
&& p.as_f64().is_some_and(|p| (p as f32) != 0.1)
|
||||
{
|
||||
attributes.insert(
|
||||
"minecraft:gameplay/creature_world_gen_spawn_probability".into(),
|
||||
p,
|
||||
);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// A dimension type: the bed rule's `explodes` is 26.3's `destroy_on_use`.
|
||||
pub(super) fn dimension_type(v: &Value) -> Value {
|
||||
let mut v = v.clone();
|
||||
if let Some(rule) = v
|
||||
.pointer_mut("/attributes/minecraft:gameplay~1bed_rule")
|
||||
.and_then(Value::as_object_mut)
|
||||
&& let Some(explodes) = rule.remove("explodes")
|
||||
{
|
||||
rule.insert("destroy_on_use".into(), explodes);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// A structure: its spawn overrides' counts in 26.3's form.
|
||||
pub(super) fn structure(v: &Value) -> Value {
|
||||
let mut v = v.clone();
|
||||
states(&mut v);
|
||||
if let Some(Value::Object(overrides)) = v.get_mut("spawn_overrides") {
|
||||
for o in overrides.values_mut() {
|
||||
if let Some(Value::Array(spawns)) = o.get_mut("spawns") {
|
||||
for s in spawns.iter_mut() {
|
||||
*s = spawner(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! Noise settings, dimensions and world presets.
|
||||
use super::{
|
||||
Report,
|
||||
density::{Context, cell_of},
|
||||
kind, states, vanilla,
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
impl Context<'_> {
|
||||
/// A density function the settings use in several places, as an id: its
|
||||
/// own id when it is one, otherwise a new named function under
|
||||
/// `<settings>/upgraded/<name>`.
|
||||
pub(super) fn named(&mut self, settings: &str, name: &str, v: Value) -> Value {
|
||||
if v.is_string() {
|
||||
return v;
|
||||
}
|
||||
let (ns, path) = settings.split_once(':').unwrap_or(("minecraft", settings));
|
||||
let id = format!("{ns}:{path}/upgraded/{name}");
|
||||
self.generated.insert(id.clone(), v);
|
||||
Value::String(id)
|
||||
}
|
||||
|
||||
/// 26.2 noise settings in 26.3's form.
|
||||
pub(super) fn settings(&mut self, id: &str, v: &Value, report: &mut Report) -> Value {
|
||||
let Value::Object(o) = v else {
|
||||
return v.clone();
|
||||
};
|
||||
if o.contains_key("material_rule") {
|
||||
return v.clone();
|
||||
}
|
||||
let router = o.get("noise_router").cloned().unwrap_or_default();
|
||||
self.cell = cell_of(o.get("noise").unwrap_or(&Value::Null));
|
||||
let conv = |this: &mut Self, report: &mut Report, name: &str| {
|
||||
this.density(&router.get(name).cloned().unwrap_or(json!(0.0)), report)
|
||||
};
|
||||
// The climate functions are named: the spawn target and the aquifer
|
||||
// exclusion refer to them by id.
|
||||
let climate = |this: &mut Self, report: &mut Report, name: &str| {
|
||||
let f = conv(this, report, name);
|
||||
this.named(id, name, f)
|
||||
};
|
||||
let temperature = climate(self, report, "temperature");
|
||||
let vegetation = climate(self, report, "vegetation");
|
||||
let continents = climate(self, report, "continents");
|
||||
let erosion = climate(self, report, "erosion");
|
||||
let depth = climate(self, report, "depth");
|
||||
let ridges = climate(self, report, "ridges");
|
||||
let final_density = conv(self, report, "final_density");
|
||||
let surface = conv(self, report, "preliminary_surface_level");
|
||||
let surface = self.named(id, "preliminary_surface_level", surface);
|
||||
let mut n = Map::new();
|
||||
let mut noise = o.get("noise").cloned().unwrap_or_default();
|
||||
if let Some(noise) = noise.as_object_mut() {
|
||||
noise.remove("size_horizontal");
|
||||
noise.remove("size_vertical");
|
||||
}
|
||||
n.insert("noise".into(), noise);
|
||||
for key in ["default_block", "default_fluid"] {
|
||||
let mut s = o.get(key).cloned().unwrap_or_default();
|
||||
states(&mut s);
|
||||
n.insert(key.into(), s);
|
||||
}
|
||||
let aquifers = o
|
||||
.get("aquifers_enabled")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
if aquifers {
|
||||
let barrier = conv(self, report, "barrier");
|
||||
let floodedness = conv(self, report, "fluid_level_floodedness");
|
||||
let spread = conv(self, report, "fluid_level_spread");
|
||||
let lava = conv(self, report, "lava");
|
||||
// 26.2's OverworldBiomeBuilder.isDeepDarkRegion: no aquifer
|
||||
// where erosion < -0.225 and depth > 0.9 (positive excludes).
|
||||
let exclusion = json!({"type": "minecraft:min",
|
||||
"left": {"type": "minecraft:sub", "left": -0.225, "right": erosion.clone()},
|
||||
"right": {"type": "minecraft:max",
|
||||
"left": {"type": "minecraft:sub", "left": depth.clone(), "right": 0.9},
|
||||
"right": 0.0}});
|
||||
n.insert(
|
||||
"aquifers".into(),
|
||||
json!({"barrier": barrier, "exclusion": exclusion,
|
||||
"fluid_level_floodedness": floodedness, "fluid_level_spread": spread,
|
||||
"lava": lava, "surface_level": surface.clone()}),
|
||||
);
|
||||
}
|
||||
let mut rule = o.get("surface_rule").cloned().unwrap_or(json!({
|
||||
"type": "minecraft:sequence", "sequence": []}));
|
||||
states(&mut rule);
|
||||
let veins = o
|
||||
.get("ore_veins_enabled")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let rule = if veins {
|
||||
let toggle = conv(self, report, "vein_toggle");
|
||||
let toggle = self.named(
|
||||
id,
|
||||
"vein_toggle",
|
||||
json!({"type": "minecraft:cache", "input": toggle}),
|
||||
);
|
||||
let ridged = conv(self, report, "vein_ridged");
|
||||
let ridged = self.named(id, "vein_ridged", ridged);
|
||||
let gap = conv(self, report, "vein_gap");
|
||||
let gap = self.named(id, "vein_gap", gap);
|
||||
// Ore veins come first: 26.2 placed them while filling the
|
||||
// noise, and its surface pass only replaced the default block.
|
||||
let mut sequence = ore_veins(&toggle, &ridged, &gap);
|
||||
sequence.push(rule);
|
||||
json!({"type": "minecraft:sequence", "sequence": sequence})
|
||||
} else {
|
||||
rule
|
||||
};
|
||||
n.insert(
|
||||
"noise_router".into(),
|
||||
json!({"temperature": temperature, "vegetation": vegetation,
|
||||
"continents": continents, "erosion": erosion,
|
||||
"depth": depth, "ridges": ridges,
|
||||
"chunk_surface_level": {"type": "minecraft:interpolated", "cell_size_xz": 16,
|
||||
"cell_size_y": 1, "input": surface},
|
||||
"final_density": {"type": "minecraft:add", "left": final_density,
|
||||
"right": {"type": "minecraft:beardifier"}}}),
|
||||
);
|
||||
// Named, as vanilla names its own (and as Horizon reads them), under
|
||||
// the settings' id so other vanilla settings keep their rule.
|
||||
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
let rule_id = format!("{ns}:{path}/upgraded/material_rule");
|
||||
self.rules.insert(rule_id.clone(), rule);
|
||||
n.insert("material_rule".into(), Value::String(rule_id));
|
||||
let climate = [
|
||||
("temperature", n["noise_router"]["temperature"].clone()),
|
||||
("humidity", n["noise_router"]["vegetation"].clone()),
|
||||
("continentalness", n["noise_router"]["continents"].clone()),
|
||||
("erosion", n["noise_router"]["erosion"].clone()),
|
||||
("weirdness", n["noise_router"]["ridges"].clone()),
|
||||
];
|
||||
let spawn: Vec<Value> = o
|
||||
.get("spawn_target")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|p| {
|
||||
for key in ["depth", "offset"] {
|
||||
if p.get(key).is_some_and(|d| d.as_f64() != Some(0.0)) {
|
||||
report.lossy(format!("noise settings: spawn target {key} other than 0"));
|
||||
}
|
||||
}
|
||||
let mut point = Map::new();
|
||||
for (param, f) in &climate {
|
||||
if let (Some(range), Some(f)) = (p.get(*param), f.as_str()) {
|
||||
point.insert(f.into(), range.clone());
|
||||
}
|
||||
}
|
||||
Value::Object(point)
|
||||
})
|
||||
.collect();
|
||||
n.insert("spawn_target".into(), Value::Array(spawn));
|
||||
for key in [
|
||||
"sea_level",
|
||||
"disable_mob_generation",
|
||||
"legacy_random_source",
|
||||
] {
|
||||
if let Some(x) = o.get(key) {
|
||||
n.insert(key.into(), x.clone());
|
||||
}
|
||||
}
|
||||
Value::Object(n)
|
||||
}
|
||||
|
||||
/// A dimension (`dimension/<id>.json` or a world preset's entry): an
|
||||
/// inline noise settings object in its generator is upgraded.
|
||||
pub(super) fn dimension(&mut self, v: &Value, report: &mut Report) -> Value {
|
||||
let mut v = v.clone();
|
||||
let noise = v.get("generator").and_then(kind) == Some("noise");
|
||||
if let Some(settings) = v.pointer("/generator/settings").cloned()
|
||||
&& settings.is_object()
|
||||
&& noise
|
||||
{
|
||||
v["generator"]["settings"] = self.settings("minecraft:inline", &settings, report);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
pub(super) fn world_preset(&mut self, v: &Value, report: &mut Report) -> Value {
|
||||
let mut v = v.clone();
|
||||
if let Some(dims) = v.get_mut("dimensions").and_then(Value::as_object_mut) {
|
||||
for d in dims.values_mut() {
|
||||
*d = self.dimension(d, report);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// 26.2's `OreVeinifier` as 26.3 material rules, one per vein type, from
|
||||
/// the pack's `vein_toggle`, `vein_ridged` and `vein_gap`: a block between
|
||||
/// the type's heights where the toggle (copper above zero, iron below)
|
||||
/// clears 0.4 less the edge roundoff, and the ridged noise is negative, is
|
||||
/// the filler with chance 0.7, and ore where the richness draw and the gap
|
||||
/// noise allow.
|
||||
fn ore_veins(toggle: &Value, ridged: &Value, gap: &Value) -> Vec<Value> {
|
||||
let types = [
|
||||
("copper_ore", "raw_copper_block", "granite", 0, 50, false),
|
||||
(
|
||||
"deepslate_iron_ore",
|
||||
"raw_iron_block",
|
||||
"tuff",
|
||||
-60,
|
||||
-8,
|
||||
true,
|
||||
),
|
||||
];
|
||||
types
|
||||
.iter()
|
||||
.map(|(ore, raw, filler, min_y, max_y, negate)| {
|
||||
let veininess = if *negate {
|
||||
json!({"type": "minecraft:negate", "input": toggle})
|
||||
} else {
|
||||
toggle.clone()
|
||||
};
|
||||
// clampedMap(min(maxY - y, y - minY), 0, 20, -0.2, 0)
|
||||
let edge = json!({"type": "minecraft:add",
|
||||
"left": {"type": "minecraft:mul",
|
||||
"left": {"type": "minecraft:clamp",
|
||||
"input": {"type": "minecraft:min",
|
||||
"left": {"type": "minecraft:sub", "left": f64::from(*max_y), "right": "minecraft:y"},
|
||||
"right": {"type": "minecraft:sub", "left": "minecraft:y", "right": f64::from(*min_y)}},
|
||||
"min": 0.0, "max": 20.0},
|
||||
"right": 0.01},
|
||||
"right": -0.2});
|
||||
let density = json!({"type": "minecraft:range_choice", "input": "minecraft:y",
|
||||
"min_inclusive": f64::from(*min_y), "max_exclusive": f64::from(max_y + 1),
|
||||
"when_in_range": {"type": "minecraft:range_choice", "input": ridged,
|
||||
"min_inclusive": -1_000_000.0, "max_exclusive": 0.0,
|
||||
"when_in_range": {"type": "minecraft:range_choice",
|
||||
"input": {"type": "minecraft:add",
|
||||
"left": {"type": "minecraft:sub", "left": veininess, "right": 0.4},
|
||||
"right": edge},
|
||||
"min_inclusive": 0.0, "max_exclusive": 1_000_000.0,
|
||||
"when_in_range": 0.7, "when_out_of_range": -1.0},
|
||||
"when_out_of_range": -1.0},
|
||||
"when_out_of_range": -1.0});
|
||||
// clampedMap(|toggle|, 0.4, 0.6, 0.1, 0.3)
|
||||
let richness = json!({"type": "minecraft:add",
|
||||
"left": {"type": "minecraft:clamp", "input": {"type": "minecraft:abs", "input": toggle},
|
||||
"min": 0.4, "max": 0.6},
|
||||
"right": -0.3});
|
||||
json!({"type": "minecraft:ore_vein", "ore_block": vanilla(ore),
|
||||
"raw_ore_block": vanilla(raw), "filler_block": vanilla(filler),
|
||||
"raw_ore_chance": 0.02, "density": density, "richness": richness,
|
||||
"filler_gap": {"type": "minecraft:sub", "left": -0.3, "right": gap}})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -3,8 +3,9 @@
|
||||
//! `item::Slot` and prints, per stack, whether every byte was consumed, the
|
||||
//! patch's component ids (added, then removed), which of them Horizon
|
||||
//! decoded into its own lossy types (the rest it reads past keeping their
|
||||
//! bytes, or decodes into ids it writes back unchanged, like trims and instruments) and
|
||||
//! whether encoding the decoded stack gives the same bytes back.
|
||||
//! bytes, or decodes into ids it writes back unchanged, like trims, instruments
|
||||
//! and stew effects) and whether encoding the decoded stack gives the same
|
||||
//! bytes back.
|
||||
//!
|
||||
//! cargo run -p horizon-protocol --example item_wire_probe -- vanilla.jsonl rust.jsonl
|
||||
use horizon_protocol::{
|
||||
@@ -49,7 +50,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.filter(|(_, c)| {
|
||||
!matches!(
|
||||
c,
|
||||
Component::Raw(_) | Component::Trim { .. } | Component::Instrument(_)
|
||||
Component::Raw(_)
|
||||
| Component::Trim { .. }
|
||||
| Component::Instrument(_)
|
||||
| Component::StewEffects(_)
|
||||
)
|
||||
})
|
||||
.map(|(k, _)| *k)
|
||||
|
||||
@@ -40,6 +40,7 @@ pub mod component {
|
||||
pub const CHARGED_PROJECTILES: i32 = 51;
|
||||
pub const BUNDLE_CONTENTS: i32 = 52;
|
||||
pub const POTION_CONTENTS: i32 = 53;
|
||||
pub const SUSPICIOUS_STEW_EFFECTS: i32 = 55;
|
||||
pub const WRITABLE_BOOK_CONTENT: i32 = 56;
|
||||
pub const WRITTEN_BOOK_CONTENT: i32 = 57;
|
||||
pub const TRIM: i32 = 58;
|
||||
@@ -119,6 +120,9 @@ pub enum Component {
|
||||
material: i32,
|
||||
pattern: i32,
|
||||
},
|
||||
/// `SuspiciousStewEffects`: each entry's `minecraft:mob_effect` id and
|
||||
/// duration in ticks.
|
||||
StewEffects(Vec<(i32, i32)>),
|
||||
/// `InstrumentComponent` naming its instrument by `minecraft:instrument`
|
||||
/// registry id; a direct instrument stays [`Component::Raw`].
|
||||
Instrument(i32),
|
||||
@@ -234,6 +238,16 @@ impl Component {
|
||||
let n = r.count(256)?;
|
||||
Self::Lore((0..n).map(|_| r.nbt()).collect::<Result<_>>()?)
|
||||
}
|
||||
// SuspiciousStewEffects.Entry.STREAM_CODEC: MobEffect.STREAM_CODEC
|
||||
// (the registry id), then the duration, both varints.
|
||||
SUSPICIOUS_STEW_EFFECTS => {
|
||||
let n = r.count(r.remaining())?;
|
||||
Self::StewEffects(
|
||||
(0..n)
|
||||
.map(|_| Ok((r.varint()?, r.varint()?)))
|
||||
.collect::<Result<_>>()?,
|
||||
)
|
||||
}
|
||||
ENCHANTMENTS | STORED_ENCHANTMENTS => {
|
||||
let n = r.count(r.remaining())?;
|
||||
Self::Enchantments(
|
||||
@@ -442,7 +456,7 @@ impl Component {
|
||||
w.varint(lines.len() as i32);
|
||||
lines.iter().for_each(|l| w.nbt(l));
|
||||
}
|
||||
Self::Enchantments(list) => {
|
||||
Self::Enchantments(list) | Self::StewEffects(list) => {
|
||||
w.varint(list.len() as i32);
|
||||
for (e, level) in list {
|
||||
w.varint(*e);
|
||||
@@ -1077,6 +1091,8 @@ mod tests {
|
||||
pattern: 1,
|
||||
}
|
||||
}
|
||||
// night_vision for 100 ticks, poison for the default 160.
|
||||
55 => *c == Component::StewEffects(vec![(15, 100), (18, 160)]),
|
||||
63 => *c == Component::Instrument(1),
|
||||
_ => matches!(c, Component::Raw(_)),
|
||||
};
|
||||
|
||||
@@ -87,6 +87,13 @@ impl Ticks {
|
||||
self.blocks.add_chunk(pos, blocks)?;
|
||||
self.fluids.add_chunk(pos, fluids)
|
||||
}
|
||||
/// A moving-piston block entity loaded with its chunk, as a new ticker.
|
||||
pub fn load_moving_piston(&mut self, pos: BlockPos, mut piston: super::pistons::MovingPiston) {
|
||||
self.moving_pistons.retain(|(p, _)| *p != pos);
|
||||
self.next_block_entity_id += 1;
|
||||
piston.id = self.next_block_entity_id;
|
||||
self.moving_pistons.push((pos, piston));
|
||||
}
|
||||
/// Register a chunk without saved ticks.
|
||||
pub fn add_empty_chunk(&mut self, pos: ChunkPos) -> Result<(), TickError> {
|
||||
self.add_chunk(pos, ChunkTicks::default(), ChunkTicks::default())
|
||||
|
||||
@@ -188,6 +188,9 @@ impl<T: Clone> BiomeRegion<T> {
|
||||
pub fn quart_at_block(&self, block: BlockPos) -> [i32; 3] {
|
||||
self.zoom.quart_at_block(block)
|
||||
}
|
||||
pub fn zoom(&self) -> BiomeZoom {
|
||||
self.zoom
|
||||
}
|
||||
/// Distinct values currently stored anywhere in the chunk. The result is
|
||||
/// sorted by biome identity; callers must not use this order as registry order.
|
||||
pub fn present_in_chunk(&self, p: ChunkPos) -> Result<Vec<T>, BiomeRegionError>
|
||||
|
||||
@@ -1738,12 +1738,28 @@ fn memo_biome(
|
||||
memo.biomes.insert(key, id);
|
||||
Ok(id)
|
||||
}
|
||||
// Lazy equality proofs for the eight quart corners of a block-biome cell.
|
||||
// Adjacent blocks share these candidates even though their zoom distances differ.
|
||||
// Per quart cell of a block-biome lookup: a lazy equality proof for its
|
||||
// eight corners, or (when they differ) their jitter and biomes, so the blocks
|
||||
// of the cell only redo the distance comparison.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum SurfaceCell {
|
||||
Unknown,
|
||||
/// A corner lookup failed: only the original lookup may report errors.
|
||||
Lookup,
|
||||
Uniform(BiomeId),
|
||||
Mixed(u32),
|
||||
}
|
||||
struct MixedCell {
|
||||
jitter: [[f64; 3]; 8],
|
||||
biomes: [BiomeId; 8],
|
||||
}
|
||||
struct UniformSurfaceBiomes {
|
||||
origin: [i32; 3],
|
||||
height: i32,
|
||||
cells: Vec<Option<Option<BiomeId>>>,
|
||||
cells: Vec<SurfaceCell>,
|
||||
mixed: Vec<MixedCell>,
|
||||
/// The cell of the previous lookup (columns are walked down in y).
|
||||
last: ([i32; 3], SurfaceCell),
|
||||
}
|
||||
impl UniformSurfaceBiomes {
|
||||
fn new(pos: ChunkPos, min_y: i32, height: i32) -> Self {
|
||||
@@ -1755,43 +1771,63 @@ impl UniformSurfaceBiomes {
|
||||
pos.z.wrapping_shl(4).wrapping_sub(2) >> 2,
|
||||
],
|
||||
height,
|
||||
cells: vec![None; 25 * height as usize],
|
||||
cells: vec![SurfaceCell::Unknown; 25 * height as usize],
|
||||
mixed: Vec::new(),
|
||||
last: ([0; 3], SurfaceCell::Unknown),
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
fn get(&mut self, region: &BiomeRegion<BiomeId>, p: BlockPos) -> Option<BiomeId> {
|
||||
let base = [p.x, p.y, p.z].map(|v| v.wrapping_sub(2) >> 2);
|
||||
let cell = if self.last.0 == base && self.last.1 != SurfaceCell::Unknown {
|
||||
self.last.1
|
||||
} else {
|
||||
let cell = self.cell(region, base);
|
||||
self.last = (base, cell);
|
||||
cell
|
||||
};
|
||||
match cell {
|
||||
SurfaceCell::Uniform(id) => Some(id),
|
||||
SurfaceCell::Mixed(index) => {
|
||||
let mixed = &self.mixed[index as usize];
|
||||
Some(mixed.biomes[BiomeZoom::select_corner(p, &mixed.jitter)])
|
||||
}
|
||||
SurfaceCell::Unknown | SurfaceCell::Lookup => None,
|
||||
}
|
||||
}
|
||||
fn cell(&mut self, region: &BiomeRegion<BiomeId>, base: [i32; 3]) -> SurfaceCell {
|
||||
let d: [i32; 3] = std::array::from_fn(|i| base[i].wrapping_sub(self.origin[i]));
|
||||
if !(0..5).contains(&d[0]) || !(0..self.height).contains(&d[1]) || !(0..5).contains(&d[2]) {
|
||||
return None;
|
||||
return SurfaceCell::Lookup;
|
||||
}
|
||||
let slot = &mut self.cells[((d[0] * 5 + d[2]) * self.height + d[1]) as usize];
|
||||
if let Some(value) = slot {
|
||||
return *value;
|
||||
if *slot != SurfaceCell::Unknown {
|
||||
return *slot;
|
||||
}
|
||||
let value = (|| {
|
||||
// These are immutable stored lookups. A missing candidate prevents
|
||||
// the shortcut; only the original selected lookup may report errors.
|
||||
let first = *region.noise_biome(base).ok()?;
|
||||
for x in 0..2 {
|
||||
for y in 0..2 {
|
||||
for z in 0..2 {
|
||||
if *region
|
||||
.noise_biome([
|
||||
base[0].wrapping_add(x),
|
||||
base[1].wrapping_add(y),
|
||||
base[2].wrapping_add(z),
|
||||
])
|
||||
.ok()?
|
||||
!= first
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// These are immutable stored lookups, in quart_at_block's corner order.
|
||||
let mut biomes = [BiomeId(0); 8];
|
||||
for (corner, biome) in biomes.iter_mut().enumerate() {
|
||||
let offsets = [(corner >> 2) & 1, (corner >> 1) & 1, corner & 1];
|
||||
match region.noise_biome(std::array::from_fn(|i| {
|
||||
base[i].wrapping_add(offsets[i] as i32)
|
||||
})) {
|
||||
Ok(id) => *biome = *id,
|
||||
Err(_) => {
|
||||
*slot = SurfaceCell::Lookup;
|
||||
return SurfaceCell::Lookup;
|
||||
}
|
||||
}
|
||||
Some(first)
|
||||
})();
|
||||
*slot = Some(value);
|
||||
}
|
||||
let value = if biomes.iter().all(|id| *id == biomes[0]) {
|
||||
SurfaceCell::Uniform(biomes[0])
|
||||
} else {
|
||||
self.mixed.push(MixedCell {
|
||||
jitter: region.zoom().corner_jitter(base),
|
||||
biomes,
|
||||
});
|
||||
SurfaceCell::Mixed(self.mixed.len() as u32 - 1)
|
||||
};
|
||||
*slot = value;
|
||||
value
|
||||
}
|
||||
}
|
||||
@@ -1838,6 +1874,14 @@ impl<R: RandomSource> MaterialSurfaceQueries for SurfaceQueries<'_, '_, R> {
|
||||
self.possible.clone()
|
||||
}
|
||||
fn biome(&mut self, p: BlockPos) -> Result<Option<Arc<str>>, MaterialRuleError> {
|
||||
Ok(self
|
||||
.biome_id(p)?
|
||||
.map(|id| self.pipeline.data.biome_names[id as usize].clone()))
|
||||
}
|
||||
fn biome_index(&self, name: &str) -> Option<Option<u32>> {
|
||||
Some(self.pipeline.data.biome_id(name).map(|id| id.0))
|
||||
}
|
||||
fn biome_id(&mut self, p: BlockPos) -> Result<Option<u32>, MaterialRuleError> {
|
||||
let data = &self.pipeline.data;
|
||||
let id = match &mut self.biome {
|
||||
Biomes::Region(region) => match self
|
||||
@@ -1864,7 +1908,7 @@ impl<R: RandomSource> MaterialSurfaceQueries for SurfaceQueries<'_, '_, R> {
|
||||
self.pipeline.zoom.quart_at_block(p),
|
||||
)?,
|
||||
};
|
||||
Ok(Some(data.biome_names[id.0 as usize].clone()))
|
||||
Ok(Some(id.0))
|
||||
}
|
||||
fn cold_enough_to_snow(
|
||||
&mut self,
|
||||
@@ -3751,4 +3795,56 @@ mod tests {
|
||||
assert!(Arc::ptr_eq(first, hit));
|
||||
}
|
||||
}
|
||||
|
||||
/// The resources the worldgen tests read.
|
||||
fn test_root() -> std::path::PathBuf {
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.or_else(|| std::env::var_os("HORIZON_RESOURCES"))
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3")
|
||||
})
|
||||
}
|
||||
/// The block registry the worldgen bundle needs. `GeneratorData` keeps it
|
||||
/// for as long as the generator lives, so it is shared, not freed.
|
||||
static TEST_REGISTRY: std::sync::LazyLock<BlockRegistry> = std::sync::LazyLock::new(|| {
|
||||
crate::level::data::load_registry(&test_root()).expect("registry")
|
||||
});
|
||||
|
||||
/// The world presets with their own noise settings generate the terrain
|
||||
/// those settings describe: the amplified and large-biome presets are not
|
||||
/// the overworld's terrain under another name.
|
||||
#[test]
|
||||
fn world_presets_use_their_own_noise_settings() {
|
||||
let root = test_root();
|
||||
if !root.join("worldgen/world_presets.json").exists() {
|
||||
eprintln!("skipping: no worldgen bundle at {}", root.display());
|
||||
return;
|
||||
}
|
||||
let data =
|
||||
GeneratorData::load(&root.join("worldgen"), &TEST_REGISTRY).expect("worldgen data");
|
||||
let surface = |preset: &str| {
|
||||
let generator = OverworldGenerator::for_world(
|
||||
data.clone(),
|
||||
1234,
|
||||
preset,
|
||||
"minecraft:overworld",
|
||||
false,
|
||||
)
|
||||
.expect("generator");
|
||||
let mut worker = generator.worker();
|
||||
(0..32)
|
||||
.map(|i| {
|
||||
worker
|
||||
.surface_sample(i * 137, i * 91)
|
||||
.expect("surface")
|
||||
.height
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let normal = surface("minecraft:normal");
|
||||
assert!(normal.iter().all(|height| (0..384).contains(height)));
|
||||
assert_ne!(normal, surface("minecraft:amplified"));
|
||||
assert_ne!(normal, surface("minecraft:large_biomes"));
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,16 @@ pub trait MaterialSurfaceQueries {
|
||||
fn biome(&mut self, _position: BlockPos) -> Result<Option<Arc<str>>, MaterialRuleError> {
|
||||
Err(MaterialRuleError::MissingBiomeProvider)
|
||||
}
|
||||
/// `biome` as the index `biome_index` gives its name, for queries that
|
||||
/// number their biomes: biome conditions then compare numbers.
|
||||
fn biome_id(&mut self, _position: BlockPos) -> Result<Option<u32>, MaterialRuleError> {
|
||||
Err(MaterialRuleError::MissingBiomeProvider)
|
||||
}
|
||||
/// `Some` when `biome_id` answers: the index of the biome `name` (`None`
|
||||
/// inside when no biome has that name).
|
||||
fn biome_index(&self, _name: &str) -> Option<Option<u32>> {
|
||||
None
|
||||
}
|
||||
fn melts_frozen_ocean(
|
||||
&mut self,
|
||||
_biome: &str,
|
||||
@@ -98,6 +108,12 @@ impl<T: MaterialSurfaceQueries + ?Sized> MaterialSurfaceQueries for &mut T {
|
||||
fn biome(&mut self, position: BlockPos) -> Result<Option<Arc<str>>, MaterialRuleError> {
|
||||
(**self).biome(position)
|
||||
}
|
||||
fn biome_id(&mut self, position: BlockPos) -> Result<Option<u32>, MaterialRuleError> {
|
||||
(**self).biome_id(position)
|
||||
}
|
||||
fn biome_index(&self, name: &str) -> Option<Option<u32>> {
|
||||
(**self).biome_index(name)
|
||||
}
|
||||
fn cold_enough_to_snow(
|
||||
&mut self,
|
||||
biome: &str,
|
||||
@@ -198,6 +214,7 @@ struct Context<S> {
|
||||
noise_indices: BTreeMap<(String, bool), usize>,
|
||||
possible_biomes: Option<BTreeSet<String>>,
|
||||
biome: Option<Arc<str>>,
|
||||
biome_id: Option<u32>,
|
||||
}
|
||||
struct CachedNoise {
|
||||
sampler: Arc<dyn Noise>,
|
||||
@@ -227,6 +244,12 @@ impl<S: MaterialSurfaceQueries> Context<S> {
|
||||
}
|
||||
Ok(self.biome.clone())
|
||||
}
|
||||
fn biome_id(&mut self) -> Result<Option<u32>, MaterialRuleError> {
|
||||
if self.biome_id.is_none() {
|
||||
self.biome_id = self.surface.biome_id(self.position)?;
|
||||
}
|
||||
Ok(self.biome_id)
|
||||
}
|
||||
fn bind_noise(&mut self, name: &str, is_3d: bool) -> Result<usize, DecodeError> {
|
||||
let key = (name.to_owned(), is_3d);
|
||||
if let Some(index) = self.noise_indices.get(&key) {
|
||||
@@ -287,6 +310,8 @@ struct Compiled<R: RandomSource> {
|
||||
enum Kind<R: RandomSource> {
|
||||
Simple(MaterialCondition),
|
||||
Biome(BTreeSet<String>),
|
||||
/// `Biome` for queries that number their biomes: a bit per index.
|
||||
BiomeIds(Vec<u64>),
|
||||
Constant(bool),
|
||||
Noise {
|
||||
sampler: usize,
|
||||
@@ -317,6 +342,7 @@ where
|
||||
match self {
|
||||
Self::Simple(condition) => Self::Simple(condition.clone()),
|
||||
Self::Biome(names) => Self::Biome(names.clone()),
|
||||
Self::BiomeIds(bits) => Self::BiomeIds(bits.clone()),
|
||||
Self::Constant(value) => Self::Constant(*value),
|
||||
Self::Noise { sampler, min, max } => Self::Noise {
|
||||
sampler: *sampler,
|
||||
@@ -416,7 +442,28 @@ impl<R: RandomSource> Compiled<R> {
|
||||
match &context.possible_biomes {
|
||||
Some(possible) if names.is_disjoint(possible) => Kind::Constant(false),
|
||||
Some(possible) if possible.is_subset(&names) => Kind::Constant(true),
|
||||
_ => Kind::Biome(names),
|
||||
// An empty list would not ask whether the surface numbers
|
||||
// its biomes.
|
||||
_ if names.is_empty() => Kind::Biome(names),
|
||||
_ => match names
|
||||
.iter()
|
||||
.map(|name| context.surface.biome_index(name))
|
||||
.collect::<Option<Vec<_>>>()
|
||||
{
|
||||
// Names no biome has never match the block's biome.
|
||||
Some(ids) => {
|
||||
let mut bits = Vec::new();
|
||||
for id in ids.into_iter().flatten() {
|
||||
let word = id as usize / 64;
|
||||
if bits.len() <= word {
|
||||
bits.resize(word + 1, 0);
|
||||
}
|
||||
bits[word] |= 1 << (id % 64);
|
||||
}
|
||||
Kind::BiomeIds(bits)
|
||||
}
|
||||
None => Kind::Biome(names),
|
||||
},
|
||||
}
|
||||
}
|
||||
MaterialCondition::NoiseThreshold {
|
||||
@@ -510,6 +557,11 @@ impl<R: RandomSource> Compiled<R> {
|
||||
.ok_or(MaterialRuleError::MissingBiome)?
|
||||
.as_ref(),
|
||||
),
|
||||
Kind::BiomeIds(bits) => {
|
||||
let id = context.biome_id()?.ok_or(MaterialRuleError::MissingBiome)?;
|
||||
bits.get(id as usize / 64)
|
||||
.is_some_and(|word| word >> (id % 64) & 1 == 1)
|
||||
}
|
||||
Kind::Simple(MaterialCondition::Hole) => context.depth <= 0,
|
||||
Kind::Simple(MaterialCondition::Steep) => {
|
||||
context.gradient[0] <= -4 || context.gradient[1] >= 4
|
||||
@@ -627,6 +679,7 @@ impl<R: RandomSource, S: MaterialSurfaceQueries> MaterialConditions<R, S> {
|
||||
noise_indices: BTreeMap::new(),
|
||||
possible_biomes,
|
||||
biome: None,
|
||||
biome_id: None,
|
||||
},
|
||||
conditions: Vec::new(),
|
||||
rules: Vec::new(),
|
||||
@@ -654,6 +707,7 @@ impl<R: RandomSource, S: MaterialSurfaceQueries> MaterialConditions<R, S> {
|
||||
pub fn update_y(&mut self, stone_above: i32, stone_below: i32, water_height: i32, y: i32) {
|
||||
self.context.block_revision = self.context.block_revision.wrapping_add(1);
|
||||
self.context.biome = None;
|
||||
self.context.biome_id = None;
|
||||
self.context.position.y = y;
|
||||
self.context.stone_above = stone_above;
|
||||
self.context.stone_below = stone_below;
|
||||
|
||||
@@ -159,6 +159,12 @@ impl<S: MaterialSurfaceQueries> MaterialSurfaceQueries for MaterialDensitySource
|
||||
fn biome(&mut self, position: BlockPos) -> Result<Option<Arc<str>>, MaterialRuleError> {
|
||||
self.surface.biome(position)
|
||||
}
|
||||
fn biome_id(&mut self, position: BlockPos) -> Result<Option<u32>, MaterialRuleError> {
|
||||
self.surface.biome_id(position)
|
||||
}
|
||||
fn biome_index(&self, name: &str) -> Option<Option<u32>> {
|
||||
self.surface.biome_index(name)
|
||||
}
|
||||
fn melts_frozen_ocean(
|
||||
&mut self,
|
||||
biome: &str,
|
||||
|
||||
@@ -70,6 +70,12 @@ impl<P: PositionalRandom, S: MaterialSurfaceQueries> MaterialSurfaceQueries
|
||||
fn biome(&mut self, position: BlockPos) -> Result<Option<Arc<str>>, MaterialRuleError> {
|
||||
self.surface.biome(position)
|
||||
}
|
||||
fn biome_id(&mut self, position: BlockPos) -> Result<Option<u32>, MaterialRuleError> {
|
||||
self.surface.biome_id(position)
|
||||
}
|
||||
fn biome_index(&self, name: &str) -> Option<Option<u32>> {
|
||||
self.surface.biome_index(name)
|
||||
}
|
||||
fn melts_frozen_ocean(
|
||||
&mut self,
|
||||
biome: &str,
|
||||
|
||||
@@ -23,10 +23,11 @@ struct Queries {
|
||||
biomes: usize,
|
||||
fail_binding: bool,
|
||||
fail_temperature: bool,
|
||||
any_biome: bool,
|
||||
}
|
||||
impl MaterialSurfaceQueries for Queries {
|
||||
fn possible_biomes(&self) -> Option<BTreeSet<String>> {
|
||||
Some(BTreeSet::from(["minecraft:plains".into()]))
|
||||
(!self.any_biome).then(|| BTreeSet::from(["minecraft:plains".into()]))
|
||||
}
|
||||
fn surface_depth(&mut self, _: i32, _: i32) -> i32 {
|
||||
1
|
||||
@@ -117,6 +118,22 @@ fn constant_branches_keep_binding_order_and_short_circuit_queries() {
|
||||
assert_eq!(q.temperatures, 0);
|
||||
}
|
||||
#[test]
|
||||
fn an_empty_biome_list_matches_nothing_on_a_surface_without_biome_ids() {
|
||||
let mut w = worker(Queries {
|
||||
any_biome: true,
|
||||
..Queries::default()
|
||||
});
|
||||
let rule = w
|
||||
.add_rule(condition(
|
||||
MaterialCondition::Biome(Vec::new()),
|
||||
MaterialRule::Block(BlockStateId(9)),
|
||||
))
|
||||
.unwrap();
|
||||
w.update_xz(4, 5, 0, 0);
|
||||
w.update_y(1, 1, i32::MIN, 64);
|
||||
assert_eq!(w.try_apply(rule, BlockPos::new(4, 64, 5)), Ok(None));
|
||||
}
|
||||
#[test]
|
||||
fn unreachable_children_still_fail_compilation_without_adding_a_rule() {
|
||||
for preceding_block in [false, true] {
|
||||
let mut w = worker(Queries {
|
||||
|
||||
@@ -15,6 +15,7 @@ To play, use [the launcher](../launcher/README.md); the project site is [playhor
|
||||
- [Extras](extras.md): optional zoom, minimap, FPS and keystroke overlays and auto-reconnect, all off by default.
|
||||
- [Shader packs](shaders.md): Horizon's own WGSL shader packs, how to make and check one (`horizon-shader`).
|
||||
- [Mods](mods.md): Horizon's own mod system (Rust, sandboxed WebAssembly), how to make and install mods.
|
||||
- [Upgrading a 26.2 data pack](datapack-upgrade.md): `horizon-port-pack upgrade-datapack` rewrites a 26.2 worldgen pack in 26.3's formats, and how the rewrite is checked against vanilla.
|
||||
- [Modpacks](modpacks.md): porting whole packs (ATM10 and smaller): import, inventory, carry-over, an agent per mod with a harness against the Java original, coverage, and how "10x" is measured.
|
||||
- [Launcher](../launcher/README.md): installs, updates and starts builds; its CLI and MCP server; releasing it.
|
||||
- [Versioning](versioning.md): how releases are numbered from 0.2 (`v0.2.x`, `[email protected]`), how installs from before 0.2 get there, and the steps for the day 0.2 ships.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Upgrading a 26.2 data pack to 26.3
|
||||
|
||||
26.3 reworked the world generation registries, and vanilla 26.3 refuses a
|
||||
worldgen data pack written for 26.2 (registry errors on load). Horizon
|
||||
targets 26.3, so such a pack has to be rewritten first:
|
||||
|
||||
```sh
|
||||
cargo run --release -p horizon-port-pack -- upgrade-datapack PACK.zip --world <world folder>
|
||||
# or into any folder:
|
||||
cargo run --release -p horizon-port-pack -- upgrade-datapack PACK.zip OUT/
|
||||
```
|
||||
|
||||
`--world` writes the upgraded pack to `<world>/datapacks/<pack name>-26.3/`.
|
||||
The input is a zip or a folder; nothing is downloaded, and the pack stays on
|
||||
your machine (packs are often All Rights Reserved: do not commit or share an
|
||||
upgraded copy).
|
||||
|
||||
Horizon does not apply a data pack's world generation yet (see
|
||||
[the mod browser](mod-browser.md)); the upgraded pack loads in vanilla 26.3,
|
||||
and the checks below run it there.
|
||||
|
||||
The command prints what it rewrote, what it could not rewrite faithfully
|
||||
(`not faithful`) and the types it does not know (`left as it is`).
|
||||
|
||||
## What changes
|
||||
|
||||
Each rewrite is the one vanilla's own data went through between 26.2 and
|
||||
26.3 (checked by `tools/datapack_upgrade_rosetta.py`, below), and the parts
|
||||
26.2 hard-coded are spelled out as data so the 26.3 pack generates what the
|
||||
26.2 pack did.
|
||||
|
||||
| 26.2 | 26.3 | How it is checked |
|
||||
|---|---|---|
|
||||
| noise `firstOctave`, `amplitudes` | `base_octave`, `octave_count`, `amplitude_modifiers` and the `base_amplitude` of vanilla's `NormalNoise.createParity` (keeps 26.2's normalisation) | all 63 vanilla noises identical to 26.3's, bit for bit |
|
||||
| density `argument`, `argument1`/`argument2` | `input`, `left`/`right` | Rosetta, and generated terrain |
|
||||
| `invert` | `reciprocal` | codec |
|
||||
| `y_clamped_gradient` (`from_y`, `to_y`) | `gradient` on axis `y` | Rosetta |
|
||||
| `shifted_noise` | `noise` with `shift_x`/`shift_y`/`shift_z` (same sampling) | Rosetta |
|
||||
| `flat_cache` | `cache`, around `slice` at y = 0 when the input depends on y (26.2's flat cache held each column's value at y = 0) | unit test |
|
||||
| `cache_2d`, `cache_once`, `cache_all_in_cell` | `cache` (26.3's caches only memoise) | Rosetta |
|
||||
| `end_islands` | `end_outer_islands` | Rosetta |
|
||||
| `constant` `{argument}` | the number | codec |
|
||||
| `interpolated` (cell from the noise settings' `size_horizontal * 4`, `size_vertical * 4`) | `interpolated` with `cell_size_xz`/`cell_size_y`, from the settings whose router reaches the function | Rosetta: vanilla's 4 x 8 |
|
||||
| noise settings `noise.size_*` | dropped (on each `interpolated`) | Rosetta |
|
||||
| router `final_density` (26.2 added the beardifier in `NoiseChunk`) | `add(final_density, beardifier)` | as vanilla's `overworld/final_density` |
|
||||
| router `barrier`, `fluid_level_*`, `lava`, `aquifers_enabled` | `aquifers` object; `exclusion` spells out 26.2's `isDeepDarkRegion` (erosion < -0.225 and depth > 0.9) from the pack's own erosion and depth; `surface_level` is the preliminary surface | as vanilla's |
|
||||
| router `preliminary_surface_level` | named function, `aquifers.surface_level`, and `chunk_surface_level` = `interpolated` 16 x 1 of it (26.2's per-chunk surface interpolation) | as vanilla's |
|
||||
| router `vein_toggle`, `vein_ridged`, `vein_gap`, `ore_veins_enabled` | two `ore_vein` material rules (copper, iron) that reproduce 26.2's `OreVeinifier`: same heights, thresholds, edge roundoff, solidness, richness and gap | written from `OreVeinifier`; terrain |
|
||||
| `surface_rule` | `material_rule` (same node types), named `<settings>/upgraded/material_rule`, after the ore veins: 26.2 placed veins while filling the noise and its surface pass only replaced the default block | terrain |
|
||||
| `spawn_target` climate names | the router's density functions by id (`depth` and `offset` dropped as vanilla did) | as vanilla's |
|
||||
| block states `{Name, Properties}` | `{id, properties}`, or the id alone | Rosetta |
|
||||
| `configured_carver/` | `carver/`, `config` unwrapped; the cave carvers' hard-coded count, thickness and room scale written out (`nether_cave` is a `cave`); canyon `yScale` into `shape.y_scale`; `lava_level`, `replaceable`, `debug_settings` dropped | all 4 vanilla carvers identical to 26.3's |
|
||||
| `configured_feature/` | `feature/`, `config` unwrapped, block state providers renamed (`simple_state_provider` is its state) | Rosetta |
|
||||
| placement `random_offset` (`xz_spread`, `y_spread`) | `offset` with `x`, `y`, `z` | Rosetta |
|
||||
| biome `spawners`, `spawn_costs` | attribute `minecraft:gameplay/natural_mob_spawns` (overlay), counts as int providers; empty categories kept so none comes from the dimension's defaults | Rosetta (vanilla leaves empty ones out) |
|
||||
| biome `creature_spawn_probability` | attribute `minecraft:gameplay/creature_world_gen_spawn_probability` (left out at the default 0.1) | Rosetta |
|
||||
| structure `spawn_overrides` `minCount`/`maxCount` | `count` | Rosetta |
|
||||
| dimension type `bed_rule.explodes` | `bed_rule.destroy_on_use` | Rosetta |
|
||||
| `pack.mcmeta` | pack format 121 | vanilla loads it |
|
||||
|
||||
Not rewritten: the feature types 26.3 replaced by compositions of other
|
||||
features (`seagrass`, `kelp`, `basalt_columns`, `nether_forest_vegetation`
|
||||
and others; listed by the command), a `cache_2d` around a function of y, a
|
||||
`y_clamped_gradient` with `from_y == to_y` (a step instead of 26.2's NaN at
|
||||
that y), carvers' custom `lava_level`/`replaceable`, and noises with more
|
||||
than 32 octaves or negative amplitudes.
|
||||
|
||||
## Checking an upgrade
|
||||
|
||||
```sh
|
||||
python3 tools/datapack_upgrade_rosetta.py
|
||||
python3 tools/datapack_upgrade_fidelity.py # calibration: vanilla 26.2 against 26.3
|
||||
python3 tools/datapack_upgrade_fidelity.py --old-pack PACK.zip --new-pack OUT/
|
||||
```
|
||||
|
||||
The Rosetta check writes vanilla 26.2's own worldgen out as a pack, upgrades
|
||||
it and compares it with 26.3's file by file. Files whose format changed and
|
||||
whose content did not must come out the same. It needs the 26.2 server jar
|
||||
(`.local/vanilla-26.2/server-game.jar`, from the launcher's 26.2 version
|
||||
manifest).
|
||||
|
||||
The fidelity check starts vanilla's 26.2 and 26.3 dedicated servers on the
|
||||
same seed, the first with the original pack and the second with the upgrade,
|
||||
force loads the same chunks and compares the saved blocks. Without packs it
|
||||
measures what 26.3 itself changed, which the upgrade cannot be blamed for.
|
||||
The server logs must hold no data pack or registry errors.
|
||||
@@ -28,7 +28,7 @@ const NAME: &str = "Native mods";
|
||||
const PREFIX: &str = "native-";
|
||||
/// horizon-mixins, for bundles that don't name the revision they were tested with.
|
||||
const MIXINS_GIT: &str = "https://github.com/horizon-rust/horizon-mixins";
|
||||
pub const MIXINS_REV: &str = "0d6b103c70633d8fd9443d8d0acd3aadaacedb06";
|
||||
pub const MIXINS_REV: &str = "3f99a5c60dfa311cc365524c44ae2fd799bfa1de";
|
||||
/// The profile and features of the release's own game (game-build.yml).
|
||||
const PROFILE: &str = "release";
|
||||
const FEATURES: &str = "horizon-client/mods";
|
||||
|
||||
@@ -198,7 +198,8 @@ class ReleaseTrainTests(unittest.TestCase):
|
||||
self.assertIn("### Known issues\n- Sound cuts out after an hour.", body)
|
||||
self.assertNotIn("#7", body)
|
||||
self.assertNotIn("#8", body)
|
||||
self.assertIn("Thanks to @cosmic.", body)
|
||||
self.assertIn("### Contributors\nThanks to @cosmic.", body)
|
||||
self.assertTrue(body.endswith("---\n" + train.DISCLAIMER + "\n"), body)
|
||||
self.assertLess(body.index("### New"), body.index("### Fixed"))
|
||||
title, _ = train.notes("game", "v2026.10.15.2", [pr(9, "Horizon's mark", labels=["feature"]),
|
||||
pr(10, "Game menus", labels=["feature"])], [])
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Does an upgraded data pack generate on 26.3 what the original did on 26.2?
|
||||
|
||||
Starts vanilla's dedicated server of each version in a new folder, with the
|
||||
pack in its world's `datapacks/` (26.2 gets `--old-pack`, 26.3 gets
|
||||
`--new-pack`, the output of `horizon-port-pack upgrade-datapack`; without
|
||||
packs both run vanilla, which calibrates what 26.3 itself changed), force
|
||||
loads the same chunks, saves, and compares the saved chunks block by block.
|
||||
Prints how many blocks differ, the most common (26.2 -> 26.3) pairs and
|
||||
which heights they are at. The server logs are checked for data pack and
|
||||
registry errors: a pack 26.3 refuses fails the run.
|
||||
|
||||
Jars: `.local/vanilla-26.2/server.jar` and `.local/minecraft/26.3/jars/server.jar`
|
||||
(the bundler jars the player downloaded). Needs Java 25 on PATH or JAVA_HOME.
|
||||
|
||||
usage: datapack_upgrade_fidelity.py [--old-pack P --new-pack Q] [--seed N]
|
||||
[--site X,Z]... [--radius R] [--status full|carvers] [--keep DIR]
|
||||
"""
|
||||
import argparse
|
||||
import collections
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import anvil_dump # noqa: E402
|
||||
from test_dedicated_server_parity import Server, free_port # noqa: E402
|
||||
|
||||
JARS = {"26.2": ROOT / ".local/vanilla-26.2/server.jar", "26.3": ROOT / ".local/minecraft/26.3/jars/server.jar"}
|
||||
ERRORS = re.compile(r"Failed to (load|parse|get element)|Errors in registry|Unknown registry key|"
|
||||
r"No key|Missing|Couldn't load|Failed to load datapacks|Exception", re.I)
|
||||
|
||||
|
||||
def java():
|
||||
home = os.environ.get("JAVA_HOME")
|
||||
return str(pathlib.Path(home) / "bin" / "java") if home else "java"
|
||||
|
||||
|
||||
def run_server(version, folder, seed, pack, chunks):
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "eula.txt").write_text("eula=true\n")
|
||||
(folder / "server.properties").write_text(
|
||||
f"level-seed={seed}\nonline-mode=false\nserver-port={free_port()}\nspawn-protection=0\n"
|
||||
"max-tick-time=-1\nsync-chunk-writes=true\nview-distance=2\nsimulation-distance=2\n")
|
||||
if pack:
|
||||
target = folder / "world/datapacks" / pack.name
|
||||
target.parent.mkdir(parents=True)
|
||||
(shutil.copytree if pack.is_dir() else shutil.copy)(pack, target)
|
||||
server = Server([java(), "-Xmx2G", "-jar", JARS[version], "nogui"], folder, f"server-{version}")
|
||||
try:
|
||||
server.wait_for(r"Done \(", timeout=1800)
|
||||
problems = [l for l in server.lines if ERRORS.search(l)]
|
||||
if pack and not any(pack.name in l for l in server.lines):
|
||||
problems.append(f"{version}: the pack {pack.name} was not mentioned in the log (not loaded?)")
|
||||
# One forceload per chunk: a rectangle over several sites would pass
|
||||
# the command's 256-chunk limit and load nothing.
|
||||
for x, z in chunks:
|
||||
server.console(f"forceload add {x * 16} {z * 16}")
|
||||
deadline = time.time() + 3600
|
||||
for poll in range(100000):
|
||||
if time.time() > deadline:
|
||||
raise AssertionError(f"{version}: chunks still not loaded after an hour")
|
||||
start = len(server.lines)
|
||||
for x, z in chunks:
|
||||
server.console(f"execute unless loaded {x * 16} 0 {z * 16} run say unloaded")
|
||||
server.console(f"say poll {poll}")
|
||||
server.wait_for(rf"\[Server\] poll {poll}$", timeout=1800)
|
||||
if not any(l.endswith("[Server] unloaded") for l in server.lines[start:]):
|
||||
break
|
||||
# Polls are cheap, but each adds lines to the kept log.
|
||||
del server.lines[start:]
|
||||
time.sleep(5)
|
||||
server.console("save-all flush")
|
||||
server.wait_for(r"Saved the game", timeout=600)
|
||||
finally:
|
||||
server.stop()
|
||||
return problems
|
||||
|
||||
|
||||
def blocks(world, chunks):
|
||||
"""{(cx, cz): {y: [state per x,z]}} of the saved world."""
|
||||
regions = {}
|
||||
out = {}
|
||||
for cx, cz in chunks:
|
||||
rx, rz = cx >> 5, cz >> 5
|
||||
if (rx, rz) not in regions:
|
||||
# 26.x keeps each dimension under dimensions/<ns>/<name>/; older worlds used region/.
|
||||
path = world / "dimensions" / "minecraft" / "overworld" / "region" / f"r.{rx}.{rz}.mca"
|
||||
if not path.exists():
|
||||
path = world / "region" / f"r.{rx}.{rz}.mca"
|
||||
regions[(rx, rz)] = anvil_dump.region_chunks(path) if path.exists() else {}
|
||||
chunk = regions[(rx, rz)].get((cx & 31, cz & 31))
|
||||
if chunk is None:
|
||||
continue
|
||||
status = chunk.get("Status", "")
|
||||
column = {}
|
||||
for section in chunk.get("sections", []):
|
||||
sy = section["Y"][1] if isinstance(section["Y"], tuple) else section["Y"]
|
||||
states = section.get("block_states")
|
||||
if not states:
|
||||
continue
|
||||
palette = [state_name(p) for p in states["palette"]]
|
||||
data = states.get("data", ("L", []))[1]
|
||||
bits = max(4, (len(palette) - 1).bit_length())
|
||||
per = 64 // bits
|
||||
mask = (1 << bits) - 1
|
||||
for i in range(4096):
|
||||
if data:
|
||||
word = data[i // per] & ((1 << 64) - 1)
|
||||
index = (word >> ((i % per) * bits)) & mask
|
||||
else:
|
||||
index = 0
|
||||
y = sy * 16 + (i >> 8)
|
||||
column.setdefault(y, [None] * 256)[i & 255] = palette[index]
|
||||
out[(cx, cz)] = (status, column)
|
||||
return out
|
||||
|
||||
|
||||
def state_name(entry):
|
||||
# 26.2 saves {Name, Properties}; 26.3 saves the bare id, or {id, properties}. In a list
|
||||
# mixing both, NBT wraps each bare id in a compound with an empty key.
|
||||
if isinstance(entry, str):
|
||||
return entry
|
||||
if "" in entry:
|
||||
return entry[""]
|
||||
name = entry.get("Name") or entry["id"]
|
||||
props = entry.get("Properties") or entry.get("properties")
|
||||
if props:
|
||||
name += "[" + ",".join(f"{k}={v}" for k, v in sorted(props.items())) + "]"
|
||||
return name
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--old-pack", type=pathlib.Path)
|
||||
parser.add_argument("--new-pack", type=pathlib.Path)
|
||||
parser.add_argument("--seed", default="12345")
|
||||
parser.add_argument("--site", action="append", help="chunk X,Z of a site's centre (default 0,0 and 40,-25)")
|
||||
parser.add_argument("--radius", type=int, default=1, help="chunks around each site")
|
||||
parser.add_argument("--keep", type=pathlib.Path, help="keep the server folders here")
|
||||
parser.add_argument("--top", type=int, default=20)
|
||||
args = parser.parse_args()
|
||||
if bool(args.old_pack) != bool(args.new_pack):
|
||||
sys.exit("pass both packs or neither")
|
||||
sites = [tuple(map(int, s.split(","))) for s in (args.site or ["0,0", "40,-25"])]
|
||||
chunks = sorted({(x + dx, z + dz) for x, z in sites
|
||||
for dx in range(-args.radius, args.radius + 1)
|
||||
for dz in range(-args.radius, args.radius + 1)})
|
||||
tmp = pathlib.Path(tempfile.mkdtemp(prefix="upgrade-fidelity-"))
|
||||
try:
|
||||
worlds, problems = {}, []
|
||||
for version, pack in (("26.2", args.old_pack), ("26.3", args.new_pack)):
|
||||
folder = tmp / version
|
||||
print(f"{version}: generating {len(chunks)} chunks" + (f" with {pack.name}" if pack else ""), flush=True)
|
||||
problems += run_server(version, folder, args.seed, pack, chunks)
|
||||
worlds[version] = blocks(folder / "world", chunks)
|
||||
for p in problems:
|
||||
print("LOG:", p)
|
||||
old, new = worlds["26.2"], worlds["26.3"]
|
||||
total = differ = 0
|
||||
pairs = collections.Counter()
|
||||
heights = collections.Counter()
|
||||
missing = 0
|
||||
for c in chunks:
|
||||
if c not in old or c not in new:
|
||||
missing += 1
|
||||
continue
|
||||
(s1, a), (s2, b) = old[c], new[c]
|
||||
for y in sorted(set(a) | set(b)):
|
||||
ra, rb = a.get(y, ["minecraft:air"] * 256), b.get(y, ["minecraft:air"] * 256)
|
||||
for i in range(256):
|
||||
total += 1
|
||||
if ra[i] != rb[i]:
|
||||
differ += 1
|
||||
pairs[(ra[i], rb[i])] += 1
|
||||
heights[(y // 64) * 64] += 1
|
||||
print(f"compared {total} blocks in {len(chunks) - missing} chunks ({missing} missing): "
|
||||
f"{differ} differ ({100 * differ / max(total, 1):.3f}%)")
|
||||
for (x, y), n in pairs.most_common(args.top):
|
||||
print(f" {n:8} {x} -> {y}")
|
||||
print(" by height:", ", ".join(f"y{h}..{h + 63}: {n}" for h, n in sorted(heights.items())))
|
||||
if problems or missing:
|
||||
sys.exit(1)
|
||||
finally:
|
||||
if args.keep:
|
||||
shutil.copytree(tmp, args.keep, dirs_exist_ok=True)
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Checks `horizon-port-pack upgrade-datapack` on vanilla's own world generation.
|
||||
|
||||
Vanilla 26.2's worldgen (from the 26.2 server jar the player downloaded
|
||||
themselves, `--jar-26-2`, default `.local/vanilla-26.2/server-game.jar`) is
|
||||
written out as a data pack, upgraded, and compared with vanilla 26.3's
|
||||
worldgen (`.local/minecraft/26.3/jars/server-game.jar`) file by file: the
|
||||
registries whose format changed and whose content did not must come out
|
||||
identical. Per registry it prints how many files match and the paths where
|
||||
the rest differ, most common first. Density functions and noise settings
|
||||
differ where Mojang rewrote expressions (`lerp` for the blend formulas) or
|
||||
changed content; those are judged by generating terrain, not here.
|
||||
|
||||
Nothing is written outside a temporary folder; the jars' files stay there.
|
||||
"""
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "tools"))
|
||||
import lab # noqa: E402
|
||||
|
||||
PREFIXES = ("data/minecraft/worldgen/", "data/minecraft/dimension_type/")
|
||||
RENAMES = {"configured_carver": "carver", "configured_feature": "feature"}
|
||||
|
||||
|
||||
def extract(jar, out):
|
||||
with zipfile.ZipFile(jar) as z:
|
||||
for name in z.namelist():
|
||||
if name.startswith(PREFIXES) and name.endswith(".json"):
|
||||
target = out / name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(z.read(name))
|
||||
|
||||
|
||||
def files(root):
|
||||
return {p.relative_to(root).as_posix(): p for p in root.rglob("*.json")}
|
||||
|
||||
|
||||
def diff(x, y, path, out):
|
||||
if isinstance(x, dict) and isinstance(y, dict):
|
||||
for k in set(x) | set(y):
|
||||
if k not in y:
|
||||
out.add(f"-{path}.{k}")
|
||||
elif k not in x:
|
||||
out.add(f"+{path}.{k}")
|
||||
else:
|
||||
diff(x[k], y[k], f"{path}.{k}", out)
|
||||
elif isinstance(x, list) and isinstance(y, list) and len(x) == len(y):
|
||||
for a, b in zip(x, y):
|
||||
diff(a, b, f"{path}[]", out)
|
||||
elif x != y:
|
||||
out.add(f"~{path}")
|
||||
|
||||
|
||||
def normalise(v, providers):
|
||||
"""26.3 names some block state providers (`worldgen/block_state_provider`)
|
||||
where 26.2 wrote them inline, and writes a default state as its bare id:
|
||||
both forms are read the same."""
|
||||
if isinstance(v, dict):
|
||||
if set(v) == {"id"}:
|
||||
return v["id"]
|
||||
if "spawns_by_category" in v:
|
||||
# The upgrade keeps empty categories so that none comes from
|
||||
# the dimension's defaults; vanilla leaves them out.
|
||||
v = dict(v, spawns_by_category={c: s for c, s in v["spawns_by_category"].items() if s})
|
||||
out = {}
|
||||
for k, x in v.items():
|
||||
if k.endswith("provider") and isinstance(x, str) and x in providers:
|
||||
x = providers[x]
|
||||
out[k] = normalise(x, providers)
|
||||
return out
|
||||
if isinstance(v, list):
|
||||
return [normalise(x, providers) for x in v]
|
||||
return v
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--jar-26-2", type=pathlib.Path,
|
||||
default=ROOT / ".local/vanilla-26.2/server-game.jar")
|
||||
parser.add_argument("--registry", action="append", help="only these registries (e.g. worldgen/noise)")
|
||||
parser.add_argument("--show", type=int, default=6, help="diff paths shown per registry")
|
||||
parser.add_argument("--keep", type=pathlib.Path, help="copy both trees here to inspect them")
|
||||
args = parser.parse_args()
|
||||
new_jar = ROOT / ".local/minecraft/26.3/jars/server-game.jar"
|
||||
for jar in (args.jar_26_2, new_jar):
|
||||
if not jar.exists():
|
||||
sys.exit(f"missing {jar}")
|
||||
target = lab.cargo_target()
|
||||
subprocess.run(["cargo", "build", "-q", "-p", "horizon-port-pack", "--target-dir", str(target)],
|
||||
cwd=ROOT, check=True)
|
||||
exe = target / "debug" / "horizon-port-pack"
|
||||
with tempfile.TemporaryDirectory(prefix="upgrade-rosetta-") as tmp:
|
||||
tmp = pathlib.Path(tmp)
|
||||
old, new, out = tmp / "26.2", tmp / "26.3", tmp / "upgraded"
|
||||
extract(args.jar_26_2, old)
|
||||
extract(new_jar, new)
|
||||
(old / "pack.mcmeta").write_text(json.dumps({"pack": {"description": "vanilla 26.2", "pack_format": 107}}))
|
||||
run = subprocess.run([str(exe), "upgrade-datapack", str(old), str(out)], capture_output=True, text=True)
|
||||
print(run.stdout.strip())
|
||||
if run.returncode:
|
||||
sys.exit(run.stderr)
|
||||
got, want = files(out), files(new)
|
||||
providers = {}
|
||||
for path, p in want.items():
|
||||
prefix = "data/minecraft/worldgen/block_state_provider/"
|
||||
if path.startswith(prefix):
|
||||
providers["minecraft:" + path[len(prefix):-5]] = json.loads(p.read_text())
|
||||
if args.keep:
|
||||
import shutil
|
||||
shutil.copytree(out, args.keep / "upgraded", dirs_exist_ok=True)
|
||||
shutil.copytree(new, args.keep / "26.3", dirs_exist_ok=True)
|
||||
per =collections.defaultdict(lambda: [0, 0, 0, collections.Counter()])
|
||||
for path, p in sorted(got.items()):
|
||||
parts = path.split("/")
|
||||
if len(parts) < 4 or path.startswith("data/minecraft/worldgen/density_function/") and "/upgraded/" in path:
|
||||
continue
|
||||
registry = "/".join(parts[2:4]) if parts[2] == "worldgen" else parts[2]
|
||||
if args.registry and registry not in args.registry:
|
||||
continue
|
||||
row = per[registry]
|
||||
if path not in want:
|
||||
row[2] += 1
|
||||
continue
|
||||
a = normalise(json.loads(p.read_text()), providers)
|
||||
b = normalise(json.loads(want[path].read_text()), providers)
|
||||
if a == b:
|
||||
row[0] += 1
|
||||
else:
|
||||
row[1] += 1
|
||||
paths = set()
|
||||
diff(a, b, "", paths)
|
||||
row[3].update(paths)
|
||||
print(f"{'registry':40} {'same':>5} {'differ':>6} {'not in 26.3':>11}")
|
||||
for registry, (same, differ, gone, paths) in sorted(per.items()):
|
||||
print(f"{registry:40} {same:5} {differ:6} {gone:11}")
|
||||
for p, n in paths.most_common(args.show):
|
||||
print(f" {n:4} {p}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -407,10 +407,10 @@ def notes(kind, version, prs, issues, previous=None):
|
||||
parts.append("### Known issues\n" + "\n".join(issues))
|
||||
contributors = sorted({pr["author"] for pr in shown if pr["author"] and pr["author"] != MAINTAINER})
|
||||
if contributors:
|
||||
parts.append("Thanks to " + ", ".join(f"@{name}" for name in contributors) + ".")
|
||||
parts.append("### Contributors\nThanks to " + ", ".join(f"@{name}" for name in contributors) + ".")
|
||||
if kind == "launcher":
|
||||
parts.append(LAUNCHER_FOOTER)
|
||||
parts.append(DISCLAIMER)
|
||||
parts.append("---\n" + DISCLAIMER)
|
||||
return title, "\n\n".join(parts) + "\n"
|
||||
|
||||
|
||||
|
||||
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check #1518: mobs and dropped items go through an End gateway.
|
||||
|
||||
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
|
||||
expected to leave it and land at the gateway's exit, the stack with its seven
|
||||
diamonds.
|
||||
"""
|
||||
import argparse
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from agent_control_e2e import Cli
|
||||
import lab
|
||||
|
||||
|
||||
# The gateway beside the End's spawn platform, where the player waits.
|
||||
GATEWAY = (104, 50, 0)
|
||||
# The diamonds the dropped stack carries.
|
||||
DIAMONDS = 7
|
||||
|
||||
|
||||
def data_get(cli, selector, path):
|
||||
"""The chat line of a `/data get entity`."""
|
||||
since = cli('events')['last']
|
||||
cli('command', command=f'data get entity {selector} {path}')
|
||||
deadline = time.monotonic() + 30
|
||||
while time.monotonic() < deadline:
|
||||
reply = cli('events', since=since)
|
||||
for event in reply['events']:
|
||||
text = event.get('text', '')
|
||||
if event.get('event') == 'chat' and ('entity data' in text or 'No entity was found' in text):
|
||||
return text
|
||||
time.sleep(0.1)
|
||||
raise AssertionError(f'no answer for /data get entity {selector} {path}')
|
||||
|
||||
|
||||
def numbers(text):
|
||||
"""The numbers of a `/data get` answer: "Pos: [104.5, 50.0, 0.5]"."""
|
||||
return [float(v) for v in re.findall(r'-?\d+(?:\.\d+)?', text.split(':', 1)[-1])]
|
||||
|
||||
|
||||
def run(bin_dir, resources, in_frame):
|
||||
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 (
|
||||
'HORIZON_HOME', 'HORIZON_LAUNCHER_HOME', 'XDG_DATA_HOME', 'XDG_CONFIG_HOME')})
|
||||
home = Path(env['HORIZON_HOME'])
|
||||
socket = work / 'game.sock'
|
||||
cli = Cli(bin_dir / 'horizon-ctl', socket, home, env)
|
||||
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:
|
||||
deadline = time.monotonic() + 120
|
||||
while not socket.exists():
|
||||
if process.poll() is not None or time.monotonic() > deadline:
|
||||
raise RuntimeError('game did not open its control socket')
|
||||
time.sleep(0.1)
|
||||
cli('wait_ready', seconds=240)
|
||||
# The End, on its spawn platform.
|
||||
cli('command', command='execute in minecraft:the_end run tp @s 100 50 0')
|
||||
for _ in range(60):
|
||||
cli('wait', ticks=20)
|
||||
if cli('state').get('dimension') == 'minecraft:the_end':
|
||||
break
|
||||
else:
|
||||
raise AssertionError('did not enter the End')
|
||||
cli('wait_ready', seconds=240)
|
||||
# A gateway beside the player, with a cow and a stack in it.
|
||||
x, y, z = GATEWAY
|
||||
cli('command', command=(
|
||||
f'execute in minecraft:the_end run setblock {x} {y} {z} minecraft:end_gateway'))
|
||||
cli('command', command=(
|
||||
f'execute in minecraft:the_end run summon minecraft:cow {x}.5 {y}.5 {z}.5 {{NoAI:1b,Tags:["horizon_gateway_cow"]}}'))
|
||||
nbt = '{Tags:["horizon_gateway_item"],Item:{id:"minecraft:diamond",count:' + str(DIAMONDS) + '}}'
|
||||
cli('command', command=(
|
||||
'execute in minecraft:the_end run summon minecraft:item '
|
||||
+ f'{x}.5 {y}.5 {z}.5 ' + nbt))
|
||||
# The gateway takes the stack, and the cow 40 ticks later
|
||||
# (TheEndGatewayBlockEntity.triggerCooldown).
|
||||
cli('wait', ticks=120)
|
||||
# The exit is 1024 blocks away (findOrCreateValidTeleportPos): the
|
||||
# player waits there, so the travellers are near enough to see.
|
||||
length = math.hypot(x, z)
|
||||
ex, ez = 1024 * x / length, 1024 * z / length
|
||||
cli('command', command=f'execute in minecraft:the_end run tp @s {ex:.1f} 80 {ez:.1f}')
|
||||
for _ in range(60):
|
||||
cli('wait', ticks=20)
|
||||
cow = data_get(cli, '@e[type=cow,tag=horizon_gateway_cow,limit=1]', 'Pos')
|
||||
if 'entity data' in cow:
|
||||
break
|
||||
else:
|
||||
raise AssertionError('the cow never arrived')
|
||||
item = data_get(cli, '@e[type=item,tag=horizon_gateway_item,limit=1]', 'Pos')
|
||||
count = numbers(data_get(cli, '@e[type=item,tag=horizon_gateway_item,limit=1]', 'Item.count'))
|
||||
cow_at, item_at = numbers(cow), numbers(item)
|
||||
print(f'cow at {cow_at}, item at {item_at}, count {count}')
|
||||
assert len(cow_at) == 3, f'the cow has no position: {cow_at}'
|
||||
assert len(item_at) == 3, f'the stack has no position: {item_at}'
|
||||
for name, at in (('cow', cow_at), ('item', item_at)):
|
||||
assert abs(at[0] - x) > 64 or abs(at[2] - z) > 64, \
|
||||
f'{name} stayed in the gateway at {at}'
|
||||
assert count and int(count[0]) == DIAMONDS, f'the stack lost diamonds: {count}'
|
||||
cli('quit')
|
||||
assert process.wait(timeout=120) == 0
|
||||
except Exception:
|
||||
log.flush()
|
||||
text = (work / 'game.log').read_text(errors='replace')
|
||||
Path('/tmp/end-gateway-game.log').write_text(text)
|
||||
for log in (home / 'logs').glob('*'):
|
||||
if log.is_file():
|
||||
Path('/tmp/end-gateway-' + log.name).write_text(
|
||||
log.read_text(errors='replace'))
|
||||
print(text[-4000:])
|
||||
raise
|
||||
finally:
|
||||
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')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user