feat: index loaded resource discovery
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%.
|
||||
|
||||
+26
-9
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -16,6 +16,14 @@ The default report is written under `user://`. Pass
|
||||
`-- --host-label="<hardware>" --output=res://docs/benchmarks/<name>.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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user