diff --git a/docs/ACTION_SYSTEM_ARCHITECTURE.md b/docs/ACTION_SYSTEM_ARCHITECTURE.md index 68588f1..04736cd 100644 --- a/docs/ACTION_SYSTEM_ARCHITECTURE.md +++ b/docs/ACTION_SYSTEM_ARCHITECTURE.md @@ -47,6 +47,10 @@ SimulationManager tick - reads target metadata from ActionDefinition; - consumes active-world facts through ActiveWorldAdapter; +- expands resource queries through nearby grid ranges and prunes farther cells + only when authoritative risk/comfort/priority bounds prove they cannot win; +- preserves the former registration-order tie behavior and keeps a linear + compatibility path for isolated adapters; - checks simulation-owned ResourceStateRecord availability; - reserves and returns stable resource target IDs; - skips full-capacity activity sites based on authoritative NPC target claims; @@ -55,7 +59,10 @@ SimulationManager tick ### ActiveWorldAdapter -- exposes loaded resource interaction positions; +- owns the disposable `LoadedResourceSpatialIndex` of loaded stable-ID resource + interaction positions; +- refreshes the index as ResourceNodes enter, leave, rebind authoritative + state, or move; - exposes loaded storage and activity-site interaction positions; - exposes activity-site capacity as an active-world fact; - contains active-world query facts, not persistent mutable authority; @@ -77,17 +84,17 @@ targets, and translates presentation callbacks into simulation transitions. It also owns one disposable `SimulationPopulationView`, rebuilt at tick start and refreshed after each NPC advances so interleaved decision semantics remain unchanged. -The manager publishes the latest `ActionSelectionResult` for presentation; the UI -does not recompute decisions. Each idle selection re-derives the capable helper -instead of consulting persisted assignment state. At completion it atomically -pays any definition-backed stored-resource cost before applying the action -effect. A late shortfall suppresses the effect and records a `task_blocked` -fact. The manager also captures actor/nearby knowledge before forwarding newly -recorded events into `RelationshipSystem`. When an NPC arrives beside a worker -at the same non-storage activity site, the manager may transfer one direct -known fact and applies its consequence only to that newly informed listener. An -open opportunity's exact trigger receives bounded priority in that conversation -before normal lasting/recent ranking. It exposes +The manager publishes the latest `ActionSelectionResult` for presentation; the +UI does not recompute decisions. Each idle selection re-derives the capable +helper instead of consulting persisted assignment state. At completion it +atomically pays any definition-backed stored-resource cost before applying the +action effect. A late shortfall suppresses the effect and records a +`task_blocked` fact. The manager also captures actor/nearby knowledge before +forwarding newly recorded events into `RelationshipSystem`. When an NPC arrives +beside a worker at the same non-storage activity site, the manager may transfer +one direct known fact and applies its consequence only to that newly informed +listener. An open opportunity's exact trigger receives bounded priority in +that conversation before normal lasting/recent ranking. It exposes knowledge, provenance, relationship, and cause queries without moving social authority into UI. After consequences are known, it protects current causal facts, enforces the recent-memory cap, and runs age review from authoritative diff --git a/docs/ARCHITECTURE_OVERVIEW.md b/docs/ARCHITECTURE_OVERVIEW.md index a22d5a4..3b2eccb 100644 --- a/docs/ARCHITECTURE_OVERVIEW.md +++ b/docs/ARCHITECTURE_OVERVIEW.md @@ -22,6 +22,7 @@ SimulationClock -> VillageOpportunitySystem projects one known unresolved need -> WorldViewManager presents travel, NPC state, and world-state cues -> ActiveWorldAdapter supplies loaded-world positions/capacity + -> LoadedResourceSpatialIndex bounds finite-anchor discovery -> NpcVisual performs local navigation, animation, and transient reactions -> PantryStockVisual derives physical stock arrangement from storage state ``` @@ -62,6 +63,13 @@ would otherwise obscure that lifecycle: - `simulation/definitions/` owns stable IDs and immutable action/profession definitions. +`world/resource_nodes/LoadedResourceSpatialIndex.gd` is a focused disposable +acceleration structure owned by `ActiveWorldAdapter`. It indexes loaded +interaction positions by action and horizontal cell, plus authoritative +metadata bounds copied at bind time. It does not own amounts, enabled state, +reservations, or persistence. `ResourceNode` enter/exit, bind, and transform +notifications keep it synchronized with the loaded presentation lifecycle. + Outside the runtime lifecycle, `simulation/benchmark/` owns reusable, schema-valid workload fixtures. CLI tools and headless scenarios consume those fixtures; production simulation does not depend on benchmark code. @@ -86,9 +94,9 @@ hard to read. | `simulation/state/` | Versioned, serializable mutable records | | `simulation/definitions/` | Stable IDs and immutable gameplay definitions | | `simulation/persistence/` | Validated local save-file storage | -| `simulation/benchmark/` | Reproducible full-fidelity headless workloads and metrics | +| `simulation/benchmark/` | Reproducible simulation and loaded-world query workloads | | `world/` | Loaded-world interaction geometry and presentation adapters | -| `world/resource_nodes/` | Finite resource presentation bound by stable ID | +| `world/resource_nodes/` | Finite resource presentation and disposable loaded-anchor index | | `world/storage/` | Storage interaction geometry, never stored quantities | | `world/activity/` | Rest/study/patrol interaction sites and capacity facts | | `player/` | Player input, camera, and active NPC presentation | @@ -127,6 +135,10 @@ improving ownership. - Derived population indexes are disposable acceleration structures. NPC records remain authoritative, and rebuilding the view after initialization, restore, or a tick must produce the same decisions and checksum. +- The loaded-resource grid is likewise disposable. `ResourceStateRecord` + remains authoritative while its scene node is absent; rebuilding or + incrementally refreshing the grid must preserve the linear resolver's exact + score winner and stable tie order. - Camera-local grass, ambient butterflies, water animation, smoke, and foliage motion are presentation only. Grass consumes bounded real actor transforms; it does not write NPC positions or become persistent state. diff --git a/docs/BUILD_IN_PUBLIC_PLAN.md b/docs/BUILD_IN_PUBLIC_PLAN.md index de4ce43..2f17001 100644 --- a/docs/BUILD_IN_PUBLIC_PLAN.md +++ b/docs/BUILD_IN_PUBLIC_PLAN.md @@ -770,17 +770,21 @@ Completed: cascading homes, bridge, waterfall, terrain-following river, conifer/tree clusters, and ambient butterflies. Roughly 10,400 camera-local grass particles remain short and patchy and bend from real actor transforms. +35. Loaded-resource spatial discovery: `ActiveWorldAdapter` now indexes loaded + stable-ID resource anchors in a resource-specific horizontal grid. Exact + expanding queries preserve current scoring and selected targets while the + reviewed 1,800-source case inspects about 4.4 candidates instead of 1,800. Next: -1. Measure and implement a loaded-resource spatial query through - `ActiveWorldAdapter` for far-apart Terrain3D-authored finite sources. Keep - simulation-owned resource records, stable IDs, current score/reachability, - reservations, player parity, and unload/rebind behavior unchanged. -2. Sculpt a real Terrain3D river channel and waterfall shelf before adding a +1. Sculpt a real Terrain3D river channel and waterfall shelf before adding a broad foliage asset pass. Use Terrain3D particles for nearby grass and - intentional instancing for tree/fern/rock masses; add livestock only with a - real identity and interaction contract. + intentional instancing for tree/fern/rock masses, then rebake and validate + the required village/resource routes. +2. During that first larger foliage pass, prove a bounded authoring workflow + that places intentional stable-ID `ResourceNode` anchors beside decorative + Terrain3D instances. Add livestock only with a real identity and interaction + contract. Do not start with GIS data, a full city, a large asset pack, or more NPC mechanics. The next proof is a beautiful stage for the systems that already diff --git a/docs/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index 2625b7c..fde7efb 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -474,6 +474,12 @@ keeps identical final checksums after one reusable per-tick population view and improves that case from 65.84 to 85.81 ticks per second. Revisit the target when world presentation or LOD enters the measured workload. +[Loaded Resource Discovery 01](benchmarks/LOADED_RESOURCE_DISCOVERY_01.md) +proves the first real spatial consumer. Exact loaded-anchor resolution remains +near four inspected candidates from 18 through 1,800 resources, preserves every +linear selected-target checksum, and keeps persistent authority outside the +index. + ## Milestone 9 — Player interaction and social agency ### Learn @@ -873,18 +879,28 @@ The 600-NPC case falls from 15,187.74 to 11,654.10 microseconds per tick, a cost. See [Simulation scaling baseline 02](benchmarks/SIMULATION_SCALING_BASELINE_02.md). -The immediate next systems slice should now prove spatial discovery for the -world vision: index loaded finite `ResourceNode` anchors through -`ActiveWorldAdapter` so Terrain3D-authored sources can be placed much farther -apart without an all-node scan for every resolution. Preserve stable IDs, -current distance/risk/comfort/priority scoring, reachability, reservations, -player parity, unload/rebind authority, and exact deterministic outcomes. First -measure 18, 180, and 1,800 loaded candidates; do not choose a generic spatial -framework or active/abstract LOD mode before that real consumer proves the -contract. +The first loaded-world spatial consumer is complete. `ActiveWorldAdapter` owns +a disposable 24 m horizontal grid of loaded finite `ResourceNode` anchors. +`ActionTargetResolver` expands through nearby ranges and stops only when +authoritative risk/comfort/priority bounds prove that no farther source can win. +Matched 18/180/1,800-source samples preserve every selected-target checksum; +the 1,800-source case falls from 6,448.00 to 46.94 microseconds per resolution. +Unload/rebind state authority, stable tie order, moved anchors, reservations, +player parity, and a deliberately far high-priority winner remain covered. See +[Loaded Resource Discovery 01](benchmarks/LOADED_RESOURCE_DISCOVERY_01.md). + +The immediate next slice should pair this systems foundation with the authored +world: sculpt the real Terrain3D river channel and waterfall shelf, rebake and +validate navigation, then use the first larger foliage/resource pass to prove a +bounded stable-ID anchor placement workflow alongside decorative Terrain3D +instancing. Do not generalize the resource grid to people/buildings/events or +introduce active/abstract LOD before those consumers need it. Recently completed: +- Loaded-resource spatial discovery: a resource-specific active-world grid + preserves exact score winners and lifecycle authority while reducing the + reviewed 1,800-anchor query from 6.45 ms to 46.94 µs. - Shared population query view: one transient per-tick all/living/starving index replaces repeated scarce-food relationship scans, preserves exact checksums, and improves the reviewed 600-NPC workload by 23.3%. diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index e131a54..01efe8a 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -195,8 +195,8 @@ study, and patrol target typed activity sites. The migration is documented in - **Terrain:** Terrain3D 1.0.2 is installed and enabled - **Jajce runtime:** reusable 512 m Terrain3D seed, stable ResourceNode placement, larger citadel/village-center composition, terrain-following river - ribbon, reactive camera-local grass, and Terrain3D-derived runtime navigation - with collision guardrails + ribbon, reactive camera-local grass, exact indexed finite-resource discovery, + and Terrain3D-derived runtime navigation with collision guardrails - **Lookdev baseline:** `Jajce Lookdev 01` is captured at `docs/baselines/jajce_lookdev_01.png` with `tools/capture_jajce_lookdev.gd` @@ -330,7 +330,9 @@ than broad resource zones: trees in foliage clusters, berry patches, animal camps, and village stockpiles can be ranked by reachability, distance, safety, profession, and NPC comfort range. The current resolver already combines distance with resource `safety_risk`, `comfort_distance`, and -`discovery_priority` metadata. +`discovery_priority` metadata. Loaded anchors are indexed by +`ActiveWorldAdapter`; exact expanding queries usually inspect only nearby cells +but retain far sources whenever their authoritative metadata can still win. ### Jajce scaffold @@ -548,6 +550,7 @@ NpcVisual navigates through the active world │ ├── SimulationClock.gd │ ├── SimulationManager.gd │ ├── actions/ Selection, execution, and target resolution +│ ├── benchmark/ Population/history and resource-query workloads │ ├── definitions/ Stable IDs and custom definition resources │ ├── economy/ Inventory and storage transactions │ ├── events/ Ordered event history and queries @@ -566,6 +569,7 @@ NpcVisual navigates through the active world │ ├── jajce_world_scaffold_test.gd │ ├── knowledge_retention_consequence_test.gd │ ├── jajce_runtime_integration_test.gd +│ ├── loaded_resource_spatial_query_test.gd │ ├── npc_visual_lifecycle_test.gd │ ├── opportunity_communication_helper_test.gd │ ├── relationship_consequence_test.gd @@ -577,6 +581,7 @@ NpcVisual navigates through the active world │ └── witnessed_knowledge_consequence_test.gd ├── terrain/jajce/ Dedicated Terrain3D seed data and assets ├── tools/ +│ ├── benchmark_loaded_resource_discovery.gd │ └── generate_jajce_terrain_seed.gd ├── world/ │ ├── jajce/ @@ -586,6 +591,7 @@ NpcVisual navigates through the active world │ │ ├── beauty_camera.gd │ │ └── vfx/WindGustField.tscn │ ├── resource_nodes/ +│ │ ├── LoadedResourceSpatialIndex.gd │ │ ├── ResourceNode.gd │ │ ├── ResourceNode.gd.uid │ │ └── ResourceNode.tscn @@ -643,7 +649,9 @@ These are expected prototype constraints, not necessarily isolated bugs: - Active navigation is used as if all agents are local; no simulation LOD exists. - Unloaded traveling NPCs preserve their state but do not yet advance through abstract travel time. -- There is no spatial query/index layer for large populations. +- Loaded finite-resource anchors have one measured active-world spatial index; + people, buildings, events, and unloaded simulation still have no spatial/LOD + layer. - SimulationManager still coordinates the tick lifecycle and bounded player-facing commands, while action rules, active-world queries, economic transactions, and event history have focused collaborators. @@ -962,11 +970,20 @@ reference target, while exposing superlinear population cost and rapid objective-log growth. Workload details and raw samples live in [`docs/benchmarks/`](benchmarks/SIMULATION_SCALING_BASELINE_01.md). -The next slice should build one stable per-tick population view for the -existing trusted-starving-subject query, which currently rebuilds an all-NPC -map for each applicable scarce-food decision. Preserve exact selection and -continuation checksums, rerun the same benchmark, and use the remaining measured -cost before committing to spatial partitions or active/abstract LOD. +The first two measured optimizations are complete. A disposable per-tick +population view preserves continuation checksums and improves the 600-NPC +fixture by 23.3%. A separate resource-specific grid inside +`ActiveWorldAdapter` preserves exact selected targets while reducing the +reviewed 1,800-loaded-anchor resolution from 6,448.00 to 46.94 microseconds. +Raw samples and workload exclusions live in +[Simulation scaling baseline 02](benchmarks/SIMULATION_SCALING_BASELINE_02.md) +and [Loaded Resource Discovery 01](benchmarks/LOADED_RESOURCE_DISCOVERY_01.md). + +The next slice should return to the authored Jajce stage: sculpt the real +Terrain3D river channel and waterfall shelf, rebake/validate navigation, and +use the following foliage expansion to prove a bounded stable-ID resource +anchor placement workflow beside decorative Terrain3D instances. Do not yet +generalize the resource grid or introduce active/abstract simulation LOD. The remaining simulation-garden target still aims for: diff --git a/docs/RESOURCE_NODE_MIGRATION.md b/docs/RESOURCE_NODE_MIGRATION.md index 95437c6..c68422b 100644 --- a/docs/RESOURCE_NODE_MIGRATION.md +++ b/docs/RESOURCE_NODE_MIGRATION.md @@ -551,11 +551,21 @@ profession, and NPC comfort range. The first scoring pass stores `safety_risk`, `comfort_distance`, and `discovery_priority` on resource state and uses them when selecting NPC resource targets. -The first validated spread contains twelve finite resources: berry bushes and -patches, animal camps, trees, and one village woodpile. Placement is guarded by -headless tests for stable IDs, food/wood coverage, semantic contexts, -discovery metadata, navigation reachability, and non-overlapping pantry/player -interaction range. +The current validated spread contains eighteen finite resources: berry bushes +and patches, animal camps, farm crops, trees, and village/mill woodpiles. +Placement is guarded by headless tests for stable IDs, food/wood coverage, +semantic contexts, discovery metadata, navigation reachability, and +non-overlapping pantry/player interaction range. + +Loaded discovery now goes through a resource-specific horizontal grid owned by +`ActiveWorldAdapter`. Queries expand from nearby cells until authoritative +resource metadata proves that no farther source can beat the current scored +winner. The index preserves stable registration-order ties and contains no +amount, reservation, or save authority. ResourceNode unload removes only the +loaded anchor; rebinding the same ID restores its indexed position against the +existing `ResourceStateRecord`. The reviewed 18/180/1,800-source benchmark and +exact-selection contract live in +[Loaded Resource Discovery 01](benchmarks/LOADED_RESOURCE_DISCOVERY_01.md). ## Test scenarios diff --git a/docs/benchmarks/LOADED_RESOURCE_DISCOVERY_01.md b/docs/benchmarks/LOADED_RESOURCE_DISCOVERY_01.md new file mode 100644 index 0000000..06e6bba --- /dev/null +++ b/docs/benchmarks/LOADED_RESOURCE_DISCOVERY_01.md @@ -0,0 +1,82 @@ +# Loaded Resource Discovery 01 + +## Question + +Can villagers resolve finite resource anchors spread across a larger Terrain3D +world without scanning every loaded `ResourceNode`, while selecting exactly the +same target as the existing distance/risk/comfort/priority score? + +## Reviewed workload + +- Godot: `4.7-stable (official)`; +- host: Apple M1 Max, 64 GB; +- benchmark seed: `17331`; +- loaded resources: 18, 180, and 1,800; +- 400 deterministic target resolutions per sample; +- seven fresh timing samples per case; +- 20 m horizontal source spacing with varied terrain-like height, safety risk, + comfort distance, discovery priority, and deterministic disabled sources; +- each query performs the real availability check, score, reservation, and + release through `ActionTargetResolver` and `ResourceStateRecord`. + +The linear reference reproduces the previous `ResourceNode.get_all()` scan. +The spatial path uses the production `ActiveWorldAdapter` and its 24 m +resource-specific horizontal grid. Fixture construction, rendering, navigation +pathfinding, simulation ticks, and serialization are excluded. + +## Results + +| Loaded anchors | Linear µs/query | Spatial µs/query | Speedup | Average inspected | Reduction | +| ---: | ---: | ---: | ---: | ---: | ---: | +| 18 | 65.04 | 33.26 | 1.96x | 18 → 3.705 | 79.42% | +| 180 | 589.03 | 38.83 | 15.17x | 180 → 4.375 | 97.57% | +| 1,800 | 6,448.00 | 46.94 | 137.37x | 1,800 → 4.445 | 99.75% | + +Every spatial sample produced the same selected-target checksum as its linear +reference. The raw samples are in +[`loaded_resource_discovery_01.json`](loaded_resource_discovery_01.json). + +These are local workload measurements, not portable performance promises. The +important shape is that ordinary local queries remain near four inspected +anchors while the loaded set grows by 100x. + +## Exactness contract + +`ActionTargetResolver` queries successively larger grid radii. It may stop only +when the best possible score of every farther anchor is strictly worse than the +current winner. The lower bound uses the loaded action's authoritative maximum +comfort distance, minimum safety risk, and maximum discovery priority. Stable +registration order preserves the former first-loaded tie behavior. + +Unavailable, depleted, and reserved state remains authoritative in +`ResourceStateRecord`; the grid indexes only loaded IDs, interaction positions, +and disposable score bounds. Unloading removes an anchor without deleting its +state. Rebinding the same ID or moving a loaded anchor refreshes the index. + +One focused regression also gives the farthest test source an extreme priority. +The query expands beyond its local cells and selects that source exactly as the +linear scan does, proving that the common local fast path is not a hard range +cutoff. + +## Decision + +Keep the resource-specific grid inside `ActiveWorldAdapter`. It has one real +consumer, preserves current gameplay, and directly supports larger authored +source fields. Do not generalize it into a people/building/event index or a +simulation-LOD framework yet. + +Terrain3D foliage instances remain decorative unless an intentional stable-ID +`ResourceNode` anchor binds simulation-owned state. A later placement workflow +can author those anchors alongside visual instancing without making every tree +an authoritative resource. + +## Capture command + +```bash +/Applications/Godot.app/Contents/MacOS/Godot \ + --headless --path "$PWD" \ + --script res://tools/benchmark_loaded_resource_discovery.gd -- \ + --queries=400 --samples=7 \ + --host-label=Apple_M1_Max_64_GB \ + --output=res://docs/benchmarks/loaded_resource_discovery_01.json +``` diff --git a/docs/benchmarks/README.md b/docs/benchmarks/README.md index c3b7c80..5437a98 100644 --- a/docs/benchmarks/README.md +++ b/docs/benchmarks/README.md @@ -16,6 +16,14 @@ The default report is written under `user://`. Pass `-- --host-label="" --output=res://docs/benchmarks/.json` only when intentionally capturing a reviewed project baseline. +Loaded-resource discovery has its own bounded runner: + +```bash +/Applications/Godot.app/Contents/MacOS/Godot \ + --headless --path "$PWD" \ + --script res://tools/benchmark_loaded_resource_discovery.gd +``` + Reviewed captures: - [Simulation scaling baseline 01](SIMULATION_SCALING_BASELINE_01.md) records @@ -24,3 +32,6 @@ Reviewed captures: - [Simulation scaling baseline 02](SIMULATION_SCALING_BASELINE_02.md) records checksum-identical results after the shared per-tick population view: the 600-NPC case is 23.3% faster, while small fixtures expose its fixed cost. +- [Loaded Resource Discovery 01](LOADED_RESOURCE_DISCOVERY_01.md) compares the + former all-node scan with the exact resource-anchor grid at 18, 180, and + 1,800 loaded sources. diff --git a/docs/benchmarks/SIMULATION_SCALING_BASELINE_02.md b/docs/benchmarks/SIMULATION_SCALING_BASELINE_02.md index ba9a3a4..a1f1f02 100644 --- a/docs/benchmarks/SIMULATION_SCALING_BASELINE_02.md +++ b/docs/benchmarks/SIMULATION_SCALING_BASELINE_02.md @@ -47,12 +47,11 @@ Keep the population view. It has a real relationship/action consumer, improves the measured pressure point, and preserves exact state. Do not add batching or active/abstract NPC modes merely to improve this data-only number. -The next bounded scale slice should support the world vision directly: a -loaded-resource spatial query owned by `ActiveWorldAdapter`. It should let -villagers discover finite, stable-ID `ResourceNode` anchors placed much farther -apart across Terrain3D while preserving the current score, reachability, -reservation, and save/rebind contracts. A benchmark should compare 18, 180, -and 1,800 loaded candidates before choosing a grid, tree, or other index. +That next bounded scale slice is now complete. The measured workload justified +a resource-specific horizontal grid owned by `ActiveWorldAdapter`; exact +expanding queries preserve every linear selected-target checksum and reduce the +1,800-anchor case from 6,448.00 to 46.94 microseconds. See +[Loaded Resource Discovery 01](LOADED_RESOURCE_DISCOVERY_01.md). Terrain3D-instanced visual trees and grass are not automatically authoritative resources. Intentional harvest anchors must continue to bind simulation-owned diff --git a/docs/benchmarks/loaded_resource_discovery_01.json b/docs/benchmarks/loaded_resource_discovery_01.json new file mode 100644 index 0000000..a38c787 --- /dev/null +++ b/docs/benchmarks/loaded_resource_discovery_01.json @@ -0,0 +1,150 @@ +{ + "benchmark_seed": 17331, + "cases": [ + { + "inspected_reduction_percent": 79.4166666666667, + "linear": { + "candidates_inspected_average": 18.0, + "elapsed_usec_median": 26016, + "elapsed_usec_samples": [ + 25865, + 25941, + 25967, + 26016, + 26055, + 26153, + 26498 + ], + "queries_per_second_median": 15375.1537515375, + "selected_checksum": "6c417e0e25360aa1d2bcba407d3cba5aafe367a2706b93fb0ca631c7bcdb6800", + "usec_per_query_median": 65.04 + }, + "query_count": 400, + "resource_count": 18, + "sample_count": 7, + "schema_version": 1, + "spatial": { + "candidates_inspected_average": 3.705, + "elapsed_usec_median": 13304, + "elapsed_usec_samples": [ + 12896, + 13249, + 13300, + 13304, + 13428, + 13465, + 13580 + ], + "queries_per_second_median": 30066.1455201443, + "selected_checksum": "6c417e0e25360aa1d2bcba407d3cba5aafe367a2706b93fb0ca631c7bcdb6800", + "usec_per_query_median": 33.26 + }, + "speedup": 1.95550210463019, + "workload_id": "loaded_resource_target_resolution" + }, + { + "inspected_reduction_percent": 97.5694444444444, + "linear": { + "candidates_inspected_average": 180.0, + "elapsed_usec_median": 235611, + "elapsed_usec_samples": [ + 235062, + 235300, + 235372, + 235611, + 235870, + 237526, + 237936 + ], + "queries_per_second_median": 1697.71360420354, + "selected_checksum": "2ac9d359d20de54b79d31141abeb59715a94dfb03e21d86aa6fcf6116223a3f6", + "usec_per_query_median": 589.0275 + }, + "query_count": 400, + "resource_count": 180, + "sample_count": 7, + "schema_version": 1, + "spatial": { + "candidates_inspected_average": 4.375, + "elapsed_usec_median": 15533, + "elapsed_usec_samples": [ + 14610, + 14791, + 15121, + 15533, + 15653, + 15814, + 15971 + ], + "queries_per_second_median": 25751.6255713642, + "selected_checksum": "2ac9d359d20de54b79d31141abeb59715a94dfb03e21d86aa6fcf6116223a3f6", + "usec_per_query_median": 38.8325 + }, + "speedup": 15.1684156312367, + "workload_id": "loaded_resource_target_resolution" + }, + { + "inspected_reduction_percent": 99.7530555555556, + "linear": { + "candidates_inspected_average": 1800.0, + "elapsed_usec_median": 2579199, + "elapsed_usec_samples": [ + 2573545, + 2576251, + 2577722, + 2579199, + 2579974, + 2580177, + 2581969 + ], + "queries_per_second_median": 155.086908765086, + "selected_checksum": "67c6bdbb5e371c9e9658808fb215aeeba1f31f71fa731f0b9b1aaae599628013", + "usec_per_query_median": 6447.9975 + }, + "query_count": 400, + "resource_count": 1800, + "sample_count": 7, + "schema_version": 1, + "spatial": { + "candidates_inspected_average": 4.445, + "elapsed_usec_median": 18775, + "elapsed_usec_samples": [ + 18226, + 18389, + 18653, + 18775, + 18903, + 19067, + 19130 + ], + "queries_per_second_median": 21304.9267643142, + "selected_checksum": "67c6bdbb5e371c9e9658808fb215aeeba1f31f71fa731f0b9b1aaae599628013", + "usec_per_query_median": 46.9375 + }, + "speedup": 137.374114513981, + "workload_id": "loaded_resource_target_resolution" + } + ], + "exclusions": [ + "fixture_construction", + "simulation_tick", + "serialization", + "rendering", + "navigation_pathfinding" + ], + "godot_version": "4.7-stable (official)", + "host_label": "Apple_M1_Max_64_GB", + "inclusions": [ + "loaded_resource_candidate_discovery", + "authoritative_availability_and_scoring", + "reservation_and_release" + ], + "resource_counts": [ + 18, + 180, + 1800 + ], + "resource_spacing": 20.0, + "schema_version": 1, + "workload_id": "loaded_resource_target_resolution" +} diff --git a/simulation/actions/ActionTargetResolver.gd b/simulation/actions/ActionTargetResolver.gd index ad53776..f651a06 100644 --- a/simulation/actions/ActionTargetResolver.gd +++ b/simulation/actions/ActionTargetResolver.gd @@ -1,6 +1,8 @@ class_name ActionTargetResolver extends RefCounted +var last_resource_query_stats: Dictionary = {} + func resolve( npc: SimNPC, origin: Vector3, simulation_manager: Node, active_world_adapter: Node @@ -28,10 +30,33 @@ func _resolve_resource( definition: ActionDefinition, simulation_manager: Node, active_world_adapter: Node +) -> Dictionary: + last_resource_query_stats = {} + if ( + active_world_adapter.has_method("get_resource_candidates_in_radius") + and active_world_adapter.has_method("get_resource_query_profile") + ): + return _resolve_resource_spatial( + npc, origin, definition, simulation_manager, active_world_adapter + ) + return _resolve_resource_linear( + npc, origin, definition, simulation_manager, active_world_adapter + ) + + +func _resolve_resource_linear( + npc: SimNPC, + origin: Vector3, + definition: ActionDefinition, + simulation_manager: Node, + active_world_adapter: Node ) -> Dictionary: var best: Dictionary = {} var best_score := INF - for candidate in active_world_adapter.get_resource_candidates(definition.resource_action_id): + var candidates: Array[Dictionary] = active_world_adapter.get_resource_candidates( + definition.resource_action_id + ) + for candidate in candidates: var node_id := StringName(candidate["target_id"]) var state: ResourceStateRecord = simulation_manager.get_resource_state(node_id) if state == null or not state.can_npc_use() or not state.is_available_for(npc.id): @@ -41,12 +66,108 @@ func _resolve_resource( if score < best_score: best_score = score best = candidate - if best.is_empty(): + last_resource_query_stats = { + "mode": "linear", + "loaded_candidate_count": candidates.size(), + "candidates_inspected": candidates.size(), + "range_pass_count": 1, + } + return _reserve_resource_candidate(best, npc, simulation_manager) + + +func _resolve_resource_spatial( + npc: SimNPC, + origin: Vector3, + definition: ActionDefinition, + simulation_manager: Node, + active_world_adapter: Node +) -> Dictionary: + var profile: Dictionary = active_world_adapter.get_resource_query_profile( + definition.resource_action_id, origin + ) + var loaded_count := int(profile.get("candidate_count", 0)) + if loaded_count == 0: + last_resource_query_stats = { + "mode": "spatial", + "loaded_candidate_count": 0, + "candidates_inspected": 0, + "range_pass_count": 0, + } return {} - var target_id := StringName(best["target_id"]) + + var best: Dictionary = {} + var best_score := INF + var best_registration_order := 9223372036854775807 + var maximum_distance := maxf(float(profile["max_distance"]), 0.0) + var radius := minf(maxf(float(profile["initial_radius"]), 0.1), maximum_distance) + var previous_radius := -1.0 + var candidates_inspected := 0 + var range_pass_count := 0 + while true: + var candidates: Array[Dictionary] = active_world_adapter.get_resource_candidates_in_radius( + definition.resource_action_id, origin, radius, previous_radius + ) + range_pass_count += 1 + candidates_inspected += candidates.size() + for candidate in candidates: + var node_id := StringName(candidate["target_id"]) + var state: ResourceStateRecord = simulation_manager.get_resource_state(node_id) + if state == null or not state.can_npc_use() or not state.is_available_for(npc.id): + continue + var position: Vector3 = candidate["position"] + var score := score_resource_candidate(npc, origin, position, state) + var registration_order := int(candidate["registration_order"]) + if ( + score < best_score + or (score == best_score and registration_order < best_registration_order) + ): + best_score = score + best_registration_order = registration_order + best = candidate + + if radius >= maximum_distance: + break + if ( + not best.is_empty() + and _minimum_resource_score_beyond(npc, radius, profile) > best_score + ): + break + previous_radius = radius + var next_radius := minf(maximum_distance, radius * 2.0) + if next_radius <= radius: + break + radius = next_radius + + last_resource_query_stats = { + "mode": "spatial", + "loaded_candidate_count": loaded_count, + "candidates_inspected": candidates_inspected, + "range_pass_count": range_pass_count, + "final_radius": radius, + } + return _reserve_resource_candidate(best, npc, simulation_manager) + + +func _minimum_resource_score_beyond(npc: SimNPC, radius: float, profile: Dictionary) -> float: + var comfort_overage := maxf(radius - float(profile["max_comfort_distance"]), 0.0) + var risk_weight := 20.0 + maxf(100.0 - npc.energy, 0.0) * 0.2 + return ( + radius + + comfort_overage * 2.5 + + float(profile["min_safety_risk"]) * risk_weight + - float(profile["max_discovery_priority"]) + ) + + +func _reserve_resource_candidate( + candidate: Dictionary, npc: SimNPC, simulation_manager: Node +) -> Dictionary: + if candidate.is_empty(): + return {} + var target_id := StringName(candidate["target_id"]) if not simulation_manager.reserve_resource(target_id, npc.id): return {} - return best + return candidate func _resolve_activity( diff --git a/simulation/benchmark/LoadedResourceDiscoveryBenchmark.gd b/simulation/benchmark/LoadedResourceDiscoveryBenchmark.gd new file mode 100644 index 0000000..f9c9849 --- /dev/null +++ b/simulation/benchmark/LoadedResourceDiscoveryBenchmark.gd @@ -0,0 +1,239 @@ +class_name LoadedResourceDiscoveryBenchmark +extends RefCounted + +const SCHEMA_VERSION := 1 +const WORKLOAD_ID := &"loaded_resource_target_resolution" +const RESOURCE_SPACING := 20.0 +const DEFAULT_QUERY_COUNT := 400 +const DEFAULT_SAMPLE_COUNT := 7 +const WARMUP_QUERY_COUNT := 40 +const SimulationManagerScript := preload("res://simulation/SimulationManager.gd") + + +class LinearResourceAdapter: + extends Node + + func get_resource_candidates(action_id: StringName) -> Array[Dictionary]: + var candidates: Array[Dictionary] = [] + for node in ResourceNode.get_all(): + if node.action_id != action_id or node.interaction_point == null: + continue + ( + candidates + . append( + { + "target_id": String(node.node_id), + "position": node.interaction_point.global_position, + "resource_id": String(node.resource_id), + "safety_risk": node.safety_risk, + "comfort_distance": node.comfort_distance, + "discovery_priority": node.discovery_priority, + } + ) + ) + return candidates + + +func create_fixture(parent: Node, resource_count: int, seed_value: int) -> Dictionary: + if parent == null or resource_count <= 0: + return {} + var fixture_root := Node.new() + fixture_root.name = "LoadedResourceDiscoveryFixture" + parent.add_child(fixture_root) + var resource_root := Node3D.new() + resource_root.name = "ResourceNodes" + fixture_root.add_child(resource_root) + for resource_index in resource_count: + resource_root.add_child(_create_resource(resource_index, resource_count)) + + var adapter := ActiveWorldAdapter.new() + adapter.name = "ActiveWorldAdapter" + fixture_root.add_child(adapter) + var manager: Node = SimulationManagerScript.new() + manager.name = "SimulationManager" + manager.debug_logs = false + manager.simulation_seed = seed_value + manager.active_world_adapter = adapter + fixture_root.add_child(manager) + manager.set_process(false) + manager.register_loaded_resource_nodes() + var npc: SimNPC = manager.npcs[0] + npc.energy = 62.0 + npc.set_task(SimulationIds.ACTION_GATHER_FOOD) + return { + "root": fixture_root, + "resource_root": resource_root, + "adapter": adapter, + "manager": manager, + "npc": npc, + "resource_count": resource_count, + } + + +func create_linear_adapter() -> Node: + return LinearResourceAdapter.new() + + +func measure_fixture( + fixture: Dictionary, + query_count: int = DEFAULT_QUERY_COUNT, + sample_count: int = DEFAULT_SAMPLE_COUNT, + include_spatial: bool = true +) -> Dictionary: + if fixture.is_empty() or query_count <= 0 or sample_count <= 0: + return {} + var adapter: ActiveWorldAdapter = fixture["adapter"] + var origins := _build_origins(int(fixture["resource_count"]), query_count) + var linear_adapter := create_linear_adapter() + (fixture["root"] as Node).add_child(linear_adapter) + _run_queries(fixture, linear_adapter, origins, mini(WARMUP_QUERY_COUNT, query_count), false) + var linear := _measure_mode(fixture, linear_adapter, origins, sample_count, false) + if linear.is_empty(): + return {} + var result := { + "schema_version": SCHEMA_VERSION, + "workload_id": String(WORKLOAD_ID), + "resource_count": int(fixture["resource_count"]), + "query_count": query_count, + "sample_count": sample_count, + "linear": linear, + } + if ( + include_spatial + and adapter.has_method("get_resource_candidates_in_radius") + and adapter.has_method("get_resource_query_profile") + ): + _run_queries(fixture, adapter, origins, mini(WARMUP_QUERY_COUNT, query_count), false) + var spatial := _measure_mode(fixture, adapter, origins, sample_count, true) + if spatial.is_empty() or spatial["selected_checksum"] != linear["selected_checksum"]: + return {} + result["spatial"] = spatial + result["speedup"] = ( + float(linear["usec_per_query_median"]) + / maxf(float(spatial["usec_per_query_median"]), 0.0001) + ) + result["inspected_reduction_percent"] = ( + ( + 1.0 + - float(spatial["candidates_inspected_average"]) / float(fixture["resource_count"]) + ) + * 100.0 + ) + return result + + +func free_fixture(fixture: Dictionary) -> void: + if fixture.has("root") and is_instance_valid(fixture["root"]): + (fixture["root"] as Node).free() + + +func _measure_mode( + fixture: Dictionary, + query_adapter: Object, + origins: Array[Vector3], + sample_count: int, + spatial: bool +) -> Dictionary: + var elapsed_samples: Array[int] = [] + var inspected_samples: Array[float] = [] + var checksum := "" + for _sample_index in sample_count: + var sample := _run_queries(fixture, query_adapter, origins, origins.size(), spatial) + if sample.is_empty(): + return {} + if checksum.is_empty(): + checksum = sample["selected_checksum"] + elif checksum != sample["selected_checksum"]: + return {} + elapsed_samples.append(int(sample["elapsed_usec"])) + inspected_samples.append(float(sample["candidates_inspected_average"])) + elapsed_samples.sort() + inspected_samples.sort() + var elapsed_median := elapsed_samples[elapsed_samples.size() / 2] + return { + "elapsed_usec_samples": elapsed_samples, + "elapsed_usec_median": elapsed_median, + "usec_per_query_median": float(elapsed_median) / float(origins.size()), + "queries_per_second_median": float(origins.size()) * 1000000.0 / float(elapsed_median), + "candidates_inspected_average": inspected_samples[inspected_samples.size() / 2], + "selected_checksum": checksum, + } + + +func _run_queries( + fixture: Dictionary, + query_adapter: Object, + origins: Array[Vector3], + query_count: int, + spatial: bool +) -> Dictionary: + var manager: Node = fixture["manager"] + var npc: SimNPC = fixture["npc"] + var selected_ids := PackedStringArray() + var inspected_total := 0 + var started_usec := Time.get_ticks_usec() + for query_index in query_count: + var result: Dictionary = manager.target_resolver.resolve( + npc, origins[query_index], manager, query_adapter + ) + if result.is_empty(): + return {} + var target_id := StringName(result["target_id"]) + selected_ids.append(String(target_id)) + manager.release_resource(target_id, npc.id) + if spatial and not manager.target_resolver.last_resource_query_stats.is_empty(): + inspected_total += int( + manager.target_resolver.last_resource_query_stats.get("candidates_inspected", 0) + ) + else: + inspected_total += int(fixture["resource_count"]) + var elapsed_usec := maxi(Time.get_ticks_usec() - started_usec, 1) + return { + "elapsed_usec": elapsed_usec, + "candidates_inspected_average": float(inspected_total) / float(query_count), + "selected_checksum": "|".join(selected_ids).sha256_text(), + } + + +func _create_resource(resource_index: int, resource_count: int) -> ResourceNode: + var node := ResourceNode.new() + node.name = "Resource_%04d" % resource_index + node.node_id = StringName("benchmark_resource_%04d" % resource_index) + node.action_id = SimulationIds.ACTION_GATHER_FOOD + node.resource_id = SimulationIds.RESOURCE_FOOD + node.initial_amount = 100000.0 + node.initial_enabled = resource_index % 29 != 0 + node.safety_risk = float(resource_index % 6) * 0.06 + node.comfort_distance = 16.0 + float(resource_index % 5) * 5.0 + node.discovery_priority = float(resource_index % 9) * 0.35 + node.debug_label_enabled = false + node.position = _resource_position(resource_index, resource_count) + var interaction_point := Marker3D.new() + interaction_point.name = "InteractionPoint" + node.add_child(interaction_point) + return node + + +func _build_origins(resource_count: int, query_count: int) -> Array[Vector3]: + var origins: Array[Vector3] = [] + for query_index in query_count: + var resource_index := (query_index * 37 + 11) % resource_count + var offset := Vector3( + float((query_index * 7) % 13) - 6.0, 0.0, float((query_index * 11) % 17) - 8.0 + ) + origins.append(_resource_position(resource_index, resource_count) + offset) + return origins + + +func _resource_position(resource_index: int, resource_count: int) -> Vector3: + var columns := ceili(sqrt(float(resource_count))) + var column := resource_index % columns + var row := resource_index / columns + var centered_column := float(column) - float(columns - 1) * 0.5 + var row_count := ceili(float(resource_count) / float(columns)) + var centered_row := float(row) - float(row_count - 1) * 0.5 + return Vector3( + centered_column * RESOURCE_SPACING, + sin(float(resource_index) * 0.37) * 2.5, + centered_row * RESOURCE_SPACING + ) diff --git a/simulation/benchmark/LoadedResourceDiscoveryBenchmark.gd.uid b/simulation/benchmark/LoadedResourceDiscoveryBenchmark.gd.uid new file mode 100644 index 0000000..ea219cd --- /dev/null +++ b/simulation/benchmark/LoadedResourceDiscoveryBenchmark.gd.uid @@ -0,0 +1 @@ +uid://ccstdqk3xgxnu diff --git a/tests/loaded_resource_spatial_query_test.gd b/tests/loaded_resource_spatial_query_test.gd new file mode 100644 index 0000000..7ff77b8 --- /dev/null +++ b/tests/loaded_resource_spatial_query_test.gd @@ -0,0 +1,203 @@ +extends SceneTree + +const BenchmarkScript := preload("res://simulation/benchmark/LoadedResourceDiscoveryBenchmark.gd") + +var failures: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var benchmark := BenchmarkScript.new() + var fixture := benchmark.create_fixture(root, 180, 17331) + _test_exact_bounded_discovery(benchmark, fixture) + _test_far_priority_is_not_pruned(benchmark, fixture) + await _test_unload_rebind_and_movement(fixture) + benchmark.free_fixture(fixture) + var tie_fixture := benchmark.create_fixture(root, 2, 17331) + await _test_stable_tie_order(benchmark, tie_fixture) + benchmark.free_fixture(tie_fixture) + _check( + ResourceNode.get_all().is_empty(), "Fixture cleanup should clear the loaded-node registry" + ) + + if failures.is_empty(): + print("[TEST] Loaded-resource spatial query passed: exact scoring -> lifecycle-safe index") + quit(0) + return + for failure in failures: + push_error("[TEST] " + failure) + quit(1) + + +func _test_exact_bounded_discovery(benchmark: RefCounted, fixture: Dictionary) -> void: + var comparison: Dictionary = benchmark.measure_fixture(fixture, 64, 2, true) + _check(not comparison.is_empty(), "Linear and spatial target resolution should both complete") + if comparison.is_empty(): + return + var linear: Dictionary = comparison["linear"] + var spatial: Dictionary = comparison.get("spatial", {}) + _check(not spatial.is_empty(), "The active-world adapter should expose a spatial query") + if spatial.is_empty(): + return + _check( + linear["selected_checksum"] == spatial["selected_checksum"], + "Spatial queries should preserve every selected target from the linear reference", + ) + _check( + float(spatial["candidates_inspected_average"]) < 12.0, + "Ordinary local queries should inspect a bounded subset of 180 loaded resources", + ) + var adapter: ActiveWorldAdapter = fixture["adapter"] + var stats := adapter.get_resource_index_stats() + _check( + int(stats["candidate_count"]) == 180 and int(stats["occupied_cell_count"]) > 1, + "The adapter should index every loaded anchor across multiple horizontal cells", + ) + + +func _test_far_priority_is_not_pruned(benchmark: RefCounted, fixture: Dictionary) -> void: + var adapter: ActiveWorldAdapter = fixture["adapter"] + var manager: Node = fixture["manager"] + var npc: SimNPC = fixture["npc"] + var far_node := ResourceNode.get_by_id(&"benchmark_resource_0179") + var far_state: ResourceStateRecord = manager.get_resource_state(far_node.node_id) + var original_priority := far_state.get_discovery_priority() + far_state.data["discovery_priority"] = 5000.0 + adapter.register_resource_node(far_node, far_state) + + var linear_adapter: Node = benchmark.create_linear_adapter() + (fixture["root"] as Node).add_child(linear_adapter) + var origin := ResourceNode.get_by_id(&"benchmark_resource_0001").global_position + var linear_result: Dictionary = manager.target_resolver.resolve( + npc, origin, manager, linear_adapter + ) + manager.release_resource(StringName(linear_result.get("target_id", "")), npc.id) + var spatial_result: Dictionary = manager.target_resolver.resolve(npc, origin, manager, adapter) + manager.release_resource(StringName(spatial_result.get("target_id", "")), npc.id) + _check( + ( + not linear_result.is_empty() + and linear_result["target_id"] == spatial_result.get("target_id", "") + and StringName(spatial_result["target_id"]) == far_node.node_id + ), + "Metadata bounds should retain a far source that legitimately wins exact scoring", + ) + _check( + int(manager.target_resolver.last_resource_query_stats["range_pass_count"]) > 1, + "A winning far source should make the query expand beyond its first local cell range", + ) + far_state.data["discovery_priority"] = original_priority + adapter.register_resource_node(far_node, far_state) + + +func _test_unload_rebind_and_movement(fixture: Dictionary) -> void: + var adapter: ActiveWorldAdapter = fixture["adapter"] + var manager: Node = fixture["manager"] + var resource_root: Node3D = fixture["resource_root"] + var original := ResourceNode.get_by_id(&"benchmark_resource_0001") + var original_position := original.global_position + var state: ResourceStateRecord = manager.get_resource_state(original.node_id) + state.set_amount_remaining(77.0) + original.free() + _check( + int(adapter.get_resource_index_stats()["candidate_count"]) == 179, + "Unloading a ResourceNode should remove only its presentation anchor from the index", + ) + _check( + ( + manager.get_resource_state(&"benchmark_resource_0001") == state + and is_equal_approx(state.get_amount_remaining(), 77.0) + ), + "Unloading an anchor should preserve authoritative resource state", + ) + + var replacement := ResourceNode.new() + replacement.name = "ReboundResource" + replacement.node_id = &"benchmark_resource_0001" + replacement.action_id = state.get_action_id() + replacement.resource_id = state.get_resource_id() + replacement.initial_amount = 100000.0 + replacement.safety_risk = state.get_safety_risk() + replacement.comfort_distance = state.get_comfort_distance() + replacement.discovery_priority = state.get_discovery_priority() + replacement.debug_label_enabled = false + replacement.position = original_position + Vector3(60.0, 0.0, 40.0) + var interaction_point := Marker3D.new() + interaction_point.name = "InteractionPoint" + replacement.add_child(interaction_point) + resource_root.add_child(replacement) + _check( + ( + replacement.state == state + and is_equal_approx(replacement.get_amount_remaining(), 77.0) + and int(adapter.get_resource_index_stats()["candidate_count"]) == 180 + ), + "Rebinding the stable ID should restore its indexed anchor without replacing state", + ) + var rebound_candidates := adapter.get_resource_candidates_in_radius( + state.get_action_id(), replacement.global_position, 0.1 + ) + _check( + ( + rebound_candidates.size() == 1 + and StringName(rebound_candidates[0]["target_id"]) == replacement.node_id + ), + "The rebound anchor should be discoverable at its new Terrain3D-authored position", + ) + + replacement.position += Vector3(48.0, 0.0, -24.0) + await process_frame + var moved_candidates := adapter.get_resource_candidates_in_radius( + state.get_action_id(), replacement.global_position, 0.1 + ) + _check( + ( + moved_candidates.size() == 1 + and StringName(moved_candidates[0]["target_id"]) == replacement.node_id + ), + "Moving a loaded anchor should refresh its spatial cell before the next query frame", + ) + + +func _test_stable_tie_order(benchmark: RefCounted, fixture: Dictionary) -> void: + var adapter: ActiveWorldAdapter = fixture["adapter"] + var manager: Node = fixture["manager"] + var npc: SimNPC = fixture["npc"] + var first := ResourceNode.get_by_id(&"benchmark_resource_0000") + var second := ResourceNode.get_by_id(&"benchmark_resource_0001") + first.position = Vector3(-10.0, 0.0, 0.0) + second.position = Vector3(10.0, 0.0, 0.0) + for node in [first, second]: + var state: ResourceStateRecord = manager.get_resource_state(node.node_id) + state.set_enabled(true) + state.data["safety_risk"] = 0.0 + state.data["comfort_distance"] = 18.0 + state.data["discovery_priority"] = 0.0 + adapter.register_resource_node(node, state) + await process_frame + + var linear_adapter: Node = benchmark.create_linear_adapter() + (fixture["root"] as Node).add_child(linear_adapter) + var linear_result: Dictionary = manager.target_resolver.resolve( + npc, Vector3.ZERO, manager, linear_adapter + ) + manager.release_resource(StringName(linear_result.get("target_id", "")), npc.id) + var spatial_result: Dictionary = manager.target_resolver.resolve( + npc, Vector3.ZERO, manager, adapter + ) + manager.release_resource(StringName(spatial_result.get("target_id", "")), npc.id) + _check( + ( + StringName(linear_result.get("target_id", "")) == first.node_id + and spatial_result.get("target_id", "") == linear_result.get("target_id", "") + ), + "Equal resource scores should preserve the former first-loaded stable tie winner", + ) + + +func _check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) diff --git a/tests/loaded_resource_spatial_query_test.gd.uid b/tests/loaded_resource_spatial_query_test.gd.uid new file mode 100644 index 0000000..4ed0755 --- /dev/null +++ b/tests/loaded_resource_spatial_query_test.gd.uid @@ -0,0 +1 @@ +uid://7yplcc3s2oks diff --git a/tools/benchmark_loaded_resource_discovery.gd b/tools/benchmark_loaded_resource_discovery.gd new file mode 100644 index 0000000..e9be7f4 --- /dev/null +++ b/tools/benchmark_loaded_resource_discovery.gd @@ -0,0 +1,108 @@ +extends SceneTree + +const BenchmarkScript := preload("res://simulation/benchmark/LoadedResourceDiscoveryBenchmark.gd") +const RESOURCE_COUNTS := [18, 180, 1800] +const BENCHMARK_SEED := 17331 +const DEFAULT_OUTPUT_PATH := "res://logs/loaded_resource_discovery.json" + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var benchmark := BenchmarkScript.new() + var cases: Array[Dictionary] = [] + var include_spatial := not _has_argument("--linear-only") + for resource_count in RESOURCE_COUNTS: + var fixture := benchmark.create_fixture(root, resource_count, BENCHMARK_SEED) + var result := benchmark.measure_fixture( + fixture, + _get_integer_argument( + "--queries=", LoadedResourceDiscoveryBenchmark.DEFAULT_QUERY_COUNT + ), + _get_integer_argument( + "--samples=", LoadedResourceDiscoveryBenchmark.DEFAULT_SAMPLE_COUNT + ), + include_spatial + ) + benchmark.free_fixture(fixture) + if result.is_empty(): + push_error( + "Loaded-resource discovery benchmark failed at %d resources" % resource_count + ) + quit(1) + return + cases.append(result) + _print_case(result) + + var report := { + "schema_version": LoadedResourceDiscoveryBenchmark.SCHEMA_VERSION, + "workload_id": String(LoadedResourceDiscoveryBenchmark.WORKLOAD_ID), + "benchmark_seed": BENCHMARK_SEED, + "host_label": _get_argument_value("--host-label=", "unlabelled"), + "godot_version": Engine.get_version_info().get("string", "unknown"), + "resource_counts": RESOURCE_COUNTS, + "resource_spacing": LoadedResourceDiscoveryBenchmark.RESOURCE_SPACING, + "inclusions": + [ + "loaded_resource_candidate_discovery", + "authoritative_availability_and_scoring", + "reservation_and_release", + ], + "exclusions": + [ + "fixture_construction", + "simulation_tick", + "serialization", + "rendering", + "navigation_pathfinding", + ], + "cases": cases, + } + var output_path := _get_argument_value("--output=", DEFAULT_OUTPUT_PATH) + var output := FileAccess.open(output_path, FileAccess.WRITE) + if output == null: + push_error("Could not write loaded-resource report to %s" % output_path) + quit(1) + return + output.store_string(JSON.stringify(report, "\t") + "\n") + output.close() + print("[BENCH] Report: %s" % ProjectSettings.globalize_path(output_path)) + quit(0) + + +func _print_case(result: Dictionary) -> void: + var linear: Dictionary = result["linear"] + var message := ( + "[BENCH] %4d resources | linear %7.2f us/query" + % [result["resource_count"], linear["usec_per_query_median"]] + ) + if result.has("spatial"): + var spatial: Dictionary = result["spatial"] + message += ( + " | spatial %7.2f us/query | %.2fx | %.1f inspected" + % [ + spatial["usec_per_query_median"], + result["speedup"], + spatial["candidates_inspected_average"], + ] + ) + print(message) + + +func _has_argument(argument: String) -> bool: + return argument in OS.get_cmdline_user_args() + + +func _get_integer_argument(prefix: String, default_value: int) -> int: + return maxi(int(_get_argument_value(prefix, str(default_value))), 1) + + +func _get_argument_value(prefix: String, default_value: String) -> String: + for argument in OS.get_cmdline_user_args(): + if argument.begins_with(prefix): + var value := argument.trim_prefix(prefix) + if not value.is_empty(): + return value + return default_value diff --git a/tools/benchmark_loaded_resource_discovery.gd.uid b/tools/benchmark_loaded_resource_discovery.gd.uid new file mode 100644 index 0000000..b71a5d8 --- /dev/null +++ b/tools/benchmark_loaded_resource_discovery.gd.uid @@ -0,0 +1 @@ +uid://o81js4vhlb0s diff --git a/world/active_world_adapter.gd b/world/active_world_adapter.gd index 0857d19..54af670 100644 --- a/world/active_world_adapter.gd +++ b/world/active_world_adapter.gd @@ -1,26 +1,62 @@ class_name ActiveWorldAdapter extends Node +const LoadedResourceSpatialIndexScript := preload( + "res://world/resource_nodes/LoadedResourceSpatialIndex.gd" +) + @export var pantry_storage: StorageNode @export var woodpile_storage: StorageNode +@export_range(4.0, 128.0, 1.0) var resource_cell_size := 24.0 + +var _resource_index := LoadedResourceSpatialIndexScript.new() + + +func _ready() -> void: + _resource_index.configure(resource_cell_size) + add_to_group("active_world_adapter") + rebuild_resource_index() + + +func rebuild_resource_index() -> void: + _resource_index.clear() + for node in ResourceNode.get_all(): + _resource_index.register_node(node, node.state) + + +func register_resource_node(node: ResourceNode, state: ResourceStateRecord = null) -> bool: + return _resource_index.register_node(node, state) + + +func unregister_resource_node(node_id: StringName) -> void: + _resource_index.unregister_node(node_id) func get_resource_candidates(action_id: StringName) -> Array[Dictionary]: - var candidates: Array[Dictionary] = [] - for node in ResourceNode.get_all(): - if node.action_id != action_id or node.interaction_point == null: - continue - candidates.append( - { - "target_id": String(node.node_id), - "position": node.interaction_point.global_position, - "resource_id": String(node.resource_id), - "safety_risk": node.safety_risk, - "comfort_distance": node.comfort_distance, - "discovery_priority": node.discovery_priority - } - ) - return candidates + return _resource_index.get_all_candidates(action_id) + + +func get_resource_candidates_in_radius( + action_id: StringName, + origin: Vector3, + max_distance: float, + min_distance_exclusive: float = -1.0 +) -> Array[Dictionary]: + return _resource_index.get_candidates_in_radius( + action_id, origin, max_distance, min_distance_exclusive + ) + + +func get_resource_query_profile(action_id: StringName, origin: Vector3) -> Dictionary: + return _resource_index.get_query_profile(action_id, origin) + + +func get_resource_nodes_in_radius(origin: Vector3, max_distance: float) -> Array[ResourceNode]: + return _resource_index.get_nodes_in_radius(origin, max_distance) + + +func get_resource_index_stats() -> Dictionary: + return _resource_index.get_stats() func get_activity_target(action_id: StringName, origin: Vector3 = Vector3.ZERO) -> Dictionary: diff --git a/world/resource_nodes/LoadedResourceSpatialIndex.gd b/world/resource_nodes/LoadedResourceSpatialIndex.gd new file mode 100644 index 0000000..edfa326 --- /dev/null +++ b/world/resource_nodes/LoadedResourceSpatialIndex.gd @@ -0,0 +1,279 @@ +class_name LoadedResourceSpatialIndex +extends RefCounted + +const DEFAULT_CELL_SIZE := 24.0 + +var cell_size := DEFAULT_CELL_SIZE +var _entries: Dictionary = {} +var _action_cells: Dictionary = {} +var _all_cells: Dictionary = {} +var _profiles: Dictionary = {} +var _dirty_profiles: Dictionary = {} +var _next_registration_order := 0 + + +func configure(configured_cell_size: float) -> void: + cell_size = maxf(configured_cell_size, 1.0) + + +func clear() -> void: + _entries.clear() + _action_cells.clear() + _all_cells.clear() + _profiles.clear() + _dirty_profiles.clear() + _next_registration_order = 0 + + +func register_node(node: ResourceNode, state: ResourceStateRecord = null) -> bool: + if node == null or node.node_id.is_empty() or node.interaction_point == null: + return false + var registration_order := _next_registration_order + var existing: Dictionary = _entries.get(node.node_id, {}) + if not existing.is_empty(): + registration_order = int(existing["registration_order"]) + _remove_entry(existing) + else: + _next_registration_order += 1 + var action_id := state.get_action_id() if state != null else node.action_id + var resource_id := state.get_resource_id() if state != null else node.resource_id + var position := node.global_transform * node.interaction_point.position + var safety_risk := state.get_safety_risk() if state != null else node.safety_risk + var comfort_distance := state.get_comfort_distance() if state != null else node.comfort_distance + var discovery_priority := ( + state.get_discovery_priority() if state != null else node.discovery_priority + ) + var entry := { + "target_id": node.node_id, + "action_id": action_id, + "resource_id": resource_id, + "position": position, + "safety_risk": safety_risk, + "comfort_distance": comfort_distance, + "discovery_priority": discovery_priority, + "registration_order": registration_order, + "cell": _cell_for(position), + "node": node, + } + _entries[node.node_id] = entry + _add_to_cell(_all_cells, entry["cell"], node.node_id) + var cells: Dictionary = _action_cells.get(action_id, {}) + _add_to_cell(cells, entry["cell"], node.node_id) + _action_cells[action_id] = cells + _dirty_profiles[action_id] = true + return true + + +func unregister_node(node_id: StringName) -> void: + var entry: Dictionary = _entries.get(node_id, {}) + if entry.is_empty(): + return + _remove_entry(entry) + + +func get_all_candidates(action_id: StringName) -> Array[Dictionary]: + var matching_entries: Array[Dictionary] = [] + for entry_value in _entries.values(): + var entry: Dictionary = entry_value + if entry["action_id"] == action_id: + matching_entries.append(entry) + matching_entries.sort_custom(_entry_order_less) + return _entries_to_candidates(matching_entries) + + +func get_candidates_in_radius( + action_id: StringName, + origin: Vector3, + max_distance: float, + min_distance_exclusive: float = -1.0 +) -> Array[Dictionary]: + var cells: Dictionary = _action_cells.get(action_id, {}) + var matching_entries := _get_entries_in_radius( + cells, origin, max_distance, min_distance_exclusive + ) + return _entries_to_candidates(matching_entries) + + +func get_nodes_in_radius(origin: Vector3, max_distance: float) -> Array[ResourceNode]: + var nodes: Array[ResourceNode] = [] + for entry in _get_entries_in_radius(_all_cells, origin, max_distance): + var node := entry["node"] as ResourceNode + if is_instance_valid(node): + nodes.append(node) + return nodes + + +func get_query_profile(action_id: StringName, origin: Vector3) -> Dictionary: + _ensure_profile(action_id) + var profile: Dictionary = _profiles.get(action_id, {}) + if profile.is_empty(): + return {"candidate_count": 0, "initial_radius": cell_size, "max_distance": 0.0} + var minimum: Vector3 = profile["minimum"] + var maximum: Vector3 = profile["maximum"] + var farthest_delta := Vector3( + maxf(absf(origin.x - minimum.x), absf(origin.x - maximum.x)), + maxf(absf(origin.y - minimum.y), absf(origin.y - maximum.y)), + maxf(absf(origin.z - minimum.z), absf(origin.z - maximum.z)) + ) + var result := profile.duplicate() + result.erase("minimum") + result.erase("maximum") + result["initial_radius"] = cell_size + result["max_distance"] = farthest_delta.length() + return result + + +func get_stats() -> Dictionary: + var action_counts := {} + for entry_value in _entries.values(): + var action_id: StringName = entry_value["action_id"] + action_counts[String(action_id)] = int(action_counts.get(String(action_id), 0)) + 1 + return { + "candidate_count": _entries.size(), + "occupied_cell_count": _all_cells.size(), + "cell_size": cell_size, + "action_counts": action_counts, + } + + +func _ensure_profile(action_id: StringName) -> void: + if not _dirty_profiles.has(action_id) and _profiles.has(action_id): + return + var count := 0 + var minimum := Vector3.ZERO + var maximum := Vector3.ZERO + var min_safety_risk := INF + var max_comfort_distance := 0.0 + var max_discovery_priority := -INF + for entry_value in _entries.values(): + var entry: Dictionary = entry_value + if entry["action_id"] != action_id: + continue + var position: Vector3 = entry["position"] + if count == 0: + minimum = position + maximum = position + else: + minimum = minimum.min(position) + maximum = maximum.max(position) + count += 1 + min_safety_risk = minf(min_safety_risk, float(entry["safety_risk"])) + max_comfort_distance = maxf(max_comfort_distance, float(entry["comfort_distance"])) + max_discovery_priority = maxf(max_discovery_priority, float(entry["discovery_priority"])) + if count == 0: + _profiles.erase(action_id) + else: + _profiles[action_id] = { + "candidate_count": count, + "occupied_cell_count": (_action_cells.get(action_id, {}) as Dictionary).size(), + "minimum": minimum, + "maximum": maximum, + "min_safety_risk": min_safety_risk, + "max_comfort_distance": max_comfort_distance, + "max_discovery_priority": max_discovery_priority, + } + _dirty_profiles.erase(action_id) + + +func _get_entries_in_radius( + cells: Dictionary, origin: Vector3, max_distance: float, min_distance_exclusive: float = -1.0 +) -> Array[Dictionary]: + var matching_entries: Array[Dictionary] = [] + if cells.is_empty() or max_distance < 0.0: + return matching_entries + var minimum_cell := _cell_for( + Vector3(origin.x - max_distance, origin.y, origin.z - max_distance) + ) + var maximum_cell := _cell_for( + Vector3(origin.x + max_distance, origin.y, origin.z + max_distance) + ) + var candidate_ids := _get_candidate_ids(cells, minimum_cell, maximum_cell) + var maximum_distance_squared := max_distance * max_distance + var minimum_distance_squared := min_distance_exclusive * min_distance_exclusive + for node_id in candidate_ids: + var entry: Dictionary = _entries.get(node_id, {}) + if entry.is_empty(): + continue + var distance_squared := origin.distance_squared_to(entry["position"]) + if distance_squared > maximum_distance_squared: + continue + if min_distance_exclusive >= 0.0 and distance_squared <= minimum_distance_squared: + continue + matching_entries.append(entry) + matching_entries.sort_custom(_entry_order_less) + return matching_entries + + +func _get_candidate_ids(cells: Dictionary, minimum_cell: Vector2i, maximum_cell: Vector2i) -> Array: + var candidate_ids: Array = [] + var rectangle_cell_count := ( + (maximum_cell.x - minimum_cell.x + 1) * (maximum_cell.y - minimum_cell.y + 1) + ) + if rectangle_cell_count <= cells.size() * 4: + for cell_x in range(minimum_cell.x, maximum_cell.x + 1): + for cell_y in range(minimum_cell.y, maximum_cell.y + 1): + candidate_ids.append_array(cells.get(Vector2i(cell_x, cell_y), [])) + return candidate_ids + for cell_value in cells: + var cell: Vector2i = cell_value + if ( + cell.x >= minimum_cell.x + and cell.x <= maximum_cell.x + and cell.y >= minimum_cell.y + and cell.y <= maximum_cell.y + ): + candidate_ids.append_array(cells[cell]) + return candidate_ids + + +func _entries_to_candidates(entries: Array[Dictionary]) -> Array[Dictionary]: + var candidates: Array[Dictionary] = [] + for entry in entries: + var candidate := { + "target_id": String(entry["target_id"]), + "position": entry["position"], + "resource_id": String(entry["resource_id"]), + "safety_risk": entry["safety_risk"], + "comfort_distance": entry["comfort_distance"], + "discovery_priority": entry["discovery_priority"], + "registration_order": entry["registration_order"], + } + candidates.append(candidate) + return candidates + + +func _remove_entry(entry: Dictionary) -> void: + var node_id := StringName(entry["target_id"]) + var action_id := StringName(entry["action_id"]) + _remove_from_cell(_all_cells, entry["cell"], node_id) + var cells: Dictionary = _action_cells.get(action_id, {}) + _remove_from_cell(cells, entry["cell"], node_id) + if cells.is_empty(): + _action_cells.erase(action_id) + else: + _action_cells[action_id] = cells + _entries.erase(node_id) + _dirty_profiles[action_id] = true + + +func _cell_for(position: Vector3) -> Vector2i: + return Vector2i(floori(position.x / cell_size), floori(position.z / cell_size)) + + +func _add_to_cell(cells: Dictionary, cell: Vector2i, node_id: StringName) -> void: + var ids: Array = cells.get(cell, []) + ids.append(node_id) + cells[cell] = ids + + +func _remove_from_cell(cells: Dictionary, cell: Vector2i, node_id: StringName) -> void: + var ids: Array = cells.get(cell, []) + ids.erase(node_id) + if ids.is_empty(): + cells.erase(cell) + else: + cells[cell] = ids + + +func _entry_order_less(first: Dictionary, second: Dictionary) -> bool: + return int(first["registration_order"]) < int(second["registration_order"]) diff --git a/world/resource_nodes/LoadedResourceSpatialIndex.gd.uid b/world/resource_nodes/LoadedResourceSpatialIndex.gd.uid new file mode 100644 index 0000000..9a08e6b --- /dev/null +++ b/world/resource_nodes/LoadedResourceSpatialIndex.gd.uid @@ -0,0 +1 @@ +uid://c3afuxcwkexf7 diff --git a/world/resource_nodes/ResourceNode.gd b/world/resource_nodes/ResourceNode.gd index 6412b0a..64623ee 100644 --- a/world/resource_nodes/ResourceNode.gd +++ b/world/resource_nodes/ResourceNode.gd @@ -45,11 +45,14 @@ func _ready() -> void: return _all.append(self) add_to_group("resource_nodes") + set_notify_transform(true) _try_register_with_simulation() + _notify_world_adapters() _update_presentation() func _exit_tree() -> void: + _unregister_from_world_adapters() _all.erase(self) if state != null and state.changed.is_connected(_on_state_changed): state.changed.disconnect(_on_state_changed) @@ -71,6 +74,7 @@ func bind_state(resource_state: ResourceStateRecord) -> bool: if not state.depleted.is_connected(_on_state_depleted): state.depleted.connect(_on_state_depleted) _update_presentation() + _notify_world_adapters() return true @@ -83,6 +87,27 @@ func _try_register_with_simulation() -> void: manager.register_resource_node(self) +func _notification(what: int) -> void: + if what == NOTIFICATION_TRANSFORM_CHANGED and is_node_ready(): + _notify_world_adapters() + + +func _notify_world_adapters() -> void: + if not is_inside_tree(): + return + for adapter in get_tree().get_nodes_in_group("active_world_adapter"): + if adapter.has_method("register_resource_node"): + adapter.register_resource_node(self, state) + + +func _unregister_from_world_adapters() -> void: + if not is_inside_tree(): + return + for adapter in get_tree().get_nodes_in_group("active_world_adapter"): + if adapter.has_method("unregister_resource_node"): + adapter.unregister_resource_node(node_id) + + func get_amount_remaining() -> float: return state.get_amount_remaining() if state != null else initial_amount