diff --git a/crates/horizon-client/src/game/world_loading.rs b/crates/horizon-client/src/game/world_loading.rs index b4a723ba16..2eb22d1d2f 100644 --- a/crates/horizon-client/src/game/world_loading.rs +++ b/crates/horizon-client/src/game/world_loading.rs @@ -700,13 +700,15 @@ pub(super) fn make_streaming( shape, ) }); - // `FlatLevelSource.createState`: a superflat world whose settings let structure sets - // generate runs them on its flat ground. + // PlaceCommand.placeJigsaw/placeStructure generate directly, independently + // of WorldOptions.generateStructures. Keep their flat locator available + // even when automatic structure starts are disabled. + let automatic_structures = + horizon_client::save::WorldGen::read(world_root).is_none_or(|w| w.structures); let flat_structures = (dim == Dim::Overworld) .then(|| horizon_client::save::world_gen_settings(world_root)) .flatten() - .and_then(|s| horizon_client::flatgen::FlatTerrain::structure_settings(&s)) - .filter(|_| horizon_client::save::WorldGen::read(world_root).is_none_or(|w| w.structures)); + .and_then(|s| horizon_client::flatgen::FlatTerrain::structure_settings(&s)); // A generator that cannot be built (a layer block the registry lacks, a level shorter // than the noise) leaves the world on its flat ground, as it was before. let flat_vanilla = flat_structures.and_then(|settings| { @@ -719,7 +721,7 @@ pub(super) fn make_streaming( &biomes, seed, &settings, - true, + automatic_structures, ) { Ok(mut terrain) => { terrain.add_mod_signs(res.facts()); @@ -742,7 +744,11 @@ pub(super) fn make_streaming( pack.context.clone(), res.threads(), ) - } else if let Some(inner) = flat_vanilla { + } else if let Some(inner) = flat_vanilla + .as_ref() + .filter(|_| automatic_structures) + .cloned() + { inner.search_rings_behind(); inner.set_reference_file(world_root.join("data").join(format!( "horizon_structure_references_{}.json", @@ -766,6 +772,9 @@ pub(super) fn make_streaming( res.threads(), ) } else if let Some(inner) = flat { + // Keep the existing flat terrain/decorations path when automatic + // structures are off; only manual commands use the locator. + locator = flat_vanilla; Streaming::new( Arc::new(Persistent { inner: Arc::new(inner), diff --git a/crates/horizon-world/src/worldgen/generator.rs b/crates/horizon-world/src/worldgen/generator.rs index a2fbee9488..e773655618 100644 --- a/crates/horizon-world/src/worldgen/generator.rs +++ b/crates/horizon-world/src/worldgen/generator.rs @@ -909,6 +909,8 @@ struct Pipeline { carver_cache: Mutex>, pub(super) decoration: Option>, pub(super) structures: Option, + /// Definitions for explicit placement when automatic structure starts are off. + manual_structures: std::sync::OnceLock>, /// The noise settings' `spawn_target` points. spawn_target: Vec, /// The chunks an older version generated, which new chunks blend with. @@ -1191,6 +1193,7 @@ where carver_cache: Mutex::new(HashMap::new()), decoration: None, structures: None, + manual_structures: std::sync::OnceLock::new(), spawn_target, old_world: std::sync::OnceLock::new(), debug_density, @@ -1216,6 +1219,23 @@ where } Ok(pipeline) } + /// Manual placement reads the registry without enabling the automatic + /// structure state, its placement sets, starts or locate searches. + fn placement_structures(&self) -> Option<&super::generator_structures::StructureState> { + self.structures.as_ref().or_else(|| { + self.manual_structures + .get_or_init(|| { + super::generator_structures::StructureState::new( + &self.data, + &BTreeSet::new(), + Arc::new(std::sync::OnceLock::new()), + ) + .ok() + .flatten() + }) + .as_ref() + }) + } fn generation(&self) -> WorldGenerationContext { // `new WorldGenerationContext(generator, level)`: the larger minimum Y and the smaller // height of the generator and the level (`FlatLevelSource`: 0 and 384). @@ -2476,8 +2496,7 @@ where structure: &str, chunk: ChunkPos, ) -> Option> { - self.structures - .as_ref()? + self.placement_structures()? .structure_start( &mut StructureQueries { pipeline: self, @@ -3057,7 +3076,7 @@ where // waterlogging applied. The pieces draw from the GenerationContext's // random, seeded from the level seed and the position's chunk, not // from the level random. - let structures = self.structures.as_ref()?; + let structures = self.placement_structures()?; let mut random = horizon_core::random::WorldgenRandom::new(LegacyRandom::new(0)); random.set_large_feature_seed(self.seed, query.position[0] >> 4, query.position[2] >> 4); let aliases = BTreeMap::new(); diff --git a/tools/test_flat_place_parity.py b/tools/test_flat_place_parity.py new file mode 100644 index 0000000000..c2756d8457 --- /dev/null +++ b/tools/test_flat_place_parity.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Manual placement in fresh flat servers, with automatic structures off and on. + +Compare console feedback from the pinned Java dedicated server and Horizon. +Readiness is four bounded `execute if loaded` probes, never a fixed tick wait. +This checks command availability/errors and the template's villager, not the +geometry or processors of generated structures. +""" +import argparse +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import time + +try: + from . import lab, test_rust_parity as base + from .test_dedicated_server_parity import Server, free_port + from .test_server_world_gen_parity import escape, structure_chunks +except ImportError: + import lab + import test_rust_parity as base + from test_dedicated_server_parity import Server, free_port + from test_server_world_gen_parity import escape, structure_chunks + +SEED = -9147835479224470908 +LOADED = [f'execute if loaded {x} 200 {z}' for x, z in ((0, 0), (16, 0), (0, 16), (16, 16))] +COMMANDS = [ + 'place template minecraft:igloo/bottom 8 200 8', + 'place jigsaw minecraft:trail_ruins/tower minecraft:road_connector 0 8 200 8', + 'place jigsaw minecraft:trail_ruins/tower minecraft:road_connector 1 8 200 8', + 'place jigsaw minecraft:trail_ruins/tower audit:missing 1 8 200 8', + 'place jigsaw audit:missing minecraft:road_connector 1 8 200 8', + 'place structure minecraft:buried_treasure 8 200 8', + 'place structure audit:missing 8 200 8', + 'execute if entity @e[type=minecraft:villager]', +] +MESSAGE = re.compile(r'\[Server thread/INFO\]: (?:System chat: )?(.*)$') + + +def run_case(command, out, label, structures): + directory = out / label / str(structures).lower() + if directory.exists(): + shutil.rmtree(directory) + directory.mkdir(parents=True) + props = { + 'server-ip': '127.0.0.1', 'server-port': str(free_port()), + 'online-mode': 'false', 'level-type': 'minecraft:flat', + 'level-seed': str(SEED), 'generate-structures': str(structures).lower(), + 'view-distance': '2', 'simulation-distance': '2', 'spawn-protection': '0', + 'pause-when-empty-seconds': '-1', 'max-tick-time': '-1', + } + (directory / 'server.properties').write_text( + ''.join(f'{k}={escape(v)}\n' for k, v in props.items()), encoding='iso-8859-1') + (directory / 'eula.txt').write_text('eula=true\n') + server = Server(command, directory, f'{label}-{str(structures).lower()}') + serial = 0 + + def reply(text, timeout=180): + nonlocal serial + serial += 1 + marker = f'flat_place_end_{serial}' + start = len(server.lines) + server.console(text) + server.console(f'say {marker}') + server.wait_for(re.escape(f'[Server] {marker}') + '$', timeout=timeout) + messages = [m.group(1) for line in server.lines[start:] if (m := MESSAGE.search(line))] + return [m for m in messages if not m.endswith(f'[Server] {marker}')] + + try: + server.wait_for(r'Done \(') + reply('tick freeze') + reply('gamerule minecraft:spawn_mobs false') + reply('forceload add -32 -32 63 63') + deadline = time.monotonic() + 300 + while True: + remaining = deadline - time.monotonic() + assert remaining > 0, f'{label}: force-loaded placement area did not become ready' + ready = [reply(c, timeout=remaining) for c in LOADED] + if all(any('Test passed' in line for line in lines) for lines in ready): + break + # Inspect saved starts before manual placement can change the world. + automatic = None + if not structures: + reply('save-all flush') + automatic = structure_chunks(directory / 'world') + records = [{'command': c, 'messages': lines} for c, lines in zip(LOADED, ready)] + records += [{'command': c, 'messages': reply(c)} for c in COMMANDS] + return {'records': records, 'automatic': automatic} + finally: + server.stop() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--binary', type=Path, help='use an already built Horizon server') + parser.add_argument('--out', type=Path, default=lab.LOCAL / 'flat-place-parity') + args = parser.parse_args() + lock, jars, libraries = base.verify_artifacts() + if args.binary: + binary = args.binary.resolve(strict=True) + else: + subprocess.run(['cargo', 'build', '--profile', 'fast', '-p', 'horizon-client', + '--bin', 'horizon-client'], cwd=lab.ROOT, check=True) + metadata = json.loads(subprocess.check_output( + ['cargo', 'metadata', '--no-deps', '--format-version', '1'], cwd=lab.ROOT, text=True)) + binary = Path(metadata['target_directory']) / 'fast' / ( + 'horizon-client.exe' if os.name == 'nt' else 'horizon-client') + out = args.out.resolve() + out.mkdir(parents=True, exist_ok=True) + classpath = os.pathsep.join(map(str, [jars['server'], *libraries])) + commands = { + 'vanilla': ['java', '-Xmx2g', '-Duser.timezone=UTC', '-cp', classpath, + 'net.minecraft.server.Main', 'nogui'], + 'horizon': [binary, '--resources', lab.RESOURCES.resolve(), '--dedicated', '--nogui'], + } + results, summary, failures = {}, {}, [] + for structures in (False, True): + key = str(structures).lower() + results[key] = {label: run_case(command, out, label, structures) + for label, command in commands.items()} + expected, actual = (results[key][label] for label in ('vanilla', 'horizon')) + mismatches = [(v, h) for v, h in zip(expected['records'], actual['records']) if v != h] + assert len(expected['records']) == len(actual['records']) == 12 + summary[key] = {'observations': 12, 'mismatches': len(mismatches)} + print(f'flat structures={key}: {12 - len(mismatches)}/12 observations match', flush=True) + failures += [{'structures': structures, 'vanilla': v, 'horizon': h} for v, h in mismatches] + if not structures: + empty = 'chunks with structure starts: 0, with references: 0' + assert expected['automatic'] == actual['automatic'] == empty, results[key] + lab.write_json(out / 'records.json', results) + lab.write_json(out / 'report.json', { + 'schema': 1, 'minecraft': lock['minecraft']['version'], 'seed': SEED, + 'cases': summary, 'failures': failures, + 'scope': 'Manual flat-world placement feedback, readiness and entity control; ' + 'disabled automatic structure starts/references before manual commands. ' + 'No structure geometry or processor parity claim.', + }) + for failure in failures: + print(json.dumps(failure, ensure_ascii=False), flush=True) + assert not failures, f'{len(failures)} placement observations differ; see {out}' + + +if __name__ == '__main__': + main()