feat: index loaded resource discovery
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user