feat: index loaded resource discovery

This commit is contained in:
Rijad Zuzo
2026-07-17 00:34:30 +02:00
parent 2c88ed6ea8
commit 5356c34fd7
21 changed files with 1392 additions and 69 deletions
+19 -12
View File
@@ -47,6 +47,10 @@ SimulationManager tick
- reads target metadata from ActionDefinition; - reads target metadata from ActionDefinition;
- consumes active-world facts through ActiveWorldAdapter; - 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; - checks simulation-owned ResourceStateRecord availability;
- reserves and returns stable resource target IDs; - reserves and returns stable resource target IDs;
- skips full-capacity activity sites based on authoritative NPC target claims; - skips full-capacity activity sites based on authoritative NPC target claims;
@@ -55,7 +59,10 @@ SimulationManager tick
### ActiveWorldAdapter ### 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 loaded storage and activity-site interaction positions;
- exposes activity-site capacity as an active-world fact; - exposes activity-site capacity as an active-world fact;
- contains active-world query facts, not persistent mutable authority; - 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 It also owns one disposable `SimulationPopulationView`, rebuilt at tick start
and refreshed after each NPC advances so interleaved decision semantics remain and refreshed after each NPC advances so interleaved decision semantics remain
unchanged. unchanged.
The manager publishes the latest `ActionSelectionResult` for presentation; the UI The manager publishes the latest `ActionSelectionResult` for presentation; the
does not recompute decisions. Each idle selection re-derives the capable helper UI does not recompute decisions. Each idle selection re-derives the capable
instead of consulting persisted assignment state. At completion it atomically helper instead of consulting persisted assignment state. At completion it
pays any definition-backed stored-resource cost before applying the action atomically pays any definition-backed stored-resource cost before applying the
effect. A late shortfall suppresses the effect and records a `task_blocked` action effect. A late shortfall suppresses the effect and records a
fact. The manager also captures actor/nearby knowledge before forwarding newly `task_blocked` fact. The manager also captures actor/nearby knowledge before
recorded events into `RelationshipSystem`. When an NPC arrives beside a worker forwarding newly recorded events into `RelationshipSystem`. When an NPC arrives
at the same non-storage activity site, the manager may transfer one direct beside a worker at the same non-storage activity site, the manager may transfer
known fact and applies its consequence only to that newly informed listener. An one direct known fact and applies its consequence only to that newly informed
open opportunity's exact trigger receives bounded priority in that conversation listener. An open opportunity's exact trigger receives bounded priority in
before normal lasting/recent ranking. It exposes that conversation before normal lasting/recent ranking. It exposes
knowledge, provenance, relationship, and cause queries without moving social knowledge, provenance, relationship, and cause queries without moving social
authority into UI. After consequences are known, it protects current causal authority into UI. After consequences are known, it protects current causal
facts, enforces the recent-memory cap, and runs age review from authoritative facts, enforces the recent-memory cap, and runs age review from authoritative
+14 -2
View File
@@ -22,6 +22,7 @@ SimulationClock
-> VillageOpportunitySystem projects one known unresolved need -> VillageOpportunitySystem projects one known unresolved need
-> WorldViewManager presents travel, NPC state, and world-state cues -> WorldViewManager presents travel, NPC state, and world-state cues
-> ActiveWorldAdapter supplies loaded-world positions/capacity -> ActiveWorldAdapter supplies loaded-world positions/capacity
-> LoadedResourceSpatialIndex bounds finite-anchor discovery
-> NpcVisual performs local navigation, animation, and transient reactions -> NpcVisual performs local navigation, animation, and transient reactions
-> PantryStockVisual derives physical stock arrangement from storage state -> 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 - `simulation/definitions/` owns stable IDs and immutable action/profession
definitions. 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, Outside the runtime lifecycle, `simulation/benchmark/` owns reusable,
schema-valid workload fixtures. CLI tools and headless scenarios consume those schema-valid workload fixtures. CLI tools and headless scenarios consume those
fixtures; production simulation does not depend on benchmark code. fixtures; production simulation does not depend on benchmark code.
@@ -86,9 +94,9 @@ hard to read.
| `simulation/state/` | Versioned, serializable mutable records | | `simulation/state/` | Versioned, serializable mutable records |
| `simulation/definitions/` | Stable IDs and immutable gameplay definitions | | `simulation/definitions/` | Stable IDs and immutable gameplay definitions |
| `simulation/persistence/` | Validated local save-file storage | | `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/` | 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/storage/` | Storage interaction geometry, never stored quantities |
| `world/activity/` | Rest/study/patrol interaction sites and capacity facts | | `world/activity/` | Rest/study/patrol interaction sites and capacity facts |
| `player/` | Player input, camera, and active NPC presentation | | `player/` | Player input, camera, and active NPC presentation |
@@ -127,6 +135,10 @@ improving ownership.
- Derived population indexes are disposable acceleration structures. NPC - Derived population indexes are disposable acceleration structures. NPC
records remain authoritative, and rebuilding the view after initialization, records remain authoritative, and rebuilding the view after initialization,
restore, or a tick must produce the same decisions and checksum. 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 - Camera-local grass, ambient butterflies, water animation, smoke, and foliage
motion are presentation only. Grass consumes bounded real actor transforms; motion are presentation only. Grass consumes bounded real actor transforms;
it does not write NPC positions or become persistent state. it does not write NPC positions or become persistent state.
+11 -7
View File
@@ -770,17 +770,21 @@ Completed:
cascading homes, bridge, waterfall, terrain-following river, conifer/tree cascading homes, bridge, waterfall, terrain-following river, conifer/tree
clusters, and ambient butterflies. Roughly 10,400 camera-local grass clusters, and ambient butterflies. Roughly 10,400 camera-local grass
particles remain short and patchy and bend from real actor transforms. 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: Next:
1. Measure and implement a loaded-resource spatial query through 1. Sculpt a real Terrain3D river channel and waterfall shelf before adding a
`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
broad foliage asset pass. Use Terrain3D particles for nearby grass and broad foliage asset pass. Use Terrain3D particles for nearby grass and
intentional instancing for tree/fern/rock masses; add livestock only with a intentional instancing for tree/fern/rock masses, then rebake and validate
real identity and interaction contract. 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 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 mechanics. The next proof is a beautiful stage for the systems that already
+25 -9
View File
@@ -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 improves that case from 65.84 to 85.81 ticks per second. Revisit the target when
world presentation or LOD enters the measured workload. 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 ## Milestone 9 — Player interaction and social agency
### Learn ### Learn
@@ -873,18 +879,28 @@ The 600-NPC case falls from 15,187.74 to 11,654.10 microseconds per tick, a
cost. See cost. See
[Simulation scaling baseline 02](benchmarks/SIMULATION_SCALING_BASELINE_02.md). [Simulation scaling baseline 02](benchmarks/SIMULATION_SCALING_BASELINE_02.md).
The immediate next systems slice should now prove spatial discovery for the The first loaded-world spatial consumer is complete. `ActiveWorldAdapter` owns
world vision: index loaded finite `ResourceNode` anchors through a disposable 24 m horizontal grid of loaded finite `ResourceNode` anchors.
`ActiveWorldAdapter` so Terrain3D-authored sources can be placed much farther `ActionTargetResolver` expands through nearby ranges and stops only when
apart without an all-node scan for every resolution. Preserve stable IDs, authoritative risk/comfort/priority bounds prove that no farther source can win.
current distance/risk/comfort/priority scoring, reachability, reservations, Matched 18/180/1,800-source samples preserve every selected-target checksum;
player parity, unload/rebind authority, and exact deterministic outcomes. First the 1,800-source case falls from 6,448.00 to 46.94 microseconds per resolution.
measure 18, 180, and 1,800 loaded candidates; do not choose a generic spatial Unload/rebind state authority, stable tie order, moved anchors, reservations,
framework or active/abstract LOD mode before that real consumer proves the player parity, and a deliberately far high-priority winner remain covered. See
contract. [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: 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 - Shared population query view: one transient per-tick all/living/starving
index replaces repeated scarce-food relationship scans, preserves exact index replaces repeated scarce-food relationship scans, preserves exact
checksums, and improves the reviewed 600-NPC workload by 23.3%. checksums, and improves the reviewed 600-NPC workload by 23.3%.
+26 -9
View File
@@ -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 - **Terrain:** Terrain3D 1.0.2 is installed and enabled
- **Jajce runtime:** reusable 512 m Terrain3D seed, stable ResourceNode - **Jajce runtime:** reusable 512 m Terrain3D seed, stable ResourceNode
placement, larger citadel/village-center composition, terrain-following river placement, larger citadel/village-center composition, terrain-following river
ribbon, reactive camera-local grass, and Terrain3D-derived runtime navigation ribbon, reactive camera-local grass, exact indexed finite-resource discovery,
with collision guardrails and Terrain3D-derived runtime navigation with collision guardrails
- **Lookdev baseline:** `Jajce Lookdev 01` is captured at - **Lookdev baseline:** `Jajce Lookdev 01` is captured at
`docs/baselines/jajce_lookdev_01.png` with `docs/baselines/jajce_lookdev_01.png` with
`tools/capture_jajce_lookdev.gd` `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, camps, and village stockpiles can be ranked by reachability, distance, safety,
profession, and NPC comfort range. The current resolver already combines profession, and NPC comfort range. The current resolver already combines
distance with resource `safety_risk`, `comfort_distance`, and 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 ### Jajce scaffold
@@ -548,6 +550,7 @@ NpcVisual navigates through the active world
│ ├── SimulationClock.gd │ ├── SimulationClock.gd
│ ├── SimulationManager.gd │ ├── SimulationManager.gd
│ ├── actions/ Selection, execution, and target resolution │ ├── actions/ Selection, execution, and target resolution
│ ├── benchmark/ Population/history and resource-query workloads
│ ├── definitions/ Stable IDs and custom definition resources │ ├── definitions/ Stable IDs and custom definition resources
│ ├── economy/ Inventory and storage transactions │ ├── economy/ Inventory and storage transactions
│ ├── events/ Ordered event history and queries │ ├── events/ Ordered event history and queries
@@ -566,6 +569,7 @@ NpcVisual navigates through the active world
│ ├── jajce_world_scaffold_test.gd │ ├── jajce_world_scaffold_test.gd
│ ├── knowledge_retention_consequence_test.gd │ ├── knowledge_retention_consequence_test.gd
│ ├── jajce_runtime_integration_test.gd │ ├── jajce_runtime_integration_test.gd
│ ├── loaded_resource_spatial_query_test.gd
│ ├── npc_visual_lifecycle_test.gd │ ├── npc_visual_lifecycle_test.gd
│ ├── opportunity_communication_helper_test.gd │ ├── opportunity_communication_helper_test.gd
│ ├── relationship_consequence_test.gd │ ├── relationship_consequence_test.gd
@@ -577,6 +581,7 @@ NpcVisual navigates through the active world
│ └── witnessed_knowledge_consequence_test.gd │ └── witnessed_knowledge_consequence_test.gd
├── terrain/jajce/ Dedicated Terrain3D seed data and assets ├── terrain/jajce/ Dedicated Terrain3D seed data and assets
├── tools/ ├── tools/
│ ├── benchmark_loaded_resource_discovery.gd
│ └── generate_jajce_terrain_seed.gd │ └── generate_jajce_terrain_seed.gd
├── world/ ├── world/
│ ├── jajce/ │ ├── jajce/
@@ -586,6 +591,7 @@ NpcVisual navigates through the active world
│ │ ├── beauty_camera.gd │ │ ├── beauty_camera.gd
│ │ └── vfx/WindGustField.tscn │ │ └── vfx/WindGustField.tscn
│ ├── resource_nodes/ │ ├── resource_nodes/
│ │ ├── LoadedResourceSpatialIndex.gd
│ │ ├── ResourceNode.gd │ │ ├── ResourceNode.gd
│ │ ├── ResourceNode.gd.uid │ │ ├── ResourceNode.gd.uid
│ │ └── ResourceNode.tscn │ │ └── 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. - 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 - Unloaded traveling NPCs preserve their state but do not yet advance through
abstract travel time. 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 - SimulationManager still coordinates the tick lifecycle and bounded
player-facing commands, while action rules, active-world queries, economic player-facing commands, while action rules, active-world queries, economic
transactions, and event history have focused collaborators. 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 objective-log growth. Workload details and raw samples live in
[`docs/benchmarks/`](benchmarks/SIMULATION_SCALING_BASELINE_01.md). [`docs/benchmarks/`](benchmarks/SIMULATION_SCALING_BASELINE_01.md).
The next slice should build one stable per-tick population view for the The first two measured optimizations are complete. A disposable per-tick
existing trusted-starving-subject query, which currently rebuilds an all-NPC population view preserves continuation checksums and improves the 600-NPC
map for each applicable scarce-food decision. Preserve exact selection and fixture by 23.3%. A separate resource-specific grid inside
continuation checksums, rerun the same benchmark, and use the remaining measured `ActiveWorldAdapter` preserves exact selected targets while reducing the
cost before committing to spatial partitions or active/abstract LOD. 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: The remaining simulation-garden target still aims for:
+15 -5
View File
@@ -551,11 +551,21 @@ profession, and NPC comfort range. The first scoring pass stores
`safety_risk`, `comfort_distance`, and `discovery_priority` on resource state `safety_risk`, `comfort_distance`, and `discovery_priority` on resource state
and uses them when selecting NPC resource targets. and uses them when selecting NPC resource targets.
The first validated spread contains twelve finite resources: berry bushes and The current validated spread contains eighteen finite resources: berry bushes
patches, animal camps, trees, and one village woodpile. Placement is guarded by and patches, animal camps, farm crops, trees, and village/mill woodpiles.
headless tests for stable IDs, food/wood coverage, semantic contexts, Placement is guarded by headless tests for stable IDs, food/wood coverage,
discovery metadata, navigation reachability, and non-overlapping pantry/player semantic contexts, discovery metadata, navigation reachability, and
interaction range. 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 ## Test scenarios
@@ -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
```
+11
View File
@@ -16,6 +16,14 @@ The default report is written under `user://`. Pass
`-- --host-label="<hardware>" --output=res://docs/benchmarks/<name>.json` only `-- --host-label="<hardware>" --output=res://docs/benchmarks/<name>.json` only
when intentionally capturing a reviewed project baseline. 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: Reviewed captures:
- [Simulation scaling baseline 01](SIMULATION_SCALING_BASELINE_01.md) records - [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 - [Simulation scaling baseline 02](SIMULATION_SCALING_BASELINE_02.md) records
checksum-identical results after the shared per-tick population view: the 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. 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.
@@ -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 the measured pressure point, and preserves exact state. Do not add batching or
active/abstract NPC modes merely to improve this data-only number. active/abstract NPC modes merely to improve this data-only number.
The next bounded scale slice should support the world vision directly: a That next bounded scale slice is now complete. The measured workload justified
loaded-resource spatial query owned by `ActiveWorldAdapter`. It should let a resource-specific horizontal grid owned by `ActiveWorldAdapter`; exact
villagers discover finite, stable-ID `ResourceNode` anchors placed much farther expanding queries preserve every linear selected-target checksum and reduce the
apart across Terrain3D while preserving the current score, reachability, 1,800-anchor case from 6,448.00 to 46.94 microseconds. See
reservation, and save/rebind contracts. A benchmark should compare 18, 180, [Loaded Resource Discovery 01](LOADED_RESOURCE_DISCOVERY_01.md).
and 1,800 loaded candidates before choosing a grid, tree, or other index.
Terrain3D-instanced visual trees and grass are not automatically authoritative Terrain3D-instanced visual trees and grass are not automatically authoritative
resources. Intentional harvest anchors must continue to bind simulation-owned resources. Intentional harvest anchors must continue to bind simulation-owned
@@ -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"
}
+125 -4
View File
@@ -1,6 +1,8 @@
class_name ActionTargetResolver class_name ActionTargetResolver
extends RefCounted extends RefCounted
var last_resource_query_stats: Dictionary = {}
func resolve( func resolve(
npc: SimNPC, origin: Vector3, simulation_manager: Node, active_world_adapter: Node npc: SimNPC, origin: Vector3, simulation_manager: Node, active_world_adapter: Node
@@ -28,10 +30,33 @@ func _resolve_resource(
definition: ActionDefinition, definition: ActionDefinition,
simulation_manager: Node, simulation_manager: Node,
active_world_adapter: 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: ) -> Dictionary:
var best: Dictionary = {} var best: Dictionary = {}
var best_score := INF 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 node_id := StringName(candidate["target_id"])
var state: ResourceStateRecord = simulation_manager.get_resource_state(node_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): 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: if score < best_score:
best_score = score best_score = score
best = candidate 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 {} 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): if not simulation_manager.reserve_resource(target_id, npc.id):
return {} return {}
return best return candidate
func _resolve_activity( func _resolve_activity(
@@ -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
)
@@ -0,0 +1 @@
uid://ccstdqk3xgxnu
+203
View File
@@ -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)
@@ -0,0 +1 @@
uid://7yplcc3s2oks
@@ -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
@@ -0,0 +1 @@
uid://o81js4vhlb0s
+51 -15
View File
@@ -1,26 +1,62 @@
class_name ActiveWorldAdapter class_name ActiveWorldAdapter
extends Node extends Node
const LoadedResourceSpatialIndexScript := preload(
"res://world/resource_nodes/LoadedResourceSpatialIndex.gd"
)
@export var pantry_storage: StorageNode @export var pantry_storage: StorageNode
@export var woodpile_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]: func get_resource_candidates(action_id: StringName) -> Array[Dictionary]:
var candidates: Array[Dictionary] = [] return _resource_index.get_all_candidates(action_id)
for node in ResourceNode.get_all():
if node.action_id != action_id or node.interaction_point == null:
continue func get_resource_candidates_in_radius(
candidates.append( action_id: StringName,
{ origin: Vector3,
"target_id": String(node.node_id), max_distance: float,
"position": node.interaction_point.global_position, min_distance_exclusive: float = -1.0
"resource_id": String(node.resource_id), ) -> Array[Dictionary]:
"safety_risk": node.safety_risk, return _resource_index.get_candidates_in_radius(
"comfort_distance": node.comfort_distance, action_id, origin, max_distance, min_distance_exclusive
"discovery_priority": node.discovery_priority )
}
)
return candidates 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: func get_activity_target(action_id: StringName, origin: Vector3 = Vector3.ZERO) -> Dictionary:
@@ -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"])
@@ -0,0 +1 @@
uid://c3afuxcwkexf7
+25
View File
@@ -45,11 +45,14 @@ func _ready() -> void:
return return
_all.append(self) _all.append(self)
add_to_group("resource_nodes") add_to_group("resource_nodes")
set_notify_transform(true)
_try_register_with_simulation() _try_register_with_simulation()
_notify_world_adapters()
_update_presentation() _update_presentation()
func _exit_tree() -> void: func _exit_tree() -> void:
_unregister_from_world_adapters()
_all.erase(self) _all.erase(self)
if state != null and state.changed.is_connected(_on_state_changed): if state != null and state.changed.is_connected(_on_state_changed):
state.changed.disconnect(_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): if not state.depleted.is_connected(_on_state_depleted):
state.depleted.connect(_on_state_depleted) state.depleted.connect(_on_state_depleted)
_update_presentation() _update_presentation()
_notify_world_adapters()
return true return true
@@ -83,6 +87,27 @@ func _try_register_with_simulation() -> void:
manager.register_resource_node(self) 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: func get_amount_remaining() -> float:
return state.get_amount_remaining() if state != null else initial_amount return state.get_amount_remaining() if state != null else initial_amount