perf: harden runtime for weaker hardware
This commit is contained in:
@@ -21,6 +21,7 @@ SimulationClock
|
|||||||
-> EventKnowledgeSystem records, ranks, transfers, and retains bounded knowledge
|
-> EventKnowledgeSystem records, ranks, transfers, and retains bounded knowledge
|
||||||
-> RelationshipSystem applies evidence-gated social consequences
|
-> RelationshipSystem applies evidence-gated social consequences
|
||||||
-> VillageOpportunitySystem projects one known unresolved need
|
-> VillageOpportunitySystem projects one known unresolved need
|
||||||
|
-> ConflictSystem advances indexed combatants and authoritative combat outcomes
|
||||||
-> 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
|
-> LoadedResourceSpatialIndex bounds finite-anchor discovery
|
||||||
@@ -48,9 +49,13 @@ would otherwise obscure that lifecycle:
|
|||||||
- `simulation/conflict/ConflictSystem.gd` owns combatants (health, weapons,
|
- `simulation/conflict/ConflictSystem.gd` owns combatants (health, weapons,
|
||||||
factions, hostility), village/tribe faction records, wolf hostility, the
|
factions, hostility), village/tribe faction records, wolf hostility, the
|
||||||
tribe war motivation decision (desire + victory confidence), raid spawning,
|
tribe war motivation decision (desire + victory confidence), raid spawning,
|
||||||
deterministic battle resolution, and war consequences. It emits combat and
|
deterministic battle resolution, and real pantry-backed war consequences.
|
||||||
war narrative facts and spawn/death signals that presentation binds to
|
Player commands are revalidated against authoritative life, faction, range,
|
||||||
`HostileCombatant` visuals;
|
and cooldown state. Player attack readiness advances in unscaled presentation
|
||||||
|
time; NPC battle cooldowns remain deterministic simulation ticks. Transient
|
||||||
|
NPC and sorted-combatant indexes bound stable-ID callbacks without entering
|
||||||
|
saves. It emits combat and war narrative facts
|
||||||
|
and spawn/death signals that presentation binds to `HostileCombatant` visuals;
|
||||||
- `simulation/events/SimulationEventLog.gd` owns ordered event identity,
|
- `simulation/events/SimulationEventLog.gd` owns ordered event identity,
|
||||||
history queries, and rate calculations;
|
history queries, and rate calculations;
|
||||||
- `simulation/knowledge/EventKnowledgeSystem.gd` owns per-NPC references to
|
- `simulation/knowledge/EventKnowledgeSystem.gd` owns per-NPC references to
|
||||||
@@ -82,7 +87,8 @@ would otherwise obscure that lifecycle:
|
|||||||
`world/creatures/creature_visual.gd` is the shared root for creature
|
`world/creatures/creature_visual.gd` is the shared root for creature
|
||||||
presentation: any `CreatureVisual` follows a simulation-owned position through
|
presentation: any `CreatureVisual` follows a simulation-owned position through
|
||||||
the navigation mesh, reports position changes, and plays a shared death
|
the navigation mesh, reports position changes, and plays a shared death
|
||||||
collapse. `HostileCombatant` extends it and builds its body from an
|
collapse. Failed paths keep their target and use bounded exponential retry
|
||||||
|
instead of querying navigation every physics frame. `HostileCombatant` extends it and builds its body from an
|
||||||
`EnemyDefinition`; `NpcVisual` and `AnimalNode` already follow the same
|
`EnemyDefinition`; `NpcVisual` and `AnimalNode` already follow the same
|
||||||
follow-the-authoritative-position contract, so future creatures (bears, boars,
|
follow-the-authoritative-position contract, so future creatures (bears, boars,
|
||||||
archers) add a definition and a visual hook instead of a new movement system.
|
archers) add a definition and a visual hook instead of a new movement system.
|
||||||
@@ -98,6 +104,16 @@ discovery uses their stable IDs and registration lifecycle, never a parent
|
|||||||
path. Decorative siblings without a `ResourceNode` remain outside the index
|
path. Decorative siblings without a `ResourceNode` remain outside the index
|
||||||
and simulation state.
|
and simulation state.
|
||||||
|
|
||||||
|
Player resource interaction uses the same radius-bounded loaded-anchor grid.
|
||||||
|
HUD proximity probes, the time dial, player status, and day/night environment
|
||||||
|
are presentation-only and update at human-readable rates rather than every
|
||||||
|
rendered frame. `JajceWorld` exposes reversible High, Balanced, and Low
|
||||||
|
presentation profiles with instance-local mutable render resources and owned
|
||||||
|
viewport scaling. The default Balanced tier reduces 3D scale, grass, shadow
|
||||||
|
coverage, and volumetric work, while Low can stop grass/effects without changing
|
||||||
|
simulation state or UI resolution. Forward+-only volumetric fog stays disabled
|
||||||
|
under Mobile and Compatibility feature selection, even when High is requested.
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -419,6 +419,18 @@ grass, conifers, richer deciduous silhouettes, and oversized butterflies add
|
|||||||
life without changing simulation authority. The matched review lives in
|
life without changing simulation authority. The matched review lives in
|
||||||
[`baselines/JAJCE_CENTER_02.md`](baselines/JAJCE_CENTER_02.md).
|
[`baselines/JAJCE_CENTER_02.md`](baselines/JAJCE_CENTER_02.md).
|
||||||
|
|
||||||
|
The world now also has a structural weak-PC contract. High restores that
|
||||||
|
authored look; Balanced is the runtime default at 85% 3D scale with ordinary
|
||||||
|
fog, shorter two-split shadows, roughly half the grass, fewer interactors, and
|
||||||
|
no volumetric fog; Low uses 70% 3D scale, one short shadow region, disables
|
||||||
|
glow/volumetric fog/color adjustment, and stops grass rendering and processing.
|
||||||
|
Use `-- --quality=high|balanced|low` for reproducible runs. Headless regression
|
||||||
|
proves the profiles are reversible, presentation-only, resource-isolated, and
|
||||||
|
warning-clean under Compatibility feature selection. This does not exercise a
|
||||||
|
real OpenGL GPU because headless Godot uses its dummy renderer. A representative
|
||||||
|
weak-PC High/Low capture with CPU/GPU p50/p95, memory, and draw statistics is
|
||||||
|
still required before claiming the visual frame-time exit condition.
|
||||||
|
|
||||||
### Exit condition
|
### Exit condition
|
||||||
|
|
||||||
The lookdev scene can produce a compelling 10–20 second clip with no gameplay.
|
The lookdev scene can produce a compelling 10–20 second clip with no gameplay.
|
||||||
@@ -854,6 +866,15 @@ Completed:
|
|||||||
49. `Jajce Seasons 16`: a deterministic four-day seasonal cycle halves gather
|
49. `Jajce Seasons 16`: a deterministic four-day seasonal cycle halves gather
|
||||||
yields and pauses berry regrowth on the cold day, so the garden keeps
|
yields and pauses berry regrowth on the cold day, so the garden keeps
|
||||||
producing real scarcity the player can help address.
|
producing real scarcity the player can help address.
|
||||||
|
50. Runtime health and weak-PC pass: the default Balanced presentation tier
|
||||||
|
removes volumetric fog, halves grass density, lowers 3D scale, and shortens
|
||||||
|
shadows; Low stops grass and costly effects while High remains reversible.
|
||||||
|
UI/environment probes are rate-limited, failed creature paths back off,
|
||||||
|
player resource prompts use the loaded-anchor grid, and simulation/combat
|
||||||
|
ID callbacks use disposable indexes. Combat authority now enforces range,
|
||||||
|
faction, unscaled player cooldown, recovery, melee approach, death ordering,
|
||||||
|
and real pantry plunder. Benchmark workload v2 validates one combatant per synthetic
|
||||||
|
NPC and records 90.63 ticks/s at 600 records with stable checksums.
|
||||||
|
|
||||||
Next:
|
Next:
|
||||||
|
|
||||||
|
|||||||
@@ -474,6 +474,13 @@ 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.
|
||||||
|
|
||||||
|
[Baseline 03](benchmarks/SIMULATION_SCALING_BASELINE_03.md) versions the
|
||||||
|
workload after combat was added and corrects its fixture to require one matching
|
||||||
|
combatant per synthetic NPC. Indexed callbacks and cached combatant iteration
|
||||||
|
keep the now-honest 600-record workload at 90.63 ticks per second with identical
|
||||||
|
checksums across three samples. It remains data-only; rendered scale still
|
||||||
|
requires the active-visual workload described by this milestone.
|
||||||
|
|
||||||
[Loaded Resource Discovery 01](benchmarks/LOADED_RESOURCE_DISCOVERY_01.md)
|
[Loaded Resource Discovery 01](benchmarks/LOADED_RESOURCE_DISCOVERY_01.md)
|
||||||
proves the first real spatial consumer. Exact loaded-anchor resolution remains
|
proves the first real spatial consumer. Exact loaded-anchor resolution remains
|
||||||
near four inspected candidates from 18 through 1,800 resources, preserves every
|
near four inspected candidates from 18 through 1,800 resources, preserves every
|
||||||
@@ -1015,6 +1022,17 @@ Headless tests cover combat determinism, wolf attacks, enemy definitions,
|
|||||||
player downing/recovery, defend duty, war motivation, raid-to-resolution
|
player downing/recovery, defend duty, war motivation, raid-to-resolution
|
||||||
chains, player kills, and save/restore.
|
chains, player kills, and save/restore.
|
||||||
|
|
||||||
|
The subsequent runtime health pass tightened those contracts: recovery revives
|
||||||
|
both player records, hostile strikes require melee reach and deterministic
|
||||||
|
approach, NPC death listeners see completed authoritative death state, player
|
||||||
|
attacks revalidate faction/exact weapon reach and use an unscaled real-time
|
||||||
|
cooldown, raids withdraw actual pantry food, and dash has one `CharacterBody3D`
|
||||||
|
movement owner with separate duration and re-entry cooldown. Presentation hot paths are
|
||||||
|
bounded through the existing spatial/population indexes, 5–10 Hz UI/environment
|
||||||
|
updates, cached combatant order, and navigation retry backoff. High, Balanced,
|
||||||
|
and Low profiles now prove a reversible weak-PC structural budget; real
|
||||||
|
weak-hardware frame-time/GPU/memory measurement remains intentionally open.
|
||||||
|
|
||||||
Recently completed:
|
Recently completed:
|
||||||
|
|
||||||
- `Jajce Villager Field Note 12`: a separate player-facing note selects the
|
- `Jajce Villager Field Note 12`: a separate player-facing note selects the
|
||||||
|
|||||||
@@ -189,6 +189,11 @@ study, and patrol target typed activity sites. The migration is documented in
|
|||||||
|
|
||||||
- **Engine:** Godot 4.7 project configuration
|
- **Engine:** Godot 4.7 project configuration
|
||||||
- **Renderer feature:** Forward Plus
|
- **Renderer feature:** Forward Plus
|
||||||
|
- **Presentation budget:** reversible High/Balanced/Low profiles; Balanced is
|
||||||
|
the default and Low can disable costly effects/grass without changing UI or
|
||||||
|
simulation state (`-- --quality=high|balanced|low`). Mutable render resources
|
||||||
|
and root-viewport scale have explicit world-instance ownership, and unsupported
|
||||||
|
volumetric fog is never enabled outside Forward+.
|
||||||
- **Language:** GDScript (`gdformat`, `gdlint`, headless Godot 4.7 scenarios,
|
- **Language:** GDScript (`gdformat`, `gdlint`, headless Godot 4.7 scenarios,
|
||||||
and GUT run through the local quality gate)
|
and GUT run through the local quality gate)
|
||||||
- **Main scene:** `res://main.tscn`
|
- **Main scene:** `res://main.tscn`
|
||||||
@@ -254,6 +259,11 @@ plugin content, not game architecture.
|
|||||||
the navigation mesh through a shared `CreatureVisual` root, and enemies are
|
the navigation mesh through a shared `CreatureVisual` root, and enemies are
|
||||||
data-driven `EnemyDefinition`s so new types add with a definition plus a
|
data-driven `EnemyDefinition`s so new types add with a definition plus a
|
||||||
visual hook.
|
visual hook.
|
||||||
|
- Runtime safety now revalidates player attack faction, distance, and cooldown;
|
||||||
|
wolves move into real claw reach before damage; recovery restores combat
|
||||||
|
targetability; and raids remove authoritative pantry food. The player owns
|
||||||
|
the only `move_and_slide()` call, so dashes do not double-step. Player combat
|
||||||
|
cooldowns use unscaled real time while deterministic NPC combat remains ticked.
|
||||||
- `Escape` releases captured mouse input.
|
- `Escape` releases captured mouse input.
|
||||||
|
|
||||||
### Village simulation
|
### Village simulation
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ 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.
|
||||||
|
- [Simulation scaling baseline 03](SIMULATION_SCALING_BASELINE_03.md) versions
|
||||||
|
the workload after conflict was added, requires one authoritative combatant
|
||||||
|
per synthetic NPC, and records 90.63 ticks/s at 600 full-fidelity records.
|
||||||
- [Loaded Resource Discovery 01](LOADED_RESOURCE_DISCOVERY_01.md) compares the
|
- [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
|
former all-node scan with the exact resource-anchor grid at 18, 180, and
|
||||||
1,800 loaded sources.
|
1,800 loaded sources.
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Simulation scaling baseline 03
|
||||||
|
|
||||||
|
This is the first reviewed capture of benchmark workload schema 2,
|
||||||
|
`full_fidelity_combatant_headless_arrival_v2`. Unlike baselines 01 and 02, the
|
||||||
|
fixture now gives every synthetic NPC exactly one authoritative combatant
|
||||||
|
record and rejects missing, duplicate, orphaned, or mismatched coverage. It
|
||||||
|
also exercises the current state-schema 14 conflict tick.
|
||||||
|
|
||||||
|
The machine-readable samples are in
|
||||||
|
[`simulation_scaling_baseline_03.json`](simulation_scaling_baseline_03.json).
|
||||||
|
They were captured on the same `Apple_M1_Max_64_GB` host label with Godot 4.7,
|
||||||
|
seed 8088, 10 warmup ticks, 200 measured ticks, and three samples per case.
|
||||||
|
|
||||||
|
## Results
|
||||||
|
|
||||||
|
| Case | NPCs | Seeded history | Median us/tick | Ticks/s | Simulated realtime | Arrival share | End JSON |
|
||||||
|
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||||
|
| Population 6 | 6 | 0 | 114.42 | 8,740.11 | 10,488.1x | 3.4% | 0.06 MiB |
|
||||||
|
| Population 60 | 60 | 0 | 904.73 | 1,105.30 | 1,326.4x | 3.0% | 0.58 MiB |
|
||||||
|
| Population 600 | 600 | 0 | 11,033.58 | 90.63 | 108.8x | 2.8% | 5.83 MiB |
|
||||||
|
| History 600 | 60 | 600 | 992.78 | 1,007.28 | 1,208.7x | 2.8% | 0.72 MiB |
|
||||||
|
| History 6,000 | 60 | 6,000 | 1,667.58 | 599.67 | 719.6x | 1.7% | 2.06 MiB |
|
||||||
|
|
||||||
|
Every sample in every case produced the same final checksum. The 600-person
|
||||||
|
fixture sustains about 90.6 ticks per second—well above the 50-tick local
|
||||||
|
reference—while updating all 600 combatants. Indexed manager callbacks reduce
|
||||||
|
the deterministic immediate-arrival phase to 2.8% of that case; cached
|
||||||
|
combatant ID ordering separately bounds work inside the simulation phase.
|
||||||
|
|
||||||
|
## Versioning decision
|
||||||
|
|
||||||
|
Do not compare baseline-03 checksums or timings directly with baselines 01/02.
|
||||||
|
Those reports predate combat and accidentally retained only the manager's six
|
||||||
|
initial combatants after replacing the synthetic population. The workload ID
|
||||||
|
and schema are bumped so this semantic change is explicit. Baselines 01/02
|
||||||
|
remain useful historical evidence for the population-view optimization, but
|
||||||
|
baseline 03 is the valid comparison point for future full-fidelity simulation
|
||||||
|
changes.
|
||||||
|
|
||||||
|
This still excludes world scenes, rendering, navigation, and `NpcVisual`.
|
||||||
|
High/low gameplay-camera frame-time and GPU/memory captures on representative
|
||||||
|
weak hardware remain a separate presentation ledger task; the data-only result
|
||||||
|
does not claim rendered 600-NPC support.
|
||||||
|
|
||||||
|
## Capture command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot \
|
||||||
|
--headless --path "$PWD" \
|
||||||
|
--script res://tools/benchmark_simulation_scaling.gd -- \
|
||||||
|
--host-label=Apple_M1_Max_64_GB \
|
||||||
|
--output=res://docs/benchmarks/simulation_scaling_baseline_03.json
|
||||||
|
```
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
{
|
||||||
|
"benchmark_seed": 8088,
|
||||||
|
"captured_utc": "2026-08-12T07:08:36Z",
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"arrival_share_percent": 3.36057335139623,
|
||||||
|
"arrival_usec_median": 769,
|
||||||
|
"arrival_usec_samples": [
|
||||||
|
740,
|
||||||
|
769,
|
||||||
|
855
|
||||||
|
],
|
||||||
|
"arrivals_processed": 209,
|
||||||
|
"case_id": "population_006",
|
||||||
|
"elapsed_usec_max": 23503,
|
||||||
|
"elapsed_usec_median": 22883,
|
||||||
|
"elapsed_usec_min": 22033,
|
||||||
|
"elapsed_usec_samples": [
|
||||||
|
22033,
|
||||||
|
22883,
|
||||||
|
23503
|
||||||
|
],
|
||||||
|
"end_event_count": 221,
|
||||||
|
"end_known_reference_count": 0,
|
||||||
|
"end_state_bytes": 60818,
|
||||||
|
"end_tick": 210,
|
||||||
|
"events_recorded": 209,
|
||||||
|
"final_checksum": "1020a2e4b05d77cf61b01490404e910c1e212a07a331ea6d16483a48e283cf4d",
|
||||||
|
"history_seed_events": 0,
|
||||||
|
"measured_ticks": 200,
|
||||||
|
"npc_combatant_count": 6,
|
||||||
|
"npc_combatant_coverage_valid": true,
|
||||||
|
"npc_updates": 1200,
|
||||||
|
"population": 6,
|
||||||
|
"realtime_factor_median": 10488.1352969453,
|
||||||
|
"sample_count": 3,
|
||||||
|
"schema_version": 2,
|
||||||
|
"seed": 8088,
|
||||||
|
"setup_usec_median": 1257,
|
||||||
|
"setup_usec_samples": [
|
||||||
|
1005,
|
||||||
|
1257,
|
||||||
|
4207
|
||||||
|
],
|
||||||
|
"simulation_state_schema_version": 14,
|
||||||
|
"simulation_usec_median": 22091,
|
||||||
|
"simulation_usec_samples": [
|
||||||
|
21276,
|
||||||
|
22091,
|
||||||
|
22630
|
||||||
|
],
|
||||||
|
"start_event_count": 12,
|
||||||
|
"start_known_reference_count": 0,
|
||||||
|
"start_state_bytes": 9863,
|
||||||
|
"start_tick": 10,
|
||||||
|
"state_growth_bytes": 50955,
|
||||||
|
"tick_interval": 1.2,
|
||||||
|
"ticks_per_second_median": 8740.11274745444,
|
||||||
|
"usec_per_tick_median": 114.415,
|
||||||
|
"warmup_arrivals": 12,
|
||||||
|
"warmup_ticks": 10,
|
||||||
|
"workload_id": "full_fidelity_combatant_headless_arrival_v2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"arrival_share_percent": 2.98210515844506,
|
||||||
|
"arrival_usec_median": 5396,
|
||||||
|
"arrival_usec_samples": [
|
||||||
|
5376,
|
||||||
|
5396,
|
||||||
|
5430
|
||||||
|
],
|
||||||
|
"arrivals_processed": 2090,
|
||||||
|
"case_id": "population_060",
|
||||||
|
"elapsed_usec_max": 181114,
|
||||||
|
"elapsed_usec_median": 180946,
|
||||||
|
"elapsed_usec_min": 178439,
|
||||||
|
"elapsed_usec_samples": [
|
||||||
|
178439,
|
||||||
|
180946,
|
||||||
|
181114
|
||||||
|
],
|
||||||
|
"end_event_count": 2210,
|
||||||
|
"end_known_reference_count": 0,
|
||||||
|
"end_state_bytes": 604532,
|
||||||
|
"end_tick": 210,
|
||||||
|
"events_recorded": 2090,
|
||||||
|
"final_checksum": "d23a14828b34e93460d96079492eda19001e8b91c589b01d7d454feaed561493",
|
||||||
|
"history_seed_events": 0,
|
||||||
|
"measured_ticks": 200,
|
||||||
|
"npc_combatant_count": 60,
|
||||||
|
"npc_combatant_coverage_valid": true,
|
||||||
|
"npc_updates": 12000,
|
||||||
|
"population": 60,
|
||||||
|
"realtime_factor_median": 1326.36256120611,
|
||||||
|
"sample_count": 3,
|
||||||
|
"schema_version": 2,
|
||||||
|
"seed": 8088,
|
||||||
|
"setup_usec_median": 6193,
|
||||||
|
"setup_usec_samples": [
|
||||||
|
6161,
|
||||||
|
6193,
|
||||||
|
6303
|
||||||
|
],
|
||||||
|
"simulation_state_schema_version": 14,
|
||||||
|
"simulation_usec_median": 175483,
|
||||||
|
"simulation_usec_samples": [
|
||||||
|
173040,
|
||||||
|
175483,
|
||||||
|
175703
|
||||||
|
],
|
||||||
|
"start_event_count": 120,
|
||||||
|
"start_known_reference_count": 0,
|
||||||
|
"start_state_bytes": 88474,
|
||||||
|
"start_tick": 10,
|
||||||
|
"state_growth_bytes": 516058,
|
||||||
|
"tick_interval": 1.2,
|
||||||
|
"ticks_per_second_median": 1105.30213433842,
|
||||||
|
"usec_per_tick_median": 904.73,
|
||||||
|
"warmup_arrivals": 120,
|
||||||
|
"warmup_ticks": 10,
|
||||||
|
"workload_id": "full_fidelity_combatant_headless_arrival_v2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"arrival_share_percent": 2.79292976211246,
|
||||||
|
"arrival_usec_median": 61632,
|
||||||
|
"arrival_usec_samples": [
|
||||||
|
60845,
|
||||||
|
61632,
|
||||||
|
61770
|
||||||
|
],
|
||||||
|
"arrivals_processed": 20950,
|
||||||
|
"case_id": "population_600",
|
||||||
|
"elapsed_usec_max": 2232943,
|
||||||
|
"elapsed_usec_median": 2206715,
|
||||||
|
"elapsed_usec_min": 2205128,
|
||||||
|
"elapsed_usec_samples": [
|
||||||
|
2205128,
|
||||||
|
2206715,
|
||||||
|
2232943
|
||||||
|
],
|
||||||
|
"end_event_count": 22150,
|
||||||
|
"end_known_reference_count": 0,
|
||||||
|
"end_state_bytes": 6116918,
|
||||||
|
"end_tick": 210,
|
||||||
|
"events_recorded": 20950,
|
||||||
|
"final_checksum": "fa32206742bb2f7ca894747fbee8a9b5720ed33ce3e13b938363a9613aaaa9f9",
|
||||||
|
"history_seed_events": 0,
|
||||||
|
"measured_ticks": 200,
|
||||||
|
"npc_combatant_count": 600,
|
||||||
|
"npc_combatant_coverage_valid": true,
|
||||||
|
"npc_updates": 120000,
|
||||||
|
"population": 600,
|
||||||
|
"realtime_factor_median": 108.758947122759,
|
||||||
|
"sample_count": 3,
|
||||||
|
"schema_version": 2,
|
||||||
|
"seed": 8088,
|
||||||
|
"setup_usec_median": 55643,
|
||||||
|
"setup_usec_samples": [
|
||||||
|
55267,
|
||||||
|
55643,
|
||||||
|
57129
|
||||||
|
],
|
||||||
|
"simulation_state_schema_version": 14,
|
||||||
|
"simulation_usec_median": 2145800,
|
||||||
|
"simulation_usec_samples": [
|
||||||
|
2143434,
|
||||||
|
2145800,
|
||||||
|
2171102
|
||||||
|
],
|
||||||
|
"start_event_count": 1200,
|
||||||
|
"start_known_reference_count": 0,
|
||||||
|
"start_state_bytes": 881819,
|
||||||
|
"start_tick": 10,
|
||||||
|
"state_growth_bytes": 5235099,
|
||||||
|
"tick_interval": 1.2,
|
||||||
|
"ticks_per_second_median": 90.6324559356328,
|
||||||
|
"usec_per_tick_median": 11033.575,
|
||||||
|
"warmup_arrivals": 1200,
|
||||||
|
"warmup_ticks": 10,
|
||||||
|
"workload_id": "full_fidelity_combatant_headless_arrival_v2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"arrival_share_percent": 2.77656065070132,
|
||||||
|
"arrival_usec_median": 5513,
|
||||||
|
"arrival_usec_samples": [
|
||||||
|
5476,
|
||||||
|
5513,
|
||||||
|
5750
|
||||||
|
],
|
||||||
|
"arrivals_processed": 2090,
|
||||||
|
"case_id": "history_000600",
|
||||||
|
"elapsed_usec_max": 201427,
|
||||||
|
"elapsed_usec_median": 198555,
|
||||||
|
"elapsed_usec_min": 193650,
|
||||||
|
"elapsed_usec_samples": [
|
||||||
|
193650,
|
||||||
|
198555,
|
||||||
|
201427
|
||||||
|
],
|
||||||
|
"end_event_count": 2810,
|
||||||
|
"end_known_reference_count": 0,
|
||||||
|
"end_state_bytes": 760212,
|
||||||
|
"end_tick": 810,
|
||||||
|
"events_recorded": 2090,
|
||||||
|
"final_checksum": "d727bf8a8f841d634796258755cb32c2a73b5d512a33494f8372cc74dfb8677b",
|
||||||
|
"history_seed_events": 600,
|
||||||
|
"measured_ticks": 200,
|
||||||
|
"npc_combatant_count": 60,
|
||||||
|
"npc_combatant_coverage_valid": true,
|
||||||
|
"npc_updates": 12000,
|
||||||
|
"population": 60,
|
||||||
|
"realtime_factor_median": 1208.7330966231,
|
||||||
|
"sample_count": 3,
|
||||||
|
"schema_version": 2,
|
||||||
|
"seed": 8088,
|
||||||
|
"setup_usec_median": 25277,
|
||||||
|
"setup_usec_samples": [
|
||||||
|
24952,
|
||||||
|
25277,
|
||||||
|
26430
|
||||||
|
],
|
||||||
|
"simulation_state_schema_version": 14,
|
||||||
|
"simulation_usec_median": 193021,
|
||||||
|
"simulation_usec_samples": [
|
||||||
|
188158,
|
||||||
|
193021,
|
||||||
|
195651
|
||||||
|
],
|
||||||
|
"start_event_count": 720,
|
||||||
|
"start_known_reference_count": 180,
|
||||||
|
"start_state_bytes": 270294,
|
||||||
|
"start_tick": 610,
|
||||||
|
"state_growth_bytes": 489918,
|
||||||
|
"tick_interval": 1.2,
|
||||||
|
"ticks_per_second_median": 1007.27758051925,
|
||||||
|
"usec_per_tick_median": 992.775,
|
||||||
|
"warmup_arrivals": 120,
|
||||||
|
"warmup_ticks": 10,
|
||||||
|
"workload_id": "full_fidelity_combatant_headless_arrival_v2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"arrival_share_percent": 1.65569268041113,
|
||||||
|
"arrival_usec_median": 5522,
|
||||||
|
"arrival_usec_samples": [
|
||||||
|
5430,
|
||||||
|
5522,
|
||||||
|
5548
|
||||||
|
],
|
||||||
|
"arrivals_processed": 2090,
|
||||||
|
"case_id": "history_006000",
|
||||||
|
"elapsed_usec_max": 336114,
|
||||||
|
"elapsed_usec_median": 333516,
|
||||||
|
"elapsed_usec_min": 328622,
|
||||||
|
"elapsed_usec_samples": [
|
||||||
|
328622,
|
||||||
|
333516,
|
||||||
|
336114
|
||||||
|
],
|
||||||
|
"end_event_count": 8210,
|
||||||
|
"end_known_reference_count": 0,
|
||||||
|
"end_state_bytes": 2158824,
|
||||||
|
"end_tick": 6210,
|
||||||
|
"events_recorded": 2090,
|
||||||
|
"final_checksum": "60fe6c481e67e4937a835c76f9a7feb80b007db50c06598bddf8cb643e6b1909",
|
||||||
|
"history_seed_events": 6000,
|
||||||
|
"measured_ticks": 200,
|
||||||
|
"npc_combatant_count": 60,
|
||||||
|
"npc_combatant_coverage_valid": true,
|
||||||
|
"npc_updates": 12000,
|
||||||
|
"population": 60,
|
||||||
|
"realtime_factor_median": 719.605656100457,
|
||||||
|
"sample_count": 3,
|
||||||
|
"schema_version": 2,
|
||||||
|
"seed": 8088,
|
||||||
|
"setup_usec_median": 156396,
|
||||||
|
"setup_usec_samples": [
|
||||||
|
154919,
|
||||||
|
156396,
|
||||||
|
157845
|
||||||
|
],
|
||||||
|
"simulation_state_schema_version": 14,
|
||||||
|
"simulation_usec_median": 328069,
|
||||||
|
"simulation_usec_samples": [
|
||||||
|
323051,
|
||||||
|
328069,
|
||||||
|
330564
|
||||||
|
],
|
||||||
|
"start_event_count": 6120,
|
||||||
|
"start_known_reference_count": 180,
|
||||||
|
"start_state_bytes": 1666896,
|
||||||
|
"start_tick": 6010,
|
||||||
|
"state_growth_bytes": 491928,
|
||||||
|
"tick_interval": 1.2,
|
||||||
|
"ticks_per_second_median": 599.671380083714,
|
||||||
|
"usec_per_tick_median": 1667.58,
|
||||||
|
"warmup_arrivals": 120,
|
||||||
|
"warmup_ticks": 10,
|
||||||
|
"workload_id": "full_fidelity_combatant_headless_arrival_v2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"engine_version": "4.7-stable (official)",
|
||||||
|
"exclusions": [
|
||||||
|
"manager_and_fixture_setup",
|
||||||
|
"state_serialization",
|
||||||
|
"world_scene",
|
||||||
|
"rendering",
|
||||||
|
"navigation",
|
||||||
|
"npc_visual"
|
||||||
|
],
|
||||||
|
"host_label": "Apple_M1_Max_64_GB",
|
||||||
|
"measured_ticks": 200,
|
||||||
|
"platform": "macOS",
|
||||||
|
"processor_count": 10,
|
||||||
|
"sample_count": 3,
|
||||||
|
"schema_version": 2,
|
||||||
|
"simulation_state_schema_version": 14,
|
||||||
|
"timed_phases": [
|
||||||
|
"simulation_tick",
|
||||||
|
"headless_arrival_completion"
|
||||||
|
],
|
||||||
|
"warmup_ticks": 10,
|
||||||
|
"workload": "All NPCs receive full per-tick needs/task updates, ordinary action decisions, and one matching authoritative combatant record; travel resolves through the deterministic immediate-arrival headless convention.",
|
||||||
|
"workload_id": "full_fidelity_combatant_headless_arrival_v2"
|
||||||
|
}
|
||||||
@@ -190,3 +190,16 @@ portable watchdog on stock macOS as well as Linux/Windows, and nonzero
|
|||||||
`gdformat`/`gdlint` exits fail the gate even when their output is not a familiar
|
`gdformat`/`gdlint` exits fail the gate even when their output is not a familiar
|
||||||
diagnostic string. Import receives a 60-second timeout on both shell and
|
diagnostic string. Import receives a 60-second timeout on both shell and
|
||||||
PowerShell; the focused checks retain their 30-second timeout.
|
PowerShell; the focused checks retain their 30-second timeout.
|
||||||
|
|
||||||
|
Scenario logs are also classified after successful process exits. Unexpected
|
||||||
|
`ERROR:`, `SCRIPT ERROR:`, and `WARNING:` headers fail the gate and name their
|
||||||
|
originating scenario. The only narrow platform/vendor exceptions are the
|
||||||
|
isolated-profile macOS certificate-store condition, Terrain3D 1.0.2's Godot 4.7
|
||||||
|
interpolation deprecation, and the dummy renderer's exact one-`DummyShader` RID
|
||||||
|
exit report. Different warning text, leak counts, or RID types remain failures;
|
||||||
|
the full unfiltered output stays in `logs/quality/latest/scenarios.log`.
|
||||||
|
|
||||||
|
The scenario stage also runs the presentation-quality contract once with
|
||||||
|
`--rendering-method gl_compatibility`. This catches unsupported feature toggles
|
||||||
|
and profile-selection regressions; because the gate is headless, it is not a
|
||||||
|
substitute for a rendered OpenGL frame-time capture on representative hardware.
|
||||||
|
|||||||
@@ -134,11 +134,14 @@ text = "Villager inspector"
|
|||||||
|
|
||||||
[node name="TimeDial" type="Control" parent="UI" unique_id=191345668]
|
[node name="TimeDial" type="Control" parent="UI" unique_id=191345668]
|
||||||
layout_mode = 3
|
layout_mode = 3
|
||||||
anchors_preset = 0
|
anchors_preset = 5
|
||||||
offset_left = 880.0
|
anchor_left = 0.5
|
||||||
|
anchor_right = 0.5
|
||||||
|
offset_left = -30.0
|
||||||
offset_top = 6.0
|
offset_top = 6.0
|
||||||
offset_right = 940.0
|
offset_right = 30.0
|
||||||
offset_bottom = 66.0
|
offset_bottom = 66.0
|
||||||
|
grow_horizontal = 2
|
||||||
script = ExtResource("12_timedial")
|
script = ExtResource("12_timedial")
|
||||||
|
|
||||||
[node name="PlayerInteractionLayer" type="CanvasLayer" parent="."]
|
[node name="PlayerInteractionLayer" type="CanvasLayer" parent="."]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const SLASH_REST_ANGLE := Vector3(-0.35, 0.0, 0.0)
|
|||||||
@export var combat_presentation: Node
|
@export var combat_presentation: Node
|
||||||
@export var sword: Node3D
|
@export var sword: Node3D
|
||||||
|
|
||||||
@export_range(0.5, 4.0, 0.1) var attack_range := 2.6
|
var attack_range := 2.4
|
||||||
@export_range(30.0, 160.0, 5.0) var attack_angle := 90.0
|
@export_range(30.0, 160.0, 5.0) var attack_angle := 90.0
|
||||||
@export var dash_speed := 22.0
|
@export var dash_speed := 22.0
|
||||||
@export var dash_duration := 0.18
|
@export var dash_duration := 0.18
|
||||||
@@ -16,7 +16,8 @@ const SLASH_REST_ANGLE := Vector3(-0.35, 0.0, 0.0)
|
|||||||
|
|
||||||
var attack_cooldown_seconds := 0.55
|
var attack_cooldown_seconds := 0.55
|
||||||
var attack_timer := 0.0
|
var attack_timer := 0.0
|
||||||
var dash_timer := 0.0
|
var dash_duration_remaining := 0.0
|
||||||
|
var dash_cooldown_remaining := 0.0
|
||||||
var dashing := false
|
var dashing := false
|
||||||
var dash_velocity := Vector3.ZERO
|
var dash_velocity := Vector3.ZERO
|
||||||
var _swing_tween: Tween
|
var _swing_tween: Tween
|
||||||
@@ -24,13 +25,13 @@ var _references_resolved := false
|
|||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
if sword == null:
|
process_physics_priority = -1
|
||||||
return
|
if sword != null:
|
||||||
sword.rotation = SLASH_REST_ANGLE
|
sword.rotation = SLASH_REST_ANGLE
|
||||||
var definition := SimulationItems.get_weapon(SimulationIds.ITEM_SWORD)
|
var definition := SimulationItems.get_weapon(SimulationIds.ITEM_SWORD)
|
||||||
if definition != null:
|
if definition != null:
|
||||||
attack_cooldown_seconds = definition.attack_cooldown
|
attack_cooldown_seconds = definition.attack_cooldown
|
||||||
attack_range = maxf(definition.reach + 0.2, attack_range)
|
attack_range = definition.reach
|
||||||
|
|
||||||
|
|
||||||
func _resolve_references() -> void:
|
func _resolve_references() -> void:
|
||||||
@@ -46,25 +47,31 @@ func _physics_process(delta: float) -> void:
|
|||||||
_resolve_references()
|
_resolve_references()
|
||||||
if simulation_manager != null and simulation_manager.has_method("update_player_combatant"):
|
if simulation_manager != null and simulation_manager.has_method("update_player_combatant"):
|
||||||
simulation_manager.update_player_combatant(player.global_position)
|
simulation_manager.update_player_combatant(player.global_position)
|
||||||
attack_timer = maxf(attack_timer - delta, 0.0)
|
_advance_timers(delta)
|
||||||
dash_timer = maxf(dash_timer - delta, 0.0)
|
|
||||||
if _player_is_downed():
|
if _player_is_downed():
|
||||||
attack_timer = 0.0
|
attack_timer = 0.0
|
||||||
dash_timer = 0.0
|
dash_duration_remaining = 0.0
|
||||||
|
dash_cooldown_remaining = 0.0
|
||||||
dashing = false
|
dashing = false
|
||||||
return
|
return
|
||||||
if dashing:
|
if dashing:
|
||||||
player.velocity = dash_velocity
|
|
||||||
if dash_timer <= 0.0:
|
|
||||||
dashing = false
|
|
||||||
player.move_and_slide()
|
|
||||||
return
|
return
|
||||||
if Input.is_action_just_pressed("attack") and attack_timer <= 0.0:
|
if Input.is_action_just_pressed("attack"):
|
||||||
_perform_attack()
|
_perform_attack()
|
||||||
if Input.is_action_just_pressed("dash") and dash_timer <= 0.0:
|
if Input.is_action_just_pressed("dash"):
|
||||||
_perform_dash()
|
_perform_dash()
|
||||||
|
|
||||||
|
|
||||||
|
func _advance_timers(delta: float) -> void:
|
||||||
|
if not is_finite(delta) or delta <= 0.0:
|
||||||
|
return
|
||||||
|
attack_timer = maxf(attack_timer - delta, 0.0)
|
||||||
|
dash_duration_remaining = maxf(dash_duration_remaining - delta, 0.0)
|
||||||
|
dash_cooldown_remaining = maxf(dash_cooldown_remaining - delta, 0.0)
|
||||||
|
if dashing and dash_duration_remaining <= 0.0:
|
||||||
|
dashing = false
|
||||||
|
|
||||||
|
|
||||||
func _player_is_downed() -> bool:
|
func _player_is_downed() -> bool:
|
||||||
return (
|
return (
|
||||||
simulation_manager != null
|
simulation_manager != null
|
||||||
@@ -74,6 +81,8 @@ func _player_is_downed() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
func _perform_attack() -> void:
|
func _perform_attack() -> void:
|
||||||
|
if attack_timer > 0.0 or not _simulation_attack_ready():
|
||||||
|
return
|
||||||
attack_timer = attack_cooldown_seconds
|
attack_timer = attack_cooldown_seconds
|
||||||
_play_swing()
|
_play_swing()
|
||||||
var facing := -player.global_transform.basis.z
|
var facing := -player.global_transform.basis.z
|
||||||
@@ -91,7 +100,10 @@ func _perform_attack() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _perform_dash() -> void:
|
func _perform_dash() -> void:
|
||||||
dash_timer = dash_duration
|
if dash_cooldown_remaining > 0.0 or _player_is_downed():
|
||||||
|
return
|
||||||
|
dash_duration_remaining = dash_duration
|
||||||
|
dash_cooldown_remaining = maxf(dash_cooldown_seconds, dash_duration)
|
||||||
dashing = true
|
dashing = true
|
||||||
var facing := -player.global_transform.basis.z
|
var facing := -player.global_transform.basis.z
|
||||||
facing.y = 0.0
|
facing.y = 0.0
|
||||||
@@ -100,6 +112,12 @@ func _perform_dash() -> void:
|
|||||||
dash_velocity.y = 0.0
|
dash_velocity.y = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _simulation_attack_ready() -> bool:
|
||||||
|
if simulation_manager == null or not simulation_manager.has_method("player_attack_ready"):
|
||||||
|
return true
|
||||||
|
return simulation_manager.player_attack_ready()
|
||||||
|
|
||||||
|
|
||||||
func _play_swing() -> void:
|
func _play_swing() -> void:
|
||||||
if sword == null:
|
if sword == null:
|
||||||
return
|
return
|
||||||
@@ -123,3 +141,11 @@ func is_attacking() -> bool:
|
|||||||
|
|
||||||
func is_dashing() -> bool:
|
func is_dashing() -> bool:
|
||||||
return dashing
|
return dashing
|
||||||
|
|
||||||
|
|
||||||
|
func get_dash_velocity() -> Vector3:
|
||||||
|
return dash_velocity if dashing else Vector3.ZERO
|
||||||
|
|
||||||
|
|
||||||
|
func get_dash_cooldown_remaining() -> float:
|
||||||
|
return dash_cooldown_remaining
|
||||||
|
|||||||
+13
-3
@@ -19,6 +19,8 @@ signal interaction_feedback(heading: String, message: String, succeeded: bool)
|
|||||||
@export var interaction_range := 3.0
|
@export var interaction_range := 3.0
|
||||||
@export var villager_inspection_range := 3.0
|
@export var villager_inspection_range := 3.0
|
||||||
|
|
||||||
|
@onready var combat_controller: Node = get_node_or_null("PlayerCombatController")
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
add_to_group("grass_interactors")
|
add_to_group("grass_interactors")
|
||||||
@@ -44,9 +46,17 @@ func _physics_process(delta: float) -> void:
|
|||||||
var direction := (right * input.x + forward * -input.y).normalized()
|
var direction := (right * input.x + forward * -input.y).normalized()
|
||||||
|
|
||||||
var target_velocity := direction * move_speed
|
var target_velocity := direction * move_speed
|
||||||
|
var dash_velocity := Vector3.ZERO
|
||||||
velocity.x = move_toward(velocity.x, target_velocity.x, acceleration * delta)
|
if combat_controller != null and combat_controller.has_method("get_dash_velocity"):
|
||||||
velocity.z = move_toward(velocity.z, target_velocity.z, acceleration * delta)
|
dash_velocity = combat_controller.get_dash_velocity()
|
||||||
|
if not dash_velocity.is_zero_approx():
|
||||||
|
target_velocity = dash_velocity
|
||||||
|
direction = dash_velocity.normalized()
|
||||||
|
velocity.x = target_velocity.x
|
||||||
|
velocity.z = target_velocity.z
|
||||||
|
else:
|
||||||
|
velocity.x = move_toward(velocity.x, target_velocity.x, acceleration * delta)
|
||||||
|
velocity.z = move_toward(velocity.z, target_velocity.z, acceleration * delta)
|
||||||
|
|
||||||
if not is_on_floor():
|
if not is_on_floor():
|
||||||
velocity.y -= 30.0 * delta
|
velocity.y -= 30.0 * delta
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ func _init(interval: float = 1.0) -> void:
|
|||||||
tick_interval = maxf(interval, 0.0001)
|
tick_interval = maxf(interval, 0.0001)
|
||||||
|
|
||||||
|
|
||||||
func advance(delta: float) -> int:
|
func advance(delta: float, max_ticks: int = 0) -> int:
|
||||||
accumulator += maxf(delta, 0.0)
|
accumulator += maxf(delta, 0.0)
|
||||||
var ticks_due := 0
|
var ticks_due := 0
|
||||||
while accumulator >= tick_interval:
|
while accumulator >= tick_interval and (max_ticks <= 0 or ticks_due < max_ticks):
|
||||||
accumulator -= tick_interval
|
accumulator -= tick_interval
|
||||||
elapsed_ticks += 1
|
elapsed_ticks += 1
|
||||||
ticks_due += 1
|
ticks_due += 1
|
||||||
@@ -27,5 +27,9 @@ func reset() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func time_of_day() -> float:
|
func time_of_day() -> float:
|
||||||
var total_seconds := elapsed_ticks * tick_interval + accumulator
|
# A capped frame can leave several whole ticks in the accumulator. Those ticks
|
||||||
|
# are backlog, not simulated time yet; only retain the sub-tick fraction for
|
||||||
|
# smooth presentation until the manager consumes the remaining work.
|
||||||
|
var fractional_progress := fmod(accumulator, tick_interval)
|
||||||
|
var total_seconds := elapsed_ticks * tick_interval + fractional_progress
|
||||||
return fmod(total_seconds / maxf(cycle_duration_seconds, 1.0), 1.0)
|
return fmod(total_seconds / maxf(cycle_duration_seconds, 1.0), 1.0)
|
||||||
|
|||||||
+156
-131
@@ -40,6 +40,7 @@ var player_system := PlayerCitizenSystem.new()
|
|||||||
|
|
||||||
var npcs: Array[SimNPC] = []
|
var npcs: Array[SimNPC] = []
|
||||||
@export var tick_interval := 1.2
|
@export var tick_interval := 1.2
|
||||||
|
@export_range(1, 16, 1) var max_ticks_per_frame := 4
|
||||||
@export var simulation_seed: int = 1337
|
@export var simulation_seed: int = 1337
|
||||||
@export var cycle_duration_seconds := 240.0
|
@export var cycle_duration_seconds := 240.0
|
||||||
@export var debug_logs := false
|
@export var debug_logs := false
|
||||||
@@ -72,6 +73,7 @@ var latest_decisions: Dictionary = {}
|
|||||||
var action_selector := ActionSelectionSystem.new()
|
var action_selector := ActionSelectionSystem.new()
|
||||||
var action_executor := ActionExecutionSystem.new()
|
var action_executor := ActionExecutionSystem.new()
|
||||||
var target_resolver := ActionTargetResolver.new()
|
var target_resolver := ActionTargetResolver.new()
|
||||||
|
var last_player_resource_query_stats: Dictionary = {}
|
||||||
var _population_view := SimulationPopulationView.new()
|
var _population_view := SimulationPopulationView.new()
|
||||||
var speed_index := 2
|
var speed_index := 2
|
||||||
const SPEED_LEVELS := [0.25, 0.5, 1.0, 2.0, 4.0, 10.0]
|
const SPEED_LEVELS := [0.25, 0.5, 1.0, 2.0, 4.0, 10.0]
|
||||||
@@ -115,7 +117,7 @@ func _ready() -> void:
|
|||||||
economy.configure(village, debug_logs)
|
economy.configure(village, debug_logs)
|
||||||
animal_care.configure(economy, active_world_adapter)
|
animal_care.configure(economy, active_world_adapter)
|
||||||
player_system.configure(economy, _record_player_event_at)
|
player_system.configure(economy, _record_player_event_at)
|
||||||
conflict_system.configure(economy)
|
conflict_system.configure(economy, tick_interval)
|
||||||
economy.initialize_storage()
|
economy.initialize_storage()
|
||||||
village.update_modifiers()
|
village.update_modifiers()
|
||||||
village.update_priorities()
|
village.update_priorities()
|
||||||
@@ -131,7 +133,8 @@ func _ready() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
var ticks_due := clock.advance(delta * SPEED_LEVELS[speed_index])
|
advance_player_combat_time(delta)
|
||||||
|
var ticks_due := clock.advance(delta * SPEED_LEVELS[speed_index], max_ticks_per_frame)
|
||||||
for tick in ticks_due:
|
for tick in ticks_due:
|
||||||
simulate_tick()
|
simulate_tick()
|
||||||
|
|
||||||
@@ -171,6 +174,10 @@ func generate_npcs() -> void:
|
|||||||
if home_count > 0:
|
if home_count > 0:
|
||||||
for i in range(npcs.size()):
|
for i in range(npcs.size()):
|
||||||
npcs[i].home_position = home_positions[i % home_count]
|
npcs[i].home_position = home_positions[i % home_count]
|
||||||
|
refresh_population_index()
|
||||||
|
|
||||||
|
|
||||||
|
func refresh_population_index() -> void:
|
||||||
_population_view.rebuild(npcs)
|
_population_view.rebuild(npcs)
|
||||||
|
|
||||||
|
|
||||||
@@ -196,7 +203,7 @@ func simulate_tick() -> void:
|
|||||||
advance_player_needs()
|
advance_player_needs()
|
||||||
_advance_resource_regrowth()
|
_advance_resource_regrowth()
|
||||||
var village_was_changed := false
|
var village_was_changed := false
|
||||||
_population_view.rebuild(npcs)
|
refresh_population_index()
|
||||||
for npc in npcs:
|
for npc in npcs:
|
||||||
village_was_changed = _simulate_npc_tick(npc) or village_was_changed
|
village_was_changed = _simulate_npc_tick(npc) or village_was_changed
|
||||||
if village_was_changed:
|
if village_was_changed:
|
||||||
@@ -299,21 +306,31 @@ func _on_conflict_combatant_spawned(combatant_id: StringName) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _on_conflict_combatant_died(combatant_id: StringName) -> void:
|
func _on_conflict_combatant_died(combatant_id: StringName) -> void:
|
||||||
combatant_died.emit(combatant_id)
|
|
||||||
var combatant := conflict_system.get_combatant(combatant_id)
|
var combatant := conflict_system.get_combatant(combatant_id)
|
||||||
if combatant == null or combatant.get_npc_id() < 0:
|
var npc: SimNPC
|
||||||
return
|
if combatant != null and combatant.get_npc_id() >= 0:
|
||||||
for npc in npcs:
|
npc = _find_npc_by_id(combatant.get_npc_id())
|
||||||
if npc.id != combatant.get_npc_id() or npc.is_dead:
|
if npc != null:
|
||||||
continue
|
_population_view.refresh_npc(npc)
|
||||||
_handle_npc_death(npc, npc.current_task, npc.target_id)
|
combatant_died.emit(combatant_id)
|
||||||
|
if npc == null or not npc.is_dead:
|
||||||
return
|
return
|
||||||
|
var previous_task := npc.last_task if npc.last_task != &"" else SimulationIds.ACTION_IDLE
|
||||||
|
_handle_npc_death(npc, previous_task, npc.target_id)
|
||||||
|
|
||||||
|
|
||||||
func player_attack(combatant_id: StringName) -> float:
|
func player_attack(combatant_id: StringName) -> float:
|
||||||
return conflict_system.player_attack(combatant_id)
|
return conflict_system.player_attack(combatant_id)
|
||||||
|
|
||||||
|
|
||||||
|
func player_attack_ready() -> bool:
|
||||||
|
return conflict_system.is_player_attack_ready()
|
||||||
|
|
||||||
|
|
||||||
|
func advance_player_combat_time(delta: float) -> void:
|
||||||
|
conflict_system.advance_realtime(delta)
|
||||||
|
|
||||||
|
|
||||||
func spawn_wolf(position: Vector3) -> StringName:
|
func spawn_wolf(position: Vector3) -> StringName:
|
||||||
return conflict_system.spawn_wolf(position)
|
return conflict_system.spawn_wolf(position)
|
||||||
|
|
||||||
@@ -450,85 +467,80 @@ func _complete_resource_gather(npc: SimNPC, completed_task: StringName) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func release_npc_reservation(npc_id: int) -> void:
|
func release_npc_reservation(npc_id: int) -> void:
|
||||||
for npc in npcs:
|
var npc := _find_npc_by_id(npc_id)
|
||||||
if npc.id == npc_id:
|
if npc == null:
|
||||||
if npc.target_id != &"":
|
return
|
||||||
var resource_state := get_resource_state(npc.target_id)
|
if npc.target_id != &"":
|
||||||
if resource_state != null:
|
var resource_state := get_resource_state(npc.target_id)
|
||||||
resource_state.release(npc.id)
|
if resource_state != null:
|
||||||
var animal_state := animal_care.get_state(npc.target_id)
|
resource_state.release(npc.id)
|
||||||
if animal_state != null:
|
var animal_state := animal_care.get_state(npc.target_id)
|
||||||
animal_state.release(npc.id)
|
if animal_state != null:
|
||||||
npc.target_id = &""
|
animal_state.release(npc.id)
|
||||||
return
|
npc.target_id = &""
|
||||||
|
|
||||||
|
|
||||||
func notify_npc_arrived(npc_id: int) -> void:
|
func notify_npc_arrived(npc_id: int) -> void:
|
||||||
for npc in npcs:
|
var npc := _find_npc_by_id(npc_id)
|
||||||
if npc.id == npc_id:
|
if npc == null or npc.is_dead:
|
||||||
if npc.is_dead:
|
return
|
||||||
|
|
||||||
|
if npc.task_state == SimNPC.TASK_STATE_TRAVELING:
|
||||||
|
var definition := SimulationDefinitions.get_action(npc.current_task)
|
||||||
|
if (
|
||||||
|
npc.target_id != &""
|
||||||
|
and definition != null
|
||||||
|
and definition.target_type == SimulationIds.TARGET_RESOURCE
|
||||||
|
):
|
||||||
|
var resource_state := get_resource_state(npc.target_id)
|
||||||
|
if (
|
||||||
|
resource_state == null
|
||||||
|
or not resource_state.can_extract()
|
||||||
|
or resource_state.get_reserved_by() != npc.id
|
||||||
|
):
|
||||||
|
if debug_logs:
|
||||||
|
NpcTickDebugLog.print_unavailable_resource(npc)
|
||||||
|
notify_npc_navigation_failed(npc.id)
|
||||||
|
return
|
||||||
|
if (
|
||||||
|
npc.target_id != &""
|
||||||
|
and definition != null
|
||||||
|
and definition.target_type == SimulationIds.TARGET_ANIMAL
|
||||||
|
):
|
||||||
|
if not animal_care.can_complete_feed(npc.target_id, npc.id):
|
||||||
|
if debug_logs:
|
||||||
|
NpcTickDebugLog.print_unavailable_animal(npc)
|
||||||
|
notify_npc_navigation_failed(npc.id)
|
||||||
|
return
|
||||||
|
if animal_care.requires_feed_pickup(npc):
|
||||||
|
if _continue_animal_feed_delivery(npc):
|
||||||
|
return
|
||||||
|
_redirect_npc_to_wander(npc)
|
||||||
return
|
return
|
||||||
|
|
||||||
if npc.task_state == SimNPC.TASK_STATE_TRAVELING:
|
npc.has_travel_target = false
|
||||||
var definition := SimulationDefinitions.get_action(npc.current_task)
|
npc.start_working(
|
||||||
if (
|
npc.current_task == SimulationIds.ACTION_FEED_ANIMAL and npc.task_progress > 0.0
|
||||||
npc.target_id != &""
|
)
|
||||||
and definition != null
|
|
||||||
and definition.target_type == SimulationIds.TARGET_RESOURCE
|
|
||||||
):
|
|
||||||
var resource_state := get_resource_state(npc.target_id)
|
|
||||||
if (
|
|
||||||
resource_state == null
|
|
||||||
or not resource_state.can_extract()
|
|
||||||
or resource_state.get_reserved_by() != npc.id
|
|
||||||
):
|
|
||||||
if debug_logs:
|
|
||||||
NpcTickDebugLog.print_unavailable_resource(npc)
|
|
||||||
notify_npc_navigation_failed(npc.id)
|
|
||||||
return
|
|
||||||
if (
|
|
||||||
npc.target_id != &""
|
|
||||||
and definition != null
|
|
||||||
and definition.target_type == SimulationIds.TARGET_ANIMAL
|
|
||||||
):
|
|
||||||
if not animal_care.can_complete_feed(npc.target_id, npc.id):
|
|
||||||
if debug_logs:
|
|
||||||
NpcTickDebugLog.print_unavailable_animal(npc)
|
|
||||||
notify_npc_navigation_failed(npc.id)
|
|
||||||
return
|
|
||||||
if animal_care.requires_feed_pickup(npc):
|
|
||||||
if _continue_animal_feed_delivery(npc):
|
|
||||||
return
|
|
||||||
_redirect_npc_to_wander(npc)
|
|
||||||
return
|
|
||||||
|
|
||||||
npc.has_travel_target = false
|
var working_speakers: Array[SimNPC] = []
|
||||||
npc.start_working(
|
if npc.target_id != &"":
|
||||||
npc.current_task == SimulationIds.ACTION_FEED_ANIMAL and npc.task_progress > 0.0
|
for other in npcs:
|
||||||
)
|
if other.id == npc.id or other.is_dead:
|
||||||
|
continue
|
||||||
|
if other.target_id != npc.target_id:
|
||||||
|
continue
|
||||||
|
if other.task_state in [SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING]:
|
||||||
|
relationship_system.increase_shared_work_familiarity(npc.id, other.id)
|
||||||
|
if other.task_state == SimNPC.TASK_STATE_WORKING:
|
||||||
|
working_speakers.append(other)
|
||||||
|
working_speakers.sort_custom(_sort_npcs_by_id)
|
||||||
|
for speaker in working_speakers:
|
||||||
|
if try_communicate_at_shared_activity(speaker.id, npc.id):
|
||||||
|
break
|
||||||
|
|
||||||
var working_speakers: Array[SimNPC] = []
|
if debug_logs:
|
||||||
if npc.target_id != &"":
|
NpcTickDebugLog.print_started_work(npc)
|
||||||
for other in npcs:
|
|
||||||
if other.id == npc.id or other.is_dead:
|
|
||||||
continue
|
|
||||||
if other.target_id != npc.target_id:
|
|
||||||
continue
|
|
||||||
if (
|
|
||||||
other.task_state
|
|
||||||
in [SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING]
|
|
||||||
):
|
|
||||||
relationship_system.increase_shared_work_familiarity(npc.id, other.id)
|
|
||||||
if other.task_state == SimNPC.TASK_STATE_WORKING:
|
|
||||||
working_speakers.append(other)
|
|
||||||
working_speakers.sort_custom(_sort_npcs_by_id)
|
|
||||||
for speaker in working_speakers:
|
|
||||||
if try_communicate_at_shared_activity(speaker.id, npc.id):
|
|
||||||
break
|
|
||||||
|
|
||||||
if debug_logs:
|
|
||||||
NpcTickDebugLog.print_started_work(npc)
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
func _continue_animal_feed_delivery(npc: SimNPC) -> bool:
|
func _continue_animal_feed_delivery(npc: SimNPC) -> bool:
|
||||||
@@ -564,66 +576,57 @@ func _redirect_npc_to_wander(npc: SimNPC) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func notify_npc_navigation_failed(npc_id: int) -> void:
|
func notify_npc_navigation_failed(npc_id: int) -> void:
|
||||||
for npc in npcs:
|
var npc := _find_npc_by_id(npc_id)
|
||||||
if npc.id != npc_id:
|
if npc == null or npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING:
|
||||||
continue
|
|
||||||
if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING:
|
|
||||||
return
|
|
||||||
|
|
||||||
var failed_task := npc.current_task
|
|
||||||
_redirect_npc_to_wander(npc)
|
|
||||||
|
|
||||||
if debug_logs:
|
|
||||||
NpcTickDebugLog.print_navigation_failure(npc, failed_task)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
var failed_task := npc.current_task
|
||||||
|
_redirect_npc_to_wander(npc)
|
||||||
|
|
||||||
|
if debug_logs:
|
||||||
|
NpcTickDebugLog.print_navigation_failure(npc, failed_task)
|
||||||
|
|
||||||
|
|
||||||
func resolve_npc_target(npc_id: int, origin: Vector3) -> bool:
|
func resolve_npc_target(npc_id: int, origin: Vector3) -> bool:
|
||||||
for npc in npcs:
|
var npc := _find_npc_by_id(npc_id)
|
||||||
if npc.id != npc_id:
|
if npc == null or npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING:
|
||||||
continue
|
return false
|
||||||
if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING:
|
if npc.current_task == SimulationIds.ACTION_SLEEP:
|
||||||
return false
|
npc.travel_target_position = npc.home_position
|
||||||
if npc.current_task == SimulationIds.ACTION_SLEEP:
|
|
||||||
npc.travel_target_position = npc.home_position
|
|
||||||
npc.has_travel_target = true
|
|
||||||
npc_travel_requested.emit(npc, npc.travel_target_position)
|
|
||||||
return true
|
|
||||||
if active_world_adapter == null:
|
|
||||||
push_error("SimulationManager: active_world_adapter is missing")
|
|
||||||
return false
|
|
||||||
var result := target_resolver.resolve(npc, origin, self, active_world_adapter)
|
|
||||||
if result.is_empty():
|
|
||||||
notify_npc_navigation_failed(npc.id)
|
|
||||||
return false
|
|
||||||
npc.target_id = StringName(result.get("target_id", ""))
|
|
||||||
npc.travel_target_position = result["position"]
|
|
||||||
npc.has_travel_target = true
|
npc.has_travel_target = true
|
||||||
npc_travel_requested.emit(npc, npc.travel_target_position)
|
npc_travel_requested.emit(npc, npc.travel_target_position)
|
||||||
return true
|
return true
|
||||||
return false
|
if active_world_adapter == null:
|
||||||
|
push_error("SimulationManager: active_world_adapter is missing")
|
||||||
|
return false
|
||||||
|
var result := target_resolver.resolve(npc, origin, self, active_world_adapter)
|
||||||
|
if result.is_empty():
|
||||||
|
notify_npc_navigation_failed(npc.id)
|
||||||
|
return false
|
||||||
|
npc.target_id = StringName(result.get("target_id", ""))
|
||||||
|
npc.travel_target_position = result["position"]
|
||||||
|
npc.has_travel_target = true
|
||||||
|
npc_travel_requested.emit(npc, npc.travel_target_position)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
func request_current_travel(npc_id: int) -> bool:
|
func request_current_travel(npc_id: int) -> bool:
|
||||||
for npc in npcs:
|
var npc := _find_npc_by_id(npc_id)
|
||||||
if npc.id != npc_id:
|
if npc == null or npc.task_state != SimNPC.TASK_STATE_TRAVELING:
|
||||||
continue
|
return false
|
||||||
if npc.task_state != SimNPC.TASK_STATE_TRAVELING:
|
if npc.has_travel_target:
|
||||||
return false
|
npc_travel_requested.emit(npc, npc.travel_target_position)
|
||||||
if npc.has_travel_target:
|
else:
|
||||||
npc_travel_requested.emit(npc, npc.travel_target_position)
|
npc_target_requested.emit(npc)
|
||||||
else:
|
return true
|
||||||
npc_target_requested.emit(npc)
|
|
||||||
return true
|
|
||||||
return false
|
|
||||||
|
|
||||||
|
|
||||||
func synchronize_npc_position(npc_id: int, active_position: Vector3) -> bool:
|
func synchronize_npc_position(npc_id: int, active_position: Vector3) -> bool:
|
||||||
for npc in npcs:
|
var npc := _find_npc_by_id(npc_id)
|
||||||
if npc.id == npc_id:
|
if npc == null:
|
||||||
npc.position = active_position
|
return false
|
||||||
return true
|
npc.position = active_position
|
||||||
return false
|
return true
|
||||||
|
|
||||||
|
|
||||||
func get_activity_target_claim_count(target_id: StringName, except_npc_id: int = -1) -> int:
|
func get_activity_target_claim_count(target_id: StringName, except_npc_id: int = -1) -> int:
|
||||||
@@ -891,6 +894,12 @@ func _get_lasting_event_ids(npc_id: int) -> Array[int]:
|
|||||||
|
|
||||||
|
|
||||||
func _find_npc_by_id(npc_id: int) -> SimNPC:
|
func _find_npc_by_id(npc_id: int) -> SimNPC:
|
||||||
|
var npc := _population_view.get_any(npc_id)
|
||||||
|
if npc != null:
|
||||||
|
return npc
|
||||||
|
# Population growth can introduce an ID between scheduled rebuild points.
|
||||||
|
# Rebuild once on a miss while keeping ordinary runtime callbacks O(1).
|
||||||
|
refresh_population_index()
|
||||||
return _population_view.get_any(npc_id)
|
return _population_view.get_any(npc_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -1083,7 +1092,21 @@ func release_resource(node_id: StringName, agent_id: int) -> void:
|
|||||||
func find_resource_node_for_player(from_position: Vector3, max_distance: float) -> ResourceNode:
|
func find_resource_node_for_player(from_position: Vector3, max_distance: float) -> ResourceNode:
|
||||||
var best: ResourceNode
|
var best: ResourceNode
|
||||||
var best_distance := max_distance * max_distance
|
var best_distance := max_distance * max_distance
|
||||||
for node in ResourceNode.get_all():
|
var candidates: Array[ResourceNode]
|
||||||
|
var query_mode := &"registry"
|
||||||
|
if (
|
||||||
|
active_world_adapter != null
|
||||||
|
and active_world_adapter.has_method("get_resource_nodes_in_radius")
|
||||||
|
):
|
||||||
|
candidates = active_world_adapter.get_resource_nodes_in_radius(from_position, max_distance)
|
||||||
|
query_mode = &"spatial"
|
||||||
|
else:
|
||||||
|
candidates = ResourceNode.get_all()
|
||||||
|
last_player_resource_query_stats = {
|
||||||
|
"mode": query_mode,
|
||||||
|
"candidate_count": candidates.size(),
|
||||||
|
}
|
||||||
|
for node in candidates:
|
||||||
var resource_state := get_resource_state(node.node_id)
|
var resource_state := get_resource_state(node.node_id)
|
||||||
if (
|
if (
|
||||||
resource_state == null
|
resource_state == null
|
||||||
@@ -1341,7 +1364,7 @@ func restore_state(record: SimulationStateRecord) -> bool:
|
|||||||
npcs.clear()
|
npcs.clear()
|
||||||
for npc_record in record.npcs:
|
for npc_record in record.npcs:
|
||||||
npcs.append(npc_record.restore(debug_logs))
|
npcs.append(npc_record.restore(debug_logs))
|
||||||
_population_view.rebuild(npcs)
|
refresh_population_index()
|
||||||
relationship_system.restore(record.relationships)
|
relationship_system.restore(record.relationships)
|
||||||
event_knowledge_system.restore(record.event_knowledge)
|
event_knowledge_system.restore(record.event_knowledge)
|
||||||
opportunity_system.restore(record.opportunities, int(record.simulation["next_opportunity_id"]))
|
opportunity_system.restore(record.opportunities, int(record.simulation["next_opportunity_id"]))
|
||||||
@@ -1362,6 +1385,8 @@ func restore_state(record: SimulationStateRecord) -> bool:
|
|||||||
player_system.player_state = record.player
|
player_system.player_state = record.player
|
||||||
conflict_system.restore_state_records(record.combatants, record.factions, tick_count)
|
conflict_system.restore_state_records(record.combatants, record.factions, tick_count)
|
||||||
conflict_system.register_npc_combatants(npcs)
|
conflict_system.register_npc_combatants(npcs)
|
||||||
|
conflict_system.configure(economy, tick_interval)
|
||||||
|
_sync_player_combatant()
|
||||||
village_changed.emit(village)
|
village_changed.emit(village)
|
||||||
state_restored.emit()
|
state_restored.emit()
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
class_name SimulationScalingBenchmark
|
class_name SimulationScalingBenchmark
|
||||||
extends RefCounted
|
extends RefCounted
|
||||||
|
|
||||||
const SCHEMA_VERSION := 1
|
const SCHEMA_VERSION := 2
|
||||||
const WORKLOAD_ID := &"full_fidelity_headless_arrival"
|
const WORKLOAD_ID := &"full_fidelity_combatant_headless_arrival_v2"
|
||||||
const RECENT_FACTS_PER_NPC := 3
|
const RECENT_FACTS_PER_NPC := 3
|
||||||
const STARTING_CLOCK_TICK := 50
|
const STARTING_CLOCK_TICK := 50
|
||||||
|
|
||||||
@@ -43,6 +43,13 @@ func prepare_manager(
|
|||||||
npc.home_position = npc.position
|
npc.home_position = npc.position
|
||||||
manager.npcs.append(npc)
|
manager.npcs.append(npc)
|
||||||
manager.wander_random_sources[npc_id] = _create_random_source(seed_value, npc_id, 1)
|
manager.wander_random_sources[npc_id] = _create_random_source(seed_value, npc_id, 1)
|
||||||
|
manager.refresh_population_index()
|
||||||
|
var no_combatants: Array[CombatantStateRecord] = []
|
||||||
|
var no_factions: Array[FactionStateRecord] = []
|
||||||
|
manager.conflict_system.restore_state_records(no_combatants, no_factions, manager.tick_count)
|
||||||
|
manager.conflict_system.register_npc_combatants(manager.npcs)
|
||||||
|
if not _get_combatant_coverage(manager)["valid"]:
|
||||||
|
return false
|
||||||
|
|
||||||
var pantry: StorageStateRecord = manager.get_pantry()
|
var pantry: StorageStateRecord = manager.get_pantry()
|
||||||
pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD))
|
pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD))
|
||||||
@@ -63,11 +70,15 @@ func measure_manager(
|
|||||||
or manager.npcs.size() != population
|
or manager.npcs.size() != population
|
||||||
or warmup_ticks < 0
|
or warmup_ticks < 0
|
||||||
or measured_ticks <= 0
|
or measured_ticks <= 0
|
||||||
|
or not _get_combatant_coverage(manager)["valid"]
|
||||||
):
|
):
|
||||||
return {}
|
return {}
|
||||||
var warmup_arrivals := 0
|
var warmup_arrivals := 0
|
||||||
for _tick in warmup_ticks:
|
for _tick in warmup_ticks:
|
||||||
warmup_arrivals += _advance_headless_tick(manager)
|
warmup_arrivals += _advance_headless_tick(manager)
|
||||||
|
var warmup_coverage := _get_combatant_coverage(manager)
|
||||||
|
if not warmup_coverage["valid"] or warmup_coverage["npc_combatant_count"] != population:
|
||||||
|
return {}
|
||||||
|
|
||||||
var start_json: String = manager.serialize_state()
|
var start_json: String = manager.serialize_state()
|
||||||
var start_event_count: int = manager.economic_events.size()
|
var start_event_count: int = manager.economic_events.size()
|
||||||
@@ -89,8 +100,12 @@ func measure_manager(
|
|||||||
var start_state_bytes := start_json.to_utf8_buffer().size()
|
var start_state_bytes := start_json.to_utf8_buffer().size()
|
||||||
var end_state_bytes := end_json.to_utf8_buffer().size()
|
var end_state_bytes := end_json.to_utf8_buffer().size()
|
||||||
var ticks_per_second := float(measured_ticks) * 1000000.0 / float(elapsed_usec)
|
var ticks_per_second := float(measured_ticks) * 1000000.0 / float(elapsed_usec)
|
||||||
|
var combatant_coverage := _get_combatant_coverage(manager)
|
||||||
|
if not combatant_coverage["valid"] or combatant_coverage["npc_combatant_count"] != population:
|
||||||
|
return {}
|
||||||
return {
|
return {
|
||||||
"schema_version": SCHEMA_VERSION,
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"simulation_state_schema_version": SimulationStateRecord.SCHEMA_VERSION,
|
||||||
"workload_id": String(WORKLOAD_ID),
|
"workload_id": String(WORKLOAD_ID),
|
||||||
"seed": manager.simulation_seed,
|
"seed": manager.simulation_seed,
|
||||||
"population": population,
|
"population": population,
|
||||||
@@ -98,6 +113,8 @@ func measure_manager(
|
|||||||
"warmup_ticks": warmup_ticks,
|
"warmup_ticks": warmup_ticks,
|
||||||
"measured_ticks": measured_ticks,
|
"measured_ticks": measured_ticks,
|
||||||
"npc_updates": population * measured_ticks,
|
"npc_updates": population * measured_ticks,
|
||||||
|
"npc_combatant_count": combatant_coverage["npc_combatant_count"],
|
||||||
|
"npc_combatant_coverage_valid": combatant_coverage["valid"],
|
||||||
"warmup_arrivals": warmup_arrivals,
|
"warmup_arrivals": warmup_arrivals,
|
||||||
"arrivals_processed": measured_arrivals,
|
"arrivals_processed": measured_arrivals,
|
||||||
"elapsed_usec": elapsed_usec,
|
"elapsed_usec": elapsed_usec,
|
||||||
@@ -121,6 +138,36 @@ func measure_manager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _get_combatant_coverage(manager: Node) -> Dictionary:
|
||||||
|
var expected_npcs := {}
|
||||||
|
for npc in manager.npcs:
|
||||||
|
expected_npcs[npc.id] = npc
|
||||||
|
var covered_npc_ids := {}
|
||||||
|
var npc_combatant_count := 0
|
||||||
|
var valid: bool = expected_npcs.size() == manager.npcs.size()
|
||||||
|
for combatant in manager.conflict_system.get_all_combatants():
|
||||||
|
if combatant.get_kind() != SimulationIds.COMBATANT_KIND_NPC:
|
||||||
|
continue
|
||||||
|
npc_combatant_count += 1
|
||||||
|
var npc_id: int = combatant.get_npc_id()
|
||||||
|
var npc := expected_npcs.get(npc_id) as SimNPC
|
||||||
|
if (
|
||||||
|
npc == null
|
||||||
|
or covered_npc_ids.has(npc_id)
|
||||||
|
or combatant.get_combatant_id() != SimulationIds.npc_combatant_id(npc_id)
|
||||||
|
or combatant.get_display_name() != npc.npc_name
|
||||||
|
or not combatant.get_position().is_equal_approx(npc.position)
|
||||||
|
):
|
||||||
|
valid = false
|
||||||
|
covered_npc_ids[npc_id] = true
|
||||||
|
valid = (
|
||||||
|
valid
|
||||||
|
and npc_combatant_count == expected_npcs.size()
|
||||||
|
and covered_npc_ids.size() == expected_npcs.size()
|
||||||
|
)
|
||||||
|
return {"valid": valid, "npc_combatant_count": npc_combatant_count}
|
||||||
|
|
||||||
|
|
||||||
func _advance_headless_tick(manager: Node) -> int:
|
func _advance_headless_tick(manager: Node) -> int:
|
||||||
manager.simulate_tick()
|
manager.simulate_tick()
|
||||||
return _complete_headless_arrivals(manager)
|
return _complete_headless_arrivals(manager)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const STRONG_STRENGTH := 5.0
|
|||||||
const DEFENSE_DECISION_INTERVAL := 200
|
const DEFENSE_DECISION_INTERVAL := 200
|
||||||
const TRIBE_FOOD_DECAY := 0.05
|
const TRIBE_FOOD_DECAY := 0.05
|
||||||
const WOLF_HUNT_RADIUS := 18.0
|
const WOLF_HUNT_RADIUS := 18.0
|
||||||
|
const DEFAULT_TICK_INTERVAL := 1.2
|
||||||
const RAID_SPAWN_OFFSET := Vector3(42.0, 0.0, 42.0)
|
const RAID_SPAWN_OFFSET := Vector3(42.0, 0.0, 42.0)
|
||||||
const RAID_SPEED := 1.2
|
const RAID_SPEED := 1.2
|
||||||
const RAID_SIZE := 4
|
const RAID_SIZE := 4
|
||||||
@@ -32,15 +33,23 @@ var combatants: Dictionary = {}
|
|||||||
var factions: Dictionary = {}
|
var factions: Dictionary = {}
|
||||||
var economy: RefCounted
|
var economy: RefCounted
|
||||||
var npcs: Array[SimNPC] = []
|
var npcs: Array[SimNPC] = []
|
||||||
|
var _npcs_by_id: Dictionary = {}
|
||||||
var next_combatant_id := 0
|
var next_combatant_id := 0
|
||||||
var next_wolf_id := 0
|
var next_wolf_id := 0
|
||||||
var current_tick := 0
|
var current_tick := 0
|
||||||
var village_center := Vector3.ZERO
|
var village_center := Vector3.ZERO
|
||||||
var last_war_resolved_tick := -1
|
var last_war_resolved_tick := -1
|
||||||
|
var tick_interval := DEFAULT_TICK_INTERVAL
|
||||||
|
var player_attack_cooldown_remaining := 0.0
|
||||||
|
var _sorted_combatant_ids_cache: Array[StringName] = []
|
||||||
|
var _combatant_ids_dirty := true
|
||||||
|
|
||||||
|
|
||||||
func configure(economy_service: RefCounted) -> void:
|
func configure(
|
||||||
|
economy_service: RefCounted, simulation_tick_interval: float = DEFAULT_TICK_INTERVAL
|
||||||
|
) -> void:
|
||||||
economy = economy_service
|
economy = economy_service
|
||||||
|
tick_interval = maxf(simulation_tick_interval, 0.0001)
|
||||||
|
|
||||||
|
|
||||||
func initialize_factions() -> void:
|
func initialize_factions() -> void:
|
||||||
@@ -69,6 +78,7 @@ func _ensure_player_combatant() -> void:
|
|||||||
sword.item_id if sword != null else SimulationIds.ITEM_SWORD,
|
sword.item_id if sword != null else SimulationIds.ITEM_SWORD,
|
||||||
CombatantStateRecord.NO_NPC_ID
|
CombatantStateRecord.NO_NPC_ID
|
||||||
)
|
)
|
||||||
|
_combatant_ids_dirty = true
|
||||||
|
|
||||||
|
|
||||||
func set_player_combatant_position(position: Vector3) -> void:
|
func set_player_combatant_position(position: Vector3) -> void:
|
||||||
@@ -87,13 +97,9 @@ func set_player_health(health: float, downed: bool) -> void:
|
|||||||
player_combatant = get_combatant(PLAYER_COMBATANT_ID)
|
player_combatant = get_combatant(PLAYER_COMBATANT_ID)
|
||||||
if player_combatant == null:
|
if player_combatant == null:
|
||||||
return
|
return
|
||||||
if not downed and player_combatant.is_alive():
|
player_combatant.set_health_and_alive(health, not downed)
|
||||||
var target := clampf(health, 0.0, player_combatant.get_max_health())
|
if downed:
|
||||||
var current := player_combatant.get_health()
|
player_attack_cooldown_remaining = 0.0
|
||||||
if target < current:
|
|
||||||
player_combatant.take_damage(current - target)
|
|
||||||
elif target > current:
|
|
||||||
player_combatant.data["health"] = target
|
|
||||||
|
|
||||||
|
|
||||||
func is_player_downed() -> bool:
|
func is_player_downed() -> bool:
|
||||||
@@ -101,8 +107,30 @@ func is_player_downed() -> bool:
|
|||||||
return player_combatant != null and not player_combatant.is_alive()
|
return player_combatant != null and not player_combatant.is_alive()
|
||||||
|
|
||||||
|
|
||||||
|
func advance_realtime(delta: float) -> void:
|
||||||
|
if not is_finite(delta) or delta <= 0.0:
|
||||||
|
return
|
||||||
|
player_attack_cooldown_remaining = maxf(player_attack_cooldown_remaining - delta, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
func is_player_attack_ready() -> bool:
|
||||||
|
var player_combatant := get_combatant(PLAYER_COMBATANT_ID)
|
||||||
|
return (
|
||||||
|
player_combatant != null
|
||||||
|
and player_combatant.is_alive()
|
||||||
|
and player_attack_cooldown_remaining <= 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func get_player_attack_cooldown_remaining() -> float:
|
||||||
|
return player_attack_cooldown_remaining
|
||||||
|
|
||||||
|
|
||||||
func register_npc_combatants(npc_list: Array[SimNPC]) -> void:
|
func register_npc_combatants(npc_list: Array[SimNPC]) -> void:
|
||||||
npcs = npc_list
|
npcs = npc_list
|
||||||
|
_npcs_by_id.clear()
|
||||||
|
for npc in npcs:
|
||||||
|
_npcs_by_id[npc.id] = npc
|
||||||
var existing_ids := {}
|
var existing_ids := {}
|
||||||
for combatant in combatants.values():
|
for combatant in combatants.values():
|
||||||
var candidate := combatant as CombatantStateRecord
|
var candidate := combatant as CombatantStateRecord
|
||||||
@@ -133,6 +161,7 @@ func _create_npc_combatant(npc: SimNPC) -> CombatantStateRecord:
|
|||||||
npc.id
|
npc.id
|
||||||
)
|
)
|
||||||
combatants[combatant.get_combatant_id()] = combatant
|
combatants[combatant.get_combatant_id()] = combatant
|
||||||
|
_combatant_ids_dirty = true
|
||||||
return combatant
|
return combatant
|
||||||
|
|
||||||
|
|
||||||
@@ -170,12 +199,26 @@ func _advance_raider_positions() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func player_attack(combatant_id: StringName) -> float:
|
func player_attack(combatant_id: StringName) -> float:
|
||||||
|
var player_combatant := get_combatant(PLAYER_COMBATANT_ID)
|
||||||
var combatant := get_combatant(combatant_id)
|
var combatant := get_combatant(combatant_id)
|
||||||
if combatant == null or not combatant.is_alive():
|
if (
|
||||||
|
not is_player_attack_ready()
|
||||||
|
or combatant == null
|
||||||
|
or not combatant.is_alive()
|
||||||
|
or not combatant.is_hostile()
|
||||||
|
or combatant.get_faction_id() == player_combatant.get_faction_id()
|
||||||
|
or (
|
||||||
|
_horizontal_distance(player_combatant.get_position(), combatant.get_position())
|
||||||
|
> _reach(player_combatant)
|
||||||
|
)
|
||||||
|
):
|
||||||
return 0.0
|
return 0.0
|
||||||
var sword := SimulationItems.get_weapon(SimulationIds.ITEM_SWORD)
|
var sword := SimulationItems.get_weapon(SimulationIds.ITEM_SWORD)
|
||||||
var damage := sword.damage if sword != null else 24.0
|
var damage := sword.damage if sword != null else 24.0
|
||||||
return _apply_damage(combatant, damage, SimulationIds.PLAYER_ACTOR_ID)
|
var applied := _apply_damage(combatant, damage, SimulationIds.PLAYER_ACTOR_ID)
|
||||||
|
if applied > 0.0:
|
||||||
|
player_attack_cooldown_remaining = sword.attack_cooldown if sword != null else 0.6
|
||||||
|
return applied
|
||||||
|
|
||||||
|
|
||||||
func spawn_wolf(position: Vector3) -> StringName:
|
func spawn_wolf(position: Vector3) -> StringName:
|
||||||
@@ -194,6 +237,7 @@ func spawn_wolf(position: Vector3) -> StringName:
|
|||||||
true
|
true
|
||||||
)
|
)
|
||||||
combatants[wolf_id] = wolf
|
combatants[wolf_id] = wolf
|
||||||
|
_combatant_ids_dirty = true
|
||||||
combatant_spawned.emit(wolf_id)
|
combatant_spawned.emit(wolf_id)
|
||||||
return wolf_id
|
return wolf_id
|
||||||
|
|
||||||
@@ -225,6 +269,11 @@ func _apply_damage(target: CombatantStateRecord, damage: float, actor_id: int) -
|
|||||||
|
|
||||||
func _kill_combatant(target: CombatantStateRecord, killer_id: int) -> void:
|
func _kill_combatant(target: CombatantStateRecord, killer_id: int) -> void:
|
||||||
var npc_id := target.get_npc_id()
|
var npc_id := target.get_npc_id()
|
||||||
|
if npc_id >= 0:
|
||||||
|
var npc := _find_npc(npc_id)
|
||||||
|
if npc != null and not npc.is_dead:
|
||||||
|
npc.last_task = npc.current_task
|
||||||
|
npc.die_from_combat()
|
||||||
_narrative_event_requested(
|
_narrative_event_requested(
|
||||||
SimulationIds.EVENT_COMBATANT_KILLED,
|
SimulationIds.EVENT_COMBATANT_KILLED,
|
||||||
killer_id,
|
killer_id,
|
||||||
@@ -235,10 +284,6 @@ func _kill_combatant(target: CombatantStateRecord, killer_id: int) -> void:
|
|||||||
0.0
|
0.0
|
||||||
)
|
)
|
||||||
combatant_died.emit(target.get_combatant_id())
|
combatant_died.emit(target.get_combatant_id())
|
||||||
if npc_id >= 0:
|
|
||||||
var npc := _find_npc(npc_id)
|
|
||||||
if npc != null and not npc.is_dead:
|
|
||||||
npc.die_from_combat()
|
|
||||||
|
|
||||||
|
|
||||||
func _advance_wolves() -> void:
|
func _advance_wolves() -> void:
|
||||||
@@ -253,6 +298,17 @@ func _advance_wolves() -> void:
|
|||||||
continue
|
continue
|
||||||
if not combatant.is_hostile():
|
if not combatant.is_hostile():
|
||||||
combatant.set_hostile(true)
|
combatant.set_hostile(true)
|
||||||
|
var reach := _reach(combatant)
|
||||||
|
var to_target := target.get_position() - combatant.get_position()
|
||||||
|
to_target.y = 0.0
|
||||||
|
if to_target.length() > reach:
|
||||||
|
var definition := SimulationEnemies.get_enemy(&"enemy_wolf")
|
||||||
|
var speed := definition.move_speed if definition != null else 3.2
|
||||||
|
var step_distance := minf(speed * tick_interval, to_target.length() - reach)
|
||||||
|
combatant.set_position(
|
||||||
|
combatant.get_position() + to_target.normalized() * maxf(step_distance, 0.0)
|
||||||
|
)
|
||||||
|
continue
|
||||||
_attack_target(combatant, target)
|
_attack_target(combatant, target)
|
||||||
|
|
||||||
|
|
||||||
@@ -386,6 +442,7 @@ func _start_raid(tribe: FactionStateRecord, simulation_tick: int) -> void:
|
|||||||
true
|
true
|
||||||
)
|
)
|
||||||
combatants[raider_id] = raider
|
combatants[raider_id] = raider
|
||||||
|
_combatant_ids_dirty = true
|
||||||
raider_ids.append(raider_id)
|
raider_ids.append(raider_id)
|
||||||
combatant_spawned.emit(raider_id)
|
combatant_spawned.emit(raider_id)
|
||||||
raid_started.emit(raider_ids)
|
raid_started.emit(raider_ids)
|
||||||
@@ -456,8 +513,13 @@ func _finish_war(next_plan: StringName, outcome: StringName) -> void:
|
|||||||
tribe.set_war_plan(next_plan, current_tick)
|
tribe.set_war_plan(next_plan, current_tick)
|
||||||
tribe.set_stance(SimulationIds.STANCE_NEUTRAL)
|
tribe.set_stance(SimulationIds.STANCE_NEUTRAL)
|
||||||
if village != null and outcome == &"village_lost":
|
if village != null and outcome == &"village_lost":
|
||||||
var stolen := minf(village.get_food() * 0.6, 15.0)
|
var stolen := 0.0
|
||||||
village.set_food(maxf(village.get_food() - stolen, 0.0))
|
if economy != null:
|
||||||
|
var pantry: StorageStateRecord = economy.get_pantry()
|
||||||
|
if pantry != null:
|
||||||
|
var requested := minf(pantry.get_amount(SimulationIds.RESOURCE_FOOD) * 0.6, 15.0)
|
||||||
|
stolen = economy.withdraw_resource(SimulationIds.RESOURCE_FOOD, requested)
|
||||||
|
village.set_food(pantry.get_amount(SimulationIds.RESOURCE_FOOD))
|
||||||
if tribe != null:
|
if tribe != null:
|
||||||
tribe.set_food(tribe.get_food() + stolen)
|
tribe.set_food(tribe.get_food() + stolen)
|
||||||
elif tribe != null:
|
elif tribe != null:
|
||||||
@@ -486,7 +548,7 @@ func _attack_target(attacker: CombatantStateRecord, target: CombatantStateRecord
|
|||||||
var damage := _attack_damage(attacker)
|
var damage := _attack_damage(attacker)
|
||||||
var applied := _apply_damage(target, damage, _actor_id_for(attacker))
|
var applied := _apply_damage(target, damage, _actor_id_for(attacker))
|
||||||
if applied > 0.0:
|
if applied > 0.0:
|
||||||
attacker.mark_attacked()
|
attacker.mark_attacked(tick_interval)
|
||||||
|
|
||||||
|
|
||||||
func _attack_damage(attacker: CombatantStateRecord) -> float:
|
func _attack_damage(attacker: CombatantStateRecord) -> float:
|
||||||
@@ -540,6 +602,10 @@ func _reach(combatant: CombatantStateRecord) -> float:
|
|||||||
return weapon.reach
|
return weapon.reach
|
||||||
|
|
||||||
|
|
||||||
|
static func _horizontal_distance(first: Vector3, second: Vector3) -> float:
|
||||||
|
return Vector2(first.x, first.z).distance_to(Vector2(second.x, second.z))
|
||||||
|
|
||||||
|
|
||||||
func _actor_id_for(combatant: CombatantStateRecord) -> int:
|
func _actor_id_for(combatant: CombatantStateRecord) -> int:
|
||||||
if combatant.get_npc_id() >= 0:
|
if combatant.get_npc_id() >= 0:
|
||||||
return combatant.get_npc_id()
|
return combatant.get_npc_id()
|
||||||
@@ -607,22 +673,22 @@ func get_living_hostiles() -> Array[CombatantStateRecord]:
|
|||||||
return hostiles
|
return hostiles
|
||||||
|
|
||||||
|
|
||||||
func _sorted_combatant_ids() -> Array:
|
func _sorted_combatant_ids() -> Array[StringName]:
|
||||||
|
if not _combatant_ids_dirty:
|
||||||
|
return _sorted_combatant_ids_cache
|
||||||
var keys: Array[String] = []
|
var keys: Array[String] = []
|
||||||
for combatant_id in combatants.keys():
|
for combatant_id in combatants.keys():
|
||||||
keys.append(String(combatant_id))
|
keys.append(String(combatant_id))
|
||||||
keys.sort()
|
keys.sort()
|
||||||
var ids: Array = []
|
_sorted_combatant_ids_cache.clear()
|
||||||
for key in keys:
|
for key in keys:
|
||||||
ids.append(StringName(key))
|
_sorted_combatant_ids_cache.append(StringName(key))
|
||||||
return ids
|
_combatant_ids_dirty = false
|
||||||
|
return _sorted_combatant_ids_cache
|
||||||
|
|
||||||
|
|
||||||
func _find_npc(npc_id: int) -> SimNPC:
|
func _find_npc(npc_id: int) -> SimNPC:
|
||||||
for npc in npcs:
|
return _npcs_by_id.get(npc_id) as SimNPC
|
||||||
if npc.id == npc_id:
|
|
||||||
return npc
|
|
||||||
return null
|
|
||||||
|
|
||||||
|
|
||||||
func _get_faction(faction_id: StringName) -> FactionStateRecord:
|
func _get_faction(faction_id: StringName) -> FactionStateRecord:
|
||||||
@@ -668,9 +734,11 @@ func restore_state_records(
|
|||||||
restored_tick: int
|
restored_tick: int
|
||||||
) -> void:
|
) -> void:
|
||||||
current_tick = restored_tick
|
current_tick = restored_tick
|
||||||
|
player_attack_cooldown_remaining = 0.0
|
||||||
combatants.clear()
|
combatants.clear()
|
||||||
for combatant in combatant_records:
|
for combatant in combatant_records:
|
||||||
combatants[combatant.get_combatant_id()] = combatant
|
combatants[combatant.get_combatant_id()] = combatant
|
||||||
|
_combatant_ids_dirty = true
|
||||||
factions.clear()
|
factions.clear()
|
||||||
for faction in faction_records:
|
for faction in faction_records:
|
||||||
factions[faction.get_faction_id()] = faction
|
factions[faction.get_faction_id()] = faction
|
||||||
|
|||||||
@@ -174,16 +174,17 @@ func restore_to_inventory(npc: SimNPC, item_id: StringName, amount: float) -> vo
|
|||||||
|
|
||||||
|
|
||||||
func consume_npc_food(npc: SimNPC) -> bool:
|
func consume_npc_food(npc: SimNPC) -> bool:
|
||||||
if npc.remove_inventory(SimulationIds.RESOURCE_FOOD, 1.0) < 1.0:
|
if npc == null:
|
||||||
|
return false
|
||||||
|
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) < 1.0:
|
||||||
npc.hunger = minf(npc.hunger + 5.0, 100.0)
|
npc.hunger = minf(npc.hunger + 5.0, 100.0)
|
||||||
npc.is_starving = npc.hunger >= 90.0
|
npc.is_starving = npc.hunger >= 90.0
|
||||||
return false
|
return false
|
||||||
|
if remove_exact_from_inventory(npc, SimulationIds.RESOURCE_FOOD, 1.0) < 1.0:
|
||||||
|
return false
|
||||||
npc.hunger = maxf(npc.hunger - 55.0, 0.0)
|
npc.hunger = maxf(npc.hunger - 55.0, 0.0)
|
||||||
npc.starvation_ticks = 0
|
npc.starvation_ticks = 0
|
||||||
npc.is_starving = npc.hunger >= 90.0
|
npc.is_starving = npc.hunger >= 90.0
|
||||||
inventory_changed.emit(
|
|
||||||
npc, SimulationIds.RESOURCE_FOOD, npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD)
|
|
||||||
)
|
|
||||||
economic_event_requested.emit(
|
economic_event_requested.emit(
|
||||||
SimulationIds.EVENT_ITEM_CONSUMED,
|
SimulationIds.EVENT_ITEM_CONSUMED,
|
||||||
npc.id,
|
npc.id,
|
||||||
|
|||||||
@@ -195,6 +195,18 @@ func set_hostile(value: bool) -> void:
|
|||||||
changed.emit(self)
|
changed.emit(self)
|
||||||
|
|
||||||
|
|
||||||
|
func set_health_and_alive(value: float, alive: bool) -> void:
|
||||||
|
if not is_finite(value):
|
||||||
|
return
|
||||||
|
var next_health := clampf(value, 0.0, get_max_health()) if alive else 0.0
|
||||||
|
var next_alive := alive and next_health > 0.0
|
||||||
|
if is_equal_approx(next_health, get_health()) and bool(data["alive"]) == next_alive:
|
||||||
|
return
|
||||||
|
data["health"] = next_health
|
||||||
|
data["alive"] = next_alive
|
||||||
|
changed.emit(self)
|
||||||
|
|
||||||
|
|
||||||
func tick_cooldown() -> void:
|
func tick_cooldown() -> void:
|
||||||
if get_attack_cooldown() > 0:
|
if get_attack_cooldown() > 0:
|
||||||
data["attack_cooldown"] = get_attack_cooldown() - 1
|
data["attack_cooldown"] = get_attack_cooldown() - 1
|
||||||
@@ -211,14 +223,14 @@ func take_damage(amount: float) -> float:
|
|||||||
return previous - get_health()
|
return previous - get_health()
|
||||||
|
|
||||||
|
|
||||||
func mark_attacked() -> void:
|
func mark_attacked(tick_interval: float = 1.2) -> void:
|
||||||
data["attack_cooldown"] = _weapon_cooldown_ticks()
|
data["attack_cooldown"] = _weapon_cooldown_ticks(tick_interval)
|
||||||
|
|
||||||
|
|
||||||
func _weapon_cooldown_ticks() -> int:
|
func _weapon_cooldown_ticks(tick_interval: float) -> int:
|
||||||
var weapon := SimulationItems.get_weapon(get_weapon_id())
|
var weapon := SimulationItems.get_weapon(get_weapon_id())
|
||||||
var seconds := weapon.attack_cooldown if weapon != null else 0.6
|
var seconds := weapon.attack_cooldown if weapon != null else 0.6
|
||||||
return maxi(int(ceil(seconds / 1.2)), 1)
|
return maxi(int(ceil(seconds / maxf(tick_interval, 0.0001))), 1)
|
||||||
|
|
||||||
|
|
||||||
func to_dictionary() -> Dictionary:
|
func to_dictionary() -> Dictionary:
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ func _run() -> void:
|
|||||||
)
|
)
|
||||||
var reloaded_goat := load("res://world/animals/goat/cozy_goat.tscn").instantiate() as AnimalNode
|
var reloaded_goat := load("res://world/animals/goat/cozy_goat.tscn").instantiate() as AnimalNode
|
||||||
animals_parent.add_child(reloaded_goat)
|
animals_parent.add_child(reloaded_goat)
|
||||||
|
# Freeze presentation movement while proving that binding itself is a pure
|
||||||
|
# reload. A physics step may otherwise land between add_child() and the next
|
||||||
|
# process frame and legitimately advance the authoritative route.
|
||||||
|
reloaded_goat.set_physics_process(false)
|
||||||
await process_frame
|
await process_frame
|
||||||
goat = reloaded_goat
|
goat = reloaded_goat
|
||||||
goat.move_speed = 4.0
|
goat.move_speed = 4.0
|
||||||
@@ -141,6 +145,7 @@ func _run() -> void:
|
|||||||
),
|
),
|
||||||
"Reloading the self-contained goat scene should bind the same state without resetting it"
|
"Reloading the self-contained goat scene should bind the same state without resetting it"
|
||||||
)
|
)
|
||||||
|
goat.set_physics_process(true)
|
||||||
for _frame in 3:
|
for _frame in 3:
|
||||||
await physics_frame
|
await physics_frame
|
||||||
_check(
|
_check(
|
||||||
|
|||||||
@@ -67,8 +67,23 @@ func _test_player_downing() -> void:
|
|||||||
for _tick in 22:
|
for _tick in 22:
|
||||||
manager.simulate_tick()
|
manager.simulate_tick()
|
||||||
_check(not player_state.is_downed(), "The downed player should recover and stand again")
|
_check(not player_state.is_downed(), "The downed player should recover and stand again")
|
||||||
|
var player_combatant: CombatantStateRecord = manager.get_combatant(&"player_combatant")
|
||||||
_check(
|
_check(
|
||||||
player_state.get_health() > 0.0, "Recovery should restore the player to a fighting baseline"
|
(
|
||||||
|
player_state.get_health() > 0.0
|
||||||
|
and player_combatant != null
|
||||||
|
and player_combatant.is_alive()
|
||||||
|
and is_equal_approx(player_combatant.get_health(), player_state.get_health())
|
||||||
|
),
|
||||||
|
"Recovery should revive both authoritative player records at matching health"
|
||||||
|
)
|
||||||
|
manager.update_player_combatant(Vector3(100.0, 0.0, 100.0))
|
||||||
|
manager.spawn_wolf(Vector3(100.0, 0.0, 100.0))
|
||||||
|
var recovered_health := player_state.get_health()
|
||||||
|
manager.simulate_tick()
|
||||||
|
_check(
|
||||||
|
player_state.get_health() < recovered_health,
|
||||||
|
"A recovered player should become targetable and vulnerable again"
|
||||||
)
|
)
|
||||||
manager.free()
|
manager.free()
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ func _initialize() -> void:
|
|||||||
|
|
||||||
func _run() -> void:
|
func _run() -> void:
|
||||||
_test_combat_and_wolf()
|
_test_combat_and_wolf()
|
||||||
|
_test_wolf_closes_distance_before_striking()
|
||||||
|
_test_npc_death_signals_observe_authoritative_death()
|
||||||
_test_war_motivation()
|
_test_war_motivation()
|
||||||
_test_player_combat_and_restore()
|
_test_player_combat_and_restore()
|
||||||
|
_test_raid_plunder_uses_authoritative_pantry()
|
||||||
_finish()
|
_finish()
|
||||||
|
|
||||||
|
|
||||||
@@ -98,15 +101,101 @@ func _test_war_motivation() -> void:
|
|||||||
weak_manager.free()
|
weak_manager.free()
|
||||||
|
|
||||||
|
|
||||||
|
func _test_wolf_closes_distance_before_striking() -> void:
|
||||||
|
var manager := _create_manager(986)
|
||||||
|
var target: SimNPC = manager.npcs[0]
|
||||||
|
target.position = Vector3.ZERO
|
||||||
|
for index in range(1, manager.npcs.size()):
|
||||||
|
manager.npcs[index].position = Vector3(80.0 + index, 0.0, 80.0)
|
||||||
|
manager.update_player_combatant(Vector3(120.0, 0.0, 120.0))
|
||||||
|
var target_combatant: CombatantStateRecord = manager.get_combatant(
|
||||||
|
SimulationIds.npc_combatant_id(target.id)
|
||||||
|
)
|
||||||
|
var wolf_id: StringName = manager.spawn_wolf(Vector3(10.0, 0.0, 0.0))
|
||||||
|
var wolf: CombatantStateRecord = manager.get_combatant(wolf_id)
|
||||||
|
var health_before := target_combatant.get_health()
|
||||||
|
var distance_before := wolf.get_position().distance_to(target.position)
|
||||||
|
|
||||||
|
manager.simulate_tick()
|
||||||
|
|
||||||
|
_check(
|
||||||
|
is_equal_approx(target_combatant.get_health(), health_before),
|
||||||
|
"The 18 metre wolf hunt radius should not become a remote melee strike"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
wolf.get_position().distance_to(target.position) < distance_before,
|
||||||
|
"An alerted wolf should move its authoritative position toward its defender"
|
||||||
|
)
|
||||||
|
manager.free()
|
||||||
|
|
||||||
|
|
||||||
|
func _test_npc_death_signals_observe_authoritative_death() -> void:
|
||||||
|
var manager := _create_manager(987)
|
||||||
|
var npc: SimNPC = manager.npcs[0]
|
||||||
|
npc.current_task = SimulationIds.ACTION_GATHER_FOOD
|
||||||
|
npc.task_state = SimNPC.TASK_STATE_TRAVELING
|
||||||
|
npc.target_id = &"missing_test_resource"
|
||||||
|
npc.has_travel_target = true
|
||||||
|
var death_observed := [false]
|
||||||
|
var task_change_observed := [false]
|
||||||
|
manager.npc_died.connect(
|
||||||
|
func(dead_npc: SimNPC):
|
||||||
|
death_observed[0] = (
|
||||||
|
dead_npc == npc
|
||||||
|
and dead_npc.is_dead
|
||||||
|
and dead_npc.current_task == SimulationIds.ACTION_DEAD
|
||||||
|
and dead_npc.target_id == &""
|
||||||
|
and not dead_npc.has_travel_target
|
||||||
|
)
|
||||||
|
)
|
||||||
|
manager.npc_task_changed.connect(
|
||||||
|
func(changed_npc: SimNPC, old_task: StringName, new_task: StringName):
|
||||||
|
task_change_observed[0] = (
|
||||||
|
changed_npc == npc
|
||||||
|
and changed_npc.is_dead
|
||||||
|
and old_task == SimulationIds.ACTION_GATHER_FOOD
|
||||||
|
and new_task == SimulationIds.ACTION_DEAD
|
||||||
|
)
|
||||||
|
)
|
||||||
|
var combatant: CombatantStateRecord = manager.get_combatant(
|
||||||
|
SimulationIds.npc_combatant_id(npc.id)
|
||||||
|
)
|
||||||
|
manager.conflict_system.call(
|
||||||
|
"_apply_damage", combatant, combatant.get_health(), SimulationIds.PLAYER_ACTOR_ID
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
death_observed[0] and task_change_observed[0],
|
||||||
|
"NPC death and task listeners should observe an already-dead, released simulation record"
|
||||||
|
)
|
||||||
|
manager.free()
|
||||||
|
|
||||||
|
|
||||||
func _test_player_combat_and_restore() -> void:
|
func _test_player_combat_and_restore() -> void:
|
||||||
var manager := _create_manager(984)
|
var manager := _create_manager(984)
|
||||||
var wolf_id: StringName = manager.spawn_wolf(Vector3(0.0, 0.0, 0.0))
|
var friendly: SimNPC = manager.npcs[0]
|
||||||
|
_check(
|
||||||
|
is_equal_approx(manager.player_attack(SimulationIds.npc_combatant_id(friendly.id)), 0.0),
|
||||||
|
"Simulation authority should reject a player attack against a friendly villager"
|
||||||
|
)
|
||||||
|
var distant_wolf_id: StringName = manager.spawn_wolf(Vector3(20.0, 0.0, 0.0))
|
||||||
|
_check(
|
||||||
|
is_equal_approx(manager.player_attack(distant_wolf_id), 0.0),
|
||||||
|
"Simulation authority should reject an out-of-range sword strike"
|
||||||
|
)
|
||||||
|
var wolf_id := distant_wolf_id
|
||||||
var wolf: CombatantStateRecord = manager.get_combatant(wolf_id)
|
var wolf: CombatantStateRecord = manager.get_combatant(wolf_id)
|
||||||
|
wolf.set_position(Vector3.ZERO)
|
||||||
_check(
|
_check(
|
||||||
is_equal_approx(manager.player_attack(wolf_id), 24.0),
|
is_equal_approx(manager.player_attack(wolf_id), 24.0),
|
||||||
"The player's sword strike should deal its configured damage"
|
"The player's sword strike should deal its configured damage"
|
||||||
)
|
)
|
||||||
_check(is_equal_approx(wolf.get_health(), 16.0), "The wolf should survive one sword strike")
|
_check(is_equal_approx(wolf.get_health(), 16.0), "The wolf should survive one sword strike")
|
||||||
|
_check(
|
||||||
|
is_equal_approx(manager.player_attack(wolf_id), 0.0),
|
||||||
|
"Simulation authority should enforce the configured player attack cooldown"
|
||||||
|
)
|
||||||
|
var sword := SimulationItems.get_weapon(SimulationIds.ITEM_SWORD)
|
||||||
|
manager.advance_player_combat_time(sword.attack_cooldown)
|
||||||
manager.player_attack(wolf_id)
|
manager.player_attack(wolf_id)
|
||||||
_check(not wolf.is_alive(), "A second sword strike should kill the wolf")
|
_check(not wolf.is_alive(), "A second sword strike should kill the wolf")
|
||||||
_check(
|
_check(
|
||||||
@@ -118,6 +207,8 @@ func _test_player_combat_and_restore() -> void:
|
|||||||
"The dead wolf should leave the living-hostile set"
|
"The dead wolf should leave the living-hostile set"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
manager.get_player_state().take_damage(35.0)
|
||||||
|
manager.call("_sync_player_combatant")
|
||||||
var saved_json: String = manager.serialize_state()
|
var saved_json: String = manager.serialize_state()
|
||||||
var restored := _create_manager(985)
|
var restored := _create_manager(985)
|
||||||
_check(
|
_check(
|
||||||
@@ -129,13 +220,42 @@ func _test_player_combat_and_restore() -> void:
|
|||||||
restored.get_state_checksum() == manager.get_state_checksum()
|
restored.get_state_checksum() == manager.get_state_checksum()
|
||||||
and restored.get_faction(SimulationIds.FACTION_TRIBE) != null
|
and restored.get_faction(SimulationIds.FACTION_TRIBE) != null
|
||||||
and not restored.get_combatant(wolf_id).is_alive()
|
and not restored.get_combatant(wolf_id).is_alive()
|
||||||
|
and is_equal_approx(
|
||||||
|
restored.get_combatant(&"player_combatant").get_health(),
|
||||||
|
restored.get_player_state().get_health()
|
||||||
|
)
|
||||||
),
|
),
|
||||||
"Restore should preserve combatant health, factions, and checksum"
|
"Restore should preserve hostile state, factions, checksum, and player health parity"
|
||||||
)
|
)
|
||||||
restored.free()
|
restored.free()
|
||||||
manager.free()
|
manager.free()
|
||||||
|
|
||||||
|
|
||||||
|
func _test_raid_plunder_uses_authoritative_pantry() -> void:
|
||||||
|
var manager := _create_manager(988)
|
||||||
|
var pantry: StorageStateRecord = manager.get_pantry()
|
||||||
|
pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD))
|
||||||
|
pantry.deposit(SimulationIds.RESOURCE_FOOD, 10.0)
|
||||||
|
manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
|
||||||
|
var village_faction: FactionStateRecord = manager.get_faction(SimulationIds.FACTION_VILLAGE)
|
||||||
|
var tribe: FactionStateRecord = manager.get_faction(SimulationIds.FACTION_TRIBE)
|
||||||
|
village_faction.set_food(10.0)
|
||||||
|
var tribe_food_before := tribe.get_food()
|
||||||
|
|
||||||
|
manager.conflict_system.call("_finish_war", SimulationIds.WAR_PLAN_ABORTED, &"village_lost")
|
||||||
|
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), 4.0)
|
||||||
|
and is_equal_approx(manager.village.food, 4.0)
|
||||||
|
and is_equal_approx(village_faction.get_food(), 4.0)
|
||||||
|
and is_equal_approx(tribe.get_food(), tribe_food_before + 6.0)
|
||||||
|
),
|
||||||
|
"Raid plunder should transfer real pantry food instead of mutating only faction display state"
|
||||||
|
)
|
||||||
|
manager.free()
|
||||||
|
|
||||||
|
|
||||||
func _set_all_strength(manager: Node, strength: float) -> void:
|
func _set_all_strength(manager: Node, strength: float) -> void:
|
||||||
for npc in manager.npcs:
|
for npc in manager.npcs:
|
||||||
npc.strength = strength
|
npc.strength = strength
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
var failures: Array[String] = []
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
call_deferred("_run")
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
var creature := CreatureVisual.new()
|
||||||
|
root.add_child(creature)
|
||||||
|
creature.global_position = Vector3.ZERO
|
||||||
|
creature.set_sim_position(Vector3.ZERO)
|
||||||
|
for _frame in 12:
|
||||||
|
await physics_frame
|
||||||
|
_check(
|
||||||
|
creature.path_query_count == 0,
|
||||||
|
"A creature already at its authoritative position should not request a path"
|
||||||
|
)
|
||||||
|
|
||||||
|
creature.set_sim_position(Vector3(12.0, 0.0, 0.0))
|
||||||
|
for _frame in 6:
|
||||||
|
await physics_frame
|
||||||
|
var first_query_count := creature.path_query_count
|
||||||
|
_check(first_query_count == 1, "A true retarget should request one navigation path")
|
||||||
|
for _frame in 20:
|
||||||
|
await physics_frame
|
||||||
|
_check(
|
||||||
|
creature.path_query_count == first_query_count,
|
||||||
|
"An unreachable target should not be queried again every physics frame"
|
||||||
|
)
|
||||||
|
for _frame in 120:
|
||||||
|
await physics_frame
|
||||||
|
_check(
|
||||||
|
creature.path_query_count <= 4,
|
||||||
|
"Unreachable navigation retries should follow bounded exponential backoff"
|
||||||
|
)
|
||||||
|
|
||||||
|
var before_retarget := creature.path_query_count
|
||||||
|
creature.set_sim_position(Vector3(24.0, 0.0, 0.0))
|
||||||
|
for _frame in 4:
|
||||||
|
await physics_frame
|
||||||
|
_check(
|
||||||
|
creature.path_query_count == before_retarget + 1,
|
||||||
|
"Moving the authoritative target should bypass stale retry delay once"
|
||||||
|
)
|
||||||
|
|
||||||
|
creature.free()
|
||||||
|
if failures.is_empty():
|
||||||
|
print("[TEST] Creature path retry passed: stationary -> backoff -> retarget")
|
||||||
|
quit(0)
|
||||||
|
return
|
||||||
|
for failure in failures:
|
||||||
|
push_error("[TEST] " + failure)
|
||||||
|
quit(1)
|
||||||
|
|
||||||
|
|
||||||
|
func _check(condition: bool, message: String) -> void:
|
||||||
|
if not condition:
|
||||||
|
failures.append(message)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://db7vm7rdi5blm
|
||||||
@@ -47,19 +47,33 @@ func _run() -> void:
|
|||||||
)
|
)
|
||||||
|
|
||||||
var wolf_visual: Node3D = combat_view.hostiles[wolf.get_combatant_id()]
|
var wolf_visual: Node3D = combat_view.hostiles[wolf.get_combatant_id()]
|
||||||
|
var sword_definition := SimulationItems.get_weapon(SimulationIds.ITEM_SWORD)
|
||||||
|
_check(
|
||||||
|
is_equal_approx(controller.attack_range, sword_definition.reach),
|
||||||
|
"Player targeting and simulation authority should share the sword definition reach"
|
||||||
|
)
|
||||||
player.global_position = wolf_visual.global_position + Vector3(-2.0, 0.0, 0.0)
|
player.global_position = wolf_visual.global_position + Vector3(-2.0, 0.0, 0.0)
|
||||||
player.look_at(wolf_visual.global_position, Vector3.UP)
|
player.look_at(wolf_visual.global_position, Vector3.UP)
|
||||||
player.rotation.x = 0.0
|
player.rotation.x = 0.0
|
||||||
|
simulation_manager.update_player_combatant(player.global_position)
|
||||||
var wolf_health: float = wolf.get_health()
|
var wolf_health: float = wolf.get_health()
|
||||||
controller.call("_perform_attack")
|
controller.call("_perform_attack")
|
||||||
_check(
|
_check(
|
||||||
wolf.get_health() < wolf_health,
|
wolf.get_health() < wolf_health,
|
||||||
"A player sword strike should damage the hostile wolf through the simulation"
|
"A player sword strike should damage the hostile wolf through the simulation"
|
||||||
)
|
)
|
||||||
|
var hit_tween: Tween = wolf_visual.get("_flash_tween") as Tween
|
||||||
|
wolf.set_position(wolf.get_position() + Vector3(0.1, 0.0, 0.0))
|
||||||
|
_check(
|
||||||
|
wolf_visual.get("_flash_tween") == hit_tween,
|
||||||
|
"An injured hostile should not replay its hit flash for a position-only state update"
|
||||||
|
)
|
||||||
var struck := 0
|
var struck := 0
|
||||||
for _attempt in 6:
|
for _attempt in 6:
|
||||||
if not wolf.is_alive():
|
if not wolf.is_alive():
|
||||||
break
|
break
|
||||||
|
controller.call("_advance_timers", sword_definition.attack_cooldown)
|
||||||
|
simulation_manager.advance_player_combat_time(sword_definition.attack_cooldown)
|
||||||
controller.call("_perform_attack")
|
controller.call("_perform_attack")
|
||||||
struck += 1
|
struck += 1
|
||||||
await process_frame
|
await process_frame
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
var failures: Array[String] = []
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
call_deferred("_run")
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
var camera := Camera3D.new()
|
||||||
|
camera.current = true
|
||||||
|
root.add_child(camera)
|
||||||
|
var world: JajceWorld = load("res://world/jajce/JajceWorld.tscn").instantiate()
|
||||||
|
root.add_child(world)
|
||||||
|
await process_frame
|
||||||
|
await physics_frame
|
||||||
|
|
||||||
|
var viewport := root as Viewport
|
||||||
|
var environment: Environment = (
|
||||||
|
(world.get_node("WorldEnvironment") as WorldEnvironment).environment
|
||||||
|
)
|
||||||
|
var light := world.get_node("DirectionalLight3D") as DirectionalLight3D
|
||||||
|
var grass := world.get_node("TerrainRoot/Terrain3D/CozyGrassField") as Node3D
|
||||||
|
var grass_controller := world.get_node("TerrainRoot/GrassInteractionController") as Node
|
||||||
|
var world_grass_material := grass.get("mesh_material_override") as ShaderMaterial
|
||||||
|
var world_process_material := grass.get("process_material") as ShaderMaterial
|
||||||
|
var world_sky: Sky = environment.sky
|
||||||
|
var world_sky_material: Material = world_sky.sky_material
|
||||||
|
var balanced_particle_count := int(grass.get("particle_count"))
|
||||||
|
var balanced_render_scale := viewport.scaling_3d_scale
|
||||||
|
|
||||||
|
_check(
|
||||||
|
world.get_active_presentation_quality() == JajceWorld.PresentationQuality.BALANCED,
|
||||||
|
"Balanced should be the default presentation tier"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
balanced_render_scale <= JajceWorld.BALANCED_RENDER_SCALE + 0.001,
|
||||||
|
(
|
||||||
|
"Balanced should reduce 3D render resolution while leaving UI resolution untouched"
|
||||||
|
+ " (found %.2f)" % balanced_render_scale
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
light.directional_shadow_max_distance <= JajceWorld.BALANCED_SHADOW_DISTANCE
|
||||||
|
and light.directional_shadow_mode == DirectionalLight3D.SHADOW_PARALLEL_2_SPLITS
|
||||||
|
and environment.fog_enabled
|
||||||
|
and not environment.volumetric_fog_enabled
|
||||||
|
),
|
||||||
|
"Balanced should bound shadows and use cheap valley fog instead of volumetric fog"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
world_grass_material == grass_controller.get("grass_material"),
|
||||||
|
"Grass rendering and interaction should share this world's isolated material"
|
||||||
|
)
|
||||||
|
|
||||||
|
_check(
|
||||||
|
world.apply_presentation_quality(JajceWorld.PresentationQuality.HIGH),
|
||||||
|
"High presentation tier should be selectable"
|
||||||
|
)
|
||||||
|
await physics_frame
|
||||||
|
var high_particle_count := int(grass.get("particle_count"))
|
||||||
|
var high_render_scale := viewport.scaling_3d_scale
|
||||||
|
var high_shadow_distance := light.directional_shadow_max_distance
|
||||||
|
_check(
|
||||||
|
high_particle_count in range(9000, 15001),
|
||||||
|
"High should restore the authored grass population"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
balanced_particle_count < high_particle_count,
|
||||||
|
"Balanced should materially reduce grass density relative to High"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
environment.glow_enabled
|
||||||
|
and (environment.volumetric_fog_enabled == world.call("_supports_volumetric_fog"))
|
||||||
|
and environment.adjustment_enabled
|
||||||
|
),
|
||||||
|
"High should preserve supported authored effects without enabling unsupported fog"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
is_equal_approx(high_shadow_distance, 180.0),
|
||||||
|
"High should preserve the authored 180 metre shadow range"
|
||||||
|
)
|
||||||
|
_check(_all_grass_particles_emitting(grass), "High should render every authored grass cell")
|
||||||
|
|
||||||
|
_check(
|
||||||
|
world.apply_presentation_quality(JajceWorld.PresentationQuality.LOW),
|
||||||
|
"Low presentation tier should be selectable"
|
||||||
|
)
|
||||||
|
await physics_frame
|
||||||
|
_check(
|
||||||
|
viewport.scaling_3d_scale <= JajceWorld.LOW_RENDER_SCALE + 0.001,
|
||||||
|
"Low should reduce 3D render resolution"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
environment.fog_enabled
|
||||||
|
and not environment.glow_enabled
|
||||||
|
and not environment.volumetric_fog_enabled
|
||||||
|
and not environment.adjustment_enabled
|
||||||
|
),
|
||||||
|
"Low should retain cheap valley fog while disabling costly post-processing"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
light.directional_shadow_max_distance <= JajceWorld.LOW_SHADOW_DISTANCE
|
||||||
|
and light.directional_shadow_mode == DirectionalLight3D.SHADOW_ORTHOGONAL
|
||||||
|
),
|
||||||
|
"Low should shorten shadows and use one orthogonal shadow region"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
int(grass.get("particle_count")) <= high_particle_count / 4,
|
||||||
|
"Low should reduce the latent grass population by at least 75 percent"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
not grass.visible and not grass.is_physics_processing(),
|
||||||
|
"Low should hide grass and stop its camera-grid physics work"
|
||||||
|
)
|
||||||
|
_check(not grass_controller.is_processing(), "Low should stop grass interaction scans")
|
||||||
|
_check(_not_any_grass_particle_emitting(grass), "Low should stop every grass particle emitter")
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
world_grass_material != null
|
||||||
|
and int(world_grass_material.get_shader_parameter("interactor_count")) == 0
|
||||||
|
),
|
||||||
|
"Low should clear grass shader interactors"
|
||||||
|
)
|
||||||
|
|
||||||
|
_check(
|
||||||
|
world.apply_presentation_quality(JajceWorld.PresentationQuality.HIGH),
|
||||||
|
"High should be restorable after Low"
|
||||||
|
)
|
||||||
|
await physics_frame
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
is_equal_approx(viewport.scaling_3d_scale, high_render_scale)
|
||||||
|
and environment.glow_enabled
|
||||||
|
and (environment.volumetric_fog_enabled == world.call("_supports_volumetric_fog"))
|
||||||
|
and environment.adjustment_enabled
|
||||||
|
and is_equal_approx(light.directional_shadow_max_distance, high_shadow_distance)
|
||||||
|
),
|
||||||
|
"Returning to High should restore the authored environment contract"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
grass.visible
|
||||||
|
and grass.is_physics_processing()
|
||||||
|
and grass_controller.is_processing()
|
||||||
|
and int(grass.get("particle_count")) == high_particle_count
|
||||||
|
and _all_grass_particles_emitting(grass)
|
||||||
|
),
|
||||||
|
"Returning to High should restore grass rendering and processing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var world_base_scale := float(world.get("_authored_render_scale"))
|
||||||
|
world.free()
|
||||||
|
_check(
|
||||||
|
is_equal_approx(viewport.scaling_3d_scale, world_base_scale),
|
||||||
|
"An exiting world should restore the viewport scale it originally owned"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
world_grass_material.resource_local_to_scene
|
||||||
|
and world_process_material.resource_local_to_scene
|
||||||
|
and environment.resource_local_to_scene
|
||||||
|
and world_sky.resource_local_to_scene
|
||||||
|
and world_sky_material.resource_local_to_scene
|
||||||
|
),
|
||||||
|
"Every mutable environment, sky, and grass resource should be local to one world"
|
||||||
|
)
|
||||||
|
var fresh_world: JajceWorld = load("res://world/jajce/JajceWorld.tscn").instantiate()
|
||||||
|
root.add_child(fresh_world)
|
||||||
|
await process_frame
|
||||||
|
await physics_frame
|
||||||
|
var fresh_environment: Environment = (
|
||||||
|
(fresh_world.get_node("WorldEnvironment") as WorldEnvironment).environment
|
||||||
|
)
|
||||||
|
var fresh_grass := fresh_world.get_node("TerrainRoot/Terrain3D/CozyGrassField") as Node3D
|
||||||
|
var fresh_grass_material := fresh_grass.get("mesh_material_override") as ShaderMaterial
|
||||||
|
var fresh_process_material := fresh_grass.get("process_material") as ShaderMaterial
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
fresh_world.get_active_presentation_quality() == JajceWorld.PresentationQuality.BALANCED
|
||||||
|
and fresh_environment.fog_enabled
|
||||||
|
and not fresh_environment.volumetric_fog_enabled
|
||||||
|
and fresh_grass.visible
|
||||||
|
and fresh_grass.is_physics_processing()
|
||||||
|
and int(fresh_grass.get("particle_count")) == balanced_particle_count
|
||||||
|
),
|
||||||
|
"A fresh world after Low should start from an isolated Balanced presentation"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
environment != fresh_environment
|
||||||
|
and environment.sky != fresh_environment.sky
|
||||||
|
and environment.sky.sky_material != fresh_environment.sky.sky_material
|
||||||
|
and world_grass_material != fresh_grass_material
|
||||||
|
and world_process_material != fresh_process_material
|
||||||
|
and _all_grass_particles_use_process_material(fresh_grass, fresh_process_material)
|
||||||
|
),
|
||||||
|
"Sequential Jajce worlds should not share mutable environment, sky, or grass resources"
|
||||||
|
)
|
||||||
|
var original_scale := float(fresh_world.get("_authored_render_scale"))
|
||||||
|
fresh_world.free()
|
||||||
|
_check(
|
||||||
|
is_equal_approx(viewport.scaling_3d_scale, original_scale),
|
||||||
|
"A sequential world should release its viewport-scale ownership"
|
||||||
|
)
|
||||||
|
|
||||||
|
var older_owner := _create_presentation_only_world(JajceWorld.PresentationQuality.LOW)
|
||||||
|
root.add_child(older_owner)
|
||||||
|
await process_frame
|
||||||
|
var newer_owner := _create_presentation_only_world(JajceWorld.PresentationQuality.BALANCED)
|
||||||
|
root.add_child(newer_owner)
|
||||||
|
await process_frame
|
||||||
|
_check(
|
||||||
|
is_equal_approx(float(newer_owner.get("_authored_render_scale")), original_scale),
|
||||||
|
"Concurrent owners should inherit the viewport base, not another owner's reduced scale"
|
||||||
|
)
|
||||||
|
older_owner.free()
|
||||||
|
_check(
|
||||||
|
is_equal_approx(viewport.scaling_3d_scale, JajceWorld.BALANCED_RENDER_SCALE),
|
||||||
|
"A stale owner exiting should not overwrite the active owner's scale"
|
||||||
|
)
|
||||||
|
newer_owner.free()
|
||||||
|
_check(
|
||||||
|
is_equal_approx(viewport.scaling_3d_scale, original_scale),
|
||||||
|
"The final viewport owner should restore the original scale on exit"
|
||||||
|
)
|
||||||
|
|
||||||
|
var main_scene: Node = load("res://main.tscn").instantiate()
|
||||||
|
var time_dial := main_scene.get_node("UI/TimeDial") as Control
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
is_equal_approx(time_dial.anchor_left, 0.5)
|
||||||
|
and is_equal_approx(time_dial.anchor_right, 0.5)
|
||||||
|
and is_equal_approx(time_dial.offset_left, -30.0)
|
||||||
|
and is_equal_approx(time_dial.offset_right, 30.0)
|
||||||
|
),
|
||||||
|
"Time dial should remain horizontally centered at weaker-PC window sizes"
|
||||||
|
)
|
||||||
|
main_scene.free()
|
||||||
|
|
||||||
|
camera.free()
|
||||||
|
if failures.is_empty():
|
||||||
|
print("Jajce presentation quality checks passed")
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
for failure in failures:
|
||||||
|
push_error(failure)
|
||||||
|
quit(1)
|
||||||
|
|
||||||
|
|
||||||
|
func _all_grass_particles_emitting(grass: Node3D) -> bool:
|
||||||
|
var particles: Array = grass.get("particle_nodes")
|
||||||
|
if particles.is_empty():
|
||||||
|
return false
|
||||||
|
for value in particles:
|
||||||
|
var particle := value as GPUParticles3D
|
||||||
|
if particle == null or not particle.emitting:
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _create_presentation_only_world(quality: int) -> JajceWorld:
|
||||||
|
var world: JajceWorld = load("res://world/jajce/JajceWorld.tscn").instantiate()
|
||||||
|
# Stable simulation-facing IDs deliberately allow only one loaded authority
|
||||||
|
# world. The ownership test needs only the presentation subtree.
|
||||||
|
world.get_node("WorldObjects").free()
|
||||||
|
world.presentation_quality = quality
|
||||||
|
return world
|
||||||
|
|
||||||
|
|
||||||
|
func _not_any_grass_particle_emitting(grass: Node3D) -> bool:
|
||||||
|
var particles: Array = grass.get("particle_nodes")
|
||||||
|
for value in particles:
|
||||||
|
var particle := value as GPUParticles3D
|
||||||
|
if particle != null and particle.emitting:
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _all_grass_particles_use_process_material(grass: Node3D, material: ShaderMaterial) -> bool:
|
||||||
|
var particles: Array = grass.get("particle_nodes")
|
||||||
|
if particles.is_empty():
|
||||||
|
return false
|
||||||
|
for value in particles:
|
||||||
|
var particle := value as GPUParticles3D
|
||||||
|
if particle == null or particle.process_material != material:
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func _check(condition: bool, message: String) -> void:
|
||||||
|
if not condition:
|
||||||
|
failures.append(message)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bs7y460mcireu
|
||||||
@@ -18,8 +18,8 @@ func _run() -> void:
|
|||||||
simulation_manager.set_process(false)
|
simulation_manager.set_process(false)
|
||||||
var saved_clock_ticks: int = simulation_manager.clock.elapsed_ticks
|
var saved_clock_ticks: int = simulation_manager.clock.elapsed_ticks
|
||||||
simulation_manager.clock.elapsed_ticks = 0
|
simulation_manager.clock.elapsed_ticks = 0
|
||||||
await process_frame
|
var day_night_cycle := main_scene.get_node("JajceWorld/DayNightCycle")
|
||||||
await process_frame
|
day_night_cycle.call("_process", 0.11)
|
||||||
var environment: Environment = (
|
var environment: Environment = (
|
||||||
(main_scene.get_node("JajceWorld/WorldEnvironment") as WorldEnvironment).environment
|
(main_scene.get_node("JajceWorld/WorldEnvironment") as WorldEnvironment).environment
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,11 +16,20 @@ func _run() -> void:
|
|||||||
var configured_assets: Terrain3DAssets = load("res://terrain/jajce/assets.tres")
|
var configured_assets: Terrain3DAssets = load("res://terrain/jajce/assets.tres")
|
||||||
var configured_texture_count := configured_assets.get_texture_count()
|
var configured_texture_count := configured_assets.get_texture_count()
|
||||||
|
|
||||||
var world: Node3D = load("res://world/jajce/JajceWorld.tscn").instantiate()
|
var world: JajceWorld = load("res://world/jajce/JajceWorld.tscn").instantiate()
|
||||||
root.add_child(world)
|
root.add_child(world)
|
||||||
await process_frame
|
await process_frame
|
||||||
for _frame in 10:
|
for _frame in 10:
|
||||||
await physics_frame
|
await physics_frame
|
||||||
|
_check(
|
||||||
|
world.get_active_presentation_quality() == JajceWorld.PresentationQuality.BALANCED,
|
||||||
|
"JajceWorld should default to the balanced presentation budget"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
world.apply_presentation_quality(JajceWorld.PresentationQuality.HIGH),
|
||||||
|
"Scaffold validation should be able to select the authored high presentation"
|
||||||
|
)
|
||||||
|
await physics_frame
|
||||||
|
|
||||||
var terrain = world.get_node("TerrainRoot/Terrain3D")
|
var terrain = world.get_node("TerrainRoot/Terrain3D")
|
||||||
_check(terrain is Terrain3D, "JajceWorld should own a Terrain3D node")
|
_check(terrain is Terrain3D, "JajceWorld should own a Terrain3D node")
|
||||||
|
|||||||
@@ -51,11 +51,23 @@ func _test_exact_bounded_discovery(benchmark: RefCounted, fixture: Dictionary) -
|
|||||||
"Ordinary local queries should inspect a bounded subset of 180 loaded resources",
|
"Ordinary local queries should inspect a bounded subset of 180 loaded resources",
|
||||||
)
|
)
|
||||||
var adapter: ActiveWorldAdapter = fixture["adapter"]
|
var adapter: ActiveWorldAdapter = fixture["adapter"]
|
||||||
|
var manager: Node = fixture["manager"]
|
||||||
var stats := adapter.get_resource_index_stats()
|
var stats := adapter.get_resource_index_stats()
|
||||||
_check(
|
_check(
|
||||||
int(stats["candidate_count"]) == 180 and int(stats["occupied_cell_count"]) > 1,
|
int(stats["candidate_count"]) == 180 and int(stats["occupied_cell_count"]) > 1,
|
||||||
"The adapter should index every loaded anchor across multiple horizontal cells",
|
"The adapter should index every loaded anchor across multiple horizontal cells",
|
||||||
)
|
)
|
||||||
|
var player_origin := ResourceNode.get_by_id(&"benchmark_resource_0001").global_position
|
||||||
|
var player_target: ResourceNode = manager.find_resource_node_for_player(player_origin, 12.0)
|
||||||
|
var player_query: Dictionary = manager.last_player_resource_query_stats
|
||||||
|
_check(player_target != null, "Player discovery should find a nearby usable resource")
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
StringName(player_query.get("mode", "")) == &"spatial"
|
||||||
|
and int(player_query.get("candidate_count", 180)) < 12
|
||||||
|
),
|
||||||
|
"Player discovery should inspect a bounded spatial subset, not all 180 loaded resources",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
func _test_far_priority_is_not_pruned(benchmark: RefCounted, fixture: Dictionary) -> void:
|
func _test_far_priority_is_not_pruned(benchmark: RefCounted, fixture: Dictionary) -> void:
|
||||||
|
|||||||
@@ -3,6 +3,23 @@ extends SceneTree
|
|||||||
const SimulationManagerScript := preload("res://simulation/SimulationManager.gd")
|
const SimulationManagerScript := preload("res://simulation/SimulationManager.gd")
|
||||||
const BenchmarkScript := preload("res://simulation/benchmark/SimulationScalingBenchmark.gd")
|
const BenchmarkScript := preload("res://simulation/benchmark/SimulationScalingBenchmark.gd")
|
||||||
|
|
||||||
|
|
||||||
|
class CoverageDroppingManager:
|
||||||
|
extends "res://simulation/SimulationManager.gd"
|
||||||
|
|
||||||
|
var drop_after_simulation_ticks := -1
|
||||||
|
var simulation_ticks_seen := 0
|
||||||
|
var removed_combatant_id: StringName = &""
|
||||||
|
|
||||||
|
func simulate_tick() -> void:
|
||||||
|
super()
|
||||||
|
simulation_ticks_seen += 1
|
||||||
|
if simulation_ticks_seen != drop_after_simulation_ticks or npcs.is_empty():
|
||||||
|
return
|
||||||
|
removed_combatant_id = SimulationIds.npc_combatant_id(npcs[-1].id)
|
||||||
|
conflict_system.combatants.erase(removed_combatant_id)
|
||||||
|
|
||||||
|
|
||||||
var failures: Array[String] = []
|
var failures: Array[String] = []
|
||||||
|
|
||||||
|
|
||||||
@@ -21,9 +38,22 @@ func _run() -> void:
|
|||||||
_check(first["fixture_valid"], "The prepared benchmark state should pass schema validation")
|
_check(first["fixture_valid"], "The prepared benchmark state should pass schema validation")
|
||||||
_check(
|
_check(
|
||||||
(
|
(
|
||||||
int(first["schema_version"]) == SimulationScalingBenchmark.SCHEMA_VERSION
|
int(first["schema_version"]) == 2
|
||||||
and StringName(first["workload_id"]) == SimulationScalingBenchmark.WORKLOAD_ID
|
and SimulationScalingBenchmark.SCHEMA_VERSION == 2
|
||||||
|
and (
|
||||||
|
int(first["simulation_state_schema_version"])
|
||||||
|
== SimulationStateRecord.SCHEMA_VERSION
|
||||||
|
)
|
||||||
|
and StringName(first["workload_id"]) == &"full_fidelity_combatant_headless_arrival_v2"
|
||||||
|
and (
|
||||||
|
SimulationScalingBenchmark.WORKLOAD_ID
|
||||||
|
== &"full_fidelity_combatant_headless_arrival_v2"
|
||||||
|
)
|
||||||
and int(first["population"]) == 12
|
and int(first["population"]) == 12
|
||||||
|
and int(first["npc_combatant_count"]) == 12
|
||||||
|
and bool(first["npc_combatant_coverage_valid"])
|
||||||
|
and int(first["fixture_npc_combatant_count"]) == 12
|
||||||
|
and bool(first["fixture_combatants_valid"])
|
||||||
and int(first["history_seed_events"]) == 24
|
and int(first["history_seed_events"]) == 24
|
||||||
and int(first["measured_ticks"]) == 8
|
and int(first["measured_ticks"]) == 8
|
||||||
and int(first["npc_updates"]) == 96
|
and int(first["npc_updates"]) == 96
|
||||||
@@ -59,6 +89,8 @@ func _run() -> void:
|
|||||||
"arrivals_processed",
|
"arrivals_processed",
|
||||||
"warmup_arrivals",
|
"warmup_arrivals",
|
||||||
"tick_interval",
|
"tick_interval",
|
||||||
|
"npc_combatant_count",
|
||||||
|
"npc_combatant_coverage_valid",
|
||||||
]:
|
]:
|
||||||
_check(
|
_check(
|
||||||
first[deterministic_key] == repeated[deterministic_key],
|
first[deterministic_key] == repeated[deterministic_key],
|
||||||
@@ -68,6 +100,14 @@ func _run() -> void:
|
|||||||
first["final_checksum"] != different_seed["final_checksum"],
|
first["final_checksum"] != different_seed["final_checksum"],
|
||||||
"A different fixture seed should produce a different deterministic checksum"
|
"A different fixture seed should produce a different deterministic checksum"
|
||||||
)
|
)
|
||||||
|
_check(
|
||||||
|
_coverage_loss_is_rejected(2, 3, 2),
|
||||||
|
"Coverage lost on the final warmup tick should reject the benchmark result"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
_coverage_loss_is_rejected(2, 3, 5),
|
||||||
|
"Coverage lost on the final measured tick should reject the benchmark result"
|
||||||
|
)
|
||||||
_finish()
|
_finish()
|
||||||
|
|
||||||
|
|
||||||
@@ -82,12 +122,57 @@ func _run_case(seed_value: int) -> Dictionary:
|
|||||||
manager.free()
|
manager.free()
|
||||||
return {}
|
return {}
|
||||||
var fixture_valid := SimulationStateRecord.from_json(manager.serialize_state()) != null
|
var fixture_valid := SimulationStateRecord.from_json(manager.serialize_state()) != null
|
||||||
|
var fixture_npc_combatant_count := 0
|
||||||
|
var fixture_combatants_valid := true
|
||||||
|
for npc in manager.npcs:
|
||||||
|
var combatant: CombatantStateRecord = manager.conflict_system.get_combatant(
|
||||||
|
SimulationIds.npc_combatant_id(npc.id)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
combatant == null
|
||||||
|
or combatant.get_npc_id() != npc.id
|
||||||
|
or combatant.get_display_name() != npc.npc_name
|
||||||
|
or not combatant.get_position().is_equal_approx(npc.position)
|
||||||
|
):
|
||||||
|
fixture_combatants_valid = false
|
||||||
|
else:
|
||||||
|
fixture_npc_combatant_count += 1
|
||||||
var result: Dictionary = benchmark.measure_manager(manager, 12, 24, 2, 8)
|
var result: Dictionary = benchmark.measure_manager(manager, 12, 24, 2, 8)
|
||||||
result["fixture_valid"] = fixture_valid
|
result["fixture_valid"] = fixture_valid
|
||||||
|
result["fixture_npc_combatant_count"] = fixture_npc_combatant_count
|
||||||
|
result["fixture_combatants_valid"] = fixture_combatants_valid
|
||||||
manager.free()
|
manager.free()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
func _coverage_loss_is_rejected(
|
||||||
|
warmup_ticks: int, measured_ticks: int, drop_after_simulation_ticks: int
|
||||||
|
) -> bool:
|
||||||
|
var manager := CoverageDroppingManager.new()
|
||||||
|
manager.simulation_seed = 9010
|
||||||
|
manager.debug_logs = false
|
||||||
|
manager.set_process(false)
|
||||||
|
root.add_child(manager)
|
||||||
|
var benchmark := BenchmarkScript.new()
|
||||||
|
var prepared := benchmark.prepare_manager(manager, 4, 0, manager.simulation_seed)
|
||||||
|
if not prepared:
|
||||||
|
manager.free()
|
||||||
|
return false
|
||||||
|
var target_id := SimulationIds.npc_combatant_id(manager.npcs[-1].id)
|
||||||
|
var target_existed := manager.conflict_system.get_combatant(target_id) != null
|
||||||
|
manager.drop_after_simulation_ticks = drop_after_simulation_ticks
|
||||||
|
var result: Dictionary = benchmark.measure_manager(manager, 4, 0, warmup_ticks, measured_ticks)
|
||||||
|
var rejected_after_exact_drop := (
|
||||||
|
target_existed
|
||||||
|
and manager.simulation_ticks_seen == drop_after_simulation_ticks
|
||||||
|
and manager.removed_combatant_id == target_id
|
||||||
|
and manager.conflict_system.get_combatant(target_id) == null
|
||||||
|
and result.is_empty()
|
||||||
|
)
|
||||||
|
manager.free()
|
||||||
|
return rejected_after_exact_drop
|
||||||
|
|
||||||
|
|
||||||
func _check(condition: bool, message: String) -> void:
|
func _check(condition: bool, message: String) -> void:
|
||||||
if not condition:
|
if not condition:
|
||||||
failures.append(message)
|
failures.append(message)
|
||||||
|
|||||||
@@ -7,6 +7,90 @@ const EventKnowledgeSystemScript := preload("res://simulation/knowledge/EventKno
|
|||||||
const SimulationManagerScript := preload("res://simulation/SimulationManager.gd")
|
const SimulationManagerScript := preload("res://simulation/SimulationManager.gd")
|
||||||
|
|
||||||
|
|
||||||
|
func test_simulation_clock_caps_frame_work_without_discarding_backlog() -> void:
|
||||||
|
var clock := SimulationClock.new(1.0)
|
||||||
|
clock.cycle_duration_seconds = 100.0
|
||||||
|
|
||||||
|
assert_eq(clock.advance(10.0, 3), 3)
|
||||||
|
assert_eq(clock.elapsed_ticks, 3)
|
||||||
|
assert_eq(clock.accumulator, 7.0)
|
||||||
|
assert_true(is_equal_approx(clock.time_of_day(), 0.03))
|
||||||
|
assert_eq(clock.advance(0.0, 3), 3)
|
||||||
|
assert_eq(clock.elapsed_ticks, 6)
|
||||||
|
assert_eq(clock.accumulator, 4.0)
|
||||||
|
assert_true(is_equal_approx(clock.time_of_day(), 0.06))
|
||||||
|
|
||||||
|
var fractional_clock := SimulationClock.new(1.0)
|
||||||
|
fractional_clock.cycle_duration_seconds = 100.0
|
||||||
|
assert_eq(fractional_clock.advance(0.25, 3), 0)
|
||||||
|
assert_true(is_equal_approx(fractional_clock.time_of_day(), 0.0025))
|
||||||
|
|
||||||
|
|
||||||
|
func test_player_attack_uses_definition_reach_and_unscaled_realtime_cooldown() -> void:
|
||||||
|
var conflict := ConflictSystem.new()
|
||||||
|
conflict.configure(null, 0.1)
|
||||||
|
conflict.initialize_factions()
|
||||||
|
conflict.set_player_combatant_position(Vector3.ZERO)
|
||||||
|
var sword := SimulationItems.get_weapon(SimulationIds.ITEM_SWORD)
|
||||||
|
assert_not_null(sword)
|
||||||
|
var wolf_id := conflict.spawn_wolf(Vector3(sword.reach + 0.01, 8.0, 0.0))
|
||||||
|
var wolf := conflict.get_combatant(wolf_id)
|
||||||
|
|
||||||
|
assert_eq(conflict.player_attack(wolf_id), 0.0)
|
||||||
|
wolf.set_position(Vector3(sword.reach - 0.01, 8.0, 0.0))
|
||||||
|
assert_eq(conflict.player_attack(wolf_id), sword.damage)
|
||||||
|
assert_false(conflict.is_player_attack_ready())
|
||||||
|
conflict.advance(1)
|
||||||
|
assert_false(conflict.is_player_attack_ready())
|
||||||
|
conflict.advance_realtime(sword.attack_cooldown - 0.01)
|
||||||
|
assert_false(conflict.is_player_attack_ready())
|
||||||
|
conflict.advance_realtime(0.02)
|
||||||
|
assert_true(conflict.is_player_attack_ready())
|
||||||
|
|
||||||
|
|
||||||
|
func test_player_dash_duration_and_cooldown_are_enforced_separately() -> void:
|
||||||
|
var controller := PlayerCombatController.new()
|
||||||
|
var player := CharacterBody3D.new()
|
||||||
|
controller.player = player
|
||||||
|
player.add_child(controller)
|
||||||
|
add_child_autofree(player)
|
||||||
|
var sword := SimulationItems.get_weapon(SimulationIds.ITEM_SWORD)
|
||||||
|
|
||||||
|
assert_true(is_equal_approx(controller.attack_range, sword.reach))
|
||||||
|
controller.call("_perform_dash")
|
||||||
|
assert_true(controller.is_dashing())
|
||||||
|
assert_true(
|
||||||
|
is_equal_approx(controller.get_dash_cooldown_remaining(), controller.dash_cooldown_seconds)
|
||||||
|
)
|
||||||
|
controller.call("_advance_timers", controller.dash_duration + 0.01)
|
||||||
|
assert_false(controller.is_dashing())
|
||||||
|
controller.call("_perform_dash")
|
||||||
|
assert_false(controller.is_dashing())
|
||||||
|
controller.call("_advance_timers", controller.get_dash_cooldown_remaining())
|
||||||
|
controller.call("_perform_dash")
|
||||||
|
assert_true(controller.is_dashing())
|
||||||
|
|
||||||
|
|
||||||
|
func test_combat_cooldown_uses_the_configured_simulation_tick() -> void:
|
||||||
|
var combatant := CombatantStateRecord.create(
|
||||||
|
&"cooldown_test",
|
||||||
|
SimulationIds.COMBATANT_KIND_PLAYER,
|
||||||
|
SimulationIds.FACTION_VILLAGE,
|
||||||
|
"Cooldown Tester",
|
||||||
|
Vector3.ZERO,
|
||||||
|
100.0,
|
||||||
|
SimulationIds.ITEM_SWORD
|
||||||
|
)
|
||||||
|
|
||||||
|
combatant.mark_attacked(0.3)
|
||||||
|
assert_eq(combatant.get_attack_cooldown(), 2)
|
||||||
|
combatant.tick_cooldown()
|
||||||
|
combatant.tick_cooldown()
|
||||||
|
assert_true(combatant.is_attack_ready())
|
||||||
|
combatant.mark_attacked(1.2)
|
||||||
|
assert_eq(combatant.get_attack_cooldown(), 1)
|
||||||
|
|
||||||
|
|
||||||
func test_storage_never_accepts_more_than_its_capacity() -> void:
|
func test_storage_never_accepts_more_than_its_capacity() -> void:
|
||||||
var storage := StorageStateRecord.create(&"test_storage", {"food": 4.5}, 5.0)
|
var storage := StorageStateRecord.create(&"test_storage", {"food": 4.5}, 5.0)
|
||||||
|
|
||||||
@@ -30,6 +114,22 @@ func test_economy_moves_inventory_into_authoritative_storage() -> void:
|
|||||||
assert_eq(village.food, 5.0)
|
assert_eq(village.food, 5.0)
|
||||||
|
|
||||||
|
|
||||||
|
func test_failed_food_consumption_preserves_fractional_inventory() -> void:
|
||||||
|
var village := SimVillage.new()
|
||||||
|
var economy := VillageEconomyScript.new()
|
||||||
|
economy.configure(village, false)
|
||||||
|
var npc := SimNPC.new(8, "Fractional", SimulationIds.PROFESSION_FARMER, 5.0, 5.0)
|
||||||
|
npc.hunger = 50.0
|
||||||
|
npc.add_inventory(SimulationIds.RESOURCE_FOOD, 0.5)
|
||||||
|
var event_count := [0]
|
||||||
|
economy.economic_event_requested.connect(func(_a, _b, _c, _d, _e, _f): event_count[0] += 1)
|
||||||
|
|
||||||
|
assert_false(economy.consume_npc_food(npc))
|
||||||
|
assert_eq(npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 0.5)
|
||||||
|
assert_eq(npc.hunger, 55.0)
|
||||||
|
assert_eq(event_count[0], 0)
|
||||||
|
|
||||||
|
|
||||||
func test_event_log_preserves_order_and_derives_consumption_rate() -> void:
|
func test_event_log_preserves_order_and_derives_consumption_rate() -> void:
|
||||||
var event_log := SimulationEventLogScript.new()
|
var event_log := SimulationEventLogScript.new()
|
||||||
event_log.record_narrative(10, SimulationIds.EVENT_TASK_STARTED, 4, &"", "Study")
|
event_log.record_narrative(10, SimulationIds.EVENT_TASK_STARTED, 4, &"", "Study")
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ func _run() -> void:
|
|||||||
|
|
||||||
var report := {
|
var report := {
|
||||||
"schema_version": SimulationScalingBenchmark.SCHEMA_VERSION,
|
"schema_version": SimulationScalingBenchmark.SCHEMA_VERSION,
|
||||||
|
"simulation_state_schema_version": SimulationStateRecord.SCHEMA_VERSION,
|
||||||
"captured_utc": Time.get_datetime_string_from_system(true) + "Z",
|
"captured_utc": Time.get_datetime_string_from_system(true) + "Z",
|
||||||
"engine_version": String(Engine.get_version_info().get("string", "unknown")),
|
"engine_version": String(Engine.get_version_info().get("string", "unknown")),
|
||||||
"platform": OS.get_name(),
|
"platform": OS.get_name(),
|
||||||
@@ -83,7 +84,8 @@ func _run() -> void:
|
|||||||
"workload_id": String(SimulationScalingBenchmark.WORKLOAD_ID),
|
"workload_id": String(SimulationScalingBenchmark.WORKLOAD_ID),
|
||||||
"workload":
|
"workload":
|
||||||
(
|
(
|
||||||
"All NPCs receive full per-tick needs/task updates and ordinary action decisions; "
|
"All NPCs receive full per-tick needs/task updates, ordinary action decisions, "
|
||||||
|
+ "and one matching authoritative combatant record; "
|
||||||
+ "travel resolves through the deterministic immediate-arrival headless convention."
|
+ "travel resolves through the deterministic immediate-arrival headless convention."
|
||||||
),
|
),
|
||||||
"timed_phases": ["simulation_tick", "headless_arrival_completion"],
|
"timed_phases": ["simulation_tick", "headless_arrival_completion"],
|
||||||
@@ -130,10 +132,18 @@ func _summarize_samples(case_id: String, samples: Array[Dictionary]) -> Dictiona
|
|||||||
"arrivals_processed",
|
"arrivals_processed",
|
||||||
"warmup_arrivals",
|
"warmup_arrivals",
|
||||||
"npc_updates",
|
"npc_updates",
|
||||||
|
"npc_combatant_count",
|
||||||
|
"npc_combatant_coverage_valid",
|
||||||
"start_tick",
|
"start_tick",
|
||||||
"end_tick",
|
"end_tick",
|
||||||
]
|
]
|
||||||
for sample in samples:
|
for sample in samples:
|
||||||
|
if (
|
||||||
|
not bool(sample.get("npc_combatant_coverage_valid", false))
|
||||||
|
or int(sample.get("npc_combatant_count", -1)) != int(sample.get("population", -2))
|
||||||
|
):
|
||||||
|
push_error("Scaling benchmark case %s lost NPC combatant coverage" % case_id)
|
||||||
|
return {}
|
||||||
if String(sample["final_checksum"]) != checksum:
|
if String(sample["final_checksum"]) != checksum:
|
||||||
push_error("Scaling benchmark case %s diverged across checksums" % case_id)
|
push_error("Scaling benchmark case %s diverged across checksums" % case_id)
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ $CROSS_PLATFORM_PLUGIN_FILES = @(
|
|||||||
"addons/terrain_3d/bin/libterrain.windows.release.x86_64.dll"
|
"addons/terrain_3d/bin/libterrain.windows.release.x86_64.dll"
|
||||||
)
|
)
|
||||||
$REQUIRED_GODOT_SERIES = "4.7"
|
$REQUIRED_GODOT_SERIES = "4.7"
|
||||||
|
$RUNNING_ON_MACOS = [Runtime.InteropServices.RuntimeInformation]::IsOSPlatform(
|
||||||
|
[Runtime.InteropServices.OSPlatform]::OSX
|
||||||
|
)
|
||||||
|
|
||||||
function Exit-Quality {
|
function Exit-Quality {
|
||||||
param([int]$ExitCode)
|
param([int]$ExitCode)
|
||||||
@@ -182,6 +185,31 @@ function To-ResPath($p) {
|
|||||||
return "res://$p"
|
return "res://$p"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Test-AllowedScenarioDiagnostic {
|
||||||
|
param([string]$Diagnostic)
|
||||||
|
|
||||||
|
# Godot 4.7 can fail to read the system certificate store on macOS when HOME
|
||||||
|
# is intentionally isolated. Scenario tests do not perform network access.
|
||||||
|
if ($script:RUNNING_ON_MACOS -and
|
||||||
|
$Diagnostic -ceq 'ERROR: Condition "ret != noErr" is true. Returning: ""') {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pinned Terrain3D 1.0.2 invokes this Godot 4.7 compatibility method while
|
||||||
|
# loading its vendored GDExtension. Remove this exception with that vendor fix.
|
||||||
|
if ($Diagnostic -ceq 'WARNING: instance_reset_physics_interpolation() is deprecated.') {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Godot 4.7's dummy headless renderer retains exactly one DummyShader RID at
|
||||||
|
# process teardown. A different RID type or count remains a gate failure.
|
||||||
|
if ($Diagnostic -ceq "ERROR: 1 RID allocations of type 'N13RendererDummy15MaterialStorage11DummyShaderE' were leaked at exit.") {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
function Invoke-GodotWithTimeout {
|
function Invoke-GodotWithTimeout {
|
||||||
param(
|
param(
|
||||||
[string]$ArgumentString,
|
[string]$ArgumentString,
|
||||||
@@ -437,6 +465,36 @@ foreach ($test in $scenarioTests) {
|
|||||||
$ERRORS += "scenario: $($test.Name) failed or timed out"
|
$ERRORS += "scenario: $($test.Name) failed or timed out"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$compatibilityTest = "jajce_presentation_quality_test.gd"
|
||||||
|
$compatibilityLabel = "$compatibilityTest (gl_compatibility)"
|
||||||
|
Add-Content -Path "$LOG/scenarios.log" -Value "[RUN] $compatibilityLabel" -Encoding utf8
|
||||||
|
$compatibilityArgs = "--headless --rendering-method gl_compatibility --path `"$ROOT`" --script res://tests/$compatibilityTest"
|
||||||
|
$compatibilityOutput, $compatibilityExit = Invoke-GodotWithTimeout -ArgumentString $compatibilityArgs
|
||||||
|
$compatibilityOutput | Add-Content -Path "$LOG/scenarios.log" -Encoding utf8
|
||||||
|
if ($compatibilityExit -eq $null -or $compatibilityExit -ne 0) {
|
||||||
|
$scenarioResult = "FAIL"
|
||||||
|
$ERRORS += "scenario: $compatibilityLabel failed or timed out"
|
||||||
|
}
|
||||||
|
$unexpectedScenarioDiagnostics = @()
|
||||||
|
$currentScenario = "unknown scenario"
|
||||||
|
foreach ($rawLine in Get-Content -Path "$LOG/scenarios.log") {
|
||||||
|
if ($rawLine.StartsWith('[RUN] ')) {
|
||||||
|
$currentScenario = $rawLine.Substring(6)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$diagnostic = $rawLine.Trim()
|
||||||
|
if ($diagnostic -cnotmatch '^(?:(?:SCRIPT )?ERROR|WARNING):') { continue }
|
||||||
|
if (-not (Test-AllowedScenarioDiagnostic $diagnostic)) {
|
||||||
|
$unexpectedScenarioDiagnostics += "$currentScenario`: $diagnostic"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($unexpectedScenarioDiagnostics.Count -gt 0) {
|
||||||
|
$scenarioResult = "FAIL"
|
||||||
|
$ERRORS += "scenario: Godot reported $($unexpectedScenarioDiagnostics.Count) unallowlisted ERROR/WARNING diagnostic(s)"
|
||||||
|
foreach ($diagnostic in $unexpectedScenarioDiagnostics | Select-Object -First 20) {
|
||||||
|
$ERRORS += "scenario: $diagnostic"
|
||||||
|
}
|
||||||
|
}
|
||||||
$scenarioLoadError = Select-String -Path "$LOG/scenarios.log" -Pattern 'SCRIPT ERROR:|Parse Error:|Failed to load script' -Quiet
|
$scenarioLoadError = Select-String -Path "$LOG/scenarios.log" -Pattern 'SCRIPT ERROR:|Parse Error:|Failed to load script' -Quiet
|
||||||
if ($scenarioLoadError) {
|
if ($scenarioLoadError) {
|
||||||
$scenarioResult = "FAIL"
|
$scenarioResult = "FAIL"
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ CROSS_PLATFORM_PLUGIN_FILES=(
|
|||||||
addons/terrain_3d/bin/libterrain.windows.release.x86_64.dll
|
addons/terrain_3d/bin/libterrain.windows.release.x86_64.dll
|
||||||
)
|
)
|
||||||
REQUIRED_GODOT_SERIES="4.7"
|
REQUIRED_GODOT_SERIES="4.7"
|
||||||
|
RUNNING_ON_MACOS=false
|
||||||
|
[[ "${OSTYPE:-}" == darwin* ]] && RUNNING_ON_MACOS=true
|
||||||
|
|
||||||
# -- tool detection -----------------------------------------------------------
|
# -- tool detection -----------------------------------------------------------
|
||||||
console_godot_path() {
|
console_godot_path() {
|
||||||
@@ -143,6 +145,31 @@ contains_element() {
|
|||||||
local e; for e in "${@:2}"; do [[ "$e" == "$1" ]] && return 0; done; return 1
|
local e; for e in "${@:2}"; do [[ "$e" == "$1" ]] && return 0; done; return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is_allowed_scenario_diagnostic() {
|
||||||
|
local diagnostic="$1"
|
||||||
|
|
||||||
|
# Godot 4.7 can fail to read the system certificate store on macOS when HOME
|
||||||
|
# is intentionally isolated. Scenario tests do not perform network access.
|
||||||
|
if $RUNNING_ON_MACOS &&
|
||||||
|
[[ "$diagnostic" == 'ERROR: Condition "ret != noErr" is true. Returning: ""' ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Pinned Terrain3D 1.0.2 invokes this Godot 4.7 compatibility method while
|
||||||
|
# loading its vendored GDExtension. Remove this exception with that vendor fix.
|
||||||
|
if [[ "$diagnostic" == "WARNING: instance_reset_physics_interpolation() is deprecated." ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Godot 4.7's dummy headless renderer retains exactly one DummyShader RID at
|
||||||
|
# process teardown. A different RID type or count remains a gate failure.
|
||||||
|
if [[ "$diagnostic" == "ERROR: 1 RID allocations of type 'N13RendererDummy15MaterialStorage11DummyShaderE' were leaked at exit." ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
run_with_timeout() {
|
run_with_timeout() {
|
||||||
local seconds="$1"
|
local seconds="$1"
|
||||||
shift
|
shift
|
||||||
@@ -380,6 +407,40 @@ for test in tests/*_test.gd; do
|
|||||||
ERRORS+=("scenario: $(basename "$test") failed or timed out")
|
ERRORS+=("scenario: $(basename "$test") failed or timed out")
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
compatibility_test="tests/jajce_presentation_quality_test.gd"
|
||||||
|
compatibility_label="$(basename "$compatibility_test") (gl_compatibility)"
|
||||||
|
echo "[RUN] $compatibility_label" >> "$LOG/scenarios.log"
|
||||||
|
if ! run_with_timeout 30 "${GODOT_ENV[@]}" "$GODOT" --headless --rendering-method gl_compatibility --path "$ROOT" --script "res://$compatibility_test" >> "$LOG/scenarios.log" 2>&1; then
|
||||||
|
scenario_result="FAIL"
|
||||||
|
ERRORS+=("scenario: $compatibility_label failed or timed out")
|
||||||
|
fi
|
||||||
|
unexpected_scenario_diagnostics=()
|
||||||
|
current_scenario="unknown scenario"
|
||||||
|
while IFS= read -r raw_line; do
|
||||||
|
raw_line="${raw_line%$'\r'}"
|
||||||
|
if [[ "$raw_line" == "[RUN] "* ]]; then
|
||||||
|
current_scenario="${raw_line#* }"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
diagnostic="$raw_line"
|
||||||
|
diagnostic="${diagnostic#"${diagnostic%%[![:space:]]*}"}"
|
||||||
|
diagnostic="${diagnostic%"${diagnostic##*[![:space:]]}"}"
|
||||||
|
if [[ ! "$diagnostic" =~ ^((SCRIPT[[:space:]])?ERROR|WARNING): ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if ! is_allowed_scenario_diagnostic "$diagnostic"; then
|
||||||
|
unexpected_scenario_diagnostics+=("$current_scenario: $diagnostic")
|
||||||
|
fi
|
||||||
|
done < "$LOG/scenarios.log"
|
||||||
|
if [[ ${#unexpected_scenario_diagnostics[@]} -gt 0 ]]; then
|
||||||
|
scenario_result="FAIL"
|
||||||
|
ERRORS+=(
|
||||||
|
"scenario: Godot reported ${#unexpected_scenario_diagnostics[@]} unallowlisted ERROR/WARNING diagnostic(s)"
|
||||||
|
)
|
||||||
|
for diagnostic in "${unexpected_scenario_diagnostics[@]:0:20}"; do
|
||||||
|
ERRORS+=("scenario: $diagnostic")
|
||||||
|
done
|
||||||
|
fi
|
||||||
if grep -qE "SCRIPT ERROR:|Parse Error:|Failed to load script" "$LOG/scenarios.log"; then
|
if grep -qE "SCRIPT ERROR:|Parse Error:|Failed to load script" "$LOG/scenarios.log"; then
|
||||||
scenario_result="FAIL"
|
scenario_result="FAIL"
|
||||||
ERRORS+=("scenario: Godot reported a script load or parse error")
|
ERRORS+=("scenario: Godot reported a script load or parse error")
|
||||||
|
|||||||
@@ -32,11 +32,7 @@ func _ready() -> void:
|
|||||||
_all.append(self)
|
_all.append(self)
|
||||||
add_to_group("activity_sites")
|
add_to_group("activity_sites")
|
||||||
_update_presentation()
|
_update_presentation()
|
||||||
|
set_process(false)
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
|
||||||
if debug_label_enabled:
|
|
||||||
_update_presentation()
|
|
||||||
|
|
||||||
|
|
||||||
func _exit_tree() -> void:
|
func _exit_tree() -> void:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ var combatant: CombatantStateRecord
|
|||||||
var simulation_manager: Node
|
var simulation_manager: Node
|
||||||
var _flash_tween: Tween
|
var _flash_tween: Tween
|
||||||
var _spawn_tween: Tween
|
var _spawn_tween: Tween
|
||||||
|
var _last_health := 0.0
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -24,22 +25,21 @@ func bind(combatant_id_value: StringName, manager: Node) -> void:
|
|||||||
global_position = combatant.get_position()
|
global_position = combatant.get_position()
|
||||||
sim_position = combatant.get_position()
|
sim_position = combatant.get_position()
|
||||||
_build_from_definition()
|
_build_from_definition()
|
||||||
|
_last_health = combatant.get_health()
|
||||||
combatant.changed.connect(_on_combatant_changed)
|
combatant.changed.connect(_on_combatant_changed)
|
||||||
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
func _on_combatant_changed(_state: CombatantStateRecord) -> void:
|
||||||
if is_dead_visual or combatant == null or not is_instance_valid(combatant):
|
if is_dead_visual or combatant == null or not is_instance_valid(combatant):
|
||||||
return
|
return
|
||||||
if not combatant.is_alive():
|
if not combatant.is_alive():
|
||||||
play_death()
|
play_death()
|
||||||
return
|
return
|
||||||
set_sim_position(combatant.get_position())
|
set_sim_position(combatant.get_position())
|
||||||
|
var current_health := combatant.get_health()
|
||||||
|
if current_health < _last_health:
|
||||||
func _on_combatant_changed(_state: CombatantStateRecord) -> void:
|
_flash_hit()
|
||||||
if combatant == null or combatant.get_health() >= combatant.get_max_health():
|
_last_health = current_health
|
||||||
return
|
|
||||||
_flash_hit()
|
|
||||||
|
|
||||||
|
|
||||||
func _flash_hit() -> void:
|
func _flash_hit() -> void:
|
||||||
@@ -51,6 +51,8 @@ func _flash_hit() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func play_death() -> void:
|
func play_death() -> void:
|
||||||
|
if is_dead_visual:
|
||||||
|
return
|
||||||
is_dead_visual = true
|
is_dead_visual = true
|
||||||
super.play_death()
|
super.play_death()
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ const HOSTILE_SCENE := preload("res://world/combat/HostileCombatant.tscn")
|
|||||||
@export var wolf_spawn_positions: PackedVector3Array = PackedVector3Array()
|
@export var wolf_spawn_positions: PackedVector3Array = PackedVector3Array()
|
||||||
|
|
||||||
var hostiles: Dictionary = {}
|
var hostiles: Dictionary = {}
|
||||||
var _initialized := false
|
|
||||||
var _wolves_spawned := false
|
var _wolves_spawned := false
|
||||||
|
|
||||||
|
|
||||||
@@ -20,7 +19,6 @@ func _ready() -> void:
|
|||||||
simulation_manager.combatant_spawned.connect(_on_combatant_spawned)
|
simulation_manager.combatant_spawned.connect(_on_combatant_spawned)
|
||||||
simulation_manager.combatant_died.connect(_on_combatant_died)
|
simulation_manager.combatant_died.connect(_on_combatant_died)
|
||||||
simulation_manager.state_restored.connect(_on_state_restored)
|
simulation_manager.state_restored.connect(_on_state_restored)
|
||||||
_initialized = true
|
|
||||||
call_deferred("_spawn_wolves")
|
call_deferred("_spawn_wolves")
|
||||||
_refresh_from_state()
|
_refresh_from_state()
|
||||||
|
|
||||||
@@ -34,14 +32,6 @@ func _spawn_wolves() -> void:
|
|||||||
simulation_manager.spawn_wolf(position)
|
simulation_manager.spawn_wolf(position)
|
||||||
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
|
||||||
if not _initialized:
|
|
||||||
return
|
|
||||||
for combatant_id in _hostile_ids():
|
|
||||||
if not hostiles.has(combatant_id):
|
|
||||||
_spawn_visual(combatant_id)
|
|
||||||
|
|
||||||
|
|
||||||
func _hostile_ids() -> Array:
|
func _hostile_ids() -> Array:
|
||||||
var ids: Array[StringName] = []
|
var ids: Array[StringName] = []
|
||||||
var living: Array = simulation_manager.get_living_hostile_combatants()
|
var living: Array = simulation_manager.get_living_hostile_combatants()
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ signal arrived_at_sim_position(creature_id: StringName)
|
|||||||
|
|
||||||
const ARRIVAL_DISTANCE := 0.5
|
const ARRIVAL_DISTANCE := 0.5
|
||||||
const PATH_RETARGET_DISTANCE := 0.8
|
const PATH_RETARGET_DISTANCE := 0.8
|
||||||
|
const PATH_RETRY_BASE_SECONDS := 0.5
|
||||||
|
const PATH_RETRY_MAX_SECONDS := 4.0
|
||||||
|
|
||||||
@export_range(0.5, 12.0, 0.1) var move_speed := 3.5
|
@export_range(0.5, 12.0, 0.1) var move_speed := 3.5
|
||||||
@export_range(0.05, 1.0, 0.05) var waypoint_reached_distance := 0.3
|
@export_range(0.05, 1.0, 0.05) var waypoint_reached_distance := 0.3
|
||||||
@@ -20,6 +22,9 @@ var path_index := 0
|
|||||||
var path_target := Vector3.INF
|
var path_target := Vector3.INF
|
||||||
var path_request_id := 0
|
var path_request_id := 0
|
||||||
var path_pending := false
|
var path_pending := false
|
||||||
|
var path_query_count := 0
|
||||||
|
var path_retry_remaining := 0.0
|
||||||
|
var path_retry_delay := PATH_RETRY_BASE_SECONDS
|
||||||
|
|
||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
@@ -30,6 +35,10 @@ func _physics_process(delta: float) -> void:
|
|||||||
if _horizontal_distance_to(sim_position) <= ARRIVAL_DISTANCE:
|
if _horizontal_distance_to(sim_position) <= ARRIVAL_DISTANCE:
|
||||||
_stop_moving()
|
_stop_moving()
|
||||||
return
|
return
|
||||||
|
if path_retry_remaining > 0.0:
|
||||||
|
path_retry_remaining = maxf(path_retry_remaining - delta, 0.0)
|
||||||
|
if path_retry_remaining > 0.0:
|
||||||
|
return
|
||||||
_ensure_path()
|
_ensure_path()
|
||||||
if path_pending:
|
if path_pending:
|
||||||
return
|
return
|
||||||
@@ -51,12 +60,17 @@ func _physics_process(delta: float) -> void:
|
|||||||
func set_sim_position(position: Vector3) -> void:
|
func set_sim_position(position: Vector3) -> void:
|
||||||
if not position.is_finite():
|
if not position.is_finite():
|
||||||
return
|
return
|
||||||
var moved := (
|
var target_changed := (
|
||||||
not path_target.is_finite()
|
not sim_position.is_finite()
|
||||||
or _horizontal_distance_to_position(sim_position, position) > PATH_RETARGET_DISTANCE
|
or _horizontal_distance_to_position(sim_position, position) > PATH_RETARGET_DISTANCE
|
||||||
)
|
)
|
||||||
sim_position = position
|
sim_position = position
|
||||||
if moved and not is_dead_visual:
|
if target_changed and not is_dead_visual:
|
||||||
|
path_retry_remaining = 0.0
|
||||||
|
path_retry_delay = PATH_RETRY_BASE_SECONDS
|
||||||
|
if _horizontal_distance_to(sim_position) <= ARRIVAL_DISTANCE:
|
||||||
|
_stop_moving()
|
||||||
|
return
|
||||||
_request_path()
|
_request_path()
|
||||||
|
|
||||||
|
|
||||||
@@ -104,17 +118,41 @@ func _request_path() -> void:
|
|||||||
current_path = NavigationServer3D.map_get_path(
|
current_path = NavigationServer3D.map_get_path(
|
||||||
get_world_3d().navigation_map, global_position, sim_position, true
|
get_world_3d().navigation_map, global_position, sim_position, true
|
||||||
)
|
)
|
||||||
|
path_query_count += 1
|
||||||
path_pending = false
|
path_pending = false
|
||||||
if current_path.is_empty():
|
if current_path.is_empty():
|
||||||
_stop_moving()
|
_schedule_path_retry()
|
||||||
|
return
|
||||||
|
path_retry_remaining = 0.0
|
||||||
|
path_retry_delay = PATH_RETRY_BASE_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
func _schedule_path_retry() -> void:
|
||||||
|
current_path = PackedVector3Array()
|
||||||
|
path_index = 0
|
||||||
|
path_pending = false
|
||||||
|
path_target = sim_position
|
||||||
|
path_retry_remaining = path_retry_delay
|
||||||
|
path_retry_delay = minf(path_retry_delay * 2.0, PATH_RETRY_MAX_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
func _stop_moving() -> void:
|
func _stop_moving() -> void:
|
||||||
|
if (
|
||||||
|
current_path.is_empty()
|
||||||
|
and not path_pending
|
||||||
|
and path_retry_remaining <= 0.0
|
||||||
|
and path_target.is_finite()
|
||||||
|
and sim_position.is_finite()
|
||||||
|
and _horizontal_distance_to_position(path_target, sim_position) <= PATH_RETARGET_DISTANCE
|
||||||
|
):
|
||||||
|
return
|
||||||
path_request_id += 1
|
path_request_id += 1
|
||||||
current_path = PackedVector3Array()
|
current_path = PackedVector3Array()
|
||||||
path_index = 0
|
path_index = 0
|
||||||
path_pending = false
|
path_pending = false
|
||||||
path_target = Vector3.INF
|
path_target = sim_position
|
||||||
|
path_retry_remaining = 0.0
|
||||||
|
path_retry_delay = PATH_RETRY_BASE_SECONDS
|
||||||
|
|
||||||
|
|
||||||
func _horizontal_distance_to(target_position: Vector3) -> float:
|
func _horizontal_distance_to(target_position: Vector3) -> float:
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ material = SubResource("Material_site_canvas")
|
|||||||
size = Vector3(2.9, 0.08, 1.4)
|
size = Vector3(2.9, 0.08, 1.4)
|
||||||
|
|
||||||
[sub_resource type="ProceduralSkyMaterial" id="SkyMaterial_jajce"]
|
[sub_resource type="ProceduralSkyMaterial" id="SkyMaterial_jajce"]
|
||||||
|
resource_local_to_scene = true
|
||||||
sky_top_color = Color(0.28, 0.52, 0.72, 1)
|
sky_top_color = Color(0.28, 0.52, 0.72, 1)
|
||||||
sky_horizon_color = Color(0.88, 0.74, 0.52, 1)
|
sky_horizon_color = Color(0.88, 0.74, 0.52, 1)
|
||||||
ground_bottom_color = Color(0.18, 0.24, 0.14, 1)
|
ground_bottom_color = Color(0.18, 0.24, 0.14, 1)
|
||||||
@@ -180,9 +181,11 @@ ground_horizon_color = Color(0.68, 0.6, 0.44, 1)
|
|||||||
sun_angle_max = 18.0
|
sun_angle_max = 18.0
|
||||||
|
|
||||||
[sub_resource type="Sky" id="Sky_jajce"]
|
[sub_resource type="Sky" id="Sky_jajce"]
|
||||||
|
resource_local_to_scene = true
|
||||||
sky_material = SubResource("SkyMaterial_jajce")
|
sky_material = SubResource("SkyMaterial_jajce")
|
||||||
|
|
||||||
[sub_resource type="Environment" id="Environment_greybox"]
|
[sub_resource type="Environment" id="Environment_greybox"]
|
||||||
|
resource_local_to_scene = true
|
||||||
background_mode = 2
|
background_mode = 2
|
||||||
sky = SubResource("Sky_jajce")
|
sky = SubResource("Sky_jajce")
|
||||||
background_energy_multiplier = 0.9
|
background_energy_multiplier = 0.9
|
||||||
@@ -204,7 +207,7 @@ fog_height = -2.0
|
|||||||
fog_height_density = 0.08
|
fog_height_density = 0.08
|
||||||
fog_aerial_perspective = 0.35
|
fog_aerial_perspective = 0.35
|
||||||
fog_sky_affect = 0.28
|
fog_sky_affect = 0.28
|
||||||
volumetric_fog_enabled = true
|
volumetric_fog_enabled = false
|
||||||
volumetric_fog_density = 0.0035
|
volumetric_fog_density = 0.0035
|
||||||
volumetric_fog_albedo = Color(0.82, 0.9, 0.82, 1)
|
volumetric_fog_albedo = Color(0.82, 0.9, 0.82, 1)
|
||||||
volumetric_fog_emission = Color(0.06, 0.09, 0.07, 1)
|
volumetric_fog_emission = Color(0.06, 0.09, 0.07, 1)
|
||||||
|
|||||||
@@ -34,9 +34,12 @@ const NIGHT_LIGHT_ENERGY := 0.3
|
|||||||
const SUNSET_LIGHT_COLOR := Color(1, 0.55, 0.3, 1)
|
const SUNSET_LIGHT_COLOR := Color(1, 0.55, 0.3, 1)
|
||||||
const SUNRISE_LIGHT_COLOR := Color(1, 0.6, 0.45, 1)
|
const SUNRISE_LIGHT_COLOR := Color(1, 0.6, 0.45, 1)
|
||||||
const SUNRISE_SUNSET_ENERGY := 0.8
|
const SUNRISE_SUNSET_ENERGY := 0.8
|
||||||
|
const PRESENTATION_UPDATE_INTERVAL_SECONDS := 0.1
|
||||||
|
|
||||||
var elapsed := 0.0
|
var elapsed := 0.0
|
||||||
var _simulation_node: Node
|
var _simulation_node: Node
|
||||||
|
var _presentation_elapsed := 0.0
|
||||||
|
var _last_applied_cycle := -1.0
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -47,17 +50,25 @@ func _ready() -> void:
|
|||||||
_apply_environment(initial_cycle)
|
_apply_environment(initial_cycle)
|
||||||
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
|
_presentation_elapsed += delta
|
||||||
|
if _presentation_elapsed < PRESENTATION_UPDATE_INTERVAL_SECONDS:
|
||||||
|
return
|
||||||
|
var update_delta := _presentation_elapsed
|
||||||
|
_presentation_elapsed = 0.0
|
||||||
if _simulation_node == null or not is_instance_valid(_simulation_node):
|
if _simulation_node == null or not is_instance_valid(_simulation_node):
|
||||||
_find_simulation_node()
|
_find_simulation_node()
|
||||||
var cycle: float
|
var cycle: float
|
||||||
if _simulation_node != null and "clock" in _simulation_node and _simulation_node.clock != null:
|
if _simulation_node != null and "clock" in _simulation_node and _simulation_node.clock != null:
|
||||||
cycle = _simulation_node.clock.time_of_day()
|
cycle = _simulation_node.clock.time_of_day()
|
||||||
else:
|
else:
|
||||||
elapsed += _delta
|
elapsed += update_delta
|
||||||
if elapsed >= cycle_duration_seconds:
|
if elapsed >= cycle_duration_seconds:
|
||||||
elapsed = fmod(elapsed, cycle_duration_seconds)
|
elapsed = fmod(elapsed, cycle_duration_seconds)
|
||||||
cycle = elapsed / cycle_duration_seconds
|
cycle = elapsed / cycle_duration_seconds
|
||||||
|
if is_equal_approx(cycle, _last_applied_cycle):
|
||||||
|
return
|
||||||
|
_last_applied_cycle = cycle
|
||||||
_apply_light_rotation(cycle)
|
_apply_light_rotation(cycle)
|
||||||
_apply_environment(cycle)
|
_apply_environment(cycle)
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,348 @@
|
|||||||
|
class_name JajceWorld
|
||||||
extends Node3D
|
extends Node3D
|
||||||
|
|
||||||
|
enum PresentationQuality {
|
||||||
|
HIGH,
|
||||||
|
BALANCED,
|
||||||
|
LOW,
|
||||||
|
}
|
||||||
|
|
||||||
|
const BALANCED_RENDER_SCALE := 0.85
|
||||||
|
const LOW_RENDER_SCALE := 0.7
|
||||||
|
const BALANCED_SHADOW_DISTANCE := 120.0
|
||||||
|
const LOW_SHADOW_DISTANCE := 80.0
|
||||||
|
const BALANCED_GRASS_SPACING := 1.0
|
||||||
|
const LOW_GRASS_SPACING := 2.0
|
||||||
|
const BALANCED_GRASS_FIXED_FPS := 18
|
||||||
|
const LOW_GRASS_FIXED_FPS := 12
|
||||||
|
const BALANCED_GRASS_INTERACTORS := 4
|
||||||
|
const BALANCED_GRASS_UPDATE_INTERVAL := 0.12
|
||||||
|
const HIGH_VOLUMETRIC_FOG_ENABLED := true
|
||||||
|
|
||||||
|
static var _viewport_scale_states: Dictionary = {}
|
||||||
|
|
||||||
|
@export_enum("High", "Balanced", "Low") var presentation_quality: int = PresentationQuality.BALANCED
|
||||||
|
|
||||||
@onready var terrain: Terrain3D = $TerrainRoot/Terrain3D
|
@onready var terrain: Terrain3D = $TerrainRoot/Terrain3D
|
||||||
|
@onready var world_environment: WorldEnvironment = $WorldEnvironment
|
||||||
|
@onready var directional_light: DirectionalLight3D = $DirectionalLight3D
|
||||||
|
@onready var grass_field: Node3D = $TerrainRoot/Terrain3D/CozyGrassField
|
||||||
|
@onready var grass_controller: Node = $TerrainRoot/GrassInteractionController
|
||||||
|
|
||||||
|
var _active_presentation_quality := -1
|
||||||
|
var _authored_presentation_captured := false
|
||||||
|
var _authored_render_scale := 1.0
|
||||||
|
var _authored_glow_enabled := false
|
||||||
|
var _authored_volumetric_fog_enabled := HIGH_VOLUMETRIC_FOG_ENABLED
|
||||||
|
var _authored_adjustment_enabled := false
|
||||||
|
var _authored_shadow_distance := 0.0
|
||||||
|
var _authored_shadow_mode := DirectionalLight3D.SHADOW_PARALLEL_4_SPLITS
|
||||||
|
var _authored_grass_visible := true
|
||||||
|
var _authored_grass_physics_enabled := true
|
||||||
|
var _authored_grass_spacing := 1.0
|
||||||
|
var _authored_grass_fixed_fps := 30
|
||||||
|
var _authored_grass_interactors := 1
|
||||||
|
var _authored_grass_update_interval := 0.1
|
||||||
|
var _authored_grass_controller_enabled := true
|
||||||
|
var _last_applied_render_scale := -1.0
|
||||||
|
var _viewport_instance_id := 0
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
_isolate_mutable_presentation_resources()
|
||||||
|
_capture_authored_presentation()
|
||||||
|
apply_presentation_quality(_resolve_requested_quality())
|
||||||
call_deferred("_initialize_world_presentation")
|
call_deferred("_initialize_world_presentation")
|
||||||
|
|
||||||
|
|
||||||
|
func _exit_tree() -> void:
|
||||||
|
_release_viewport_scale_owner()
|
||||||
|
|
||||||
|
|
||||||
|
func apply_presentation_quality(quality: int) -> bool:
|
||||||
|
if quality not in PresentationQuality.values():
|
||||||
|
push_warning("JajceWorld: unknown presentation quality %s" % quality)
|
||||||
|
return false
|
||||||
|
if not _authored_presentation_captured:
|
||||||
|
_capture_authored_presentation()
|
||||||
|
if not _authored_presentation_captured:
|
||||||
|
return false
|
||||||
|
if quality == _active_presentation_quality:
|
||||||
|
return true
|
||||||
|
|
||||||
|
match quality:
|
||||||
|
PresentationQuality.HIGH:
|
||||||
|
_apply_environment_quality(
|
||||||
|
_authored_render_scale,
|
||||||
|
_authored_glow_enabled,
|
||||||
|
_authored_volumetric_fog_enabled,
|
||||||
|
_authored_adjustment_enabled,
|
||||||
|
_authored_shadow_distance,
|
||||||
|
_authored_shadow_mode
|
||||||
|
)
|
||||||
|
_apply_grass_quality(
|
||||||
|
_authored_grass_visible,
|
||||||
|
_authored_grass_physics_enabled,
|
||||||
|
_authored_grass_spacing,
|
||||||
|
_authored_grass_fixed_fps,
|
||||||
|
_authored_grass_interactors,
|
||||||
|
_authored_grass_update_interval,
|
||||||
|
_authored_grass_controller_enabled
|
||||||
|
)
|
||||||
|
PresentationQuality.BALANCED:
|
||||||
|
_apply_environment_quality(
|
||||||
|
minf(_authored_render_scale, BALANCED_RENDER_SCALE),
|
||||||
|
_authored_glow_enabled,
|
||||||
|
false,
|
||||||
|
_authored_adjustment_enabled,
|
||||||
|
minf(_authored_shadow_distance, BALANCED_SHADOW_DISTANCE),
|
||||||
|
DirectionalLight3D.SHADOW_PARALLEL_2_SPLITS
|
||||||
|
)
|
||||||
|
_apply_grass_quality(
|
||||||
|
_authored_grass_visible,
|
||||||
|
_authored_grass_physics_enabled,
|
||||||
|
maxf(_authored_grass_spacing, BALANCED_GRASS_SPACING),
|
||||||
|
mini(_authored_grass_fixed_fps, BALANCED_GRASS_FIXED_FPS),
|
||||||
|
mini(_authored_grass_interactors, BALANCED_GRASS_INTERACTORS),
|
||||||
|
maxf(_authored_grass_update_interval, BALANCED_GRASS_UPDATE_INTERVAL),
|
||||||
|
_authored_grass_controller_enabled
|
||||||
|
)
|
||||||
|
PresentationQuality.LOW:
|
||||||
|
_apply_environment_quality(
|
||||||
|
minf(_authored_render_scale, LOW_RENDER_SCALE),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
minf(_authored_shadow_distance, LOW_SHADOW_DISTANCE),
|
||||||
|
DirectionalLight3D.SHADOW_ORTHOGONAL
|
||||||
|
)
|
||||||
|
_apply_grass_quality(
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
maxf(_authored_grass_spacing, LOW_GRASS_SPACING),
|
||||||
|
mini(_authored_grass_fixed_fps, LOW_GRASS_FIXED_FPS),
|
||||||
|
0,
|
||||||
|
_authored_grass_update_interval,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
_active_presentation_quality = quality
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func get_active_presentation_quality() -> int:
|
||||||
|
return _active_presentation_quality
|
||||||
|
|
||||||
|
|
||||||
|
func get_active_presentation_quality_name() -> StringName:
|
||||||
|
match _active_presentation_quality:
|
||||||
|
PresentationQuality.HIGH:
|
||||||
|
return &"high"
|
||||||
|
PresentationQuality.BALANCED:
|
||||||
|
return &"balanced"
|
||||||
|
PresentationQuality.LOW:
|
||||||
|
return &"low"
|
||||||
|
return &"unknown"
|
||||||
|
|
||||||
|
|
||||||
|
func _capture_authored_presentation() -> void:
|
||||||
|
if _authored_presentation_captured:
|
||||||
|
return
|
||||||
|
var environment := _environment()
|
||||||
|
if environment == null or grass_field == null or grass_controller == null:
|
||||||
|
return
|
||||||
|
_register_viewport_scale_owner()
|
||||||
|
_authored_glow_enabled = environment.glow_enabled
|
||||||
|
# Keep the serialized scene compatibility-safe; High deliberately opts into
|
||||||
|
# this Forward+-only authored effect after the quality profile is resolved.
|
||||||
|
_authored_volumetric_fog_enabled = HIGH_VOLUMETRIC_FOG_ENABLED
|
||||||
|
_authored_adjustment_enabled = environment.adjustment_enabled
|
||||||
|
_authored_shadow_distance = directional_light.directional_shadow_max_distance
|
||||||
|
_authored_shadow_mode = directional_light.directional_shadow_mode
|
||||||
|
_authored_grass_visible = grass_field.visible
|
||||||
|
_authored_grass_physics_enabled = grass_field.is_physics_processing()
|
||||||
|
_authored_grass_spacing = float(grass_field.get("instance_spacing"))
|
||||||
|
_authored_grass_fixed_fps = int(grass_field.get("process_fixed_fps"))
|
||||||
|
_authored_grass_interactors = int(grass_controller.get("max_interactors"))
|
||||||
|
_authored_grass_update_interval = float(grass_controller.get("update_interval"))
|
||||||
|
_authored_grass_controller_enabled = grass_controller.is_processing()
|
||||||
|
_authored_presentation_captured = true
|
||||||
|
|
||||||
|
|
||||||
|
func _isolate_mutable_presentation_resources() -> void:
|
||||||
|
var source_environment := _environment()
|
||||||
|
if source_environment != null:
|
||||||
|
var isolated_environment := source_environment.duplicate(true) as Environment
|
||||||
|
if source_environment.sky != null:
|
||||||
|
var isolated_sky := source_environment.sky.duplicate(true) as Sky
|
||||||
|
if source_environment.sky.sky_material != null:
|
||||||
|
isolated_sky.sky_material = source_environment.sky.sky_material.duplicate(true)
|
||||||
|
isolated_environment.sky = isolated_sky
|
||||||
|
world_environment.environment = isolated_environment
|
||||||
|
|
||||||
|
var source_grass_material := grass_field.get("mesh_material_override") as ShaderMaterial
|
||||||
|
if source_grass_material != null:
|
||||||
|
var isolated_grass_material := source_grass_material.duplicate(true) as ShaderMaterial
|
||||||
|
grass_field.set("mesh_material_override", isolated_grass_material)
|
||||||
|
grass_controller.set("grass_material", isolated_grass_material)
|
||||||
|
var source_process_material := grass_field.get("process_material") as ShaderMaterial
|
||||||
|
if source_process_material != null:
|
||||||
|
var isolated_process_material := source_process_material.duplicate(true) as ShaderMaterial
|
||||||
|
grass_field.set("process_material", isolated_process_material)
|
||||||
|
var particles: Array = grass_field.get("particle_nodes")
|
||||||
|
for value in particles:
|
||||||
|
var particle := value as GPUParticles3D
|
||||||
|
if particle != null:
|
||||||
|
particle.process_material = isolated_process_material
|
||||||
|
|
||||||
|
|
||||||
|
func _resolve_requested_quality() -> int:
|
||||||
|
for argument in OS.get_cmdline_user_args():
|
||||||
|
if argument.begins_with("--quality="):
|
||||||
|
return _quality_from_name(argument.trim_prefix("--quality="))
|
||||||
|
if argument.begins_with("--presentation-quality="):
|
||||||
|
return _quality_from_name(argument.trim_prefix("--presentation-quality="))
|
||||||
|
return presentation_quality
|
||||||
|
|
||||||
|
|
||||||
|
func _quality_from_name(value: String) -> int:
|
||||||
|
match value.strip_edges().to_lower():
|
||||||
|
"high":
|
||||||
|
return PresentationQuality.HIGH
|
||||||
|
"low":
|
||||||
|
return PresentationQuality.LOW
|
||||||
|
"balanced":
|
||||||
|
return PresentationQuality.BALANCED
|
||||||
|
push_warning("JajceWorld: unknown presentation quality '%s'; using scene default" % value)
|
||||||
|
return presentation_quality
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_environment_quality(
|
||||||
|
render_scale: float,
|
||||||
|
glow_enabled: bool,
|
||||||
|
volumetric_fog_enabled: bool,
|
||||||
|
adjustment_enabled: bool,
|
||||||
|
shadow_distance: float,
|
||||||
|
shadow_mode: int
|
||||||
|
) -> void:
|
||||||
|
_claim_viewport_scale_owner()
|
||||||
|
get_viewport().scaling_3d_scale = render_scale
|
||||||
|
_last_applied_render_scale = render_scale
|
||||||
|
var environment := _environment()
|
||||||
|
if environment != null:
|
||||||
|
environment.glow_enabled = glow_enabled
|
||||||
|
environment.volumetric_fog_enabled = (volumetric_fog_enabled and _supports_volumetric_fog())
|
||||||
|
environment.adjustment_enabled = adjustment_enabled
|
||||||
|
directional_light.directional_shadow_max_distance = shadow_distance
|
||||||
|
directional_light.directional_shadow_mode = shadow_mode
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_grass_quality(
|
||||||
|
is_visible: bool,
|
||||||
|
physics_enabled: bool,
|
||||||
|
instance_spacing: float,
|
||||||
|
fixed_fps: int,
|
||||||
|
max_interactors: int,
|
||||||
|
update_interval: float,
|
||||||
|
controller_enabled: bool
|
||||||
|
) -> void:
|
||||||
|
grass_field.set("instance_spacing", instance_spacing)
|
||||||
|
grass_field.set("process_fixed_fps", fixed_fps)
|
||||||
|
grass_field.visible = is_visible
|
||||||
|
grass_field.set_physics_process(physics_enabled)
|
||||||
|
grass_controller.set("max_interactors", maxi(max_interactors, 1))
|
||||||
|
grass_controller.set("update_interval", update_interval)
|
||||||
|
grass_controller.set_process(controller_enabled)
|
||||||
|
grass_controller.set("_elapsed", update_interval)
|
||||||
|
|
||||||
|
var particles: Array = grass_field.get("particle_nodes")
|
||||||
|
for value in particles:
|
||||||
|
var particle := value as GPUParticles3D
|
||||||
|
if particle == null:
|
||||||
|
continue
|
||||||
|
particle.emitting = is_visible and physics_enabled
|
||||||
|
if particle.emitting:
|
||||||
|
particle.restart(true)
|
||||||
|
|
||||||
|
var grass_material := grass_field.get("mesh_material_override") as ShaderMaterial
|
||||||
|
if grass_material != null and not controller_enabled:
|
||||||
|
grass_material.set_shader_parameter("interactor_count", 0)
|
||||||
|
if physics_enabled:
|
||||||
|
grass_field.set("last_pos", Vector3.INF)
|
||||||
|
|
||||||
|
|
||||||
|
func _environment() -> Environment:
|
||||||
|
if world_environment == null:
|
||||||
|
return null
|
||||||
|
return world_environment.environment
|
||||||
|
|
||||||
|
|
||||||
|
func _supports_volumetric_fog() -> bool:
|
||||||
|
return RenderingServer.get_current_rendering_method() == "forward_plus"
|
||||||
|
|
||||||
|
|
||||||
|
func _register_viewport_scale_owner() -> void:
|
||||||
|
var viewport := get_viewport()
|
||||||
|
_viewport_instance_id = viewport.get_instance_id()
|
||||||
|
var state: Dictionary = _viewport_scale_states.get(_viewport_instance_id, {})
|
||||||
|
if state.is_empty():
|
||||||
|
state = {"base_scale": viewport.scaling_3d_scale, "owners": []}
|
||||||
|
_authored_render_scale = float(state["base_scale"])
|
||||||
|
_viewport_scale_states[_viewport_instance_id] = state
|
||||||
|
_claim_viewport_scale_owner()
|
||||||
|
|
||||||
|
|
||||||
|
func _claim_viewport_scale_owner() -> void:
|
||||||
|
if _viewport_instance_id == 0:
|
||||||
|
return
|
||||||
|
var state: Dictionary = _viewport_scale_states.get(_viewport_instance_id, {})
|
||||||
|
if state.is_empty():
|
||||||
|
return
|
||||||
|
var owners: Array = state["owners"]
|
||||||
|
for index in range(owners.size() - 1, -1, -1):
|
||||||
|
var owner: Object = (owners[index] as WeakRef).get_ref()
|
||||||
|
if owner == null or owner == self:
|
||||||
|
owners.remove_at(index)
|
||||||
|
owners.append(weakref(self))
|
||||||
|
state["owners"] = owners
|
||||||
|
_viewport_scale_states[_viewport_instance_id] = state
|
||||||
|
|
||||||
|
|
||||||
|
func _release_viewport_scale_owner() -> void:
|
||||||
|
if _viewport_instance_id == 0 or not _viewport_scale_states.has(_viewport_instance_id):
|
||||||
|
return
|
||||||
|
var viewport := get_viewport()
|
||||||
|
var state: Dictionary = _viewport_scale_states[_viewport_instance_id]
|
||||||
|
var owners: Array = state["owners"]
|
||||||
|
var was_active_owner := false
|
||||||
|
for index in range(owners.size() - 1, -1, -1):
|
||||||
|
var owner: Object = (owners[index] as WeakRef).get_ref()
|
||||||
|
if owner == self:
|
||||||
|
was_active_owner = index == owners.size() - 1
|
||||||
|
owners.remove_at(index)
|
||||||
|
elif owner == null:
|
||||||
|
owners.remove_at(index)
|
||||||
|
if not owners.is_empty():
|
||||||
|
state["owners"] = owners
|
||||||
|
_viewport_scale_states[_viewport_instance_id] = state
|
||||||
|
if (
|
||||||
|
was_active_owner
|
||||||
|
and is_equal_approx(viewport.scaling_3d_scale, _last_applied_render_scale)
|
||||||
|
):
|
||||||
|
var previous_owner := (owners[-1] as WeakRef).get_ref() as JajceWorld
|
||||||
|
if previous_owner != null and previous_owner._last_applied_render_scale >= 0.0:
|
||||||
|
viewport.scaling_3d_scale = previous_owner._last_applied_render_scale
|
||||||
|
else:
|
||||||
|
_viewport_scale_states.erase(_viewport_instance_id)
|
||||||
|
if (
|
||||||
|
was_active_owner
|
||||||
|
and is_equal_approx(viewport.scaling_3d_scale, _last_applied_render_scale)
|
||||||
|
):
|
||||||
|
viewport.scaling_3d_scale = float(state["base_scale"])
|
||||||
|
_viewport_instance_id = 0
|
||||||
|
_last_applied_render_scale = -1.0
|
||||||
|
|
||||||
|
|
||||||
func _initialize_world_presentation() -> void:
|
func _initialize_world_presentation() -> void:
|
||||||
var active_camera := get_viewport().get_camera_3d()
|
var active_camera := get_viewport().get_camera_3d()
|
||||||
if active_camera != null:
|
if active_camera != null:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
[ext_resource type="Shader" path="res://world/jajce/materials/cozy_grass.gdshader" id="1_grass"]
|
[ext_resource type="Shader" path="res://world/jajce/materials/cozy_grass.gdshader" id="1_grass"]
|
||||||
|
|
||||||
[resource]
|
[resource]
|
||||||
|
resource_local_to_scene = true
|
||||||
render_priority = 0
|
render_priority = 0
|
||||||
shader = ExtResource("1_grass")
|
shader = ExtResource("1_grass")
|
||||||
shader_parameter/wind_direction = Vector2(1, 0.7)
|
shader_parameter/wind_direction = Vector2(1, 0.7)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ seamless = true
|
|||||||
noise = SubResource("FastNoise_grass")
|
noise = SubResource("FastNoise_grass")
|
||||||
|
|
||||||
[resource]
|
[resource]
|
||||||
|
resource_local_to_scene = true
|
||||||
shader = ExtResource("1_process_shader")
|
shader = ExtResource("1_process_shader")
|
||||||
shader_parameter/main_noise = SubResource("NoiseTexture_grass")
|
shader_parameter/main_noise = SubResource("NoiseTexture_grass")
|
||||||
shader_parameter/main_noise_scale = 0.012
|
shader_parameter/main_noise_scale = 0.012
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ static var _all: Array = []
|
|||||||
|
|
||||||
@onready var interaction_point: Marker3D = $InteractionPoint
|
@onready var interaction_point: Marker3D = $InteractionPoint
|
||||||
|
|
||||||
|
const PRESENTATION_UPDATE_INTERVAL_SECONDS := 0.25
|
||||||
|
|
||||||
var state: StorageStateRecord
|
var state: StorageStateRecord
|
||||||
var _last_label_text := ""
|
var _last_label_text := ""
|
||||||
|
var _presentation_elapsed := 0.0
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -35,11 +38,15 @@ func _ready() -> void:
|
|||||||
add_to_group("storage_nodes")
|
add_to_group("storage_nodes")
|
||||||
_try_register_with_simulation()
|
_try_register_with_simulation()
|
||||||
_update_presentation()
|
_update_presentation()
|
||||||
|
set_process(debug_label_enabled)
|
||||||
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
if debug_label_enabled:
|
_presentation_elapsed += delta
|
||||||
_update_presentation()
|
if _presentation_elapsed < PRESENTATION_UPDATE_INTERVAL_SECONDS:
|
||||||
|
return
|
||||||
|
_presentation_elapsed = fmod(_presentation_elapsed, PRESENTATION_UPDATE_INTERVAL_SECONDS)
|
||||||
|
_update_presentation()
|
||||||
|
|
||||||
|
|
||||||
func _exit_tree() -> void:
|
func _exit_tree() -> void:
|
||||||
@@ -93,6 +100,8 @@ func _update_presentation() -> void:
|
|||||||
|
|
||||||
func set_debug_label_enabled(is_enabled: bool) -> void:
|
func set_debug_label_enabled(is_enabled: bool) -> void:
|
||||||
debug_label_enabled = is_enabled
|
debug_label_enabled = is_enabled
|
||||||
|
_presentation_elapsed = 0.0
|
||||||
|
set_process(is_enabled)
|
||||||
_update_presentation()
|
_update_presentation()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ class_name PlayerInteractionHud
|
|||||||
extends Control
|
extends Control
|
||||||
|
|
||||||
const FEEDBACK_SECONDS := 2.0
|
const FEEDBACK_SECONDS := 2.0
|
||||||
|
const PROBE_INTERVAL_SECONDS := 0.1
|
||||||
const ENTER_OFFSET := 7.0
|
const ENTER_OFFSET := 7.0
|
||||||
const ACCENT_READY := Color(0.9, 0.63, 0.28, 0.95)
|
const ACCENT_READY := Color(0.9, 0.63, 0.28, 0.95)
|
||||||
const ACCENT_SUCCESS := Color(0.55, 0.78, 0.38, 0.95)
|
const ACCENT_SUCCESS := Color(0.55, 0.78, 0.38, 0.95)
|
||||||
@@ -20,6 +21,7 @@ var current_context_key := ""
|
|||||||
var feedback_version := 0
|
var feedback_version := 0
|
||||||
var feedback_active := false
|
var feedback_active := false
|
||||||
var feedback_succeeded := false
|
var feedback_succeeded := false
|
||||||
|
var _probe_elapsed := 0.0
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -34,12 +36,19 @@ func _ready() -> void:
|
|||||||
call_deferred("refresh_prompt", true)
|
call_deferred("refresh_prompt", true)
|
||||||
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
if not feedback_active:
|
if feedback_active:
|
||||||
refresh_prompt()
|
return
|
||||||
|
_probe_elapsed += delta
|
||||||
|
if _probe_elapsed < PROBE_INTERVAL_SECONDS:
|
||||||
|
return
|
||||||
|
_probe_elapsed = fmod(_probe_elapsed, PROBE_INTERVAL_SECONDS)
|
||||||
|
refresh_prompt()
|
||||||
|
|
||||||
|
|
||||||
func refresh_prompt(force: bool = false) -> void:
|
func refresh_prompt(force: bool = false) -> void:
|
||||||
|
if force:
|
||||||
|
_probe_elapsed = 0.0
|
||||||
if player == null or not player.has_method("get_interaction_context"):
|
if player == null or not player.has_method("get_interaction_context"):
|
||||||
_hide_prompt()
|
_hide_prompt()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
class_name PlayerStatusHud
|
class_name PlayerStatusHud
|
||||||
extends Control
|
extends Control
|
||||||
|
|
||||||
|
const REFRESH_INTERVAL_SECONDS := 0.2
|
||||||
|
|
||||||
@export var simulation_manager: Node
|
@export var simulation_manager: Node
|
||||||
|
|
||||||
@onready var status_label: Label = $StatusLabel
|
@onready var status_label: Label = $StatusLabel
|
||||||
|
|
||||||
var cached_text := ""
|
var cached_text := ""
|
||||||
|
var _refresh_elapsed := 0.0
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -13,7 +16,11 @@ func _ready() -> void:
|
|||||||
_refresh(true)
|
_refresh(true)
|
||||||
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
|
_refresh_elapsed += delta
|
||||||
|
if _refresh_elapsed < REFRESH_INTERVAL_SECONDS:
|
||||||
|
return
|
||||||
|
_refresh_elapsed = fmod(_refresh_elapsed, REFRESH_INTERVAL_SECONDS)
|
||||||
_refresh(false)
|
_refresh(false)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const SIZE_DIAL := 60.0
|
|||||||
const RING_RADIUS := 24.0
|
const RING_RADIUS := 24.0
|
||||||
const RING_THICKNESS := 4.0
|
const RING_THICKNESS := 4.0
|
||||||
const GLOW_RADIUS := 2.5
|
const GLOW_RADIUS := 2.5
|
||||||
|
const REFRESH_INTERVAL_SECONDS := 0.1
|
||||||
|
|
||||||
const COLOR_NIGHT := Color(0.12, 0.16, 0.42, 1)
|
const COLOR_NIGHT := Color(0.12, 0.16, 0.42, 1)
|
||||||
const COLOR_DAWN := Color(0.95, 0.5, 0.2, 1)
|
const COLOR_DAWN := Color(0.95, 0.5, 0.2, 1)
|
||||||
@@ -14,9 +15,14 @@ const COLOR_DUSK := Color(0.9, 0.35, 0.2, 1)
|
|||||||
const COLOR_BG := Color(0.06, 0.06, 0.08, 0.65)
|
const COLOR_BG := Color(0.06, 0.06, 0.08, 0.65)
|
||||||
|
|
||||||
var _last_fraction := -1.0
|
var _last_fraction := -1.0
|
||||||
|
var _refresh_elapsed := 0.0
|
||||||
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
|
_refresh_elapsed += delta
|
||||||
|
if _refresh_elapsed < REFRESH_INTERVAL_SECONDS:
|
||||||
|
return
|
||||||
|
_refresh_elapsed = fmod(_refresh_elapsed, REFRESH_INTERVAL_SECONDS)
|
||||||
var fraction := _cycle_fraction()
|
var fraction := _cycle_fraction()
|
||||||
if not is_equal_approx(fraction, _last_fraction):
|
if not is_equal_approx(fraction, _last_fraction):
|
||||||
_last_fraction = fraction
|
_last_fraction = fraction
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ extends Control
|
|||||||
|
|
||||||
const ENTER_OFFSET := 9.0
|
const ENTER_OFFSET := 9.0
|
||||||
const EXIT_OFFSET := 4.0
|
const EXIT_OFFSET := 4.0
|
||||||
|
const PROBE_INTERVAL_SECONDS := 0.1
|
||||||
|
|
||||||
@export var player: Node
|
@export var player: Node
|
||||||
@export var simulation_manager: Node
|
@export var simulation_manager: Node
|
||||||
@@ -21,6 +22,7 @@ var current_context_key := ""
|
|||||||
var motion_tween: Tween
|
var motion_tween: Tween
|
||||||
var motion_version := 0
|
var motion_version := 0
|
||||||
var is_hiding := false
|
var is_hiding := false
|
||||||
|
var _probe_elapsed := 0.0
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -32,11 +34,17 @@ func _ready() -> void:
|
|||||||
call_deferred("refresh_note", true)
|
call_deferred("refresh_note", true)
|
||||||
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
|
_probe_elapsed += delta
|
||||||
|
if _probe_elapsed < PROBE_INTERVAL_SECONDS:
|
||||||
|
return
|
||||||
|
_probe_elapsed = fmod(_probe_elapsed, PROBE_INTERVAL_SECONDS)
|
||||||
refresh_note()
|
refresh_note()
|
||||||
|
|
||||||
|
|
||||||
func refresh_note(force: bool = false) -> void:
|
func refresh_note(force: bool = false) -> void:
|
||||||
|
if force:
|
||||||
|
_probe_elapsed = 0.0
|
||||||
if player == null or not player.has_method("get_nearby_villager_inspection"):
|
if player == null or not player.has_method("get_nearby_villager_inspection"):
|
||||||
current_context_key = ""
|
current_context_key = ""
|
||||||
_hide_note()
|
_hide_note()
|
||||||
|
|||||||
Reference in New Issue
Block a user