Compare commits

...
Author SHA1 Message Date
Ludovic Marie 7e322f591a Merge branch 'pr/2570' into union/pt2-a 2026-10-29 10:40:33 +01:00
Ludovic Marie 95ace2feff Merge branch 'pr/3401' into union/pt2-a 2026-10-29 10:40:33 +01:00
Ludovic Marie 409ac72702 Merge branch 'pr/2689' into union/pt2-a 2026-10-29 10:40:32 +01:00
Ludovic Marie 0548953031 Merge branch 'pr/3410' into union/pt2-a 2026-10-29 10:40:32 +01:00
Ludovic Marie 0235d33aef Merge branch 'upd/3148' into union/pt2-a 2026-10-29 10:40:31 +01:00
Ludovic Marie 78616f1f94 Merge branch 'pr/3404' into union/pt2-a 2026-10-29 10:40:31 +01:00
Ludovic Marie 9ec105172f Merge branch 'pr/3380' into union/pt2-a 2026-10-29 10:40:31 +01:00
Ludovic Marie a3c239b849 Merge branch 'pr/3543' into union/pt2-a 2026-10-29 10:40:30 +01:00
Ludovic Marie 04cd6ece23 Drop the test that only exercised std::mem::replace (the fix is the one line in as_guest) 2026-10-29 10:16:08 +01:00
Ludovic Marie 9218dc3e7d Leave horizon-server's lists test as on main (not part of this fix) 2026-10-29 10:15:47 +01:00
Ludovic Marie 5b66bd0ec0 Merge remote-tracking branch 'origin/main' into upd/3148 2026-10-29 10:15:46 +01:00
levzzzandOpenAI Codex f75896ec45 Retry unloaded gateway arrivals and identify the fixture entities by tag
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:13:49 +05:00
levzzzandOpenAI Codex a80973335c Merge current main before rerunning End gateway cargo proof
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 14:03:47 +05:00
levzzzandOpenAI Codex c91ca07115 Merge current main and use its preset-aware terrain interface
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 13:58:50 +05:00
degepieandClaude Opus 5.5 955e7fe48d Hurt players flash red for the other players too
The server broadcast LivingEntity.hurtServer's damage event for mobs only,
so in multiplayer and on LAN (and single player's server thread) nobody saw
another player turn red when hit. The LAN flush now sends it for the host
and each guest whose hurt time restarted (a hit that was not blocked by a
shield), to the players around and the hurt player itself. An in-frame
host's models of its guests also carry their hurt time.

Fixes #2827.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 05:19:11 +05:00
JacobRWebbandClaude Opus 5.5 ee90b3b34b Moving blocks take their light from the cells their faces look at
A finished piston's block is drawn over the chunk mesh until that mesh
shows it, but it was lit with the light stored at the moving piston's own
cell. Once the placed block stands there and the edit is relit, that cell
is opaque and holds 0: the pushed or pulled block and the piston base
flashed black for the frames until the mesh came up (#3002).

Vanilla's PistonHeadRenderer.createMovingBlock gives each block a
MovingBlockRenderState (the lone block at the cell it moves from, the
level's light engine), and MovingBlockFeatureRenderer tesselates it with
ModelBlockRenderer.tesselateBlock: in tesselateFlat each quad reads
BlockModelLighter.getLightCoords at its cull-face neighbour, or
prepareQuadFlat's position (the neighbour when cubic, else the block).
Moving blocks now follow that through MeshTables::flat_light, the chunk
mesher's own flat-light rules; a finished one is lit as the block placed
at its cell, which it stands in for.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:46:24 -05:00
degepie 8740d3897c Merge remote-tracking branch 'origin/main' into fix/goat-horn-instrument-sync
# Conflicts:
#	crates/horizon-client/src/lan/guest.rs
2026-10-29 03:34:06 +05:00
JacobRWebbandClaude Opus 5.5 a72f06cfab Test the moving piston's saved fields against vanilla's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:28:37 -05:00
JacobRWebbandClaude Opus 5.5 fab9c37413 Moving pistons save facing as a byte, as Direction.LEGACY_ID_CODEC does
PistonMovingBlockEntity.saveAdditional stores facing through
Direction.LEGACY_ID_CODEC, Codec.BYTE of get3DDataValue. The block-entity
canonicaliser rewrote it as an int too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:27:45 -05:00
degepie 57a6f4e9aa Merge remote-tracking branch 'origin/main' into fix/goat-horn-instrument-sync
# Conflicts:
#	crates/horizon-client/src/item_sync.rs
#	crates/horizon-protocol/src/item.rs
2026-10-29 03:06:31 +05:00
degepieandClaude Opus 5.5 99207f16b1 Suggest the crosshair entity's UUID for entity arguments (#3223)
EntityArgument.listSuggestions adds SharedSuggestionProvider.getSelectedEntities
to the player names unless the argument takes players only;
ClientSuggestionProvider.getSelectedEntities is the UUID of the entity under
the crosshair. The chat screen now fills Registries::selected_entities from
the nearest entity hit before any block.

For the UUID to name the entity back, two server-side gaps are closed:
- the entity tracker sent a made-up UUID (entity_uuid) in AddEntity; it now
  sends the entity's own UUID (ClientboundAddEntityPacket: entity.getUUID()),
  assigned on first tracking;
- a UUID in an entity argument also set playerName, which findEntities checks
  first among players, so `/tp <uuid> ...` found nothing. parseNameOrUUID
  sets entityUUID alone.

The chat_screen and command_parse oracles now return a selected entity from
getSelectedEntities, and the fixtures read it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 02:49:02 +05:00
JacobRWebbandClaude Opus 5.5 4a094bee90 Pistons: moving blocks keep their tint, survive saves and leave no black ghost
- Moving blocks are tinted per face with the chunk mesher's block colours
  (MeshTables::block_tints), not the terrain particle tint, which is none
  for grass blocks: a pushed grass block was drawn gray.
- PistonMovingBlockEntity is saved with its chunk as vanilla's
  minecraft:piston block entity and loaded back into the level's moving
  pistons, so blocks caught mid-move finish it after a rejoin instead of
  leaving an invisible, solid moving_piston (#3310).
- A server client drops its moving piston when the server changes that
  block (LevelChunk.setBlockState removes the block entity), instead of
  animating it for five more death ticks over the placed block, black and
  z-fighting (#3002).
- A finished piston stays drawn until the chunk mesh shows its block, and
  no longer, so no frame shows neither.

Fixes #3310
Fixes #3002

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 16:29:10 -05:00
degepieandClaude Opus 5.5 66e1fc5079 Credit the curing player of a zombie villager on the server thread
ZombieVillager.mobInteract records the interacting player as the
conversion starter and finishConversion credits the player
level.getPlayerByUUID finds: the ZOMBIE_VILLAGER_CURED gossip and the
cured_zombie_villager trigger. Horizon used the server Game's own
level.player_uuid for both, so on the server thread (and for LAN guests)
the gossip went to a UUID nobody trades as and the player got no
discount and no Zombie Doctor (#3306).

The starter is now the acting player, and the cure credits the host or
the placed guest in this level with that UUID. lan_e2e gains a
cure-discount check: host and guest each cure one and pay 14 wheat
instead of 20.

Fixes #3306.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 02:27:31 +05:00
degepieandClaude Opus 5.5 c404ef5e2a Goat horns keep their instrument from the server
The client read InstrumentComponent past without keeping it, so every goat
horn a server sent arrived with no minecraft:instrument and its tooltip fell
back to "Ponder" (#3181). Decode the holder like ArmorTrim's: a reference
becomes Component::Instrument(registry id) and is named from the synced
minecraft:instrument registry in item_sync. A direct instrument stays Raw.
The LAN host and creative client send it back by the same id.

The item wire harness gains an "instrument registry" fixture, and its probe
treats Instrument like Trim (a byte-exact id, not a lossy type), so the
round trip is checked byte for byte.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 01:14:59 +05:00
Shreyas 380d71d8a8 lists: fall back to offline resolution when services do not answer in tests 2026-10-28 16:31:23 +05:30
Shreyas 0543cf0345 Select the picked hotbar slot in singleplayer and LAN
Preserve the guest's updated hotbar selection when restoring the host
player state after an action. The previous restore sequence discarded
the guest's new selection while keeping the inventory changes.

The held-slot response now uses the selected slot produced by the pick,
so integrated-server and LAN players select the picked block or spawn
egg. Keep the host's selection independent of the guest's selection.

Vanilla reference: ServerGamePacketListenerImpl.tryPickItem,
Minecraft.pickBlockOrEntity, and ClientPacketListener.handleSetHeldSlot.

Fixes #3031
2026-10-28 16:00:22 +05:30
Ludovic MarieandClaude Opus 5.5 e1d825312f Read the flat world's horizon from WorldGen's preset
main's dark disc and void fog read WorldGen.flat, which is now the preset
(seen by sirrayi's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:25:52 +01:00
Ludovic MarieandClaude Opus 5.5 5ea61ed3d8 Merge main into world-types
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:25:45 +01:00
levzzzandminimax-m3.1-flash 1232745064 Keep a traveller that waits for an End gateway's exit
The traveller was dropped from the queue before the exit was resolved, so a
failure to resolve it lost the entity for good. The traveller now waits and
the failure is retried, with the reason in the log.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 18:34:07 +05:00
levzzzandminimax-m3.1-flash 3382b666ce Add the audit that drives a cow and a stack through an End gateway
The script plays the End in a scratch home: it places a gateway beside the
spawn platform, puts a cow and a seven-diamond stack in it, and checks that
both leave it and land at the gateway's exit with their data, in the
server-thread and the in-frame singleplayer.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 18:27:43 +05:00
levzzzandminimax-m3.1-flash 92c073de61 Merge upstream/main into fix/end-gateway-entities
Upstream now keeps a gateway's exit in the gateway's own block entity
(TheEndGatewayBlockEntity.setExitPosition), so the player travels to the
exit the gateway was given (#1517). The entity travel stays: the mobs and
the dropped items go through the same gateway, and the exit the first
traveller found is reused, so the player and the entities land in the same
place and only one island is built for a gateway.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 17:27:46 +05:00
levzzzandminimax-m3.1-flash 139de824aa Re-run the checks after the audit script fix
The pull request's last push did not start a run; this empty commit
starts one on the same tree.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 17:10:44 +05:00
Ludovic MarieandClaude Opus 5.5 ed01a93df6 Merge main into fix/world-types
main's Superflat Customize screen (#2214) still set the removed WorldOptions.flat: it now picks WorldPreset::Flat,
and the agent's customize-flat state reads the preset. structure_mobs.rs's test passes the new preset argument
(minecraft:normal) to VanillaTerrain::for_dimension.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:39:11 +01:00
levzzzandminimax-m3.1-flash 1a56e74324 Fix the f-string in the end cargo audit script
The braces were one too many, so the file did not parse and the Python
tests failed on it. The command it builds is the same now: the item
stack {Item:{id:"minecraft:diamond",count:3}}.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:35:44 +05:00
Claude Codeandminimax-m3.1-flash 25b84dfc2e Format the world preset test
rustfmt keeps the registry read on one line.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:11:14 +05:00
levzzzandminimax-m3.1-flash a2098c2ffa Let mobs and dropped items through End gateways
Only the player was checked for End gateways, so a cow or a dropped
stack standing in one stayed there for good. The entities now go
through a gateway the way the player does: the block their box swept
through takes them in (unless the gateway is cooling down, as
EndGatewayBlock.entityInside does), they travel on the next tick
(Entity.handlePortal, with the End gateway's transition time of 0) and
land where the gateway leads once its chunks have loaded, keeping their
data and counts.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:11:14 +05:00
Claude Codeandminimax-m3.1-flash d98dc8edc9 Read the test registry without the explicit deref
clippy's explicit_auto_deref asks for the LazyLock itself, which
derefs to the registry the generator data keeps.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:11:14 +05:00
Claude Codeandminimax-m3.1-flash bf05176692 Add the amplified, large-biome and single-biome world types
The create screen only offered Default and Superflat, so the amplified
world type of #1873 could not be chosen at all, and the generator
always built the overworld's noise settings.

The world-type button now cycles through the presets vanilla's
WorldPresetTags.NORMAL tag holds (normal, flat, large_biomes, amplified,
single_biome_surface), the chosen preset is written into the new world's
world_gen_settings.dat with the generator
worldgen/world_preset/<preset>.json has, and the generator is built from
that preset, so an amplified world gets the amplified noise settings.
A world opened again reads its preset back from its generator.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 16:11:14 +05:00
46 changed files with 1611 additions and 153 deletions

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(
@@ -54,6 +54,9 @@ pub struct Registries {
pub function_tags: Vec<String>,
/// The block the source is looking at (relevant coordinates).
pub target_block: Option<[i32; 3]>,
/// `ClientSuggestionProvider.getSelectedEntities`: the UUID of the
/// entity under the crosshair (`Entity.getStringUUID`), if any.
pub selected_entities: Vec<String>,
/// Every registry's element and tag ids (`minecraft:item` -> ...), as
/// the server's registry access and reloadable registries hold them.
pub by_registry: BTreeMap<String, RegistryIds>,
@@ -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(&center);
let tints = |name| {
tables.block_tints(
state(name),
[5, 70, 9],
&region,
&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);
}
@@ -43,6 +43,7 @@ pub fn registries(r: &Value, permission: u8) -> Registries {
reg.timelines = strings(&r["timelines"]);
reg.dimensions = strings(&r["dimensions"]);
reg.players = strings(&r["players"]);
reg.selected_entities = strings(&r["selected_entities"]);
reg.teams = strings(&r["teams"]);
reg.objectives = strings(&r["objectives"]);
reg.tracked = strings(&r["tracked"]);
@@ -178,6 +178,7 @@ pub fn main() -> horizon_client::Result<()> {
reg.timelines = strings(&r["timelines"]);
reg.dimensions = strings(&r["dimensions"]);
reg.players = strings(&r["players"]);
reg.selected_entities = strings(&r["selected_entities"]);
reg.teams = strings(&r["teams"]);
reg.objectives = strings(&r["objectives"]);
reg.tracked = strings(&r["tracked"]);
+159
View File
@@ -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));
}
}
+26 -4
View File
@@ -3028,6 +3028,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);
}
@@ -3239,10 +3243,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:
@@ -5607,8 +5621,10 @@ impl Game<'_> {
});
}
if running {
horizon_client::spans::time("entity_portals", || self.handle_entity_portals());
horizon_client::spans::time("tick_items", || self.tick_items());
horizon_client::spans::time("entity_portals", || {
self.handle_entity_portals();
self.handle_end_gateway_portals();
});
horizon_client::spans::time("touch_arrows", || self.touch_arrows());
}
horizon_client::spans::time("spawn_loaded_entities", || self.spawn_loaded_entities());
@@ -5630,6 +5646,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())?;
@@ -9228,7 +9249,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();
+2 -2
View File
@@ -564,7 +564,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(),
}),
@@ -577,7 +577,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,
+3 -1
View File
@@ -415,9 +415,11 @@ impl Game<'_> {
.map(|(k, _)| k.clone())
.collect();
}
/// The block the player is looking at, for coordinate suggestions.
/// The block the player is looking at, for coordinate suggestions, and
/// the entity, for entity arguments (ClientSuggestionProvider).
fn refresh_dynamic_registries(&mut self) {
self.ui.registries.target_block = self.pick().map(|h| h.pos);
self.ui.registries.selected_entities = self.crosshair_entity_uuid().into_iter().collect();
// ClientPacketListener.getOnlinePlayerNames on a server.
if let Some(names) = self.remote_online_names() {
self.ui.registries.players = names;
@@ -73,7 +73,7 @@ impl Game<'_> {
/// `EntitySelector.findEntities` with a UUID: the player with it, else
/// the entity.
pub(super) fn target_by_uuid(&self, uuid: u128) -> Option<Target> {
pub(in crate::game) fn target_by_uuid(&self, uuid: u128) -> Option<Target> {
let own = self.lan_acting_uuid.or_else(|| {
self.level.player_uuid.map(|[a, b, c, d]| {
(u128::from(a as u32) << 96)
+20 -23
View File
@@ -298,15 +298,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`.
@@ -323,11 +322,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",
@@ -336,18 +342,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(
@@ -363,7 +360,7 @@ impl Game<'_> {
None
};
let options = horizon_client::save::WorldGen {
flat,
preset,
structures: props.generate_structures,
bonus_chest: false,
};
+230 -7
View File
@@ -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.
@@ -931,6 +957,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<()> {
@@ -1027,13 +1064,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]]
@@ -1043,6 +1074,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();
@@ -1404,6 +1443,190 @@ 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;
}
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)]
+37 -1
View File
@@ -69,7 +69,8 @@ pub(super) struct LanHost {
pub play: HashMap<u32, super::lan_actions::GuestPlay>,
/// Blocks broken this tick (their sound is a level event for the guests).
pub broken: Vec<[f64; 3]>,
/// Hurt time, death and swing time (while swinging) of the mobs guests see.
/// Hurt time, death and swing time (while swinging) of the mobs guests
/// see; the players' hurt time by entity id.
mob_state: HashMap<super::Dim, HashMap<u32, (i32, bool, Option<i32>)>>,
/// The guest each mob sees as its player, when not the host (game/lan_mobs.rs).
pub mob_players: HashMap<u32, u32>,
@@ -1519,6 +1520,7 @@ impl Game<'_> {
self.lan_riding(host_entity, &guests, &mut host.hub.riding);
self.lan_entities(host_entity, &host.mob_players, &mut host.hub, &mut states);
self.lan_hook_states(host_entity, &host.play, &host.hub, &mut states);
self.lan_entity_uuids(&mut states);
} else {
states.clear();
}
@@ -1785,6 +1787,7 @@ impl Game<'_> {
// A dedicated server has no host player.
self.lan_entities(None, &host.mob_players, &mut host.hub, &mut theirs);
self.lan_hook_states(None, &host.play, &host.hub, &mut theirs);
self.lan_entity_uuids(&mut theirs);
theirs.as_slice()
} else {
entities
@@ -2008,6 +2011,34 @@ impl Game<'_> {
}
seen.insert(m.id, (m.hurt_time, dead, swing));
}
// LivingEntity.hurtServer for a player: a hit that restarted the hurt
// time and was not blocked by a shield broadcasts the damage event,
// so the clients around (and the player's own) flash it red.
let host_player = self.player.as_ref().map(|p| {
(
host.host_entity,
[p.position.x, p.position.y, p.position.z],
p,
)
});
let guests = host.hub.guests.iter().filter(|g| host.hub.here(g));
let players: Vec<_> = host_player
.into_iter()
.chain(guests.filter_map(|g| g.motion.as_deref().map(|m| (g.entity_id, g.position, m))))
.map(|(id, at, p)| (id, at, p.health.hurt_animation_time, p.hurt_log.last()))
.collect();
for (id, at, hurt, record) in players {
let old = host
.mob_state
.get(&self.dim)
.and_then(|s| s.get(&(id as u32)));
if hurt > old.map_or(0, |o| o.0) {
// Starving keeps no record: any type flashes the same.
let kind = record.map_or("minecraft:generic", |r| r.source.as_str());
host.hub.damage_event(id, kind, None, None, at);
}
seen.insert(id as u32, (hurt, false, None));
}
host.mob_state.insert(self.dim, seen);
// Level.broadcastEntityEvent(player, 2) of a spear's stab.
for attacker in kinetic_hits {
@@ -2116,6 +2147,11 @@ impl Game<'_> {
m.limb_speed += ((dist * 4.0).min(1.0) - m.limb_speed) * 0.4;
m.limb_position += m.limb_speed;
m.age = m.age.wrapping_add(1);
// The hurt red flash (LivingEntity.hurtTime).
m.hurt_time = g
.motion
.as_ref()
.map_or(0, |p| p.health.hurt_animation_time);
m.extra.main_hand = g
.inventory
.get(g.selected)
@@ -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);
@@ -7,6 +7,7 @@ use super::Game;
use horizon_client::lan::{
entity::{EntityState, Riding, States},
hub::Hub,
playerdata::ints_uuid,
};
use horizon_protocol::entity_data::{Value, serializer};
use horizon_world::nbt::Tag;
@@ -270,6 +271,16 @@ impl Game<'_> {
}
impl Game<'_> {
/// `ClientboundAddEntityPacket(Entity, ServerEntity, int)`: the entity's
/// own UUID (`Entity.getUUID`), the one `/data`, saves and UUID
/// selectors use, so a client can name it back (the crosshair UUID that
/// `ClientSuggestionProvider.getSelectedEntities` suggests).
pub(super) fn lan_entity_uuids(&mut self, states: &mut States) {
for e in states.as_mut_slice() {
e.uuid = ints_uuid(self.uuid_for(e.id as u32));
}
}
/// Gives the entities Horizon keeps without an id one (`Entity.id`, from
/// the same counter as the rest) before they are first tracked.
pub(super) fn lan_assign_ids(&mut self) {
+24 -1
View File
@@ -1227,6 +1227,22 @@ impl Game<'_> {
light: u32,
diffuse: bool,
out: &mut Vec<Vertex>,
) {
self.tinted_block_model_vertices(state, seed, m, |_| light, diffuse, |_| None, 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;
@@ -1248,11 +1264,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
};
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));
@@ -1261,6 +1283,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
@@ -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(&section)
|| 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],
&region,
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
@@ -686,7 +684,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()
+100 -2
View File
@@ -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) {
+33 -1
View File
@@ -2,7 +2,7 @@
//! `ServerGamePacketListenerImpl.handlePickItemFromEntity`): the client aims,
//! the server answers with `Entity.getPickResult`.
use super::Game;
use horizon_client::items::ItemStack;
use horizon_client::{items::ItemStack, lan::playerdata::uuid_ints, save::uuid_text};
impl Game<'_> {
/// The entity the crosshair hits before any block, as `Entity.getId`; an
@@ -33,6 +33,38 @@ impl Game<'_> {
best.map(|b| b.1)
}
/// `ClientSuggestionProvider.getSelectedEntities`: the UUID
/// (`Entity.getStringUUID`) of the entity the crosshair hits before any
/// block (`Minecraft.hitResult` an `EntityHitResult`).
pub(super) fn crosshair_entity_uuid(&self) -> Option<String> {
// The nearest entity wins, as in `pick_entity_hit`; one whose UUID
// the client doesn't know (the dragon's parts) suggests nothing.
let mut best: Option<(f64, Option<[i32; 4]>)> = None;
let mut offer = |distance: f64, uuid: Option<[i32; 4]>| {
if self.before_block(distance) && best.is_none_or(|b| distance < b.0) {
best = Some((distance, uuid));
}
};
let uuid_of = |id: u32| self.persist.uuids.get(&id).copied();
if let Some(hit) = self.remote_object_hit(true) {
offer(hit.distance, uuid_of(hit.id as u32));
}
if let Some((i, d)) = self.mob_hit() {
offer(d, uuid_of(self.mobs[i].id));
}
if let Some((conn, d)) = self.guest_hit() {
let guest = self.lan.as_ref().and_then(|h| h.hub.guest(conn));
offer(d, guest.map(|g| uuid_ints(g.uuid)));
}
if let Some((i, d)) = self.redirectable_hit() {
offer(d, uuid_of(self.creatures.projectiles[i].id));
}
if let Some((_, d)) = self.dragon_hit() {
offer(d, None);
}
best.and_then(|b| b.1).map(uuid_text)
}
/// `Entity.getPickResult` of the entity `id`: a mob's spawn egg, the item
/// of a boat, minecart, armor stand, cushion, painting, item frame (the
/// framed item when it holds one) or end crystal.
+11
View File
@@ -1337,10 +1337,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)
@@ -2,10 +2,11 @@
//! into zombie villagers (`Zombie.killedEntity`), the golden apple that starts
//! a cure (`ZombieVillager.mobInteract`), and the cured villager that
//! remembers who saved it (`ZombieVillager.finishConversion`).
use super::Game;
use super::{Game, commands::Target};
use horizon_client::{
advancements as adv,
gossip::GossipType,
lan::playerdata,
mobs::{Mob, Species, villager, zombie_villager},
};
use horizon_core::random::RandomSource;
@@ -93,7 +94,8 @@ impl Game<'_> {
return true;
}
self.consume_held();
let starter = self.level.player_uuid;
// `player.getUUID()`: the acting player, a server's guest included.
let starter = Some(self.acting_player_uuid());
let time = zombie_villager::WAIT_MIN
+ self
.world
@@ -127,11 +129,15 @@ impl Game<'_> {
return;
};
convert_to_villager(&old, v);
// `Villager.onReputationEventFrom(ZOMBIE_VILLAGER_CURED)`.
// `level.getPlayerByUUID(conversionStarter)`: the player who fed the
// apple, if still in this level (the host or a server's guest).
let starter = zombie_villager::cure(&old).and_then(|c| c.starter);
let player = self.level.player_uuid;
let cured_by_player = starter.is_some() && starter == player;
if cured_by_player && let Some(uuid) = starter {
let curer = starter.and_then(|uuid| Some((uuid, self.player_here(uuid)?)));
// `Villager.onReputationEventFrom(ZOMBIE_VILLAGER_CURED)`.
let Some(v) = self.mobs.iter_mut().find(|m| m.id == vid) else {
return;
};
if let Some((uuid, _)) = curer {
let g = &mut villager::ext_mut(v).gossips;
g.add(uuid, GossipType::MajorPositive, 20);
g.add(uuid, GossipType::MinorPositive, 25);
@@ -139,8 +145,10 @@ impl Game<'_> {
let cured = v.clone();
let villager_view = self.mob_entity(&cured);
let zombie_view = self.mob_entity(&old);
if cured_by_player {
self.trigger_cured_zombie_villager(&zombie_view, &villager_view);
if let Some((_, who)) = curer {
self.for_player(who, |g| {
g.trigger_cured_zombie_villager(&zombie_view, &villager_view)
});
}
let pitch = (self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0;
self.audio.play(
@@ -151,6 +159,18 @@ impl Game<'_> {
);
}
/// `ServerLevel.getPlayerByUUID`: the player with `uuid` in this level.
fn player_here(&self, uuid: [i32; 4]) -> Option<Target> {
match self.target_by_uuid(playerdata::ints_uuid(uuid))? {
Target::Player => Some(Target::Player),
Target::Guest(conn) => {
let g = self.lan.as_ref()?.hub.guest(conn)?;
(g.dimension == self.dim.id()).then_some(Target::Guest(conn))
}
_ => None,
}
}
/// `CuredZombieVillagerTrigger`.
fn trigger_cured_zombie_villager(
&mut self,
+5
View File
@@ -90,6 +90,11 @@ fn components_tag(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> Option<Tag>
("pattern", Tag::String(pattern.into())),
])
}
// Instruments are a synced registry: a goat horn names its own.
Component::Instrument(id) => match synced.name("minecraft:instrument", *id) {
Some(name) => Tag::String(name.into()),
None => continue,
},
// BannerPatternLayers: each layer's pattern is a synced registry entry.
Component::BannerPatterns(layers) => {
let layers: Option<Vec<Tag>> = layers
+3
View File
@@ -128,6 +128,9 @@ impl States {
pub fn as_slice(&self) -> &[EntityState] {
&self.list[..self.len]
}
pub fn as_mut_slice(&mut self) -> &mut [EntityState] {
&mut self.list[..self.len]
}
}
impl Extend<EntityState> for States {
+34
View File
@@ -668,6 +668,22 @@ fn network_stack(
));
}
stack.added.extend(firework_components(d));
// A goat horn's instrument, by its `minecraft:instrument` id.
if let Some(id) = d
.other("minecraft:instrument")
.and_then(|t| crate::save::text(Some(t)))
.and_then(|name| {
synced_id(
"minecraft:instrument",
&horizon_world::datafix::dynamic::namespaced(&name),
)
})
{
stack.added.push((
horizon_protocol::item::component::INSTRUMENT,
horizon_protocol::item::Component::Instrument(id),
));
}
// The compass's lodestone target.
if let Some(t) = d.other("minecraft:lodestone_tracker") {
let target = t.get("target").and_then(|g| {
@@ -826,6 +842,24 @@ mod tests {
assert_eq!(stack.component(component::TRIM), None);
}
#[test]
fn goat_horns_send_their_instrument_by_registry_id() {
let mut horn = ItemStack::new("minecraft:goat_horn", 1);
horn.data_mut().other.push((
"minecraft:instrument".into(),
horizon_world::nbt::Tag::String("minecraft:sing_goat_horn".into()),
));
let synced_id = |registry: &str, name: &str| match (registry, name) {
("minecraft:instrument", "minecraft:sing_goat_horn") => Some(1),
_ => None,
};
let stack = stack_to_network(|_| 7, synced_id, &Some(horn)).unwrap();
assert_eq!(
stack.component(component::INSTRUMENT),
Some(&Component::Instrument(1))
);
}
fn guest_at(position: [f64; 3]) -> Guest {
let (tx, _rx) = std::sync::mpsc::channel();
Guest::new(
+1
View File
@@ -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;
@@ -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 {
+102 -17
View File
@@ -2503,14 +2503,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,
}
@@ -2524,8 +2593,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),
})
@@ -2558,7 +2626,7 @@ impl WorldGen {
impl Default for WorldGen {
fn default() -> Self {
Self {
flat: false,
preset: WorldPreset::Normal,
structures: true,
bonus_chest: false,
}
@@ -2633,6 +2701,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",
@@ -2646,18 +2742,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(
+1 -1
View File
@@ -399,7 +399,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 {
+10 -18
View File
@@ -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,
@@ -46,7 +46,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,
@@ -69,9 +69,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,
@@ -94,7 +93,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(),
}
@@ -119,8 +118,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());
}
@@ -255,14 +254,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,
),
@@ -272,7 +264,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.
+95 -13
View File
@@ -797,27 +797,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
}
@@ -835,26 +857,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;
+8 -3
View File
@@ -1662,10 +1662,15 @@ impl ArgType {
let _ = context;
match self {
ArgType::Bool => builder.suggest_matching(["true", "false"]),
ArgType::Entity { .. } => {
let players = reg.players.clone();
ArgType::Entity { players, .. } => {
// EntityArgument.listSuggestions: the online player names,
// then (unless players only) getSelectedEntities.
let mut names = reg.players.clone();
if !players {
names.extend(reg.selected_entities.iter().cloned());
}
super::selector::suggest(builder, reg, &|b| {
b.suggest_matching(players.iter().map(String::as_str))
b.suggest_matching(names.iter().map(String::as_str))
});
}
ArgType::BlockPos => suggest_coordinates(builder, reg, self, true),
+2 -1
View File
@@ -267,10 +267,11 @@ impl<'r, 'a> SelectorParser<'r, 'a> {
}
let start = self.reader.cursor;
let name = self.reader.read_string()?;
// A UUID selects by entityUUID alone: findEntities looks a
// playerName up first, among the players only.
if let Some(uuid) = java_uuid(&name) {
self.s.uuid = Some(uuid);
self.s.include_entities = true;
self.s.player_name = Some(name);
} else {
if name.is_empty() || name.encode_utf16().count() > 16 {
self.reader.cursor = start;
+55
View File
@@ -257,6 +257,61 @@ fn suggestions_like_vanilla() {
assert!(suggestions("time set ").contains(&"minecraft:day".to_owned()));
}
#[test]
fn entity_arguments_suggest_the_selected_entity() {
// EntityArgument.listSuggestions: getSelectedEntities (the crosshair
// entity's UUID) joins the player names unless the argument takes
// players only; GameProfileArgument and ScoreHolderArgument never ask.
let uuid = "0b9c6a7e-3d1f-4a2b-9c8d-7e6f5a4b3c2d";
let mut reg = reg();
reg.selected_entities = vec![uuid.into()];
let d = vanilla::dispatcher();
let suggest = |input: &str| -> Vec<String> {
let parse = d.parse(input, &reg);
d.suggestions(&parse, input.len(), &reg)
.1
.into_iter()
.map(|s| s.text)
.collect()
};
assert_eq!(
suggest("tp "),
[
"0b9c6a7e-3d1f-4a2b-9c8d-7e6f5a4b3c2d",
"@a",
"@e",
"@n",
"@p",
"@r",
"@s",
"Player",
"~",
"~ ~",
"~ ~ ~"
]
);
assert!(suggest("kill ").contains(&uuid.to_owned()));
assert_eq!(suggest("kill 0b9"), [uuid]);
for players_only in ["gamemode creative ", "op ", "scoreboard players set "] {
assert!(
!suggest(players_only).contains(&uuid.to_owned()),
"{players_only}"
);
}
}
#[test]
fn a_uuid_selects_by_entity_uuid() {
// EntitySelectorParser.parseNameOrUUID: a UUID sets entityUUID and
// includesEntities, never playerName (which findEntities checks first).
let reg = reg();
let mut r = Reader::new("2de5a9ad-2740-4058-835e-cf255e0d7798");
let s = selector::parse(&mut r, &reg, true).unwrap();
assert_eq!(s.uuid, Some(0x2de5a9ad_2740_4058_835e_cf255e0d7798));
assert!(s.include_entities && s.player_name.is_none());
let mut r = Reader::new("Steve");
let s = selector::parse(&mut r, &reg, true).unwrap();
assert_eq!((s.uuid, s.player_name.as_deref()), (None, Some("Steve")));
}
#[test]
fn smart_usage_matches_brigadier() {
let d = vanilla::dispatcher();
let usage = |name: &str| {
@@ -3,7 +3,7 @@
//! `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
//! bytes, or decodes into ids it writes back unchanged, like trims and instruments) and
//! whether encoding the decoded stack gives the same bytes back.
//!
//! cargo run -p horizon-protocol --example item_wire_probe -- vanilla.jsonl rust.jsonl
@@ -46,7 +46,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let typed: Vec<i32> = stack
.iter()
.flat_map(|s| &s.added)
.filter(|(_, c)| !matches!(c, Component::Raw(_) | Component::Trim { .. }))
.filter(|(_, c)| {
!matches!(
c,
Component::Raw(_) | Component::Trim { .. } | Component::Instrument(_)
)
})
.map(|(k, _)| *k)
.collect();
json!({
+20
View File
@@ -43,6 +43,7 @@ pub mod component {
pub const WRITABLE_BOOK_CONTENT: i32 = 56;
pub const WRITTEN_BOOK_CONTENT: i32 = 57;
pub const TRIM: i32 = 58;
pub const INSTRUMENT: i32 = 63;
pub const LODESTONE_TRACKER: i32 = 69;
pub const FIREWORK_EXPLOSION: i32 = 70;
pub const FIREWORKS: i32 = 71;
@@ -117,6 +118,9 @@ pub enum Component {
material: i32,
pattern: i32,
},
/// `InstrumentComponent` naming its instrument by `minecraft:instrument`
/// registry id; a direct instrument stays [`Component::Raw`].
Instrument(i32),
/// `BannerPatternLayers`: each layer's `banner_pattern` registry id and
/// its dye id (`DyeColor.getId`). A layer with a direct pattern has no
/// registry name: that value stays [`Component::Raw`].
@@ -358,6 +362,18 @@ impl Component {
_ => Self::Raw(bytes.to_vec()),
}
}
// InstrumentComponent.STREAM_CODEC: a holder, a reference's id plus one.
// A direct instrument has no registry name: read past.
INSTRUMENT => {
let start = r.position();
skip::component(INSTRUMENT, r)?;
let bytes = r.since(start);
let mut ids = Reader::new(bytes);
match ids.varint() {
Ok(id @ 1..) if ids.remaining() == 0 => Self::Instrument(id - 1),
_ => Self::Raw(bytes.to_vec()),
}
}
// DyeColor.STREAM_CODEC: its id.
BASE_COLOR => Self::Int(r.varint()?),
// BannerPatternLayers.STREAM_CODEC: a list of a pattern holder (a
@@ -408,6 +424,7 @@ impl Component {
w.varint(material + 1);
w.varint(pattern + 1);
}
Self::Instrument(id) => w.varint(id + 1),
Self::Int(v) if kind == component::DYED_COLOR => w.i32(*v),
Self::Int(v) => w.varint(*v),
Self::Unit => {}
@@ -1027,6 +1044,8 @@ mod tests {
("01890b01004a02200e000b6d696e6563726166743a7801790b", 74),
("01880301004c01f60801000001be0c0100000001d30c010000", 76),
("01a408010037020f6412a001", 55),
// A goat horn's instrument by reference (InstrumentComponent's holder).
("01bd0801003f02", 63),
] {
let bytes: Vec<u8> = (0..hex.len())
.step_by(2)
@@ -1042,6 +1061,7 @@ mod tests {
pattern: 1,
}
}
63 => *c == Component::Instrument(1),
_ => matches!(c, Component::Raw(_)),
};
assert!(
+7
View File
@@ -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())
@@ -3322,4 +3322,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"));
}
}
@@ -114,7 +114,7 @@ public final class VanillaChatScreenOracle {
private Provider() { super(null, null, null); }
@Override public Collection<String> getOnlinePlayerNames() { return new ArrayList<>(List.of(VanillaCommandParseOracle.PLAYERS)); }
@Override public Collection<String> getCustomTabSuggestions() { return getOnlinePlayerNames(); }
@Override public Collection<String> getSelectedEntities() { return List.of(); }
@Override public Collection<String> getSelectedEntities() { return new ArrayList<>(List.of(VanillaCommandParseOracle.SELECTED)); }
@Override public Collection<String> getAllTeams() { return new ArrayList<>(List.of(VanillaCommandParseOracle.TEAMS)); }
@Override public java.util.stream.Stream<Identifier> getAvailableSounds() { return BuiltInRegistries.SOUND_EVENT.keySet().stream(); }
@Override public java.util.stream.Stream<Identifier> getAvailablePostEffects() { return source.getAvailablePostEffects(); }
@@ -67,6 +67,8 @@ import net.minecraft.world.phys.Vec3;
public final class VanillaCommandParseOracle {
static sun.misc.Unsafe unsafe;
static final String[] PLAYERS = {"Player", "Steve", "Alex"};
/** ClientSuggestionProvider.getSelectedEntities: the crosshair entity's UUID. */
static final String[] SELECTED = {"0b9c6a7e-3d1f-4a2b-9c8d-7e6f5a4b3c2d"};
static final String[] TEAMS = {"red", "blue"};
static final String[] OBJECTIVES = {"kills", "health", "obj"};
static final String[] TRACKED = {"#total", "Steve", "fake"};
@@ -83,6 +85,7 @@ public final class VanillaCommandParseOracle {
this.lookup = lookup;
}
@Override public Collection<String> getOnlinePlayerNames() { return new ArrayList<>(List.of(PLAYERS)); }
@Override public Collection<String> getSelectedEntities() { return new ArrayList<>(List.of(SELECTED)); }
@Override public Collection<String> getAllTeams() { return new ArrayList<>(List.of(TEAMS)); }
@Override public RegistryAccess registryAccess() { return access; }
@Override public Set<ResourceKey<Level>> levels() { return Set.of(Level.OVERWORLD, Level.NETHER, Level.END); }
@@ -374,6 +377,7 @@ public final class VanillaCommandParseOracle {
o.add("timelines", dynamic(access, Registries.TIMELINE));
o.add("dimensions", arr("minecraft:overworld", "minecraft:the_nether", "minecraft:the_end"));
o.add("players", arr(PLAYERS));
o.add("selected_entities", arr(SELECTED));
o.add("teams", arr(TEAMS));
o.add("objectives", arr(OBJECTIVES));
o.add("tracked", arr(TRACKED));
+105 -2
View File
@@ -38,7 +38,7 @@ address. The script then checks, from both sides, what a guest's game used to ge
respawned player is a new entity to the others); the host dies, and on the server thread
the guest too;
- hurt: a pig the guest hits, and one the host hits, flashes red on the other side too (the
server's damage event) (#848);
server's damage event) (#848), and so does a hurt host or guest (#2827);
- containers: the guest's ender chest holds the guest's items, not the host's, and a double chest
opens as 9x6 with the right half first;
- machines: a hopper, a dispenser and a dropper open on the guest's screen with their 5 and 9
@@ -262,6 +262,8 @@ address. The script then checks, from both sides, what a guest's game used to ge
sends each target its packet).
- hero-trading: the host and the guest each trade 20 wheat for an emerald, paying 14 with Hero I
and 12 with Hero III; clearing the effect restores the ordinary price (#1365).
- cure-discount: the host and the guest each cure a zombie villager with a golden apple and pay 14
wheat instead of 20 for its emerald (the ZOMBIE_VILLAGER_CURED gossip, #3306).
- falling: an item, a falling block and primed TNT the host summons high up fall smoothly on the
guest, a little further every tick between the server's updates (every 20 ticks, 10 for
TNT), as their own client tick moves them (they used to hang, then jump down once a
@@ -1354,9 +1356,29 @@ def check_hurt(host, guest, out):
watcher.shot(out, 'hurt-' + name.replace(' ', '-') + '-seen')
attacker.wait(20)
host('command', command='kill @e[type=minecraft:pig]')
# A hurt player flashes red for the other one too (#2827).
# An in-frame host draws its guests as models of its own, not entities: only the screenshot.
watched = ((('host hurt', host, guest),) if not SERVER_THREAD else
(('host hurt', host, guest), ('guest hurt', guest, host)))
for name, hurt, watcher in watched:
hurt.wait(30)
# Instant damage hurts on the next effect tick.
hurt('command', command='effect give @s minecraft:instant_damage 1 0')
for _ in range(10):
other = watcher.raw('entities', 6, 'player')['entities'][0]
if other['hurt_time'] > 0:
break
watcher.wait(1)
seen[name] = {'watcher': other['hurt_time']}
watcher.shot(out, 'hurt-' + name.replace(' ', '-') + '-seen')
if not SERVER_THREAD:
guest.wait(30)
guest('command', command='effect give @s minecraft:instant_damage 1 0')
guest.wait(2)
host.shot(out, 'hurt-guest-hurt-seen')
# LivingEntity.hurtTime: 10 when hurt, one less each tick; the reads come a few ticks later.
for name, sides in seen.items():
assert all(t > 0 for t in sides.values()), f'{name}: the pig\'s hurt flash {seen}'
assert all(t > 0 for t in sides.values()), f'{name}: the hurt flash {seen}'
return seen
@@ -6532,6 +6554,87 @@ CHECKS += ['hero-trading']
CHECK_FNS.update({'hero-trading': check_hero_trading})
def check_cure_discount(host, guest, out):
"""#3306: ZombieVillager.finishConversion credits the player who fed the golden apple
(conversionStarter, level.getPlayerByUUID): the ZOMBIE_VILLAGER_CURED gossip (+20 major and +25
minor positive, reputation 125) lowers that player's price by floor(125 * 0.05) = 6, for the
host on its server thread and for a guest alike."""
x, y, z = (int(v // 1) for v in pos_of(host))
tag = 'cure_trade'
at = (x + 2.5, y, z + 0.5)
seen = {}
try:
host('command', command=f'fill {x - 4} {y - 1} {z - 2} {x + 4} {y - 1} {z + 2} minecraft:stone')
host('command', command=f'fill {x - 4} {y} {z - 2} {x + 4} {y + 3} {z + 2} minecraft:air')
host('command', command='time set midnight')
for game, other in ((host, guest), (guest, host)):
back_to_game(game)
back_to_game(other)
other('command', command=f'tp @s {x - 3.5} {y} {z + 0.5}')
game('command', command='gamemode survival')
game('command', command='effect clear @s')
game('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} -90 0')
game('command', command='clear @s')
game('command', command='give @s minecraft:golden_apple')
game.wait(10)
game.raw('select', 'minecraft:golden_apple')
host('command', command=f'summon minecraft:zombie_villager {at[0]} {at[1]} {at[2]} '
f'{{Tags:["{tag}"],NoAI:1b,PersistenceRequired:1b,Xp:1,VillagerData:{{type:"minecraft:plains",'
'profession:"minecraft:farmer",level:1},Offers:{Recipes:[{buy:{id:"minecraft:wheat",count:20},'
'sell:{id:"minecraft:emerald",count:1},maxUses:16,uses:0,xp:0,priceMultiplier:0.05f,demand:0}]}}')
host('command', command=f'effect give @e[tag={tag}] minecraft:weakness 60 0')
game.wait(20)
zombie = next(e['id'] for e in game.raw('entities', 8, 'minecraft:zombie_villager')['entities']
if math.dist(e['pos'], at) < 0.5)
# ZombieVillager.mobInteract: the apple starts the cure; then it is cut short.
game.raw('interact-entity', zombie)
game.wait(10)
# Horizon counts the cure down in the AI step, so NoAI goes for the last tick.
host('command', command=f'data merge entity @e[tag={tag},limit=1] {{ConversionTime:1,NoAI:0b}}')
villager = None
for _ in range(30):
game.wait(5)
villager = next((e['id'] for e in game.raw('entities', 8, 'minecraft:villager')['entities']
if math.dist(e['pos'], at) < 2), None)
if villager is not None:
break
assert villager is not None, f'{game.name}: the zombie villager was not cured'
near = f'x={at[0]},y={at[1]},z={at[2]},distance=..3,sort=nearest,limit=1'
host('command', command=f'data merge entity @e[type=minecraft:villager,{near}] '
f'{{NoAI:1b,Tags:["{tag}"]}}')
game('command', command='clear @s')
game('command', command='give @s minecraft:wheat 20')
game.wait(10)
game.raw('interact-entity', villager)
game.raw('wait-for', 'screen', 'station', '--timeout', 10)
assert game('screen')['station'] == 'Merchant', f'{game.name}: no merchant screen'
game.raw('click-gui', -1, 28)
game.wait(10)
assert station_slots(game).get(650) == ('minecraft:emerald', 1), f'{game.name}: no result'
game.shot(out, f'cure-discount-{game.name}')
game.raw('click-slot', 650, 'shift')
game.wait(10)
game.raw('key', 'esc', 'tap')
game.wait(10)
case = {item: server_count(game, item) for item in ('wheat', 'emerald')}
seen[game.name] = case
want = {'wheat': 20 - 14, 'emerald': 1}
assert case == want, f'{game.name}: expected {want}, got {case}'
host('command', command=f'kill @e[tag={tag}]')
game.wait(5)
finally:
for game in (host, guest):
back_to_game(game)
game('command', command='clear @s')
host('command', command=f'kill @e[tag={tag}]')
host('command', command='time set day')
return seen
CHECKS += ['cure-discount']
CHECK_FNS.update({'cure-discount': check_cure_discount})
def check_spectator_travel(host, guest, out):
"""Player.tick's spectator noPhysics survives a replacement body after dimension travel (#1299)."""
players = (host, guest) if SERVER_THREAD else (host,)
+149
View File
@@ -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()
+2 -1
View File
@@ -70,6 +70,7 @@ FIXTURES = [
('bees', '{bees:[{entity_data:{id:"minecraft:bee",Health:5f},ticks_in_hive:5,min_ticks_in_hive:10}]}'),
('entity_data', '{entity_data:{id:"minecraft:pig",NoAI:1b}}'),
('bucket_entity_data', '{bucket_entity_data:{Health:5f}}'),
('instrument registry', '{instrument:"minecraft:sing_goat_horn"}'),
('instrument direct', '{instrument:{sound_event:{sound_id:"minecraft:x",range:8f},use_duration:3f,range:64f,description:"Horn"}}'),
('painting variant', '{"minecraft:painting/variant":"minecraft:kebab"}'),
('sign_text_front', '{sign_text_front:{messages:["a","b","c","d"],filtered_messages:["e","f","g","h"],color:"red",has_glowing_text:1b}}'),
@@ -104,7 +105,7 @@ FIXTURES = [
# Fixtures on another item than a stick (the components need it, or read better there).
ITEMS = {'profile name': 'player_head', 'profile full': 'player_head', 'trim registry': 'diamond_chestplate',
'trim direct': 'diamond_chestplate', 'banner_patterns': 'white_banner', 'pot_decorations': 'decorated_pot',
'everything at once': 'diamond_chestplate'}
'everything at once': 'diamond_chestplate', 'instrument registry': 'goat_horn'}
def compare(vanilla, rust):
+9 -5
View File
@@ -15,8 +15,8 @@ the worker's number is left out). In a world without structures (`generate-struc
finds none) and how many of the saved chunks hold structure starts or references (none:
ChunkStatusTasks.generateStructureStarts does nothing).
Horizon generates the normal and flat presets only: `large_biomes`, `amplified`, `single_biome_surface` and
`debug_all_block_states` aren't tested.
Horizon generates the normal, flat, amplified, large-biome and single-biome presets:
`debug_all_block_states` isn't tested.
"""
import json
import os
@@ -61,6 +61,9 @@ CASES = {
'generator-settings': '{"layers":[{"block":"minecraft:glass","height":5}],"biome":"nowhere"}',
},
'normal': {'level-type': 'minecraft:normal', 'level-seed': 'abc', 'generate-structures': 'false'},
'amplified': {'level-type': 'minecraft:amplified', 'level-seed': '1'},
'large-biomes': {'level-type': 'minecraft:large_biomes', 'level-seed': '2'},
'single-biome': {'level-type': 'minecraft:single_biome_surface', 'level-seed': '3'},
'unknown': {'level-type': 'Some Type', 'level-seed': '42'},
}
WORKER = re.compile(r'\[Worker-Main-\d+/(WARN|ERROR)\]: (.*)')
@@ -155,9 +158,10 @@ def main():
schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(),
java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0],
cases=list(CASES), lines=len(vanilla), output_sha256=lab.digest(build / 'horizon.txt', 'sha256'),
scope='a new dedicated-server world: level-type (flat, normal, unknown), a flat world\'s '
'generator-settings, generate-structures, as world_gen_settings.dat and the lines logged; without '
'structures, locate structure\'s replies and the saved chunks\' structure starts and references.'))
scope='a new dedicated-server world: level-type (flat, normal, amplified, large_biomes, '
'single_biome_surface, unknown), a flat world\'s generator-settings, generate-structures, as '
'world_gen_settings.dat and the lines logged; without structures, locate structure\'s replies and '
'the saved chunks\' structure starts and references.'))
print(f'server world gen: {len(CASES)} cases, {len(vanilla)} lines identical')