diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 26211a501d..cdb28e3091 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -120,13 +120,16 @@ use horizon_client::{ vanillagen::VanillaTerrain, world::World, }; -use horizon_core::{geometry::Direction, position::BlockPos}; use horizon_core::{geometry::Vec3, random::RandomSource}; +use horizon_core::{ + geometry::{Aabb, Direction}, + position::BlockPos, +}; use horizon_world::{ block_collision::BlockCollisionRegistry, level::{ - Behaviours, Dimension as LevelDimension, Landing, LevelAccess, LevelEvent, PlaceContext, - PlaceOutcome, PlayerContext, PlayerEffect, Ticks, flags as level_flags, + Behaviours, Dimension as LevelDimension, EntityQuery, Landing, LevelAccess, LevelEvent, + PlaceContext, PlaceOutcome, PlayerContext, PlayerEffect, Ticks, flags as level_flags, }, registry::BlockStateId, }; @@ -312,6 +315,8 @@ struct GameLevel<'w> { raining: bool, thundering: bool, player_box: Option<([f64; 3], [f64; 3])>, + /// Minecart boxes (detector rails). + cart_boxes: Vec<([f64; 3], [f64; 3])>, biomes: &'w Biomes, seed: i64, behaviours: &'w Behaviours, @@ -444,6 +449,22 @@ impl LevelAccess for GameLevel<'_> { && z < hi[2] - 1e-7 && z + 1.0 > lo[2] + 1e-7) } + /// Entities that trigger pressure plates, tripwires and detector rails: + /// the player (a mob) and the minecarts. + fn count_entities(&self, area: Aabb, query: EntityQuery) -> usize { + let hit = |(lo, hi): &([f64; 3], [f64; 3])| { + area.intersects(Aabb::new( + Vec3::new(lo[0], lo[1], lo[2]), + Vec3::new(hi[0], hi[1], hi[2]), + )) + }; + let carts = || self.cart_boxes.iter().filter(|b| hit(b)).count(); + match query { + EntityQuery::Minecart => carts(), + EntityQuery::Mobs => self.player_box.iter().filter(|b| hit(b)).count(), + EntityQuery::Everything => self.player_box.iter().filter(|b| hit(b)).count() + carts(), + } + } fn emit(&mut self, event: LevelEvent) { self.events.push(event); } @@ -1191,6 +1212,17 @@ impl Game<'_> { ], ) }); + let cart_boxes = self + .carts + .iter() + .map(|c| { + let p = c.position; + ( + [p.x - 0.49, p.y, p.z - 0.49], + [p.x + 0.49, p.y + 0.7, p.z + 0.49], + ) + }) + .collect(); let dimension = match self.dim { Dim::Overworld => LevelDimension::Overworld, Dim::Nether => LevelDimension::Nether, @@ -1198,6 +1230,7 @@ impl Game<'_> { }; let rules = self.level_rules(); let mut level = GameLevel { + cart_boxes, world: &mut self.world, facts: &self.res.facts, events: &mut self.level_events, @@ -1266,7 +1299,41 @@ impl Game<'_> { .collect(); ticking.push((x, z, sections)); } + // Entity.checkInsideBlocks for the plates, tripwires and detector rails + // under the player and the minecarts. + let mut inside = Vec::new(); + let boxes = self + .player + .iter() + .filter(|_| !self.spectator()) + .map(|p| { + let w = f64::from(p.dimensions.width) / 2.0; + let h = f64::from(p.dimensions.height); + ( + [p.position.x - w, p.position.y, p.position.z - w], + [p.position.x + w, p.position.y + h, p.position.z + w], + ) + }) + .chain(self.carts.iter().map(|c| { + let p = c.position; + ( + [p.x - 0.49, p.y, p.z - 0.49], + [p.x + 0.49, p.y + 0.7, p.z + 0.49], + ) + })); + for (lo, hi) in boxes { + for x in (lo[0] + 1e-7).floor() as i32..=(hi[0] - 1e-7).floor() as i32 { + for y in (lo[1] + 1e-7).floor() as i32..=(hi[1] - 1e-7).floor() as i32 { + for z in (lo[2] + 1e-7).floor() as i32..=(hi[2] - 1e-7).floor() as i32 { + inside.push(BlockPos::new(x, y, z)); + } + } + } + } self.with_level(|b, level, ticks| { + for pos in inside { + b.entity_inside(level, ticks, pos); + } b.tick_scheduled(level, ticks); for (x, z, sections) in ticking { b.tick_chunk_random( diff --git a/crates/horizon-world/src/level/hooks.rs b/crates/horizon-world/src/level/hooks.rs index fcd77961bd..579c497bfe 100644 --- a/crates/horizon-world/src/level/hooks.rs +++ b/crates/horizon-world/src/level/hooks.rs @@ -90,7 +90,10 @@ impl Sim<'_, L> { Class::BaseFire => self.base_fire_on_place(pos, state, same), Class::DetectorRail | Class::BaseRail => { if !same { - self.rail_update_state(pos, state, moved); + let updated = self.rail_update_state(pos, state, moved); + if class == Class::DetectorRail { + self.detector_rail_check_pressed(pos, updated); + } } } Class::Brushable => { @@ -416,6 +419,10 @@ impl Sim<'_, L> { if signal == 0 { self.plate_check_pressed(pos, state, signal); } + } else if data.is(Class::DetectorRail) { + if !b.table.bool(state, b.props.powered) { + self.detector_rail_check_pressed(pos, state); + } } else if data.is(Class::TripWire) && !b.table.bool(state, b.props.powered) && !self.ticks.has_block_tick(pos, b.block(state)) diff --git a/crates/horizon-world/src/level/mod.rs b/crates/horizon-world/src/level/mod.rs index 310a65021e..4f8b7f69a9 100644 --- a/crates/horizon-world/src/level/mod.rs +++ b/crates/horizon-world/src/level/mod.rs @@ -196,6 +196,8 @@ pub enum EntityQuery { Everything, /// Living entities only (stone plates). Mobs, + /// Minecarts only (detector rails). + Minecart, } /// Side effects reported to the caller, in vanilla call order. diff --git a/crates/horizon-world/src/level/rails.rs b/crates/horizon-world/src/level/rails.rs index b5a7847963..748719735c 100644 --- a/crates/horizon-world/src/level/rails.rs +++ b/crates/horizon-world/src/level/rails.rs @@ -515,6 +515,40 @@ impl Sim<'_, L> { } } } + /// `DetectorRailBlock.checkPressed`. + pub(crate) fn detector_rail_check_pressed(&mut self, pos: BlockPos, state: BlockStateId) { + let b = self.b; + if !self.can_survive(state, pos) || !b.is_block(self.state(pos), b.block(state)) { + return; + } + let was = b.table.bool(state, b.props.powered); + // getInteractingMinecartOfType: the 0.2-inset search box. + let (x, y, z) = (f64::from(pos.x), f64::from(pos.y), f64::from(pos.z)); + let area = Aabb::new( + Vec3::new(x + 0.2, y, z + 0.2), + Vec3::new(x + 0.8, y + 0.8, z + 0.8), + ); + let pressed = self + .level + .count_entities(area, super::EntityQuery::Minecart) + > 0; + let block = b.block(state); + if pressed != was { + let next = b.table.set_bool(state, b.props.powered, pressed); + self.set_block_and_update(pos, next); + let rail = self.rail_new(pos, next); + for connection in rail.connections { + let at = self.state(connection); + self.neighbor_changed_full(at, connection, b.block(at), false); + } + self.update_neighbors_at(pos, block); + self.update_neighbors_at(below(pos), block); + } + if pressed { + self.schedule_tick(pos, block, 20); + } + self.update_neighbour_for_output_signal(pos, block); + } pub(crate) fn tripwire_tick(&mut self, pos: BlockPos) { let b = self.b; let state = self.state(pos); diff --git a/crates/horizon-world/src/level/tick.rs b/crates/horizon-world/src/level/tick.rs index 344e2b4ba1..09aaa45393 100644 --- a/crates/horizon-world/src/level/tick.rs +++ b/crates/horizon-world/src/level/tick.rs @@ -52,6 +52,11 @@ impl Sim<'_, L> { self.plate_check_pressed(pos, state, signal); } } + Class::DetectorRail => { + if b.table.bool(state, b.props.powered) { + self.detector_rail_check_pressed(pos, state); + } + } Class::BigDripleaf => self.big_dripleaf_tick(pos, state), Class::AbstractCauldron => self.cauldron_tick(state, pos), Class::BubbleColumn => { diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaBlockUpdateOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaBlockUpdateOracle.java index 0df61602c2..8b7e198e61 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaBlockUpdateOracle.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaBlockUpdateOracle.java @@ -117,6 +117,7 @@ public final class VanillaBlockUpdateOracle { World() { super(null, null, null, null, null, null, false, 0L, List.of(), false); } + java.util.LinkedHashSet blockEvents; void emit(String line) { log.append(line).append('\n'); } @@ -173,7 +174,7 @@ public final class VanillaBlockUpdateOracle { emit("G " + pos(BlockPos.containing(p)) + " " + e.getRegisteredName()); } @Override public int sendParticles(T p, double x, double y, double z, int n, double a, double b, double c, double s) { return 0; } - @Override public void blockEvent(BlockPos p, Block block, int a, int b) { emit("B " + pos(p) + " " + BuiltInRegistries.BLOCK.getKey(block) + " " + a + " " + b); } + @Override public void blockEvent(BlockPos p, Block block, int a, int b) { blockEvents.add("B " + pos(p) + " " + BuiltInRegistries.BLOCK.getKey(block) + " " + a + " " + b); } @Override public void updatePOIOnBlockStateChange(BlockPos p, BlockState a, BlockState b) { } @Override public boolean isPositionEntityTicking(BlockPos p) { return true; } @Override public boolean shouldTickBlocksAt(BlockPos p) { return true; } @@ -287,6 +288,7 @@ public final class VanillaBlockUpdateOracle { World w = make(World.class); w.cells = new HashMap<>(); w.log = new StringBuilder(); + w.blockEvents = new java.util.LinkedHashSet<>(); for (var cell : f.getAsJsonArray("cells")) { var c = cell.getAsJsonArray(); BlockState s = Block.stateById(c.get(3).getAsInt()); @@ -372,6 +374,7 @@ public final class VanillaBlockUpdateOracle { for (String s : summary(w, before)) result.append(s).append('\n'); pending(w, result); result.append(w.log); + for (String s : w.blockEvents) result.append(s).append('\n'); result.append("rng ").append(w.rng.nextLong()).append('\n'); } } diff --git a/tools/test_random_tick_parity.py b/tools/test_random_tick_parity.py new file mode 100755 index 0000000000..7dc2a0a492 --- /dev/null +++ b/tools/test_random_tick_parity.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Compare vanilla random ticks (and scheduled ticks) with horizon-world, state by state. + +Every block state is put at the origin of a random neighbourhood and `randomTick` +(or `tick`) is run with a seeded RNG on both sides; the resulting block changes, +scheduled ticks, side effects and the RNG fingerprint after the call must match. +""" +import random +try: + from . import block_update_harness as h +except ImportError: + import block_update_harness as h + + +def cases(facts, count, seed, only): + rng = random.Random(seed) + pool = h.state_pool(facts, only) + for n in range(count): + state = pool[n % len(pool)] + case = {**h.environment(rng), 'pos': [0, 0, 0], 'cells': facts.cells(rng, state), + 'mode': 'random' if n % 4 else 'tick'} + yield case + + +def main(): + args = h.argument_parser(__doc__).parse_args() + facts = h.load_facts() + differing = h.execute('random-tick-parity', list(cases(facts, args.count, args.seed, args.only)), args, facts) + h.finish(differing) + + +if __name__ == '__main__': + main() diff --git a/tools/test_redstone_parity.py b/tools/test_redstone_parity.py new file mode 100755 index 0000000000..8babe0d458 --- /dev/null +++ b/tools/test_redstone_parity.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Compare vanilla redstone circuits with horizon-world, tick by tick. + +A small floor carries randomly placed redstone components (wire, repeaters, +comparators, torches, observers, pistons, lamps, rails, plates, buttons...). A power +source is placed and later removed through `Level.setBlock` and the scheduled-tick +queue is run for N game ticks; the block changes and pending ticks after every +tick (and the RNG fingerprint at the end) must match. +""" +import random +try: + from . import block_update_harness as h +except ImportError: + import block_update_harness as h + +COMPONENTS = [ + 'redstone_wire', 'redstone_wire', 'redstone_wire', 'redstone_wire', 'repeater', 'repeater', 'comparator', 'redstone_torch', + 'redstone_wall_torch', 'observer', 'piston', 'sticky_piston', 'redstone_lamp', 'lever', 'stone_button', 'oak_button', + 'stone_pressure_plate', 'note_block', 'target', 'dropper', 'dispenser', 'hopper', 'rail', 'powered_rail', 'activator_rail', + 'detector_rail', 'oak_door', 'iron_door', 'oak_trapdoor', 'oak_fence_gate', 'tnt', 'slime_block', 'honey_block', 'stone', + 'redstone_block', 'daylight_detector', 'trapped_chest', 'copper_bulb', 'sculk_sensor', 'tripwire_hook', 'lectern', + 'oak_slab', 'iron_block', 'glass', 'crafter', +] + + +def build(facts, rng): + floor = facts.defaults['minecraft:stone'] + names = [n for n in ('minecraft:' + c for c in COMPONENTS) if n in facts.by_block] + cells = [] + for x in range(-4, 5): + for z in range(-4, 5): + cells.append([x, -1, z, floor]) + if rng.random() < 0.5: + name = rng.choice(names) + states = facts.by_block[name] + state = rng.choice(states) + cells.append([x, 0, z, state]) + if rng.random() < 0.15: + cells.append([x, 1, z, rng.choice(facts.by_block[rng.choice(names)])]) + return cells + + +def cases(facts, count, seed, only): + rng = random.Random(seed) + source = facts.defaults['minecraft:redstone_block'] + air = facts.defaults['minecraft:air'] + for n in range(count): + cells = build(facts, rng) + at = [rng.randrange(-4, 5), 0, rng.randrange(-4, 5)] + cells = [c for c in cells if c[:3] != at] + cells.append([*at, air]) + case = {**h.environment(rng), 'mode': 'run', 'pos': [0, -1, 0], 'cells': cells, 'ops': [ + {'op': 'set', 'pos': at, 'state': source, 'flags': 3}, + {'op': 'ticks', 'n': 12, 'snap': True}, + {'op': 'set', 'pos': at, 'state': air, 'flags': 3}, + {'op': 'ticks', 'n': 12, 'snap': True}, + ]} + case.pop('raining', None) + yield case + + +def main(): + args = h.argument_parser(__doc__).parse_args() + args.count = min(args.count, 4000) if args.count == 40000 else args.count + facts = h.load_facts() + differing = h.execute('redstone-parity', list(cases(facts, args.count, args.seed, args.only)), args, facts) + h.finish(differing) + + +if __name__ == '__main__': + main()