From e8888ed05a17bec9195c58ac2cec4f66a3e0acea Mon Sep 17 00:00:00 2001 From: Voyager Date: Wed, 19 Aug 2026 17:32:40 -0400 Subject: [PATCH] Add deterministic generated worlds --- main/main.gd | 92 +++- main/main.tscn | 40 +- network/network_protocol.gd | 9 +- network/network_session.gd | 39 ++ network/player_spawn_service.gd | 4 + save/player_save_manager.gd | 46 +- scripts/run_validations.sh | 2 + server/dedicated_server_config.gd | 13 + tests/dedicated_server_config_validation.gd | 8 + tests/fish_hotbar_showcase_validation.gd | 4 +- tests/fish_quality_validation.gd | 8 +- tests/fishing_authority_validation.gd | 30 +- tests/generated_world_runtime_validation.gd | 236 ++++++++ .../generated_world_runtime_validation.gd.uid | 1 + ...nventory_storage_persistence_validation.gd | 5 +- tests/job_system_validation.gd | 2 +- tests/light_performance_profile_validation.gd | 80 +-- .../terrain_chunk_compatibility_report.gd | 81 +++ .../terrain_chunk_compatibility_report.gd.uid | 1 + .../terrain_chunk_generator_test.gd | 33 ++ .../terrain_chunk_generator_test.gd.uid | 1 + .../terrain_chunk_generator_test.tscn | 46 ++ tests/player_experience_validation.gd | 6 +- tests/terrain_chunk_generator_validation.gd | 216 ++++++++ .../terrain_chunk_generator_validation.gd.uid | 1 + tests/world_spawn_protocol_validation.gd | 2 +- tools/blender/export_terrain_chunks.py | 438 +++++++++++++++ ui/title_screen.gd | 15 +- world/generation/chunks/assets/chunk_0000.glb | Bin 0 -> 7700 bytes .../chunks/assets/chunk_0000.glb.import | 45 ++ .../chunks/assets/chunk_0000_grass_lite.png | Bin 0 -> 6317 bytes .../assets/chunk_0000_grass_lite.png.import | 45 ++ world/generation/chunks/assets/chunk_0001.glb | Bin 0 -> 7660 bytes .../chunks/assets/chunk_0001.glb.import | 45 ++ .../chunks/assets/chunk_0001_sand.png | Bin 0 -> 6289 bytes .../chunks/assets/chunk_0001_sand.png.import | 45 ++ world/generation/chunks/assets/chunk_0002.glb | Bin 0 -> 14472 bytes .../chunks/assets/chunk_0002.glb.import | 45 ++ .../chunks/assets/chunk_0002_grass_lite.png | Bin 0 -> 6317 bytes .../assets/chunk_0002_grass_lite.png.import | 45 ++ .../chunks/assets/chunk_0002_sand.png | Bin 0 -> 6289 bytes .../chunks/assets/chunk_0002_sand.png.import | 45 ++ world/generation/chunks/assets/chunk_0003.glb | Bin 0 -> 24376 bytes .../chunks/assets/chunk_0003.glb.import | 45 ++ .../chunks/assets/chunk_0003_dirt.png | Bin 0 -> 5589 bytes .../chunks/assets/chunk_0003_dirt.png.import | 45 ++ .../chunks/assets/chunk_0003_dirt_wall.png | Bin 0 -> 3734 bytes .../assets/chunk_0003_dirt_wall.png.import | 45 ++ .../chunks/assets/chunk_0003_grass_lite.png | Bin 0 -> 6317 bytes .../assets/chunk_0003_grass_lite.png.import | 45 ++ world/generation/chunks/assets/chunk_0004.glb | Bin 0 -> 27232 bytes .../chunks/assets/chunk_0004.glb.import | 45 ++ .../chunks/assets/chunk_0004_dirt.png | Bin 0 -> 5589 bytes .../chunks/assets/chunk_0004_dirt.png.import | 45 ++ .../chunks/assets/chunk_0004_dirt_wall.png | Bin 0 -> 3734 bytes .../assets/chunk_0004_dirt_wall.png.import | 45 ++ .../chunks/assets/chunk_0004_grass_lite.png | Bin 0 -> 6317 bytes .../assets/chunk_0004_grass_lite.png.import | 45 ++ .../chunks/definitions/chunk_0000.tres | 14 + .../chunks/definitions/chunk_0001.tres | 14 + .../chunks/definitions/chunk_0002.tres | 14 + .../chunks/definitions/chunk_0003.tres | 17 + .../chunks/definitions/chunk_0004.tres | 16 + .../chunks/definitions/chunk_spawn.tres | 15 + .../chunks/terrain_chunk_catalog.tres | 15 + world/generation/generated_world_region.gd | 507 ++++++++++++++++++ .../generation/generated_world_region.gd.uid | 1 + world/generation/generated_world_region.tscn | 94 ++++ world/generation/terrain_chunk_analyzer.gd | 216 ++++++++ .../generation/terrain_chunk_analyzer.gd.uid | 1 + world/generation/terrain_chunk_catalog.gd | 50 ++ world/generation/terrain_chunk_catalog.gd.uid | 1 + world/generation/terrain_chunk_definition.gd | 24 + .../terrain_chunk_definition.gd.uid | 1 + .../generation/terrain_chunk_edge_profile.gd | 97 ++++ .../terrain_chunk_edge_profile.gd.uid | 1 + world/generation/terrain_chunk_generator.gd | 396 ++++++++++++++ .../generation/terrain_chunk_generator.gd.uid | 1 + world/generation/terrain_chunk_topology.gd | 53 ++ .../generation/terrain_chunk_topology.gd.uid | 1 + world/generation/terrain_chunk_variant.gd | 39 ++ world/generation/terrain_chunk_variant.gd.uid | 1 + world/regions/world_region.gd | 32 ++ world/test_world.gd | 85 ++- world/test_world.tscn | 4 +- world/water_body_authoring.gd | 7 + world/water_recovery_controller.gd | 31 ++ 87 files changed, 3743 insertions(+), 139 deletions(-) create mode 100644 tests/generated_world_runtime_validation.gd create mode 100644 tests/generated_world_runtime_validation.gd.uid create mode 100644 tests/manual/terrain_chunk_generator/terrain_chunk_compatibility_report.gd create mode 100644 tests/manual/terrain_chunk_generator/terrain_chunk_compatibility_report.gd.uid create mode 100644 tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.gd create mode 100644 tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.gd.uid create mode 100644 tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.tscn create mode 100644 tests/terrain_chunk_generator_validation.gd create mode 100644 tests/terrain_chunk_generator_validation.gd.uid create mode 100644 tools/blender/export_terrain_chunks.py create mode 100644 world/generation/chunks/assets/chunk_0000.glb create mode 100644 world/generation/chunks/assets/chunk_0000.glb.import create mode 100644 world/generation/chunks/assets/chunk_0000_grass_lite.png create mode 100644 world/generation/chunks/assets/chunk_0000_grass_lite.png.import create mode 100644 world/generation/chunks/assets/chunk_0001.glb create mode 100644 world/generation/chunks/assets/chunk_0001.glb.import create mode 100644 world/generation/chunks/assets/chunk_0001_sand.png create mode 100644 world/generation/chunks/assets/chunk_0001_sand.png.import create mode 100644 world/generation/chunks/assets/chunk_0002.glb create mode 100644 world/generation/chunks/assets/chunk_0002.glb.import create mode 100644 world/generation/chunks/assets/chunk_0002_grass_lite.png create mode 100644 world/generation/chunks/assets/chunk_0002_grass_lite.png.import create mode 100644 world/generation/chunks/assets/chunk_0002_sand.png create mode 100644 world/generation/chunks/assets/chunk_0002_sand.png.import create mode 100644 world/generation/chunks/assets/chunk_0003.glb create mode 100644 world/generation/chunks/assets/chunk_0003.glb.import create mode 100644 world/generation/chunks/assets/chunk_0003_dirt.png create mode 100644 world/generation/chunks/assets/chunk_0003_dirt.png.import create mode 100644 world/generation/chunks/assets/chunk_0003_dirt_wall.png create mode 100644 world/generation/chunks/assets/chunk_0003_dirt_wall.png.import create mode 100644 world/generation/chunks/assets/chunk_0003_grass_lite.png create mode 100644 world/generation/chunks/assets/chunk_0003_grass_lite.png.import create mode 100644 world/generation/chunks/assets/chunk_0004.glb create mode 100644 world/generation/chunks/assets/chunk_0004.glb.import create mode 100644 world/generation/chunks/assets/chunk_0004_dirt.png create mode 100644 world/generation/chunks/assets/chunk_0004_dirt.png.import create mode 100644 world/generation/chunks/assets/chunk_0004_dirt_wall.png create mode 100644 world/generation/chunks/assets/chunk_0004_dirt_wall.png.import create mode 100644 world/generation/chunks/assets/chunk_0004_grass_lite.png create mode 100644 world/generation/chunks/assets/chunk_0004_grass_lite.png.import create mode 100644 world/generation/chunks/definitions/chunk_0000.tres create mode 100644 world/generation/chunks/definitions/chunk_0001.tres create mode 100644 world/generation/chunks/definitions/chunk_0002.tres create mode 100644 world/generation/chunks/definitions/chunk_0003.tres create mode 100644 world/generation/chunks/definitions/chunk_0004.tres create mode 100644 world/generation/chunks/definitions/chunk_spawn.tres create mode 100644 world/generation/chunks/terrain_chunk_catalog.tres create mode 100644 world/generation/generated_world_region.gd create mode 100644 world/generation/generated_world_region.gd.uid create mode 100644 world/generation/generated_world_region.tscn create mode 100644 world/generation/terrain_chunk_analyzer.gd create mode 100644 world/generation/terrain_chunk_analyzer.gd.uid create mode 100644 world/generation/terrain_chunk_catalog.gd create mode 100644 world/generation/terrain_chunk_catalog.gd.uid create mode 100644 world/generation/terrain_chunk_definition.gd create mode 100644 world/generation/terrain_chunk_definition.gd.uid create mode 100644 world/generation/terrain_chunk_edge_profile.gd create mode 100644 world/generation/terrain_chunk_edge_profile.gd.uid create mode 100644 world/generation/terrain_chunk_generator.gd create mode 100644 world/generation/terrain_chunk_generator.gd.uid create mode 100644 world/generation/terrain_chunk_topology.gd create mode 100644 world/generation/terrain_chunk_topology.gd.uid create mode 100644 world/generation/terrain_chunk_variant.gd create mode 100644 world/generation/terrain_chunk_variant.gd.uid diff --git a/main/main.gd b/main/main.gd index 1e68247..2db40ac 100644 --- a/main/main.gd +++ b/main/main.gd @@ -409,6 +409,12 @@ func _start_dedicated_server() -> void: ): _fail_dedicated_server("The server operator list is invalid.") return + if not _apply_generated_world(config.world_seed, false): + _fail_dedicated_server("The configured world seed could not be generated.") + return + if not _network_session.set_host_world_seed(config.world_seed): + _fail_dedicated_server("The configured world seed is invalid.") + return _configure_portable_stores() if not _discovery.configure_dedicated_runtime(config.server_name): _fail_dedicated_server("The server room name is invalid.") @@ -1666,14 +1672,24 @@ func _resize_native_overlays() -> void: _shop_backdrop.size = Vector2(get_window().size) -func _on_new_game_requested() -> void: +func _on_new_game_requested(world_seed: int) -> void: if _gameplay_started or _quit_in_progress: return + if not _apply_generated_world(world_seed, true): + _game_ui.get_title_screen().report_network_error( + "Could not generate that world seed. Try rolling another world." + ) + return + if not _prepare_host_world_seed(world_seed): + _game_ui.get_title_screen().report_network_error( + "Could not prepare the generated world for hosting." + ) + return if not _prepare_private_host(): return if ( not _save_manager.delete_progression_save() - or not _save_manager.initialize_new_game() + or not _save_manager.initialize_new_game(world_seed) ): _network_session.disconnect_session("New Game setup failed.") _game_ui.get_title_screen().report_network_error( @@ -1686,14 +1702,22 @@ func _on_new_game_requested() -> void: func _on_continue_game_requested() -> void: if _gameplay_started or _quit_in_progress: return - if not _prepare_private_host(): - return if not _save_manager.load_player_data(): - _network_session.disconnect_session("Continue failed.") _game_ui.get_title_screen().report_network_error( "Failed to load save. The original was preserved." ) return + var world_seed: int = _save_manager.get_world_seed() + if ( + not _apply_generated_world(world_seed, true) + or not _prepare_host_world_seed(world_seed) + ): + _game_ui.get_title_screen().report_network_error( + "The saved world could not be generated. The save was preserved." + ) + return + if not _prepare_private_host(): + return _enter_gameplay() @@ -1716,6 +1740,15 @@ func _prepare_private_host() -> bool: return true +func _prepare_host_world_seed(world_seed: int) -> bool: + if _network_session.state in [ + NetworkSessionType.State.CONNECTION_FAILED, + NetworkSessionType.State.SERVER_LOST, + ]: + _network_session.reset_failure() + return _network_session.set_host_world_seed(world_seed) + + func _enter_gameplay() -> void: _fade_out_title_music() _game_ui.get_title_screen().hide() @@ -1805,10 +1838,14 @@ func _on_network_join_authenticated() -> void: ) _join_requested_from_title = false return + if not _apply_joined_world(server_metadata): + return _fade_out_title_music() _game_ui.get_title_screen().hide() _set_gameplay_active(true) elif _join_requested_from_pause: + if not _apply_joined_world(server_metadata): + return _set_gameplay_active(true) _join_requested_from_title = false _join_requested_from_pause = false @@ -1912,7 +1949,12 @@ func _on_reset_progress_requested() -> void: if not _save_manager.delete_progression_save(): pause_menu.report_reset_failure() return - if not _save_manager.initialize_new_game(): + var world_seed: int = PlayerSaveManager.roll_world_seed() + if not _save_manager.initialize_new_game(world_seed): + pause_menu.report_reset_failure() + return + _network_session.disconnect_session("Progress reset.") + if not _apply_generated_world(world_seed, true): pause_menu.report_reset_failure() return pause_menu.close_for_title_transition() @@ -1921,6 +1963,44 @@ func _on_reset_progress_requested() -> void: _show_title_music(true) +func _apply_joined_world(server_metadata: Dictionary) -> bool: + var world_seed: int = int(server_metadata.get("world_seed", 0)) + if _apply_generated_world(world_seed, true): + return true + _network_session.disconnect_session("World generation failed.") + _join_requested_from_title = false + _join_requested_from_pause = false + _set_gameplay_active(false) + var title_screen: TitleScreenType = _game_ui.get_title_screen() + title_screen.reopen_to_menu() + title_screen.report_network_error( + "The host's generated world could not be built locally." + ) + _show_title_music(true) + return false + + +func _apply_generated_world(world_seed: int, reposition_player: bool) -> bool: + if not _test_world.generate_world(world_seed): + return false + var spawn_transform: Transform3D = _test_world.get_player_spawn_transform() + _player_spawn_service.set_spawn_transform(spawn_transform) + if reposition_player: + _player.global_transform = spawn_transform + _player.velocity = Vector3.ZERO + if _application_initialized and not _dedicated_runtime: + _water_recovery.update_world_context( + spawn_transform, + _test_world.get_player_water_triggers(), + _test_world.get_safe_respawn_points(), + ) + _shoreline_ambience.configure( + _player, + _test_world.get_saltwater_shoreline_mesh(), + ) + return true + + func _on_water_recovery_starting() -> void: _local_recovery_attempt_id = ( "recovery:%s" diff --git a/main/main.tscn b/main/main.tscn index d48f42e..e8dba76 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -298,48 +298,11 @@ unique_name_in_owner = true script = ExtResource("36_player_list") [node name="TestWorld" parent="." unique_id=1334932296 instance=ExtResource("1_world")] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2.4475808, 0, -1.8959346) +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0) [node name="Sun" parent="TestWorld/Environment" index="1"] transform = Transform3D(0.8480481, 0.4287137, -0.31147876, 0, 0.5877853, 0.809017, 0.52991927, -0.68608534, 0.49847022, 0, 0, 0) -[node name="Shape" parent="TestWorld/Regions/StarterIslandRegion/Terrain/Collision" index="0" unique_id=674742331] -shape = SubResource("ConcavePolygonShape3D_hifdc") - -[node name="Pond" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies" index="0" unique_id=995477450] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -9.4, 2.51, 2.1) - -[node name="VisualWater" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Pond" index="0" unique_id=1034265960] -mesh = SubResource("PlaneMesh_7ny4s") - -[node name="Shape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Pond/FishingRegion" index="0" unique_id=1185360438] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -2, 0) -shape = SubResource("BoxShape3D_agj3o") - -[node name="Shape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Pond/RecoveryRegion" index="0" unique_id=1718808476] -shape = SubResource("BoxShape3D_l6210") - -[node name="VisualWater" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean" index="0" unique_id=1176408937] -mesh = SubResource("PlaneMesh_6upbg") - -[node name="Shape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/FishingRegions/OceanFishingRegion" index="0" unique_id=357406062] -shape = SubResource("BoxShape3D_umo15") - -[node name="WestShape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/RecoveryRegions/OceanRecoveryRegion" index="0" unique_id=1183368645] -shape = SubResource("BoxShape3D_1fdkm") - -[node name="EastShape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/RecoveryRegions/OceanRecoveryRegion" index="1" unique_id=1742419808] -shape = SubResource("BoxShape3D_y2c0j") - -[node name="NorthShape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/RecoveryRegions/OceanRecoveryRegion" index="2" unique_id=1844920260] -shape = SubResource("BoxShape3D_uwwqy") - -[node name="SouthShape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/RecoveryRegions/OceanRecoveryRegion" index="3" unique_id=1316495770] -shape = SubResource("BoxShape3D_hf28p") - -[node name="FishingShopWorld" parent="TestWorld/Regions/StarterIslandRegion/Interactables" index="0" unique_id=1255373524] -transform = Transform3D(-0.9976046, 0, -0.06917416, 0, 1, 0, 0.06917416, 0, -0.9976046, 7.3292284, 3.4012775, 14.888193) - [node name="BelowWorldFailsafe" parent="TestWorld/Safety" index="0"] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12, -7, 0) @@ -436,4 +399,3 @@ unique_name_in_owner = true unique_name_in_owner = true [editable path="TestWorld"] -[editable path="TestWorld/Regions/StarterIslandRegion"] diff --git a/network/network_protocol.gd b/network/network_protocol.gd index 352fcc5..141756a 100644 --- a/network/network_protocol.gd +++ b/network/network_protocol.gd @@ -1,7 +1,7 @@ class_name NetworkProtocol extends RefCounted -const PROTOCOL_VERSION: int = 8 +const PROTOCOL_VERSION: int = 9 const GAME_BUILD: String = "prealpha" const MAX_GAME_VERSION_LENGTH: int = 64 const MAX_DISPLAY_NAME_LENGTH: int = 24 @@ -28,6 +28,9 @@ const FISH_QUALITY_CAPABILITY: String = "fish_quality_v1" const JOBS_CAPABILITY: String = "jobs_v1" const WORLD_SPAWN_CAPABILITY: String = "world_spawn_envelope_v1" const APPEARANCE_PREVIEW_CAPABILITY: String = "appearance_preview_v1" +const WORLD_GENERATION_CAPABILITY: String = "world_generation_v1" +const DEFAULT_WORLD_SEED: int = 13001 +const MAX_WORLD_SEED: int = 2147483646 enum RejectionCode { NONE, @@ -178,6 +181,7 @@ static func make_client_hello( WORLD_SPAWN_CAPABILITY, APPEARANCE_PREVIEW_CAPABILITY, BACKPACK_SHOP_CAPABILITY, + WORLD_GENERATION_CAPABILITY, ]), "cosmetic_snapshot": cosmetic_snapshot, "identity_fingerprint": identity_fingerprint, @@ -281,6 +285,7 @@ static func make_server_hello( player_count: int, max_players: int, server_display_name: String = "NETfishing", + world_seed: int = DEFAULT_WORLD_SEED, ) -> Dictionary: return { "accepted": accepted, @@ -292,6 +297,7 @@ static func make_server_hello( "server_display_name": server_display_name, "player_count": player_count, "max_players": max_players, + "world_seed": world_seed, "capability_flags": PackedStringArray([ "movement_v1", "fishing_v1", @@ -309,6 +315,7 @@ static func make_server_hello( JOBS_CAPABILITY, WORLD_SPAWN_CAPABILITY, APPEARANCE_PREVIEW_CAPABILITY, + WORLD_GENERATION_CAPABILITY, "chat_v1", "mail_v1", "profile_v1", diff --git a/network/network_session.gd b/network/network_session.gd index cc564b2..39796f5 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -85,6 +85,7 @@ var _last_server_max_players: int = DEFAULT_SESSION_MAX_PLAYERS var _last_server_player_count: int = 0 var _last_server_display_name: String = "" var _last_server_protocol_version: int = 0 +var _last_server_world_seed: int = NetworkProtocol.DEFAULT_WORLD_SEED var _server_capabilities: PackedStringArray = PackedStringArray() var _profile_ready: bool = false var _player_identity: PlayerIdentityStore @@ -111,6 +112,7 @@ var _configured_operator_fingerprints: Dictionary[String, bool] = {} var _session_operator_fingerprints: Dictionary[String, bool] = {} var _operator_peer_ids: Dictionary[int, bool] = {} var _local_operator: bool = false +var _host_world_seed: int = NetworkProtocol.DEFAULT_WORLD_SEED func _ready() -> void: @@ -272,6 +274,7 @@ func _register_player_host() -> void: NetworkProtocol.JOBS_CAPABILITY, NetworkProtocol.WORLD_SPAWN_CAPABILITY, NetworkProtocol.BACKPACK_SHOP_CAPABILITY, + NetworkProtocol.WORLD_GENERATION_CAPABILITY, ]), ) _registry.update_appearance(1, _local_appearance_snapshot) @@ -527,9 +530,25 @@ func get_last_server_metadata() -> Dictionary: "protocol_version": _last_server_protocol_version, "player_count": _last_server_player_count, "max_players": _last_server_max_players, + "world_seed": _last_server_world_seed, } +func set_host_world_seed(seed: int) -> bool: + if ( + state != State.INACTIVE + or seed <= 0 + or seed > NetworkProtocol.MAX_WORLD_SEED + ): + return false + _host_world_seed = seed + return true + + +func get_authoritative_world_seed() -> int: + return _last_server_world_seed if is_joined_client() else _host_world_seed + + func can_use_host_gameplay() -> bool: return is_host() @@ -563,6 +582,7 @@ func supports_server_capability(capability: StringName) -> bool: NetworkProtocol.WORLD_WEATHER_CAPABILITY, NetworkProtocol.WORLD_SPAWN_CAPABILITY, NetworkProtocol.APPEARANCE_PREVIEW_CAPABILITY, + NetworkProtocol.WORLD_GENERATION_CAPABILITY, "chat_v1", "mail_v1", "profile_v1", @@ -1164,6 +1184,12 @@ func submit_client_hello(data: Dictionary) -> void: NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT, ) return + if NetworkProtocol.WORLD_GENERATION_CAPABILITY not in client_capabilities: + _reject_peer( + sender_id, + NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT, + ) + return var identity: Dictionary = _authenticated_identity_cache.get(sender_id, {}) if identity.is_empty(): _reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF) @@ -1251,6 +1277,7 @@ func submit_client_hello(data: Dictionary) -> void: _registry.size(), session_max_players, _session_display_name, + _host_world_seed, ) ) receive_spawn_list.rpc_id(sender_id, _build_spawn_list()) @@ -1304,6 +1331,7 @@ func receive_server_hello(data: Dictionary) -> void: or typeof(data.get("protocol_version")) != TYPE_INT or typeof(data.get("game_version")) != TYPE_STRING or typeof(data.get("rejection_code")) != TYPE_INT + or typeof(data.get("world_seed")) != TYPE_INT ): _teardown_peer() _fail("The server sent an invalid handshake response.") @@ -1329,6 +1357,11 @@ func receive_server_hello(data: Dictionary) -> void: _teardown_peer() _fail("The server uses a different network protocol.") return + var received_world_seed := int(data["world_seed"]) + if received_world_seed <= 0 or received_world_seed > NetworkProtocol.MAX_WORLD_SEED: + _teardown_peer() + _fail("The server sent an invalid world seed.") + return _session_id = str(data.get("session_id", "")) _last_server_max_players = int(data.get( "max_players", @@ -1339,6 +1372,7 @@ func receive_server_hello(data: Dictionary) -> void: _last_server_protocol_version = int(data.get( "protocol_version", NetworkProtocol.PROTOCOL_VERSION )) + _last_server_world_seed = received_world_seed _server_capabilities = PackedStringArray() var advertised_capabilities: Variant = data.get( "capability_flags", PackedStringArray() @@ -1353,6 +1387,10 @@ func receive_server_hello(data: Dictionary) -> void: _teardown_peer() _fail("This server does not support fish quality data.") return + if NetworkProtocol.WORLD_GENERATION_CAPABILITY not in _server_capabilities: + _teardown_peer() + _fail("This server does not support generated worlds.") + return var local_peer_id: int = multiplayer.get_unique_id() _registry.clear() _registry.add_peer( @@ -1368,6 +1406,7 @@ func receive_server_hello(data: Dictionary) -> void: NetworkProtocol.WORLD_TIME_CAPABILITY, NetworkProtocol.WORLD_WEATHER_CAPABILITY, NetworkProtocol.BACKPACK_SHOP_CAPABILITY, + NetworkProtocol.WORLD_GENERATION_CAPABILITY, ]), ) _registry.update_appearance(local_peer_id, _local_appearance_snapshot) diff --git a/network/player_spawn_service.gd b/network/player_spawn_service.gd index ae6ee00..0b8179b 100644 --- a/network/player_spawn_service.gd +++ b/network/player_spawn_service.gd @@ -23,6 +23,10 @@ func setup( _spawn_transform = spawn_transform +func set_spawn_transform(spawn_transform: Transform3D) -> void: + _spawn_transform = spawn_transform + + func register_local_player(peer_id: int) -> void: if _local_player == null: return diff --git a/save/player_save_manager.gd b/save/player_save_manager.gd index 159a9bc..7a2e626 100644 --- a/save/player_save_manager.gd +++ b/save/player_save_manager.gd @@ -33,9 +33,11 @@ const WorldWeatherServiceType = preload( ) const PlayerJobServiceType = preload("res://jobs/player_job_service.gd") -const SAVE_VERSION: int = 8 +const SAVE_VERSION: int = 9 const BASIC_ROD_ID: StringName = &"basic_fishing_rod" const MAX_SAFE_BALANCE: int = 1000000000000 +const DEFAULT_WORLD_SEED: int = 13001 +const MAX_WORLD_SEED: int = 2147483646 class LoadSnapshot: extends RefCounted @@ -56,6 +58,7 @@ class LoadSnapshot: var cooler_capacity_level: int = 0 var art_unlock_mask: int = 0 var total_experience: int = 0 + var world_seed: int = DEFAULT_WORLD_SEED var world_time_hours: float = WorldTimeServiceType.DEFAULT_START_HOUR var has_world_weather_state: bool = false var world_weather: WorldWeatherServiceType.Weather = ( @@ -93,6 +96,7 @@ var _autosave_enabled: bool = false var _save_path := "" var _expected_hash := "" var _data_root: PlayerDataRoot +var _world_seed: int = DEFAULT_WORLD_SEED func configure_storage(path: String, data_root: PlayerDataRoot) -> void: @@ -298,6 +302,7 @@ func load_player_data() -> bool: var world_time_restored: bool = ( _world_time.restore_persistent_time_hours(snapshot.world_time_hours) ) + _world_seed = snapshot.world_seed var world_weather_restored: bool = true if snapshot.has_world_weather_state: world_weather_restored = _world_weather.restore_persistent_state( @@ -403,14 +408,28 @@ func inspect_save() -> SaveInspectionType: return result -func initialize_new_game() -> bool: +func initialize_new_game(world_seed: int = DEFAULT_WORLD_SEED) -> bool: if not _is_configured: return false + if world_seed <= 0 or world_seed > MAX_WORLD_SEED: + return false _automatic_saving_blocked = false _restore_defaults() + _world_seed = world_seed + _is_dirty = true return true +func get_world_seed() -> int: + return _world_seed + + +static func roll_world_seed() -> int: + var random := RandomNumberGenerator.new() + random.randomize() + return random.randi_range(1, MAX_WORLD_SEED) + + func delete_progression_save() -> bool: if FileAccess.file_exists(_save_path) and not _remove_if_present(_save_path): return false @@ -584,6 +603,7 @@ func _build_save_dictionary() -> Dictionary: "art": _art_unlocks.to_save_data(), "experience": _experience.to_save_data(), "world": { + "seed": _world_seed, "time_hours": _world_time.get_persistent_time_hours(), "weather": int(_world_weather.get_persistent_weather()), "weather_seconds_remaining": ( @@ -664,6 +684,14 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot: return null var snapshot := LoadSnapshot.new() + if world_data.has("seed"): + snapshot.world_seed = _read_integer( + world_data["seed"], + -1, + MAX_WORLD_SEED, + ) + if snapshot.world_seed <= 0: + return null snapshot.inventory_layout_data = inventory_layout_data.duplicate(true) snapshot.wallet_balance = balance snapshot.total_experience = _read_integer( @@ -965,6 +993,8 @@ func _migrate_save( migrated = _migrate_version_6_to_7(migrated) 7: migrated = _migrate_version_7_to_8(migrated) + 8: + migrated = _migrate_version_8_to_9(migrated) _: return {} if migrated.is_empty(): @@ -1201,6 +1231,17 @@ func _migrate_version_7_to_8(data: Dictionary) -> Dictionary: return migrated +func _migrate_version_8_to_9(data: Dictionary) -> Dictionary: + var migrated: Dictionary = data.duplicate(true) + var world_data: Dictionary = {} + if typeof(migrated.get("world")) == TYPE_DICTIONARY: + world_data = (migrated["world"] as Dictionary).duplicate(true) + world_data["seed"] = DEFAULT_WORLD_SEED + migrated["world"] = world_data + migrated["save_version"] = 9 + return migrated + + func _mark_dirty() -> void: if ( _is_restoring @@ -1319,6 +1360,7 @@ func _restore_defaults() -> void: WorldTimeServiceType.DEFAULT_START_HOUR ) _world_weather.reset_persistent_state() + _world_seed = DEFAULT_WORLD_SEED _jobs.reset_to_defaults() _is_restoring = false _is_dirty = false diff --git a/scripts/run_validations.sh b/scripts/run_validations.sh index d773c4a..e612f37 100755 --- a/scripts/run_validations.sh +++ b/scripts/run_validations.sh @@ -30,6 +30,7 @@ readonly -a QUICK_TESTS=( "tests/fishing_audio_validation.gd" "tests/fishing_surface_validation.gd" "tests/fur_pattern_validation.gd" + "tests/generated_world_runtime_validation.gd" "tests/gathering_marker_surface_validation.gd" "tests/inventory_storage_validation.gd" "tests/keyboard_mouse_mapping_validation.gd" @@ -43,6 +44,7 @@ readonly -a QUICK_TESTS=( "tests/surface_drawing_validation.gd" "tests/tackle_order_validation.gd" "tests/terrain_blender_material_validation.gd" + "tests/terrain_chunk_generator_validation.gd" "tests/texture_sampling_validation.gd" "tests/tree_gathering_prototype_validation.gd" "tests/unified_inventory_validation.gd" diff --git a/server/dedicated_server_config.gd b/server/dedicated_server_config.gd index 0618466..7812800 100644 --- a/server/dedicated_server_config.gd +++ b/server/dedicated_server_config.gd @@ -5,12 +5,15 @@ const DEFAULT_NAME: String = "NETfishing Dedicated Server" const DEFAULT_BIND_ADDRESS: String = "*" const DEFAULT_PORT: int = 7777 const DEFAULT_MAX_PLAYERS: int = 8 +const DEFAULT_WORLD_SEED: int = 13001 +const MAX_WORLD_SEED: int = 2147483646 const MAX_OPERATORS: int = 64 var server_name: String = DEFAULT_NAME var bind_address: String = DEFAULT_BIND_ADDRESS var port: int = DEFAULT_PORT var max_players: int = DEFAULT_MAX_PLAYERS +var world_seed: int = DEFAULT_WORLD_SEED var public_listing: bool = false var discovery_url: String = "" var data_directory: String = "" @@ -58,6 +61,7 @@ func _load_file(path: String) -> bool: max_players = int(file.get_value( "server", "max_players", max_players )) + world_seed = int(file.get_value("world", "seed", world_seed)) public_listing = bool(file.get_value( "server", "public", public_listing )) @@ -84,6 +88,9 @@ func _apply_environment() -> void: max_players = _environment_int( "NETFISHING_SERVER_MAX_PLAYERS", max_players ) + world_seed = _environment_int( + "NETFISHING_WORLD_SEED", world_seed + ) public_listing = _environment_bool( "NETFISHING_SERVER_PUBLIC", public_listing ) @@ -111,6 +118,10 @@ func _apply_arguments(arguments: PackedStringArray) -> void: max_players = _parse_int( argument.trim_prefix("--max-players="), max_players ) + elif argument.begins_with("--world-seed="): + world_seed = _parse_int( + argument.trim_prefix("--world-seed="), world_seed + ) elif argument.begins_with("--data-dir="): data_directory = argument.trim_prefix("--data-dir=") elif argument.begins_with("--discovery-url="): @@ -141,6 +152,8 @@ func _validate() -> void: error_message = "Server port must be between 1 and 65535." elif max_players < 1 or max_players > 128: error_message = "Maximum players must be between 1 and 128." + elif world_seed <= 0 or world_seed > MAX_WORLD_SEED: + error_message = "World seed must be between 1 and %d." % MAX_WORLD_SEED elif not data_directory.is_empty() and not data_directory.is_absolute_path(): error_message = "Server data directory must be an absolute path." elif public_listing and not ( diff --git a/tests/dedicated_server_config_validation.gd b/tests/dedicated_server_config_validation.gd index 6fef7c6..c9a82c0 100644 --- a/tests/dedicated_server_config_validation.gd +++ b/tests/dedicated_server_config_validation.gd @@ -28,6 +28,7 @@ func _run() -> void: file.set_value("server", "bind_address", "127.0.0.1") file.set_value("server", "port", 17777) file.set_value("server", "max_players", 12) + file.set_value("world", "seed", 928374) file.set_value("server", "public", true) file.set_value("server", "data_directory", "/tmp/configured-server") file.set_value("discovery", "url", "https://discovery.netfishing.org/") @@ -44,6 +45,7 @@ func _run() -> void: assert(str(configured.get("bind_address")) == "127.0.0.1") assert(int(configured.get("port")) == 17777) assert(int(configured.get("max_players")) == 12) + assert(int(configured.get("world_seed")) == 928374) assert(bool(configured.get("public_listing"))) assert(str(configured.get("discovery_url")) == "https://discovery.netfishing.org") assert( @@ -81,6 +83,12 @@ func _run() -> void: invalid.call("_validate") assert(not invalid.is_valid()) + var invalid_seed := ConfigType.new() + invalid_seed.set("world_seed", 0) + invalid_seed.set("data_directory", "/tmp/invalid-seed-server") + invalid_seed.call("_validate") + assert(not invalid_seed.is_valid()) + var discovery := DiscoveryClient.new() assert( str(discovery.call("_default_room_name", "River")) diff --git a/tests/fish_hotbar_showcase_validation.gd b/tests/fish_hotbar_showcase_validation.gd index 999561a..d61d002 100644 --- a/tests/fish_hotbar_showcase_validation.gd +++ b/tests/fish_hotbar_showcase_validation.gd @@ -106,7 +106,7 @@ func _run() -> void: var hotbar_data: Dictionary = (parsed as Dictionary)["hotbar"] assert(typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY) assert(str((hotbar_data["fish_slots"] as Array)[1]) == fish_catch.catch_id) - assert(int((parsed as Dictionary)["save_version"]) == 8) + assert(int((parsed as Dictionary)["save_version"]) == 9) assert( int((parsed as Dictionary)["experience"]["total_experience"]) == 125 @@ -185,7 +185,7 @@ func _run() -> void: var invalid_state: Dictionary = valid_state.duplicate(true) invalid_state["display_scale"] = 1000.0 assert(not NetworkFishShowcaseProtocol.validate_state(invalid_state)) - assert(NetworkProtocol.PROTOCOL_VERSION == 8) + assert(NetworkProtocol.PROTOCOL_VERSION == 9) assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10) assert( NetworkProtocol.FISH_QUALITY_CAPABILITY diff --git a/tests/fish_quality_validation.gd b/tests/fish_quality_validation.gd index da9f4be..31e4cc1 100644 --- a/tests/fish_quality_validation.gd +++ b/tests/fish_quality_validation.gd @@ -25,7 +25,7 @@ func _run() -> void: _validate_mail_round_trip() _validate_collection_mastery() _validate_version_four_migration() - assert(NetworkProtocol.PROTOCOL_VERSION == 8) + assert(NetworkProtocol.PROTOCOL_VERSION == 9) assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10) assert(NetworkProtocol.FISH_QUALITY_CAPABILITY == "fish_quality_v1") print("Fish quality validation: PASS") @@ -433,7 +433,7 @@ func _validate_version_four_migration() -> void: version_four, 4, ) - assert(int(migrated.get("save_version", -1)) == 8) + assert(int(migrated.get("save_version", -1)) == 9) assert(int((migrated["experience"] as Dictionary)["total_experience"]) == 0) assert( is_equal_approx( @@ -441,6 +441,10 @@ func _validate_version_four_migration() -> void: 8.0, ) ) + assert( + int((migrated["world"] as Dictionary)["seed"]) + == PlayerSaveManager.DEFAULT_WORLD_SEED + ) var catches: Array = migrated["inventory"]["catches"] assert(int((catches[0] as Dictionary)["quality"]) == 0) var collection: Dictionary = migrated["collection"] diff --git a/tests/fishing_authority_validation.gd b/tests/fishing_authority_validation.gd index c06ab36..8046668 100644 --- a/tests/fishing_authority_validation.gd +++ b/tests/fishing_authority_validation.gd @@ -39,19 +39,33 @@ func _run() -> void: assert(not session.is_open_host()) assert(service != null and fishing_spot != null and player != null) assert(player.hotbar.get_selected_item_id() == &"basic_fishing_rod") - var pond := main.get_node( - "TestWorld/Regions/StarterIslandRegion/WaterBodies/Pond" + var fresh_root := main.get_node( + "TestWorld/Regions/GeneratedWorldRegion/WaterBodies/FreshWaterBodies" ) as Node3D + var pond: WaterBodyAuthoring + for child: Node in fresh_root.get_children(): + var candidate := child as WaterBodyAuthoring + if candidate != null and &"pond" in candidate.location_tags: + pond = candidate + break + assert(pond != null) var pond_region := pond.get_node("FishingRegion") as FishableWaterRegion - assert(pond != null and pond_region != null) + assert(pond_region != null) var pond_surface_y: float = pond_region.get_surface_height() - player.global_position = pond.global_position + Vector3(8.9, 1.44, 0.0) + player.global_position = pond.global_transform * Vector3( + pond.surface_size.x * 0.5 + 0.8, + 1.44, + 0.0, + ) + var pond_direction := pond.global_position - player.global_position + pond_direction.y = 0.0 + pond_direction = pond_direction.normalized() var visuals := player.get_node("Visuals") as Node3D - visuals.rotation.y = PI * 0.5 + visuals.global_rotation.y = atan2(-pond_direction.x, -pond_direction.z) for _frame: int in 4: await physics_frame - assert(player.get_facing_direction().dot(Vector3.LEFT) > 0.99) + assert(player.get_facing_direction().dot(pond_direction) > 0.99) fishing_spot.call("_begin_aiming", player) assert(fishing_spot.state == FishingSpotType.FishingState.AIMING_CAST) @@ -59,7 +73,9 @@ func _run() -> void: fishing_spot.set("_cast_charge", 0.32) fishing_spot.call("_update_cast_charge", 0.0) var aimed_target: Vector3 = fishing_spot.get("_cast_target") - assert(aimed_target.x < player.global_position.x - 0.85) + var aimed_direction := aimed_target - player.global_position + aimed_direction.y = 0.0 + assert(aimed_direction.normalized().dot(pond_direction) > 0.99) assert(is_equal_approx(aimed_target.y, pond_surface_y)) assert(fishing_spot.is_target_fishable(aimed_target)) fishing_spot.call("_confirm_cast") diff --git a/tests/generated_world_runtime_validation.gd b/tests/generated_world_runtime_validation.gd new file mode 100644 index 0000000..fdc8cc2 --- /dev/null +++ b/tests/generated_world_runtime_validation.gd @@ -0,0 +1,236 @@ +extends SceneTree + +const RegionScene: PackedScene = preload( + "res://world/generation/generated_world_region.tscn" +) +const FIRST_SEED := 13001 +const SECOND_SEED := 13002 + + +func _initialize() -> void: + call_deferred(&"_run") + + +func _run() -> void: + var region := RegionScene.instantiate() as GeneratedWorldRegion + assert(region != null) + root.add_child(region) + await process_frame + await physics_frame + + var generator := region.get_node( + "Terrain/TerrainChunkGenerator" + ) as TerrainChunkGenerator + assert(generator != null) + assert(region.get_generation_seed() == FIRST_SEED) + _validate_generated_region(region, generator) + + var first_layout := generator.placement_keys() + var first_decorations := _decoration_signature(region) + assert(region.generate_world(FIRST_SEED)) + assert(generator.placement_keys() == first_layout) + assert(_decoration_signature(region) == first_decorations) + + assert(region.generate_world(SECOND_SEED)) + await physics_frame + _validate_generated_region(region, generator) + assert(generator.placement_keys() != first_layout) + + region.queue_free() + await process_frame + print("Generated world runtime validation: PASS") + quit() + + +func _validate_generated_region( + region: GeneratedWorldRegion, + generator: TerrainChunkGenerator, +) -> void: + var placements := generator.placement_keys() + assert(placements.size() == 25) + assert(placements[12].begins_with("chunk_spawn@")) + assert(_placement_count(placements, "chunk_spawn") == 1) + assert(_placement_count(placements, "chunk_0001") >= 1) + assert(_placement_count(placements, "chunk_0002") >= 1) + assert(_placement_count(placements, "chunk_0003") >= 1) + assert(_placement_count(placements, "chunk_0004") >= 1) + assert(generator.get_generated_chunks_root().get_child_count() == 25) + + var shop := region.get_node("Interactables/FishingShopWorld") as Node3D + var storage := region.get_node("Interactables/PlayerStorageBox") as Node3D + assert(shop != null and shop.has_node("Shopkeeper")) + assert(storage != null and storage.has_node("InteractionArea")) + assert(absf(shop.position.x) < 5.0 and absf(shop.position.z) < 5.0) + assert(absf(storage.position.x) < 5.0 and absf(storage.position.z) < 5.0) + assert(region.get_fishing_shop() != null) + assert(region.get_player_storage() != null) + assert(region.get_player_spawn_transform().origin.y > 0.0) + + var ocean := region.get_node("WaterBodies/OceanWater") as WaterBodyAuthoring + var fresh_root := region.get_node("WaterBodies/FreshWaterBodies") as Node3D + assert(ocean != null and fresh_root != null) + assert(ocean.water_type == WaterType.Type.SALT_WATER) + var fresh_placement_count := 0 + for record: Dictionary in generator.placement_records(): + var tags: PackedStringArray = record.get("tags", PackedStringArray()) + if "fresh_water" in tags: + fresh_placement_count += 1 + assert(fresh_root.get_child_count() == fresh_placement_count) + for child: Node in fresh_root.get_children(): + var fresh := child as WaterBodyAuthoring + assert(fresh != null) + assert(fresh.water_type == WaterType.Type.FRESH_WATER) + assert(fresh.visible) + assert(fresh.surface_size.x < 10.0 or fresh.surface_size.y < 10.0) + assert(not fresh.visual_surface_enabled) + assert(not (fresh.get_node("VisualWater") as MeshInstance3D).visible) + + _validate_authored_chunk_surfaces(region, generator) + + var decorations := region.get_node("Decorations") as Node3D + var anchors := region.get_node( + "GatherableAnchors/ReachableTreeTrunks" + ) as GatherableAnchorSet3D + assert(decorations != null and decorations.get_child_count() > 0) + assert(anchors != null) + assert(anchors.get_spawn_positions().size() > 0) + for child: Node in decorations.get_children(): + assert( + child.name.begins_with("regular_tree_") + or child.name.begins_with("palm_tree_") + ) + _validate_decoration_transform(child as Node3D) + assert(_decoration_count(decorations, "regular_tree_") > 0) + assert(_decoration_count(decorations, "palm_tree_") > 0) + + +func _validate_decoration_transform(prop: Node3D) -> void: + assert(prop != null) + assert(prop.scale.is_equal_approx(Vector3.ONE)) + var visual := prop.get_node_or_null("Visual") as MeshInstance3D + var collision := prop.get_node_or_null( + "TrunkCollision/CollisionShape" + ) as CollisionShape3D + assert(visual != null and visual.mesh != null) + assert(collision != null and collision.shape is CylinderShape3D) + assert(collision.global_basis.get_scale().is_equal_approx(Vector3.ONE)) + var bounds := visual.mesh.get_aabb() + var minimum_y := INF + for corner_index: int in 8: + var corner := bounds.position + Vector3( + bounds.size.x if (corner_index & 1) != 0 else 0.0, + bounds.size.y if (corner_index & 2) != 0 else 0.0, + bounds.size.z if (corner_index & 4) != 0 else 0.0, + ) + minimum_y = minf( + minimum_y, + (visual.global_transform * corner).y, + ) + assert(absf(minimum_y - prop.global_position.y) <= 0.01) + + +func _validate_authored_chunk_surfaces( + region: GeneratedWorldRegion, + generator: TerrainChunkGenerator, +) -> void: + var generated := generator.get_generated_chunks_root() + var found_beach := false + var found_pond := false + for chunk_root: Node in generated.get_children(): + if chunk_root.name.begins_with("chunk_0002r"): + var beach := chunk_root.find_child( + "chunk_0002", true, false + ) as MeshInstance3D + assert(beach != null and beach.mesh != null) + var material := beach.get_active_material(0) + assert(material != null and material.resource_name == "sand") + found_beach = true + elif chunk_root.name.begins_with("chunk_0004r"): + var pond := chunk_root.find_child( + "chunk_0004", true, false + ) as MeshInstance3D + assert(pond != null and pond.mesh != null) + for surface_index: int in pond.mesh.get_surface_count(): + var arrays := pond.mesh.surface_get_arrays(surface_index) + var normals := arrays[Mesh.ARRAY_NORMAL] as PackedVector3Array + for normal: Vector3 in normals: + assert(normal.y >= -0.001) + _validate_pond_collision(region, pond) + found_pond = true + assert(found_beach) + assert(found_pond) + + +func _validate_pond_collision( + region: GeneratedWorldRegion, + pond: MeshInstance3D, +) -> void: + var best_centroid := Vector3.ZERO + var best_height := -INF + for surface_index: int in pond.mesh.get_surface_count(): + var arrays := pond.mesh.surface_get_arrays(surface_index) + var vertices := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array + var normals := arrays[Mesh.ARRAY_NORMAL] as PackedVector3Array + var indices := arrays[Mesh.ARRAY_INDEX] as PackedInt32Array + var triangle_count := ( + indices.size() / 3 if not indices.is_empty() else vertices.size() / 3 + ) + for triangle_index: int in triangle_count: + var offset := triangle_index * 3 + var index_a: int = indices[offset] if not indices.is_empty() else offset + var index_b: int = ( + indices[offset + 1] if not indices.is_empty() else offset + 1 + ) + var index_c: int = ( + indices[offset + 2] if not indices.is_empty() else offset + 2 + ) + var a: Vector3 = vertices[index_a] + var b: Vector3 = vertices[index_b] + var c: Vector3 = vertices[index_c] + var normal := ( + (normals[index_a] + normals[index_b] + normals[index_c]) / 3.0 + ).normalized() + var centroid := (a + b + c) / 3.0 + if normal.y > 0.5 and centroid.y > best_height: + best_height = centroid.y + best_centroid = centroid + assert(best_height > -INF) + var target := pond.global_transform * best_centroid + var query := PhysicsRayQueryParameters3D.create( + target + Vector3.UP * 2.0, + target + Vector3.DOWN * 2.0, + 1, + ) + var hit := region.get_world_3d().direct_space_state.intersect_ray(query) + assert(not hit.is_empty()) + var collider := hit.get("collider") as Node + assert(collider != null and collider.get_parent() == pond) + + +func _decoration_count(decorations: Node3D, prefix: String) -> int: + var count := 0 + for child: Node in decorations.get_children(): + if child.name.begins_with(prefix): + count += 1 + return count + + +func _placement_count(keys: PackedStringArray, stable_id: String) -> int: + var count := 0 + for key: String in keys: + if key.begins_with(stable_id + "@"): + count += 1 + return count + + +func _decoration_signature(region: GeneratedWorldRegion) -> PackedStringArray: + var result := PackedStringArray() + var decorations := region.get_node("Decorations") as Node3D + for child: Node in decorations.get_children(): + var prop := child as Node3D + result.append("%s:%s:%s" % [ + prop.name, + prop.position, + prop.rotation, + ]) + return result diff --git a/tests/generated_world_runtime_validation.gd.uid b/tests/generated_world_runtime_validation.gd.uid new file mode 100644 index 0000000..060cb22 --- /dev/null +++ b/tests/generated_world_runtime_validation.gd.uid @@ -0,0 +1 @@ +uid://opamwsr0t7px diff --git a/tests/inventory_storage_persistence_validation.gd b/tests/inventory_storage_persistence_validation.gd index 3af05fe..013ebbf 100644 --- a/tests/inventory_storage_persistence_validation.gd +++ b/tests/inventory_storage_persistence_validation.gd @@ -22,7 +22,8 @@ func _run() -> void: var save_manager := main.get("_save_manager") as PlayerSaveManager var player := main.get("_player") as Player assert(save_manager != null and player != null) - assert(save_manager.initialize_new_game()) + const TEST_WORLD_SEED := 918273 + assert(save_manager.initialize_new_game(TEST_WORLD_SEED)) save_manager.set_autosave_enabled(true) assert(player.bag.add_item(&"art_kit")) assert(player.bag.add_item(&"coffee")) @@ -39,12 +40,14 @@ func _run() -> void: save_file.close() assert(typeof(parsed) == TYPE_DICTIONARY) var records: Array = (parsed as Dictionary)["bag"]["items"] + assert(int((parsed as Dictionary)["world"]["seed"]) == TEST_WORLD_SEED) assert(_saved_slot(records, &"basic_fishing_rod") == 14) assert(_saved_slot(records, &"coffee") == 12) assert(player.bag.move_item_to_storage_slot(&"basic_fishing_rod", 0)) assert(player.bag.move_item_to_storage_slot(&"coffee", 0)) assert(save_manager.load_player_data()) + assert(save_manager.get_world_seed() == TEST_WORLD_SEED) assert(player.bag.get_storage_slot(&"basic_fishing_rod") == 14) assert(player.bag.get_storage_slot(&"coffee") == 12) diff --git a/tests/job_system_validation.gd b/tests/job_system_validation.gd index 81d97e2..d2f2ae7 100644 --- a/tests/job_system_validation.gd +++ b/tests/job_system_validation.gd @@ -203,7 +203,7 @@ func _run() -> void: save_file.close() assert(typeof(parsed) == TYPE_DICTIONARY) var save_data: Dictionary = parsed - assert(int(save_data.get("save_version", -1)) == 8) + assert(int(save_data.get("save_version", -1)) == 9) assert(PlayerJobService.validate_save_data(save_data.get("jobs", {}))) _validate_pause_session_switch(main, session) diff --git a/tests/light_performance_profile_validation.gd b/tests/light_performance_profile_validation.gd index 0353cf9..9421901 100644 --- a/tests/light_performance_profile_validation.gd +++ b/tests/light_performance_profile_validation.gd @@ -4,11 +4,6 @@ const MainScene: PackedScene = preload("res://main/main.tscn") const RuntimePerformanceProfileType = preload( "res://main/runtime_performance_profile.gd" ) -const FOLIAGE_WIND_SHADER: Shader = preload( - "res://world/materials/foliage_wind.gdshader" -) - - func _initialize() -> void: call_deferred("_run") @@ -77,27 +72,19 @@ func _validate_main_profile(main: Node) -> void: var screen_grid := pixelation.get_node("ScreenGrid") as ColorRect assert(screen_grid != null and not screen_grid.visible) - var pond := main.get_node( - "TestWorld/Regions/StarterIslandRegion/WaterBodies/Pond/VisualWater" - ) as MeshInstance3D + var pond := _first_fresh_water_visual(main) var ocean := main.get_node( - "TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/VisualWater" + "TestWorld/Regions/GeneratedWorldRegion/WaterBodies/OceanWater/VisualWater" ) as MeshInstance3D - assert(pond.material_override is StandardMaterial3D) + assert(not pond.visible) assert(ocean.material_override is StandardMaterial3D) - assert(not pond.material_override is ShaderMaterial) assert(not ocean.material_override is ShaderMaterial) - assert(ocean is WaterSurfaceMotion) - assert(not ocean.is_processing()) - assert(is_zero_approx(ocean.position.y)) _validate_ocean_fishing_coverage(main, ocean) - var island := main.get_node( - "TestWorld/Regions/StarterIslandRegion" - ) as StarterIslandRegion - assert(island != null and not island.is_foliage_wind_enabled()) - assert(_count_foliage_wind_overrides( - island.get_node("Terrain/Visual") - ) == 0) + var region := main.get_node( + "TestWorld/Regions/GeneratedWorldRegion" + ) as GeneratedWorldRegion + assert(region != null) + assert(region.get_node("Decorations").get_child_count() > 0) var title_background := main.get_node("%TitleBackground") as ColorRect var title_material := title_background.material as ShaderMaterial @@ -143,24 +130,18 @@ func _validate_normal_profile(main: Node) -> void: assert(is_equal_approx(root.scaling_3d_scale, 1.0)) var pixelation: Node = main.get_node("%WorldPixelationPostprocess") assert(not bool(pixelation.call("is_light_performance_profile"))) - var pond := main.get_node( - "TestWorld/Regions/StarterIslandRegion/WaterBodies/Pond/VisualWater" - ) as MeshInstance3D + var pond := _first_fresh_water_visual(main) var ocean := main.get_node( - "TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/VisualWater" + "TestWorld/Regions/GeneratedWorldRegion/WaterBodies/OceanWater/VisualWater" ) as MeshInstance3D - assert(pond.material_override is ShaderMaterial) + assert(not pond.visible) assert(ocean.material_override is ShaderMaterial) - assert(ocean is WaterSurfaceMotion) - assert(ocean.is_processing()) _validate_ocean_fishing_coverage(main, ocean) - var island := main.get_node( - "TestWorld/Regions/StarterIslandRegion" - ) as StarterIslandRegion - assert(island != null and island.is_foliage_wind_enabled()) - assert(_count_foliage_wind_overrides( - island.get_node("Terrain/Visual") - ) > 0) + var region := main.get_node( + "TestWorld/Regions/GeneratedWorldRegion" + ) as GeneratedWorldRegion + assert(region != null) + assert(region.get_node("Decorations").get_child_count() > 0) var game_ui := main.get_node("%GameUI") as GameUI var title_screen := game_ui.get_title_screen() assert(title_screen != null) @@ -211,6 +192,14 @@ func _validate_normal_profile(main: Node) -> void: ) +func _first_fresh_water_visual(main: Node) -> MeshInstance3D: + var root := main.get_node( + "TestWorld/Regions/GeneratedWorldRegion/WaterBodies/FreshWaterBodies" + ) as Node3D + assert(root != null and root.get_child_count() > 0) + return root.get_child(0).get_node("VisualWater") as MeshInstance3D + + func _validate_ocean_fishing_coverage( main: Node, ocean: MeshInstance3D, @@ -218,34 +207,19 @@ func _validate_ocean_fishing_coverage( var ocean_mesh := ocean.mesh as PlaneMesh assert(ocean_mesh != null) var shape_node := main.get_node( - "TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/" - + "FishingRegions/OceanFishingRegion/Shape" + "TestWorld/Regions/GeneratedWorldRegion/WaterBodies/OceanWater/" + + "FishingRegion/Shape" ) as CollisionShape3D assert(shape_node != null) var fishing_shape := shape_node.shape as BoxShape3D assert(fishing_shape != null) assert(fishing_shape.size.is_equal_approx(Vector3( ocean_mesh.size.x, - 2.0, + 4.0, ocean_mesh.size.y, ))) -func _count_foliage_wind_overrides(root_node: Node) -> int: - var count: int = 0 - var mesh_instance := root_node as MeshInstance3D - if mesh_instance != null and mesh_instance.mesh != null: - for surface_index: int in mesh_instance.mesh.get_surface_count(): - var material := mesh_instance.get_surface_override_material( - surface_index - ) as ShaderMaterial - if material != null and material.shader == FOLIAGE_WIND_SHADER: - count += 1 - for child: Node in root_node.get_children(): - count += _count_foliage_wind_overrides(child) - return count - - func _stop_audio_players(root_node: Node) -> void: if root_node is AudioStreamPlayer: (root_node as AudioStreamPlayer).stop() diff --git a/tests/manual/terrain_chunk_generator/terrain_chunk_compatibility_report.gd b/tests/manual/terrain_chunk_generator/terrain_chunk_compatibility_report.gd new file mode 100644 index 0000000..2420d0d --- /dev/null +++ b/tests/manual/terrain_chunk_generator/terrain_chunk_compatibility_report.gd @@ -0,0 +1,81 @@ +extends SceneTree + +const CATALOG: TerrainChunkCatalog = preload( + "res://world/generation/chunks/terrain_chunk_catalog.tres" +) +const MATCH_TOLERANCE := 0.01 + + +func _initialize() -> void: + call_deferred(&"_run") + + +func _run() -> void: + var variants: Array[TerrainChunkVariant] = [] + for definition: TerrainChunkDefinition in CATALOG.definitions: + var seen: Dictionary[String, bool] = {} + for variant: TerrainChunkVariant in TerrainChunkAnalyzer.create_variants( + definition, + CATALOG.chunk_size, + ): + var signature := variant.topology_signature(0.001) + if seen.has(signature): + continue + seen[signature] = true + variants.append(variant) + + print("Terrain chunk variants: %d" % variants.size()) + for variant: TerrainChunkVariant in variants: + print("\n", variant.stable_key()) + for edge_value: int in TerrainChunkTopology.Edge.values(): + var edge := edge_value as TerrainChunkTopology.Edge + var matches := PackedStringArray() + for other: TerrainChunkVariant in variants: + var opposite := TerrainChunkTopology.opposite_edge(edge) + if variant.profile(edge).matches( + other.profile(opposite), + MATCH_TOLERANCE, + ): + matches.append( + "%s.%s" + % [ + other.stable_key(), + TerrainChunkTopology.edge_name(opposite), + ] + ) + print( + " %s [%d points]: %s" + % [ + TerrainChunkTopology.edge_name(edge), + variant.profile(edge).points.size(), + ", ".join(matches) if not matches.is_empty() else "NONE", + ] + ) + + var generator := TerrainChunkGenerator.new() + generator.catalog = CATALOG + generator.grid_size = Vector2i(5, 5) + generator.generation_seed = 13001 + generator.generate_on_ready = false + generator.build_collision = false + generator.force_center_chunk_id = &"chunk_spawn" + generator.required_chunk_ids = PackedStringArray( + [ + "chunk_spawn", + "chunk_0001", + "chunk_0002", + "chunk_0003", + "chunk_0004", + ] + ) + root.add_child(generator) + if generator.generate(): + print("\nSeeded diagnostic layout:") + var keys := generator.placement_keys() + for row: int in generator.grid_size.y: + var cells := PackedStringArray() + for column: int in generator.grid_size.x: + cells.append(keys[row * generator.grid_size.x + column]) + print(" ", " ".join(cells)) + generator.free() + quit(0) diff --git a/tests/manual/terrain_chunk_generator/terrain_chunk_compatibility_report.gd.uid b/tests/manual/terrain_chunk_generator/terrain_chunk_compatibility_report.gd.uid new file mode 100644 index 0000000..1da131f --- /dev/null +++ b/tests/manual/terrain_chunk_generator/terrain_chunk_compatibility_report.gd.uid @@ -0,0 +1 @@ +uid://di4yaor4f3s4e diff --git a/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.gd b/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.gd new file mode 100644 index 0000000..f815109 --- /dev/null +++ b/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.gd @@ -0,0 +1,33 @@ +extends Node3D + +@onready var _generator: TerrainChunkGenerator = $TerrainChunkGenerator +@onready var _status: Label = $Instructions/Status + + +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed(&"ui_cancel"): + get_viewport().set_input_as_handled() + get_tree().quit() + return + var key_event := event as InputEventKey + if ( + key_event != null + and key_event.pressed + and not key_event.echo + and key_event.physical_keycode == KEY_R + ): + get_viewport().set_input_as_handled() + _generator.generation_seed += 1 + _generator.generate() + + +func _on_generation_completed(summary: Dictionary) -> void: + _status.text = ( + "Terrain generator diagnostic • seed %d • %d chunks • " + + "%d variants • %d backtracks\nR: regenerate • Esc/B: close" + ) % [ + int(summary["seed"]), + int(summary["chunk_count"]), + int(summary["variant_count"]), + int(summary["backtracks"]), + ] diff --git a/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.gd.uid b/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.gd.uid new file mode 100644 index 0000000..4713f51 --- /dev/null +++ b/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.gd.uid @@ -0,0 +1 @@ +uid://0dmjswsm038h diff --git a/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.tscn b/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.tscn new file mode 100644 index 0000000..742d2e8 --- /dev/null +++ b/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.tscn @@ -0,0 +1,46 @@ +[gd_scene load_steps=7 format=3] + +[ext_resource type="Script" path="res://tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.gd" id="1_test"] +[ext_resource type="Script" path="res://world/generation/terrain_chunk_generator.gd" id="2_generator"] +[ext_resource type="Resource" path="res://world/generation/chunks/terrain_chunk_catalog.tres" id="3_catalog"] +[ext_resource type="PackedScene" path="res://player/player.tscn" id="4_player"] +[ext_resource type="Environment" path="res://world/environment/netfishing_environment.tres" id="5_environment"] + +[node name="TerrainChunkGeneratorTest" type="Node3D"] +script = ExtResource("1_test") + +[node name="WorldEnvironment" type="WorldEnvironment" parent="."] +environment = ExtResource("5_environment") + +[node name="Sun" type="DirectionalLight3D" parent="."] +rotation_degrees = Vector3(-54, -32, 0) +light_color = Color(0.88, 0.94, 0.96, 1) +light_energy = 1.0 +shadow_enabled = true + +[node name="TerrainChunkGenerator" type="Node3D" parent="."] +script = ExtResource("2_generator") +catalog = ExtResource("3_catalog") +grid_size = Vector2i(5, 5) +generation_seed = 13001 +build_collision = true +show_chunk_labels = false +force_center_chunk_id = &"chunk_spawn" +required_chunk_ids = PackedStringArray("chunk_spawn", "chunk_0001", "chunk_0002", "chunk_0003", "chunk_0004") + +[node name="Player" parent="." instance=ExtResource("4_player")] +position = Vector3(0, 0.3, 0) + +[node name="Instructions" type="CanvasLayer" parent="."] +layer = 20 + +[node name="Status" type="Label" parent="Instructions"] +offset_left = 18.0 +offset_top = 18.0 +offset_right = 930.0 +offset_bottom = 74.0 +text = "Terrain generator diagnostic\nR: regenerate • Esc/B: close" +theme_override_colors/font_color = Color(0.76, 0.88, 0.9, 1) +theme_override_font_sizes/font_size = 18 + +[connection signal="generation_completed" from="TerrainChunkGenerator" to="." method="_on_generation_completed"] diff --git a/tests/player_experience_validation.gd b/tests/player_experience_validation.gd index 2250706..0c1a9f7 100644 --- a/tests/player_experience_validation.gd +++ b/tests/player_experience_validation.gd @@ -143,11 +143,15 @@ func _validate_save_migration() -> void: version_five, 5, ) - assert(int(migrated.get("save_version", -1)) == 8) + assert(int(migrated.get("save_version", -1)) == 9) var experience_data: Dictionary = migrated.get("experience", {}) assert(int(experience_data.get("total_experience", -1)) == 0) var world_data: Dictionary = migrated.get("world", {}) assert(is_equal_approx(float(world_data.get("time_hours", -1.0)), 8.0)) + assert( + int(world_data.get("seed", 0)) + == PlayerSaveManager.DEFAULT_WORLD_SEED + ) assert( PlayerJobService.validate_save_data(migrated.get("jobs", {})) ) diff --git a/tests/terrain_chunk_generator_validation.gd b/tests/terrain_chunk_generator_validation.gd new file mode 100644 index 0000000..c82f28d --- /dev/null +++ b/tests/terrain_chunk_generator_validation.gd @@ -0,0 +1,216 @@ +extends SceneTree + +const CATALOG: TerrainChunkCatalog = preload( + "res://world/generation/chunks/terrain_chunk_catalog.tres" +) +const EXPECTED_IDS: Array[String] = [ + "chunk_0000", + "chunk_0001", + "chunk_0002", + "chunk_0003", + "chunk_0004", + "chunk_spawn", +] +const REQUIRED_IDS: Array[String] = [ + "chunk_spawn", + "chunk_0001", + "chunk_0002", + "chunk_0003", + "chunk_0004", +] +const TEST_SEED := 13001 + +var _failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred(&"_run") + + +func _run() -> void: + _validate_catalog() + _validate_profiles() + _validate_generation() + _finish() + + +func _validate_catalog() -> void: + for error: String in CATALOG.validation_errors(): + _check(false, error) + _check( + CATALOG.definitions.size() == EXPECTED_IDS.size(), + "Catalog must contain the authored chunks and spawn definition.", + ) + for stable_id: String in EXPECTED_IDS: + var definition := CATALOG.definition_for_id(StringName(stable_id)) + _check(definition != null, "Catalog is missing %s." % stable_id) + if definition == null: + continue + _check( + definition.packed_scene != null, + "%s must reference an imported GLB." % stable_id, + ) + + +func _validate_profiles() -> void: + for definition: TerrainChunkDefinition in CATALOG.definitions: + var variants := TerrainChunkAnalyzer.create_variants( + definition, + CATALOG.chunk_size, + ) + _check( + not variants.is_empty(), + "%s must produce at least one rotation variant." + % definition.stable_id, + ) + for variant: TerrainChunkVariant in variants: + _check( + variant.edge_profiles.size() == 4, + "%s must expose four edge profiles." % variant.stable_key(), + ) + for profile: TerrainChunkEdgeProfile in variant.edge_profiles: + _check( + not profile.points.is_empty(), + "%s has an empty edge profile." % variant.stable_key(), + ) + + +func _validate_generation() -> void: + var first := _make_generator() + root.add_child(first) + var first_solved := first.generate() + _check(first_solved, "Generator must solve the prototype 5x5 grid.") + if not first_solved: + first.free() + return + var first_keys := first.placement_keys() + _check(first_keys.size() == 25, "Generator must place exactly 25 chunks.") + for stable_id: String in REQUIRED_IDS: + _check( + _placements_contain(first_keys, stable_id), + "Generated diagnostic must contain %s." % stable_id, + ) + _check( + _all_neighbor_edges_match(first), + "Every generated neighboring edge must match.", + ) + _check( + _count_placements(first_keys, "chunk_spawn") == 1, + "Generated layouts must contain exactly one spawn chunk.", + ) + _check( + first_keys[12].begins_with("chunk_spawn@"), + "The spawn chunk must occupy the center cell.", + ) + + var second := _make_generator() + root.add_child(second) + var second_solved := second.generate() + _check(second_solved, "Repeated deterministic generation must solve.") + if not second_solved: + first.free() + second.free() + return + _check( + second.placement_keys() == first_keys, + "The same seed and catalog must produce the same layout.", + ) + first.free() + second.free() + + for seed_offset: int in range(1, 6): + var generator := _make_generator() + generator.generation_seed = TEST_SEED + seed_offset + root.add_child(generator) + var solved := generator.generate() + _check( + solved, + "Generator must solve seed %d." % generator.generation_seed, + ) + if solved: + var keys := generator.placement_keys() + for stable_id: String in REQUIRED_IDS: + _check( + _placements_contain(keys, stable_id), + "Seed %d must contain %s." + % [generator.generation_seed, stable_id], + ) + _check( + _all_neighbor_edges_match(generator), + "Seed %d has a mismatched neighboring edge." + % generator.generation_seed, + ) + generator.free() + + +func _make_generator() -> TerrainChunkGenerator: + var generator := TerrainChunkGenerator.new() + generator.catalog = CATALOG + generator.grid_size = Vector2i(5, 5) + generator.generation_seed = TEST_SEED + generator.generate_on_ready = false + generator.build_collision = false + generator.force_center_chunk_id = &"chunk_spawn" + generator.required_chunk_ids = PackedStringArray(REQUIRED_IDS) + generator.maximum_backtracks = 100000 + return generator + + +func _placements_contain(keys: PackedStringArray, stable_id: String) -> bool: + for key: String in keys: + if key.begins_with(stable_id + "@"): + return true + return false + + +func _count_placements(keys: PackedStringArray, stable_id: String) -> int: + var count := 0 + for key: String in keys: + if key.begins_with(stable_id + "@"): + count += 1 + return count + + +func _all_neighbor_edges_match(generator: TerrainChunkGenerator) -> bool: + for index: int in generator._placements.size(): + var coordinate := Vector2i( + index % generator.grid_size.x, + index / generator.grid_size.x, + ) + var current: TerrainChunkVariant = generator._placements[index] + if coordinate.x > 0: + var west: TerrainChunkVariant = generator._placements[index - 1] + if not generator._edges_are_compatible( + current, + TerrainChunkTopology.Edge.WEST, + west, + TerrainChunkTopology.Edge.EAST, + ): + return false + if coordinate.y > 0: + var north: TerrainChunkVariant = ( + generator._placements[index - generator.grid_size.x] + ) + if not generator._edges_are_compatible( + current, + TerrainChunkTopology.Edge.NORTH, + north, + TerrainChunkTopology.Edge.SOUTH, + ): + return false + return true + + +func _check(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) + + +func _finish() -> void: + if _failures.is_empty(): + print("Terrain chunk generator validation: PASS") + quit(0) + return + for failure: String in _failures: + printerr("Terrain chunk generator validation: ", failure) + quit(1) diff --git a/tests/terrain_chunk_generator_validation.gd.uid b/tests/terrain_chunk_generator_validation.gd.uid new file mode 100644 index 0000000..d13a22b --- /dev/null +++ b/tests/terrain_chunk_generator_validation.gd.uid @@ -0,0 +1 @@ +uid://bt6pjgkomx76s diff --git a/tests/world_spawn_protocol_validation.gd b/tests/world_spawn_protocol_validation.gd index 11d2ac7..66b43fb 100644 --- a/tests/world_spawn_protocol_validation.gd +++ b/tests/world_spawn_protocol_validation.gd @@ -16,7 +16,7 @@ const CalendarSeasonType = preload("res://world/calendar_season.gd") func _initialize() -> void: - assert(NetworkProtocol.PROTOCOL_VERSION == 8) + assert(NetworkProtocol.PROTOCOL_VERSION == 9) assert( NetworkWorldSpawnProtocol.CAPABILITY == NetworkProtocol.WORLD_SPAWN_CAPABILITY diff --git a/tools/blender/export_terrain_chunks.py b/tools/blender/export_terrain_chunks.py new file mode 100644 index 0000000..f6ed976 --- /dev/null +++ b/tools/blender/export_terrain_chunks.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +"""Export convention-named terrain chunk collections to individual GLBs. + +Run through Blender rather than a standalone Python interpreter: + + blender --background terrain_chunks.blend \ + --python tools/blender/export_terrain_chunks.py -- \ + --output /path/to/terrain_chunks + +Collections use ``chunk_####_description`` and contain one primary terrain +mesh named ``chunk_####``. Additional production objects may live in the same +collection; unrelated collections are ignored. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import re +import struct +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +import bpy +from mathutils import Vector + + +CHUNK_SIZE_METERS = 10.0 +DEFAULT_TOLERANCE = 0.001 +COLLECTION_PATTERN = re.compile( + r"^chunk_(?P\d{4})(?:_(?P