diff --git a/ASSET-LICENSE.md b/ASSET-LICENSE.md index 50933b5..c438995 100644 --- a/ASSET-LICENSE.md +++ b/ASSET-LICENSE.md @@ -1,4 +1,4 @@ -# NETfishing Asset License 1.0 +# NETfishing Asset License 1.1 Copyright © 2026 Woofmeow. All rights reserved except for the permissions expressly granted below. @@ -7,8 +7,9 @@ expressly granted below. This license applies to original NETfishing artwork, graphics, textures, models, animations, music, sound recordings, narrative content, visual -designs, characters, and other creative assets owned by the NETfishing owners, -publishing as Woofmeow (the **“NETfishing Assets”**). +designs, characters, and other creative assets owned by the NETfishing owners +or licensed to them with authority to sublicense, publishing as Woofmeow (the +**“NETfishing Assets”**). It does not apply to: @@ -35,6 +36,13 @@ NETfishing Software. A Mod may add, replace, or alter content loaded by the Official NETfishing Software, but it may not redistribute a replacement copy of the game or represent a fork, clone, sequel, or separate game. +**Contribution Fork** means a source-control fork or clone of an official +NETfishing source repository that is created and maintained in good faith +primarily to prepare, test, review, or submit changes for possible inclusion +in Official NETfishing Software. It does not include a fork presented or +developed as an independent game, community edition, replacement distribution, +or ongoing product separate from the official NETfishing project. + ## 3. Permission to use the official game You may reproduce NETfishing Assets as technically necessary to install, run, @@ -64,7 +72,7 @@ A distributed Mod must: 3. include the following notice in its documentation or distribution page: > Contains or adapts NETfishing assets © 2026 Woofmeow, used under the - > NETfishing Asset License 1.0. This Mod is unofficial and is not endorsed + > NETfishing Asset License 1.1. This Mod is unofficial and is not endorsed > by Woofmeow. 4. identify material created by the Mod author separately from NETfishing @@ -77,14 +85,57 @@ charging for access to it, licensing it commercially, or using NETfishing Assets in paid products requires separate written permission from both NETfishing owners. -## 5. Uses not permitted +## 5. Contribution Fork license -Except with separate written permission from both NETfishing owners, you may -not use or distribute NETfishing Assets: +Subject to this license, you may reproduce, modify, and distribute NETfishing +Assets within a Contribution Fork. You may also make the nonpublic builds +reasonably necessary to develop, test, and review its proposed contribution. -- in a fork, clone, remake, sequel, or standalone game; +This permission is worldwide, nonexclusive, royalty-free, and available for +as long as you comply with this license. Public source-control hosting of the +Contribution Fork and the repository history technically necessary for that +fork are permitted distributions. + +A Contribution Fork must: + +1. remain primarily directed toward preparing changes for possible inclusion + in the official NETfishing project; +2. preserve this license and the official NETfishing project's applicable + attribution, third-party license, and copyright notices; +3. remain visibly linked to or identified as a fork of the official source + repository and not be presented as official or endorsed; +4. limit test-build access to the owners, maintainers, contributors, and + testers directly participating in development or review of the proposed + contribution; +5. not publish general-audience binaries, releases, packages, or downloads, + operate a persistent public alternative service, or monetize access; +6. not distribute the NETfishing Assets as a standalone extraction, archive, + or general-purpose asset pack; and +7. comply with `TRADEMARKS.md`. + +Temporary access-controlled servers may be operated when reasonably necessary +to test a proposed contribution. They may not be promoted or maintained as a +public alternative to Official NETfishing Software or its services. + +Submitting work from a Contribution Fork is governed separately by +`CONTRIBUTOR-TERMS.md` and does not require the NETfishing owners to accept the +work. Declining or closing a proposed contribution does not retroactively +invalidate good-faith use under this section. A compliant Contribution Fork +may remain publicly accessible as an archival development record, but it may +not be repurposed for continued development or distribution of a separate +product containing NETfishing Assets. + +## 6. Uses not permitted + +Except as expressly permitted by Sections 3, 4, and 5 or by separate written +permission from both NETfishing owners, you may not use or distribute +NETfishing Assets: + +- in a fork, clone, remake, sequel, or standalone game that is not a qualifying + Contribution Fork; - with a modified distribution of the GPL-covered NETfishing code that is not - Official NETfishing Software; + Official NETfishing Software, except for nonpublic contribution-testing + builds permitted by Section 5; - in another game, application, asset library, template, or merchandise; - as a substantially complete asset extraction or archive; - in a way that implies sponsorship, endorsement, or official status; @@ -92,22 +143,25 @@ not use or distribute NETfishing Assets: identity; or - under terms that claim ownership of the NETfishing Assets themselves. -The GPL permits forks and redistribution of the GPL-covered code. This asset -license does not permit those forks to carry NETfishing Assets. A code fork -must remove or replace the NETfishing Assets and reserved branding unless both -owners grant separate written permission. +The GPL permits forks and redistribution of the GPL-covered code. Except for a +qualifying Contribution Fork under Section 5, this asset license does not +permit those forks to carry NETfishing Assets. A code fork that is not a +Contribution Fork must remove or replace the NETfishing Assets and reserved +branding unless both owners grant separate written permission. -## 6. Ownership of Mods +## 7. Ownership of Mods and contribution work -You retain ownership of your original Mod material. The NETfishing owners -retain ownership of the NETfishing Assets and adaptations of those assets to -the extent provided by applicable law. +You retain ownership of your original Mod material and original material +created in a Contribution Fork. The NETfishing owners retain ownership of the +NETfishing Assets and adaptations of those assets to the extent provided by +applicable law. -This license does not require you to assign your original Mod material to the -NETfishing owners. It also does not grant you ownership of the underlying -NETfishing Assets. +This license does not require you to assign or submit your original material. +It also does not grant you ownership of the underlying NETfishing Assets. If +you intentionally submit material for inclusion in NETfishing, +`CONTRIBUTOR-TERMS.md` applies separately. -## 7. Termination and cure +## 8. Termination and cure Your permissions under this license terminate automatically if you violate its terms. They are reinstated provisionally when you stop the violation and @@ -117,7 +171,7 @@ within sixty days after it has been cured. Termination does not limit remedies available for activity outside this license. -## 8. No warranty +## 9. No warranty THE NETFISHING ASSETS ARE PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A @@ -125,7 +179,7 @@ PARTICULAR PURPOSE, TITLE, OR NON-INFRINGEMENT. TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE OWNERS WILL NOT BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY ARISING FROM THIS LICENSE OR USE OF THE NETFISHING ASSETS. -## 9. Additional permission +## 10. Additional permission Uses not granted here require written permission from both NETfishing owners. A permission granted to one person or project does not amend this license for diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 62c7813..6e982f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,21 @@ applicable grants and representations in affirmative record of that agreement. Contact the owners before submitting if separate written terms are needed. +## Contribution forks + +The [NETfishing Asset License](ASSET-LICENSE.md) permits a public source-control +fork to retain NETfishing assets when the fork is maintained in good faith to +prepare, test, review, or submit changes for possible inclusion in the official +project. Keep the hosting platform's fork relationship visible, preserve all +license and attribution notices, and do not publish general-audience builds or +use the fork as an independent game or distribution. + +Private test builds and temporary access-controlled test servers may be shared +with people directly participating in development or review of the proposed +contribution. Submission remains subject to `CONTRIBUTOR-TERMS.md`; permission +to maintain a Contribution Fork does not guarantee that its changes will be +accepted. + ## Change discipline - Keep a change focused; avoid unrelated cleanup. diff --git a/README.md b/README.md index 47caefa..cd48545 100644 --- a/README.md +++ b/README.md @@ -95,8 +95,8 @@ world and UI authoring, and testing are consolidated in [GPL-3.0-or-later](LICENSE). - Project-owned artwork, models, music, and other creative assets are covered by the [NETfishing Asset License](ASSET-LICENSE.md). It permits community - Mods for official NETfishing software, but not use in forks, clones, or - unrelated products. + Mods for official NETfishing software and narrowly scoped contribution forks, + but not independent forks, clones, or unrelated products. - The NETfishing and Woofmeow names and branding are reserved as described in [TRADEMARKS.md](TRADEMARKS.md). - Third-party materials retain their own terms; see the consolidated diff --git a/TRADEMARKS.md b/TRADEMARKS.md index 6848f47..363248b 100644 --- a/TRADEMARKS.md +++ b/TRADEMARKS.md @@ -24,10 +24,24 @@ An unofficial Mod may use wording such as “Example Mod for NETfishing” if it name and presentation remain clearly distinct and it displays an unofficial notice. -## Forks and redistributed code +## Contribution forks + +A qualifying Contribution Fork under `ASSET-LICENSE.md` may retain the +NETfishing and Woofmeow names, logos, icons, and trade dress already present in +the official source repository solely as necessary to reproduce, modify, and +test the Project while preparing a proposed contribution. The source-control +host's visible fork relationship or an equivalent repository notice must make +its unofficial contribution purpose clear. + +This permission does not allow a Contribution Fork to use NETfishing or +Woofmeow branding to advertise a public build, release, service, community +edition, or independent product. It does not imply acceptance, sponsorship, +endorsement, or official status. + +## Other forks and redistributed code The GNU GPL permits forks and redistribution of covered code. A fork or -modified distribution must: +modified distribution that is not a qualifying Contribution Fork must: - use a distinct product name; - remove NETfishing and Woofmeow logos, icons, and official trade dress; diff --git a/docs/ATTRIBUTION.md b/docs/ATTRIBUTION.md index 5f9e387..18b2797 100644 --- a/docs/ATTRIBUTION.md +++ b/docs/ATTRIBUTION.md @@ -53,11 +53,12 @@ alone does not relicense third-party material. The GPL permits study, modification, redistribution, and forks of covered code. It does not grant rights to NETfishing creative assets or branding. The NETfishing Asset License separately permits qualifying noncommercial add-on -Mods for official NETfishing software; it does not permit those assets to -accompany code forks, clones, standalone games, unrelated products, or -general-purpose asset packs. A GPL fork must replace NETfishing assets and -reserved branding with independently licensed material and a distinct -identity. +Mods for official NETfishing software and narrowly scoped Contribution Forks +used to prepare changes for possible upstream inclusion. It does not permit +those assets to accompany independent code forks, clones, standalone games, +unrelated products, or general-purpose asset packs. A GPL fork that is not a +qualifying Contribution Fork must replace NETfishing assets and reserved +branding with independently licensed material and a distinct identity. Project-wide code, asset, branding, or contributor-license changes require written approval from both NETfishing owners under their private joint 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..67b8abe 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" @@ -42,7 +43,9 @@ readonly -a QUICK_TESTS=( "tests/shoreline_ambience_validation.gd" "tests/surface_drawing_validation.gd" "tests/tackle_order_validation.gd" + "tests/terrain_biome_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..c6ac945 --- /dev/null +++ b/tests/generated_world_runtime_validation.gd @@ -0,0 +1,510 @@ +extends SceneTree + +const RegionScene: PackedScene = preload( + "res://world/generation/generated_world_region.tscn" +) +const FIRST_SEED := 13001 +const SECOND_SEED := 13004 +const EXPECTED_PROP_IDS: Array[StringName] = [ + &"prop_bridge", + &"prop_dock", + &"prop_log", + &"prop_log_post_1", + &"prop_log_post_2", + &"prop_mushroom", + &"prop_palm", + &"prop_pine", + &"prop_timber_1", + &"prop_timber_2", + &"prop_tree_1", + &"prop_tree_2", + &"prop_tree_3", +] +const EXPECTED_BIOME_IDS: Array[StringName] = [ + &"biome_plains", + &"biome_forest", + &"biome_pine_forest", + &"biome_coast", +] +const PROCEDURAL_PROP_IDS: Array[StringName] = [ + &"prop_log", + &"prop_mushroom", + &"prop_palm", + &"prop_pine", + &"prop_tree_1", + &"prop_tree_2", + &"prop_tree_3", +] + + +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_biomes := _biome_signature(region, generator) + var first_decorations := _decoration_signature(region) + assert(region.generate_world(FIRST_SEED)) + assert(generator.placement_keys() == first_layout) + assert(_biome_signature(region, generator) == first_biomes) + assert(_decoration_signature(region) == first_decorations) + + assert(region.generate_world(SECOND_SEED)) + await process_frame + await physics_frame + await physics_frame + _validate_generated_region(region, generator) + assert(generator.placement_keys() != first_layout) + assert(_biome_signature(region, generator) != first_biomes) + + 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() == 49) + assert(placements[24].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(_placement_count(placements, "chunk_0005") >= 1) + assert(_placement_count(placements, "chunk_0006") >= 1) + assert(_placement_count(placements, "chunk_0007") >= 1) + assert(generator.get_generated_chunks_root().get_child_count() == 49) + assert(generator.get_primary_terrain_meshes().size() == 49) + + 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) + _validate_prop_catalog(region) + _validate_biome_catalog(region, generator) + + 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 "coast" in tags: + _validate_ocean_facing_record(record, generator.grid_size) + 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(): + var prop_id := StringName(child.get_meta(&"terrain_prop_id", &"")) + assert(PROCEDURAL_PROP_IDS.has(prop_id)) + var definition := region.get_prop_catalog().definition_for_id(prop_id) + assert(definition != null) + var biome_id := StringName( + child.get_meta(&"terrain_biome_id", &"") + ) + var biome := region.get_biome_catalog().definition_for_id(biome_id) + assert(biome != null) + var biome_rule := biome.prop_rule_for_group( + definition.procedural_group + ) + assert(biome_rule != null and biome_rule.allows_prop(definition)) + var coordinate: Vector2i = child.get_meta( + &"terrain_chunk_coordinate", + Vector2i.ZERO, + ) + var center := Vector2i( + generator.grid_size.x / 2, + generator.grid_size.y / 2, + ) + var spawn_distance := ( + absi(coordinate.x - center.x) + + absi(coordinate.y - center.y) + ) + assert(spawn_distance >= definition.minimum_spawn_chunk_distance) + _validate_decoration_transform( + region, + child as Node3D, + definition, + ) + assert(_decoration_group_count(region, &"grass_tree") > 0) + assert(_decoration_group_count(region, &"sand_tree") > 0) + assert( + _decoration_biome_group_count( + region, + &"biome_forest", + &"grass_tree", + ) >= 2 + ) + assert( + _decoration_biome_group_count( + region, + &"biome_pine_forest", + &"grass_tree", + ) >= 2 + ) + + +func _validate_ocean_facing_record( + record: Dictionary, + grid_size: Vector2i, +) -> void: + var coordinate: Vector2i = record.get("coordinate", Vector2i.ZERO) + var ocean_edges := int(record.get("ocean_facing_edges", 0)) + assert(ocean_edges != 0) + for edge_value: int in TerrainChunkTopology.Edge.values(): + if (ocean_edges & (1 << edge_value)) == 0: + continue + var ocean_coordinate := ( + coordinate + + TerrainChunkTopology.grid_offset( + edge_value as TerrainChunkTopology.Edge + ) + ) + assert( + ocean_coordinate.x < 0 + or ocean_coordinate.y < 0 + or ocean_coordinate.x >= grid_size.x + or ocean_coordinate.y >= grid_size.y + ) + + +func _validate_prop_catalog(region: GeneratedWorldRegion) -> void: + var catalog := region.get_prop_catalog() + assert(catalog != null) + assert(catalog.validation_errors().is_empty()) + assert(catalog.definitions.size() == EXPECTED_PROP_IDS.size()) + for stable_id: StringName in EXPECTED_PROP_IDS: + var definition := catalog.definition_for_id(stable_id) + assert(definition != null and definition.packed_scene != null) + var instance := definition.packed_scene.instantiate() as Node3D + assert(instance != null) + assert(instance.position.is_zero_approx()) + assert(_find_mesh_instance(instance) != null) + instance.free() + + +func _validate_biome_catalog( + region: GeneratedWorldRegion, + generator: TerrainChunkGenerator, +) -> void: + var catalog := region.get_biome_catalog() + assert(catalog != null) + assert(catalog.validation_errors().is_empty()) + assert(catalog.definitions.size() == EXPECTED_BIOME_IDS.size()) + var counts: Dictionary[StringName, int] = {} + for record: Dictionary in generator.placement_records(): + var coordinate: Vector2i = record.get("coordinate", Vector2i.ZERO) + var biome_id := region.get_biome_at(coordinate) + var tags: PackedStringArray = record.get("tags", PackedStringArray()) + if "grass" in tags or "sand" in tags: + assert(biome_id != &"") + var biome := catalog.definition_for_id(biome_id) + assert(biome != null and biome.supports_chunk_tags(tags)) + counts[biome_id] = counts.get(biome_id, 0) + 1 + else: + assert(biome_id == &"") + for biome_id: StringName in EXPECTED_BIOME_IDS: + assert(counts.get(biome_id, 0) > 0) + var center := Vector2i( + generator.grid_size.x / 2, + generator.grid_size.y / 2, + ) + assert(region.get_biome_at(center) == &"biome_plains") + for child: Node in generator.get_generated_chunks_root().get_children(): + var coordinate: Vector2i = child.get_meta( + &"terrain_chunk_coordinate", + Vector2i.ZERO, + ) + assert( + StringName(child.get_meta(&"terrain_biome_id", &"")) + == region.get_biome_at(coordinate) + ) + + +func _validate_decoration_transform( + region: GeneratedWorldRegion, + prop: Node3D, + definition: TerrainPropDefinition, +) -> void: + assert(prop != null) + assert(prop.scale.is_equal_approx(Vector3.ONE)) + var visual_root := prop.get_node_or_null("Visual") as Node3D + var visual := _find_mesh_instance(visual_root) + var collision := prop.get_node_or_null( + "TrunkCollision/CollisionShape" + ) as CollisionShape3D + assert(visual_root != null and visual_root.position.is_zero_approx()) + assert(visual != null and visual.mesh != null) + if definition.has_collision(): + assert(collision != null) + assert( + collision.shape is CylinderShape3D + or collision.shape is BoxShape3D + ) + assert(collision.global_basis.get_scale().is_equal_approx(Vector3.ONE)) + else: + assert(collision == null) + var query := PhysicsRayQueryParameters3D.create( + prop.global_position + Vector3.UP * 8.0, + prop.global_position + Vector3.DOWN * 8.0, + 1, + ) + var prop_body := prop.get_node_or_null( + "TrunkCollision", + ) as CollisionObject3D + if prop_body != null: + query.exclude = [prop_body.get_rid()] + var hit := region.get_world_3d().direct_space_state.intersect_ray(query) + assert( + not hit.is_empty(), + "No terrain collision below %s (%s) at %s." + % [prop.name, definition.stable_id, prop.global_position], + ) + var ground_position: Vector3 = hit["position"] + assert(absf(ground_position.y - prop.global_position.y) <= 0.01) + + +func _find_mesh_instance(root_node: Node) -> MeshInstance3D: + if root_node is MeshInstance3D: + return root_node as MeshInstance3D + if root_node == null: + return null + for child: Node in root_node.get_children(): + var mesh_instance := _find_mesh_instance(child) + if mesh_instance != null: + return mesh_instance + return null + + +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 + var found_grass_ocean_edge := false + var found_grass_beach_transition := false + var found_grass_ocean_corner := 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 + elif chunk_root.name.begins_with("chunk_0005r"): + var grass_ocean_edge := chunk_root.find_child( + "chunk_0005", true, false + ) as MeshInstance3D + assert(grass_ocean_edge != null and grass_ocean_edge.mesh != null) + var edge_materials := _material_names(grass_ocean_edge) + assert("grass_lite" in edge_materials) + assert("cliff_wall" in edge_materials) + found_grass_ocean_edge = true + elif chunk_root.name.begins_with("chunk_0006r"): + var grass_beach_transition := chunk_root.find_child( + "chunk_0006", true, false + ) as MeshInstance3D + assert( + grass_beach_transition != null + and grass_beach_transition.mesh != null + ) + var transition_materials := _material_names( + grass_beach_transition + ) + assert("sand" in transition_materials) + assert("grass_lite" in transition_materials) + assert("cliff_wall" in transition_materials) + found_grass_beach_transition = true + elif chunk_root.name.begins_with("chunk_0007r"): + var grass_ocean_corner := chunk_root.find_child( + "chunk_0007", true, false + ) as MeshInstance3D + assert(grass_ocean_corner != null and grass_ocean_corner.mesh != null) + var corner_materials := _material_names(grass_ocean_corner) + assert("grass_lite" in corner_materials) + assert("cliff_wall" in corner_materials) + found_grass_ocean_corner = true + assert(found_beach) + assert(found_pond) + assert(found_grass_ocean_edge) + assert(found_grass_beach_transition) + assert(found_grass_ocean_corner) + + +func _material_names(mesh_instance: MeshInstance3D) -> PackedStringArray: + var result := PackedStringArray() + for surface_index: int in mesh_instance.mesh.get_surface_count(): + var material := mesh_instance.get_active_material(surface_index) + if material != null: + result.append(material.resource_name) + return result + + +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_group_count( + region: GeneratedWorldRegion, + group: StringName, +) -> int: + var count := 0 + var decorations := region.get_node("Decorations") as Node3D + for child: Node in decorations.get_children(): + var stable_id := StringName(child.get_meta(&"terrain_prop_id", &"")) + var definition := region.get_prop_catalog().definition_for_id(stable_id) + if definition != null and definition.procedural_group == group: + count += 1 + return count + + +func _decoration_biome_group_count( + region: GeneratedWorldRegion, + biome_id: StringName, + group: StringName, +) -> int: + var count := 0 + var decorations := region.get_node("Decorations") as Node3D + for child: Node in decorations.get_children(): + if StringName(child.get_meta(&"terrain_biome_id", &"")) != biome_id: + continue + var prop_id := StringName(child.get_meta(&"terrain_prop_id", &"")) + var definition := region.get_prop_catalog().definition_for_id(prop_id) + if definition != null and definition.procedural_group == group: + count += 1 + return count + + +func _biome_signature( + region: GeneratedWorldRegion, + generator: TerrainChunkGenerator, +) -> PackedStringArray: + var result := PackedStringArray() + for row: int in generator.grid_size.y: + for column: int in generator.grid_size.x: + result.append( + String(region.get_biome_at(Vector2i(column, row))) + ) + return result + + +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..64d5f1f --- /dev/null +++ b/tests/manual/terrain_chunk_generator/terrain_chunk_compatibility_report.gd @@ -0,0 +1,124 @@ +extends SceneTree + +const CATALOG: TerrainChunkCatalog = preload( + "res://world/generation/chunks/terrain_chunk_catalog.tres" +) +const BIOME_CATALOG: TerrainBiomeCatalog = preload( + "res://world/generation/biomes/terrain_biome_catalog.tres" +) + + +func _initialize() -> void: + call_deferred(&"_run") + + +func _run() -> void: + var generator := _make_generator() + root.add_child(generator) + if not generator._prepare_catalog(): + generator.free() + quit(1) + return + var variants: Array[TerrainChunkVariant] = [] + variants.assign(generator._solver_variants) + + print( + "Terrain chunk rotations: %d authored, %d distinct constraints" + % [generator._variants.size(), variants.size()] + ) + for variant: TerrainChunkVariant in variants: + print("\n", variant.stable_key()) + var ocean_edges := variant.rotated_edge_mask( + variant.definition.ocean_facing_edges + ) + 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 generator._edges_are_compatible( + variant, + edge, + other, + opposite, + ): + matches.append( + "%s.%s" + % [ + other.stable_key(), + TerrainChunkTopology.edge_name(opposite), + ] + ) + print( + " %s%s [%d points]: %s" + % [ + TerrainChunkTopology.edge_name(edge), + ( + " [ocean]" + if (ocean_edges & (1 << edge_value)) != 0 + else "" + ), + variant.profile(edge).points.size(), + ", ".join(matches) if not matches.is_empty() else "NONE", + ] + ) + + if generator.generate(): + var summary := generator._build_summary() + print( + ( + "\nSeeded diagnostic layout %s " + + "(%d backtracks, %d repeated edges):" + ) + % [ + str(summary["layout_fingerprint"]).substr(0, 12), + int(summary["backtracks"]), + int(summary["adjacent_repeat_edges"]), + ] + ) + 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)) + var biomes := TerrainBiomeAssigner.assign( + BIOME_CATALOG, + generator.placement_records(), + generator.generation_seed, + ) + print("\nBiome layout:") + for row: int in generator.grid_size.y: + var cells := PackedStringArray() + for column: int in generator.grid_size.x: + var biome_id: StringName = biomes.get( + Vector2i(column, row), + &"", + ) + cells.append(String(biome_id).trim_prefix("biome_")) + print(" ", " ".join(cells)) + generator.free() + quit(0) + + +func _make_generator() -> TerrainChunkGenerator: + var generator := TerrainChunkGenerator.new() + generator.catalog = CATALOG + generator.grid_size = Vector2i(7, 7) + 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", + "chunk_0005", + "chunk_0006", + "chunk_0007", + ] + ) + return generator 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..657347f --- /dev/null +++ b/tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.gd @@ -0,0 +1,37 @@ +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 rotations / %d constraints • %d backtracks • " + + "%d repeated edges\nlayout %s • R: regenerate • Esc/B: close" + ) % [ + int(summary["seed"]), + int(summary["chunk_count"]), + int(summary["variant_count"]), + int(summary["solver_variant_count"]), + int(summary["backtracks"]), + int(summary["adjacent_repeat_edges"]), + str(summary["layout_fingerprint"]).substr(0, 12), + ] 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..964da9a --- /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(7, 7) +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", "chunk_0005", "chunk_0006", "chunk_0007") + +[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_biome_validation.gd b/tests/terrain_biome_validation.gd new file mode 100644 index 0000000..968ff5e --- /dev/null +++ b/tests/terrain_biome_validation.gd @@ -0,0 +1,135 @@ +extends SceneTree + +const BIOME_CATALOG: TerrainBiomeCatalog = preload( + "res://world/generation/biomes/terrain_biome_catalog.tres" +) +const PROP_CATALOG: TerrainPropCatalog = preload( + "res://world/generation/props/terrain_prop_catalog.tres" +) +const EXPECTED_BIOMES: Array[StringName] = [ + &"biome_plains", + &"biome_forest", + &"biome_pine_forest", + &"biome_coast", +] +const TEST_SEED := 13001 + +var _failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred(&"_run") + + +func _run() -> void: + _validate_catalog() + _validate_assignment() + _finish() + + +func _validate_catalog() -> void: + for error: String in BIOME_CATALOG.validation_errors(): + _check(false, error) + _check( + BIOME_CATALOG.definitions.size() == EXPECTED_BIOMES.size(), + "The biome catalog must contain all initial authored profiles.", + ) + for biome_id: StringName in EXPECTED_BIOMES: + var biome := BIOME_CATALOG.definition_for_id(biome_id) + _check(biome != null, "The biome catalog is missing %s." % biome_id) + if biome == null: + continue + for rule: TerrainBiomePropRule in biome.prop_rules: + for prop_id_value: String in rule.allowed_prop_ids: + var prop := PROP_CATALOG.definition_for_id( + StringName(prop_id_value) + ) + _check( + prop != null and rule.allows_prop(prop), + "%s has an invalid %s prop rule." + % [biome_id, prop_id_value], + ) + + +func _validate_assignment() -> void: + var records := _synthetic_records() + var first := TerrainBiomeAssigner.assign( + BIOME_CATALOG, + records, + TEST_SEED, + ) + var second := TerrainBiomeAssigner.assign( + BIOME_CATALOG, + records, + TEST_SEED, + ) + _check(first == second, "Biome assignment must be deterministic by seed.") + _check( + TerrainBiomeAssigner.fingerprint(first).length() == 64, + "Biome assignments must expose a SHA-256 fingerprint.", + ) + var counts := TerrainBiomeAssigner.counts(first) + for biome_id: StringName in EXPECTED_BIOMES: + _check( + counts.get(biome_id, 0) > 0, + "The synthetic map must contain %s." % biome_id, + ) + _check( + first.get(Vector2i(3, 3), &"") == &"biome_plains", + "The spawn-tagged center must remain plains.", + ) + for record: Dictionary in records: + var coordinate: Vector2i = record["coordinate"] + var tags: PackedStringArray = record["tags"] + var biome_id: StringName = first.get(coordinate, &"") + if "fresh_water" in tags: + _check( + biome_id == &"", + "Freshwater-only chunks must not inherit a land-prop biome.", + ) + continue + var biome := BIOME_CATALOG.definition_for_id(biome_id) + _check( + biome != null and biome.supports_chunk_tags(tags), + "Biome %s does not support chunk %s." + % [biome_id, coordinate], + ) + + +func _synthetic_records() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for row: int in 7: + for column: int in 7: + var coordinate := Vector2i(column, row) + var tags := PackedStringArray(["land", "walkable", "grass"]) + if row == 0: + tags = PackedStringArray(["land", "walkable", "sand"]) + elif coordinate == Vector2i(0, 6): + tags = PackedStringArray([ + "land", + "walkable", + "fresh_water", + "pond", + ]) + elif coordinate == Vector2i(3, 3): + tags.append("spawn") + result.append({ + "coordinate": coordinate, + "tags": tags, + }) + return result + + +func _check(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) + + +func _finish() -> void: + if _failures.is_empty(): + print("Terrain biome validation: PASS") + quit(0) + return + for failure: String in _failures: + printerr("Terrain biome validation: ", failure) + quit(1) diff --git a/tests/terrain_biome_validation.gd.uid b/tests/terrain_biome_validation.gd.uid new file mode 100644 index 0000000..40de9e4 --- /dev/null +++ b/tests/terrain_biome_validation.gd.uid @@ -0,0 +1 @@ +uid://cxioi62f6oap0 diff --git a/tests/terrain_chunk_generator_validation.gd b/tests/terrain_chunk_generator_validation.gd new file mode 100644 index 0000000..0d70816 --- /dev/null +++ b/tests/terrain_chunk_generator_validation.gd @@ -0,0 +1,850 @@ +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_0005", + "chunk_0006", + "chunk_0007", + "chunk_spawn", +] +const REQUIRED_IDS: Array[String] = [ + "chunk_spawn", + "chunk_0001", + "chunk_0002", + "chunk_0003", + "chunk_0004", + "chunk_0005", + "chunk_0006", + "chunk_0007", +] +const TEST_SEED := 13001 +const CONNECTOR_STRESS_SEED := 287 +const EXPECTED_SOLVER_VARIANT_COUNT := 27 +const EXPECTED_VARIANT_COUNTS: Dictionary[StringName, int] = { + &"chunk_0000": 4, + &"chunk_0001": 4, + &"chunk_0002": 4, + &"chunk_0003": 4, + &"chunk_0004": 4, + &"chunk_0005": 4, + &"chunk_0006": 4, + &"chunk_0007": 4, + &"chunk_spawn": 1, +} + +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, + ) + var grass := CATALOG.definition_for_id(&"chunk_0000") + var sand := CATALOG.definition_for_id(&"chunk_0001") + var beach := CATALOG.definition_for_id(&"chunk_0002") + var grass_ocean_edge := CATALOG.definition_for_id(&"chunk_0005") + var grass_beach_transition := CATALOG.definition_for_id(&"chunk_0006") + var grass_ocean_corner := CATALOG.definition_for_id(&"chunk_0007") + var spawn := CATALOG.definition_for_id(&"chunk_spawn") + _check( + ( + grass != null + and sand != null + and beach != null + and grass_ocean_edge != null + and grass_beach_transition != null + and grass_ocean_corner != null + and spawn != null + ), + "The authored biome-rule definitions must be available.", + ) + if ( + grass != null + and sand != null + and beach != null + and grass_ocean_edge != null + and grass_beach_transition != null + and grass_ocean_corner != null + and spawn != null + ): + _check( + sand.allows_non_water_neighbor(beach), + "Flat sand must permit an adjacent authored beach.", + ) + _check( + sand.allows_non_water_neighbor(grass), + "Flat sand must permit the inland transition to grass.", + ) + _check( + grass.allows_non_water_neighbor(sand), + "Grass must permit the inland side of a flat-sand transition.", + ) + _check( + "coast" in sand.required_neighbor_tags, + "Every flat-sand chunk must remain attached to an authored beach.", + ) + _check( + spawn.minimum_required_neighbors == 3, + "Spawn must retain its three-sided safe-grass requirement.", + ) + _check( + beach.ocean_facing_edges + == (1 << int(TerrainChunkTopology.Edge.EAST)), + "The authored beach must descend eastward into ocean before rotation.", + ) + _check( + grass_ocean_edge.ocean_facing_edges + == (1 << int(TerrainChunkTopology.Edge.EAST)), + "The authored grass cliff must face eastward ocean before rotation.", + ) + _check( + grass_beach_transition.ocean_facing_edges + == (1 << int(TerrainChunkTopology.Edge.EAST)), + "The authored grass/beach join must face eastward ocean before rotation.", + ) + _check( + grass_beach_transition.minimum_required_neighbors == 2 + and "coast" in grass_beach_transition.required_neighbor_tags, + "The grass/beach join must remain between two coastline chunks.", + ) + _check( + grass_beach_transition.allows_non_water_neighbor_on_edge( + grass, + TerrainChunkTopology.Edge.WEST, + ) + and not grass_beach_transition.allows_non_water_neighbor_on_edge( + sand, + TerrainChunkTopology.Edge.WEST, + ), + "The grass-backed transition edge must accept grass and reject sand.", + ) + _check( + grass_beach_transition.allows_non_water_neighbor_on_edge( + beach, + TerrainChunkTopology.Edge.NORTH, + ) + and grass_beach_transition.allows_non_water_neighbor_on_edge( + grass_ocean_edge, + TerrainChunkTopology.Edge.SOUTH, + ), + "The transition's coastline seams must retain their authored roles.", + ) + _check( + not grass_ocean_edge.allows_non_water_neighbor_on_edge( + sand, + TerrainChunkTopology.Edge.WEST, + ), + "The straight grass coast's inland edge must reject sand.", + ) + _check( + grass_ocean_corner.ocean_facing_edges + == ( + (1 << int(TerrainChunkTopology.Edge.EAST)) + | (1 << int(TerrainChunkTopology.Edge.SOUTH)) + ), + "The authored grass corner must expose east and south to ocean.", + ) + _check( + grass_ocean_corner.minimum_required_neighbors == 2 + and ( + "grass_ocean_edge" + in grass_ocean_corner.required_neighbor_tags + ), + "The grass corner must join two straight grass ocean edges.", + ) + _check( + grass_ocean_corner.allows_non_water_neighbor_on_edge( + grass_ocean_edge, + TerrainChunkTopology.Edge.NORTH, + ) + and grass_ocean_corner.allows_non_water_neighbor_on_edge( + grass_ocean_edge, + TerrainChunkTopology.Edge.WEST, + ), + "Both corner land seams must accept straight grass ocean edges.", + ) + + +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(), + ) + var ocean_edges := variant.rotated_edge_mask( + definition.ocean_facing_edges + ) + for edge_index: int in variant.edge_profiles.size(): + var profile := variant.edge_profiles[edge_index] + _check( + ( + not profile.points.is_empty() + or (ocean_edges & (1 << edge_index)) != 0 + ), + ( + "%s has an empty non-ocean 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 7x7 grid.") + if not first_solved: + first.free() + return + var first_keys := first.placement_keys() + _check(first_keys.size() == 49, "Generator must place exactly 49 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[24].begins_with("chunk_spawn@"), + "The spawn chunk must occupy the center cell.", + ) + _validate_layout_rules(first) + _validate_authored_rotations(first) + _validate_generation_summary(first) + var first_generated_root := first.get_generated_chunks_root() + _check( + first.generate(), + "Repeated generation on one generator must solve.", + ) + _check( + first.placement_keys() == first_keys, + "Repeated generation on one generator must remain deterministic.", + ) + _check( + first.get_generated_chunks_root() != first_generated_root, + "Successful regeneration must replace the generated scene atomically.", + ) + + 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.", + ) + var restored := _make_generator() + restored.generation_seed = TEST_SEED + 999 + root.add_child(restored) + _check( + restored.generate_from_placement_keys(first_keys), + "A resolved placement manifest must rebuild successfully.", + ) + _check( + restored.placement_keys() == first_keys, + "A resolved placement manifest must preserve exact rotations.", + ) + first.free() + second.free() + restored.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, + ) + _validate_layout_rules(generator) + generator.free() + + var connector_stress := _make_generator() + connector_stress.generation_seed = CONNECTOR_STRESS_SEED + connector_stress.maximum_backtracks = 100 + root.add_child(connector_stress) + _check( + connector_stress.generate(), + "Connector propagation must solve the known stress seed.", + ) + _check( + connector_stress._backtrack_count < 100, + "Connector propagation must reject unsupported branches early.", + ) + connector_stress.free() + + +func _validate_authored_rotations(generator: TerrainChunkGenerator) -> void: + var counts: Dictionary[StringName, int] = {} + for variant: TerrainChunkVariant in generator._variants: + var stable_id := variant.definition.stable_id + counts[stable_id] = counts.get(stable_id, 0) + 1 + for stable_id: StringName in EXPECTED_VARIANT_COUNTS: + _check( + counts.get(stable_id, 0) == EXPECTED_VARIANT_COUNTS[stable_id], + "%s must preserve every explicitly allowed rotation." % stable_id, + ) + + var stream := CATALOG.definition_for_id(&"chunk_0003") + _check(stream != null, "The stream definition must be available.") + if stream == null: + return + for quarter_turns: int in 4: + var stream_variant := _find_variant( + generator, + &"chunk_0003", + quarter_turns, + ) + _check( + stream_variant != null, + "The stream must support rotation %d." % quarter_turns, + ) + if stream_variant == null: + continue + var expected_inlet := 1 << int(TerrainChunkTopology.rotated_edge( + TerrainChunkTopology.Edge.NORTH, + quarter_turns, + )) + var expected_outlet := 1 << int(TerrainChunkTopology.rotated_edge( + TerrainChunkTopology.Edge.SOUTH, + quarter_turns, + )) + _check( + stream_variant.rotated_edge_mask(stream.water_inlet_edges) + == expected_inlet, + "The rotated stream inlet must follow its authored orientation.", + ) + _check( + stream_variant.rotated_edge_mask(stream.water_outlet_edges) + == expected_outlet, + "The rotated stream outlet must follow its authored orientation.", + ) + + var beach := CATALOG.definition_for_id(&"chunk_0002") + _check(beach != null, "The beach definition must be available.") + if beach == null: + return + for quarter_turns: int in 4: + var beach_variant := _find_variant( + generator, + &"chunk_0002", + quarter_turns, + ) + _check( + beach_variant != null, + "The beach must support rotation %d." % quarter_turns, + ) + if beach_variant == null: + continue + var expected_ocean_edge := 1 << int( + TerrainChunkTopology.rotated_edge( + TerrainChunkTopology.Edge.EAST, + quarter_turns, + ) + ) + _check( + beach_variant.rotated_edge_mask(beach.ocean_facing_edges) + == expected_ocean_edge, + "The rotated beach must keep its low edge facing the ocean.", + ) + _check( + not generator._variant_respects_ocean_boundary( + beach_variant, + Vector2i(3, 3), + ), + "A beach slope must never direct its low edge into the map.", + ) + var boundary_coordinate := Vector2i(3, 3) + match TerrainChunkTopology.rotated_edge( + TerrainChunkTopology.Edge.EAST, + quarter_turns, + ): + TerrainChunkTopology.Edge.NORTH: + boundary_coordinate.y = 0 + TerrainChunkTopology.Edge.EAST: + boundary_coordinate.x = generator.grid_size.x - 1 + TerrainChunkTopology.Edge.SOUTH: + boundary_coordinate.y = generator.grid_size.y - 1 + TerrainChunkTopology.Edge.WEST: + boundary_coordinate.x = 0 + _check( + generator._variant_respects_ocean_boundary( + beach_variant, + boundary_coordinate, + ), + "A beach slope must be legal when its low edge faces open ocean.", + ) + + var grass_ocean_edge := CATALOG.definition_for_id(&"chunk_0005") + var grass_beach_transition := CATALOG.definition_for_id(&"chunk_0006") + var grass_ocean_corner := CATALOG.definition_for_id(&"chunk_0007") + _check( + ( + grass_ocean_edge != null + and grass_beach_transition != null + and grass_ocean_corner != null + ), + "The authored coastline additions must be available.", + ) + if ( + grass_ocean_edge == null + or grass_beach_transition == null + or grass_ocean_corner == null + ): + return + var beach_zero := _find_variant(generator, &"chunk_0002", 0) + var grass_edge_zero := _find_variant(generator, &"chunk_0005", 0) + var grass_edge_south := _find_variant(generator, &"chunk_0005", 3) + var transition_zero := _find_variant(generator, &"chunk_0006", 0) + var corner_zero := _find_variant(generator, &"chunk_0007", 0) + _check( + beach_zero != null + and grass_edge_zero != null + and grass_edge_south != null + and transition_zero != null + and corner_zero != null, + "The unrotated coastline variants must be available.", + ) + if ( + beach_zero != null + and grass_edge_zero != null + and grass_edge_south != null + and transition_zero != null + and corner_zero != null + ): + _check( + grass_edge_zero.profile( + TerrainChunkTopology.Edge.EAST + ).points.is_empty(), + "The grass cliff's authored east edge must remain open to ocean.", + ) + _check( + transition_zero.profile( + TerrainChunkTopology.Edge.NORTH + ).matches( + beach_zero.profile(TerrainChunkTopology.Edge.SOUTH), + generator.edge_match_tolerance, + ), + "The transition's north side must match the authored beach.", + ) + _check( + transition_zero.profile( + TerrainChunkTopology.Edge.SOUTH + ).matches( + grass_edge_zero.profile(TerrainChunkTopology.Edge.NORTH), + generator.edge_match_tolerance, + ), + "The transition's south side must match the grass cliff.", + ) + _check( + corner_zero.profile(TerrainChunkTopology.Edge.EAST).points.is_empty() + and corner_zero.profile( + TerrainChunkTopology.Edge.SOUTH + ).points.is_empty(), + "The corner's authored east and south edges must remain open to ocean.", + ) + _check( + corner_zero.profile(TerrainChunkTopology.Edge.NORTH).matches( + grass_edge_zero.profile(TerrainChunkTopology.Edge.SOUTH), + generator.edge_match_tolerance, + ), + "The corner's north side must join a straight grass ocean edge.", + ) + _check( + corner_zero.profile(TerrainChunkTopology.Edge.WEST).matches( + grass_edge_south.profile(TerrainChunkTopology.Edge.EAST), + generator.edge_match_tolerance, + ), + "The corner's west side must join a straight grass ocean edge.", + ) + var flat_grass := _find_variant(generator, &"chunk_0000", 0) + var flat_sand := _find_variant(generator, &"chunk_0001", 0) + _check( + flat_grass != null and flat_sand != null, + "Flat grass and sand variants must be available for seam validation.", + ) + if flat_grass != null and flat_sand != null: + for quarter_turns: int in 4: + var transition := _find_variant( + generator, + &"chunk_0006", + quarter_turns, + ) + if transition == null: + continue + var inland_edge := TerrainChunkTopology.rotated_edge( + TerrainChunkTopology.Edge.WEST, + quarter_turns, + ) + var neighbor_edge := TerrainChunkTopology.opposite_edge(inland_edge) + _check( + generator._edges_are_compatible( + transition, + inland_edge, + flat_grass, + neighbor_edge, + ), + "Every transition rotation must accept grass behind it.", + ) + _check( + not generator._edges_are_compatible( + transition, + inland_edge, + flat_sand, + neighbor_edge, + ), + "No transition rotation may accept sand behind it.", + ) + for stable_id: StringName in [&"chunk_0005", &"chunk_0006", &"chunk_0007"]: + for quarter_turns: int in 4: + var coast_variant := _find_variant( + generator, + stable_id, + quarter_turns, + ) + _check( + coast_variant != null, + "%s must support rotation %d." % [stable_id, quarter_turns], + ) + if coast_variant == null: + continue + _check( + not generator._variant_respects_ocean_boundary( + coast_variant, + Vector2i(3, 3), + ), + "%s must never place its ocean edge inside the map." % stable_id, + ) + if stable_id != &"chunk_0007": + continue + var boundary_coordinate := Vector2i(3, 3) + var ocean_edges := coast_variant.rotated_edge_mask( + grass_ocean_corner.ocean_facing_edges + ) + for edge_value: int in TerrainChunkTopology.Edge.values(): + if (ocean_edges & (1 << edge_value)) == 0: + continue + match edge_value as TerrainChunkTopology.Edge: + TerrainChunkTopology.Edge.NORTH: + boundary_coordinate.y = 0 + TerrainChunkTopology.Edge.EAST: + boundary_coordinate.x = generator.grid_size.x - 1 + TerrainChunkTopology.Edge.SOUTH: + boundary_coordinate.y = generator.grid_size.y - 1 + TerrainChunkTopology.Edge.WEST: + boundary_coordinate.x = 0 + _check( + generator._variant_respects_ocean_boundary( + coast_variant, + boundary_coordinate, + ), + "Each grass corner rotation must fit its matching map corner.", + ) + + +func _validate_generation_summary(generator: TerrainChunkGenerator) -> void: + var summary := generator._build_summary() + _check( + int(summary.get("variant_count", 0)) == 33, + "The current catalog must expose all 33 authored rotations.", + ) + _check( + int(summary.get("solver_variant_count", 0)) + == EXPECTED_SOLVER_VARIANT_COUNT, + "Equivalent visual rotations must share one solver constraint.", + ) + _check( + int(summary.get("adjacent_repeat_edges", -1)) + == generator._count_adjacent_repeat_edges(), + "The summary must report adjacent repeated chunk definitions.", + ) + _check( + str(summary.get("layout_fingerprint", "")) + == generator.placement_fingerprint(), + "The summary must identify the exact resolved layout.", + ) + _check( + generator.placement_fingerprint().length() == 64, + "The resolved layout fingerprint must be SHA-256.", + ) + var variant_counts: Dictionary = summary.get("variant_counts", {}) + for stable_id: StringName in EXPECTED_VARIANT_COUNTS: + _check( + int(variant_counts.get(stable_id, 0)) + == EXPECTED_VARIANT_COUNTS[stable_id], + "The summary must report %s rotation candidates." % stable_id, + ) + + +func _find_variant( + generator: TerrainChunkGenerator, + stable_id: StringName, + quarter_turns: int, +) -> TerrainChunkVariant: + for variant: TerrainChunkVariant in generator._variants: + if ( + variant.definition.stable_id == stable_id + and variant.quarter_turns == quarter_turns + ): + return variant + return null + + +func _make_generator() -> TerrainChunkGenerator: + var generator := TerrainChunkGenerator.new() + generator.catalog = CATALOG + generator.grid_size = Vector2i(7, 7) + 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 _validate_layout_rules(generator: TerrainChunkGenerator) -> void: + _check( + generator._neighbor_requirement_validation_error( + generator._placements + ).is_empty(), + "Every generated chunk must satisfy its authored neighbor count.", + ) + _check( + generator._walkable_connectivity_validation_error( + generator._placements + ).is_empty(), + "All walkable generated terrain must remain connected to spawn.", + ) + var center := Vector2i( + generator.grid_size.x / 2, + generator.grid_size.y / 2, + ) + var safe_spawn_neighbors := 0 + for edge_value: int in TerrainChunkTopology.Edge.values(): + var neighbor_coordinate := ( + center + + TerrainChunkTopology.grid_offset( + edge_value as TerrainChunkTopology.Edge + ) + ) + var neighbor := generator._placements[ + neighbor_coordinate.y * generator.grid_size.x + + neighbor_coordinate.x + ] + if "spawn_safe" in neighbor.definition.tags: + safe_spawn_neighbors += 1 + _check( + safe_spawn_neighbors >= 3, + "At least three cardinal spawn neighbors must be safe flat grass.", + ) + for index: int in generator._placements.size(): + var placement := generator._placements[index] + var coordinate := Vector2i( + index % generator.grid_size.x, + index / generator.grid_size.x, + ) + if "coast" in placement.definition.tags: + _check( + generator._variant_respects_ocean_boundary( + placement, + coordinate, + ), + "Every coastline chunk must direct its ocean edge outside the grid.", + ) + if placement.definition.stable_id == &"chunk_0006": + var touches_beach := false + var touches_grass_edge := false + for edge_value: int in TerrainChunkTopology.Edge.values(): + var neighbor_coordinate := ( + coordinate + + TerrainChunkTopology.grid_offset( + edge_value as TerrainChunkTopology.Edge + ) + ) + if not generator._coordinate_is_inside_grid(neighbor_coordinate): + continue + var neighbor := generator._placements[ + neighbor_coordinate.y * generator.grid_size.x + + neighbor_coordinate.x + ] + touches_beach = ( + touches_beach + or neighbor.definition.stable_id == &"chunk_0002" + ) + touches_grass_edge = ( + touches_grass_edge + or neighbor.definition.stable_id == &"chunk_0005" + ) + _check( + touches_beach and touches_grass_edge, + "Every grass/beach transition must join both authored edge types.", + ) + if placement.definition.stable_id == &"chunk_0007": + var straight_edge_neighbors := 0 + for edge_value: int in TerrainChunkTopology.Edge.values(): + var neighbor_coordinate := ( + coordinate + + TerrainChunkTopology.grid_offset( + edge_value as TerrainChunkTopology.Edge + ) + ) + if not generator._coordinate_is_inside_grid(neighbor_coordinate): + continue + var neighbor := generator._placements[ + neighbor_coordinate.y * generator.grid_size.x + + neighbor_coordinate.x + ] + if neighbor.definition.stable_id == &"chunk_0005": + straight_edge_neighbors += 1 + _check( + straight_edge_neighbors == 2, + "Every grass corner must join two straight grass ocean edges.", + ) + if placement.definition.stable_id != &"chunk_0001": + continue + var touches_coast := false + for edge_value: int in TerrainChunkTopology.Edge.values(): + var neighbor_coordinate := ( + coordinate + + TerrainChunkTopology.grid_offset( + edge_value as TerrainChunkTopology.Edge + ) + ) + if not generator._coordinate_is_inside_grid(neighbor_coordinate): + continue + var neighbor := generator._placements[ + neighbor_coordinate.y * generator.grid_size.x + + neighbor_coordinate.x + ] + if "coast" in neighbor.definition.tags: + touches_coast = true + _check( + touches_coast, + "Every flat-sand chunk must remain directly attached to a beach.", + ) + + +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..27b775d --- /dev/null +++ b/tools/blender/export_terrain_chunks.py @@ -0,0 +1,805 @@ +#!/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. Reusable procedural props use individual ``prop_description`` +mesh objects. They may be arranged anywhere in the source file because each +object's authored origin becomes the exported runtime anchor. A same-named +``prop_description`` collection remains supported for multi-object props. +Unrelated objects and 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 +# Match the runtime boundary analyzer's five-millimeter authoring tolerance. +DEFAULT_TOLERANCE = 0.005 +COLLECTION_PATTERN = re.compile( + r"^chunk_(?P\d{4})(?:_(?P