perf: harden runtime for weaker hardware
This commit is contained in:
@@ -21,6 +21,7 @@ SimulationClock
|
||||
-> EventKnowledgeSystem records, ranks, transfers, and retains bounded knowledge
|
||||
-> RelationshipSystem applies evidence-gated social consequences
|
||||
-> VillageOpportunitySystem projects one known unresolved need
|
||||
-> ConflictSystem advances indexed combatants and authoritative combat outcomes
|
||||
-> WorldViewManager presents travel, NPC state, and world-state cues
|
||||
-> ActiveWorldAdapter supplies loaded-world positions/capacity
|
||||
-> LoadedResourceSpatialIndex bounds finite-anchor discovery
|
||||
@@ -48,9 +49,13 @@ would otherwise obscure that lifecycle:
|
||||
- `simulation/conflict/ConflictSystem.gd` owns combatants (health, weapons,
|
||||
factions, hostility), village/tribe faction records, wolf hostility, the
|
||||
tribe war motivation decision (desire + victory confidence), raid spawning,
|
||||
deterministic battle resolution, and war consequences. It emits combat and
|
||||
war narrative facts and spawn/death signals that presentation binds to
|
||||
`HostileCombatant` visuals;
|
||||
deterministic battle resolution, and real pantry-backed war consequences.
|
||||
Player commands are revalidated against authoritative life, faction, range,
|
||||
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,
|
||||
history queries, and rate calculations;
|
||||
- `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
|
||||
presentation: any `CreatureVisual` follows a simulation-owned position through
|
||||
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
|
||||
follow-the-authoritative-position contract, so future creatures (bears, boars,
|
||||
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
|
||||
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,
|
||||
schema-valid workload fixtures. CLI tools and headless scenarios consume those
|
||||
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
|
||||
[`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
|
||||
|
||||
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
|
||||
yields and pauses berry regrowth on the cold day, so the garden keeps
|
||||
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:
|
||||
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
proves the first real spatial consumer. Exact loaded-anchor resolution remains
|
||||
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
|
||||
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:
|
||||
|
||||
- `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
|
||||
- **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,
|
||||
and GUT run through the local quality gate)
|
||||
- **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
|
||||
data-driven `EnemyDefinition`s so new types add with a definition plus a
|
||||
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.
|
||||
|
||||
### Village simulation
|
||||
|
||||
@@ -32,6 +32,9 @@ Reviewed captures:
|
||||
- [Simulation scaling baseline 02](SIMULATION_SCALING_BASELINE_02.md) records
|
||||
checksum-identical results after the shared per-tick population view: the
|
||||
600-NPC case is 23.3% faster, while small fixtures expose its fixed cost.
|
||||
- [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
|
||||
former all-node scan with the exact resource-anchor grid at 18, 180, and
|
||||
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
|
||||
diagnostic string. Import receives a 60-second timeout on both shell and
|
||||
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]
|
||||
layout_mode = 3
|
||||
anchors_preset = 0
|
||||
offset_left = 880.0
|
||||
anchors_preset = 5
|
||||
anchor_left = 0.5
|
||||
anchor_right = 0.5
|
||||
offset_left = -30.0
|
||||
offset_top = 6.0
|
||||
offset_right = 940.0
|
||||
offset_right = 30.0
|
||||
offset_bottom = 66.0
|
||||
grow_horizontal = 2
|
||||
script = ExtResource("12_timedial")
|
||||
|
||||
[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 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 var dash_speed := 22.0
|
||||
@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_timer := 0.0
|
||||
var dash_timer := 0.0
|
||||
var dash_duration_remaining := 0.0
|
||||
var dash_cooldown_remaining := 0.0
|
||||
var dashing := false
|
||||
var dash_velocity := Vector3.ZERO
|
||||
var _swing_tween: Tween
|
||||
@@ -24,13 +25,13 @@ var _references_resolved := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if sword == null:
|
||||
return
|
||||
sword.rotation = SLASH_REST_ANGLE
|
||||
process_physics_priority = -1
|
||||
if sword != null:
|
||||
sword.rotation = SLASH_REST_ANGLE
|
||||
var definition := SimulationItems.get_weapon(SimulationIds.ITEM_SWORD)
|
||||
if definition != null:
|
||||
attack_cooldown_seconds = definition.attack_cooldown
|
||||
attack_range = maxf(definition.reach + 0.2, attack_range)
|
||||
attack_range = definition.reach
|
||||
|
||||
|
||||
func _resolve_references() -> void:
|
||||
@@ -46,25 +47,31 @@ func _physics_process(delta: float) -> void:
|
||||
_resolve_references()
|
||||
if simulation_manager != null and simulation_manager.has_method("update_player_combatant"):
|
||||
simulation_manager.update_player_combatant(player.global_position)
|
||||
attack_timer = maxf(attack_timer - delta, 0.0)
|
||||
dash_timer = maxf(dash_timer - delta, 0.0)
|
||||
_advance_timers(delta)
|
||||
if _player_is_downed():
|
||||
attack_timer = 0.0
|
||||
dash_timer = 0.0
|
||||
dash_duration_remaining = 0.0
|
||||
dash_cooldown_remaining = 0.0
|
||||
dashing = false
|
||||
return
|
||||
if dashing:
|
||||
player.velocity = dash_velocity
|
||||
if dash_timer <= 0.0:
|
||||
dashing = false
|
||||
player.move_and_slide()
|
||||
return
|
||||
if Input.is_action_just_pressed("attack") and attack_timer <= 0.0:
|
||||
if Input.is_action_just_pressed("attack"):
|
||||
_perform_attack()
|
||||
if Input.is_action_just_pressed("dash") and dash_timer <= 0.0:
|
||||
if Input.is_action_just_pressed("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:
|
||||
return (
|
||||
simulation_manager != null
|
||||
@@ -74,6 +81,8 @@ func _player_is_downed() -> bool:
|
||||
|
||||
|
||||
func _perform_attack() -> void:
|
||||
if attack_timer > 0.0 or not _simulation_attack_ready():
|
||||
return
|
||||
attack_timer = attack_cooldown_seconds
|
||||
_play_swing()
|
||||
var facing := -player.global_transform.basis.z
|
||||
@@ -91,7 +100,10 @@ func _perform_attack() -> 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
|
||||
var facing := -player.global_transform.basis.z
|
||||
facing.y = 0.0
|
||||
@@ -100,6 +112,12 @@ func _perform_dash() -> void:
|
||||
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:
|
||||
if sword == null:
|
||||
return
|
||||
@@ -123,3 +141,11 @@ func is_attacking() -> bool:
|
||||
|
||||
func is_dashing() -> bool:
|
||||
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 villager_inspection_range := 3.0
|
||||
|
||||
@onready var combat_controller: Node = get_node_or_null("PlayerCombatController")
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
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 target_velocity := direction * move_speed
|
||||
|
||||
velocity.x = move_toward(velocity.x, target_velocity.x, acceleration * delta)
|
||||
velocity.z = move_toward(velocity.z, target_velocity.z, acceleration * delta)
|
||||
var dash_velocity := Vector3.ZERO
|
||||
if combat_controller != null and combat_controller.has_method("get_dash_velocity"):
|
||||
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():
|
||||
velocity.y -= 30.0 * delta
|
||||
|
||||
@@ -11,10 +11,10 @@ func _init(interval: float = 1.0) -> void:
|
||||
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)
|
||||
var ticks_due := 0
|
||||
while accumulator >= tick_interval:
|
||||
while accumulator >= tick_interval and (max_ticks <= 0 or ticks_due < max_ticks):
|
||||
accumulator -= tick_interval
|
||||
elapsed_ticks += 1
|
||||
ticks_due += 1
|
||||
@@ -27,5 +27,9 @@ func reset() -> void:
|
||||
|
||||
|
||||
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)
|
||||
|
||||
+156
-131
@@ -40,6 +40,7 @@ var player_system := PlayerCitizenSystem.new()
|
||||
|
||||
var npcs: Array[SimNPC] = []
|
||||
@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 cycle_duration_seconds := 240.0
|
||||
@export var debug_logs := false
|
||||
@@ -72,6 +73,7 @@ var latest_decisions: Dictionary = {}
|
||||
var action_selector := ActionSelectionSystem.new()
|
||||
var action_executor := ActionExecutionSystem.new()
|
||||
var target_resolver := ActionTargetResolver.new()
|
||||
var last_player_resource_query_stats: Dictionary = {}
|
||||
var _population_view := SimulationPopulationView.new()
|
||||
var speed_index := 2
|
||||
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)
|
||||
animal_care.configure(economy, active_world_adapter)
|
||||
player_system.configure(economy, _record_player_event_at)
|
||||
conflict_system.configure(economy)
|
||||
conflict_system.configure(economy, tick_interval)
|
||||
economy.initialize_storage()
|
||||
village.update_modifiers()
|
||||
village.update_priorities()
|
||||
@@ -131,7 +133,8 @@ func _ready() -> 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:
|
||||
simulate_tick()
|
||||
|
||||
@@ -171,6 +174,10 @@ func generate_npcs() -> void:
|
||||
if home_count > 0:
|
||||
for i in range(npcs.size()):
|
||||
npcs[i].home_position = home_positions[i % home_count]
|
||||
refresh_population_index()
|
||||
|
||||
|
||||
func refresh_population_index() -> void:
|
||||
_population_view.rebuild(npcs)
|
||||
|
||||
|
||||
@@ -196,7 +203,7 @@ func simulate_tick() -> void:
|
||||
advance_player_needs()
|
||||
_advance_resource_regrowth()
|
||||
var village_was_changed := false
|
||||
_population_view.rebuild(npcs)
|
||||
refresh_population_index()
|
||||
for npc in npcs:
|
||||
village_was_changed = _simulate_npc_tick(npc) or 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:
|
||||
combatant_died.emit(combatant_id)
|
||||
var combatant := conflict_system.get_combatant(combatant_id)
|
||||
if combatant == null or combatant.get_npc_id() < 0:
|
||||
return
|
||||
for npc in npcs:
|
||||
if npc.id != combatant.get_npc_id() or npc.is_dead:
|
||||
continue
|
||||
_handle_npc_death(npc, npc.current_task, npc.target_id)
|
||||
var npc: SimNPC
|
||||
if combatant != null and combatant.get_npc_id() >= 0:
|
||||
npc = _find_npc_by_id(combatant.get_npc_id())
|
||||
if npc != null:
|
||||
_population_view.refresh_npc(npc)
|
||||
combatant_died.emit(combatant_id)
|
||||
if npc == null or not npc.is_dead:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
for npc in npcs:
|
||||
if npc.id == npc_id:
|
||||
if npc.target_id != &"":
|
||||
var resource_state := get_resource_state(npc.target_id)
|
||||
if resource_state != null:
|
||||
resource_state.release(npc.id)
|
||||
var animal_state := animal_care.get_state(npc.target_id)
|
||||
if animal_state != null:
|
||||
animal_state.release(npc.id)
|
||||
npc.target_id = &""
|
||||
return
|
||||
var npc := _find_npc_by_id(npc_id)
|
||||
if npc == null:
|
||||
return
|
||||
if npc.target_id != &"":
|
||||
var resource_state := get_resource_state(npc.target_id)
|
||||
if resource_state != null:
|
||||
resource_state.release(npc.id)
|
||||
var animal_state := animal_care.get_state(npc.target_id)
|
||||
if animal_state != null:
|
||||
animal_state.release(npc.id)
|
||||
npc.target_id = &""
|
||||
|
||||
|
||||
func notify_npc_arrived(npc_id: int) -> void:
|
||||
for npc in npcs:
|
||||
if npc.id == npc_id:
|
||||
if npc.is_dead:
|
||||
var npc := _find_npc_by_id(npc_id)
|
||||
if npc == null or 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
|
||||
|
||||
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
|
||||
npc.has_travel_target = false
|
||||
npc.start_working(
|
||||
npc.current_task == SimulationIds.ACTION_FEED_ANIMAL and npc.task_progress > 0.0
|
||||
)
|
||||
|
||||
npc.has_travel_target = false
|
||||
npc.start_working(
|
||||
npc.current_task == SimulationIds.ACTION_FEED_ANIMAL and npc.task_progress > 0.0
|
||||
)
|
||||
var working_speakers: Array[SimNPC] = []
|
||||
if npc.target_id != &"":
|
||||
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 npc.target_id != &"":
|
||||
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
|
||||
if debug_logs:
|
||||
NpcTickDebugLog.print_started_work(npc)
|
||||
|
||||
|
||||
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:
|
||||
for npc in npcs:
|
||||
if npc.id != npc_id:
|
||||
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)
|
||||
var npc := _find_npc_by_id(npc_id)
|
||||
if npc == null or 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)
|
||||
|
||||
|
||||
func resolve_npc_target(npc_id: int, origin: Vector3) -> bool:
|
||||
for npc in npcs:
|
||||
if npc.id != npc_id:
|
||||
continue
|
||||
if npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING:
|
||||
return false
|
||||
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"]
|
||||
var npc := _find_npc_by_id(npc_id)
|
||||
if npc == null or npc.is_dead or npc.task_state != SimNPC.TASK_STATE_TRAVELING:
|
||||
return false
|
||||
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
|
||||
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:
|
||||
for npc in npcs:
|
||||
if npc.id != npc_id:
|
||||
continue
|
||||
if npc.task_state != SimNPC.TASK_STATE_TRAVELING:
|
||||
return false
|
||||
if npc.has_travel_target:
|
||||
npc_travel_requested.emit(npc, npc.travel_target_position)
|
||||
else:
|
||||
npc_target_requested.emit(npc)
|
||||
return true
|
||||
return false
|
||||
var npc := _find_npc_by_id(npc_id)
|
||||
if npc == null or npc.task_state != SimNPC.TASK_STATE_TRAVELING:
|
||||
return false
|
||||
if npc.has_travel_target:
|
||||
npc_travel_requested.emit(npc, npc.travel_target_position)
|
||||
else:
|
||||
npc_target_requested.emit(npc)
|
||||
return true
|
||||
|
||||
|
||||
func synchronize_npc_position(npc_id: int, active_position: Vector3) -> bool:
|
||||
for npc in npcs:
|
||||
if npc.id == npc_id:
|
||||
npc.position = active_position
|
||||
return true
|
||||
return false
|
||||
var npc := _find_npc_by_id(npc_id)
|
||||
if npc == null:
|
||||
return false
|
||||
npc.position = active_position
|
||||
return true
|
||||
|
||||
|
||||
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:
|
||||
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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
var best: ResourceNode
|
||||
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)
|
||||
if (
|
||||
resource_state == null
|
||||
@@ -1341,7 +1364,7 @@ func restore_state(record: SimulationStateRecord) -> bool:
|
||||
npcs.clear()
|
||||
for npc_record in record.npcs:
|
||||
npcs.append(npc_record.restore(debug_logs))
|
||||
_population_view.rebuild(npcs)
|
||||
refresh_population_index()
|
||||
relationship_system.restore(record.relationships)
|
||||
event_knowledge_system.restore(record.event_knowledge)
|
||||
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
|
||||
conflict_system.restore_state_records(record.combatants, record.factions, tick_count)
|
||||
conflict_system.register_npc_combatants(npcs)
|
||||
conflict_system.configure(economy, tick_interval)
|
||||
_sync_player_combatant()
|
||||
village_changed.emit(village)
|
||||
state_restored.emit()
|
||||
return true
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
class_name SimulationScalingBenchmark
|
||||
extends RefCounted
|
||||
|
||||
const SCHEMA_VERSION := 1
|
||||
const WORKLOAD_ID := &"full_fidelity_headless_arrival"
|
||||
const SCHEMA_VERSION := 2
|
||||
const WORKLOAD_ID := &"full_fidelity_combatant_headless_arrival_v2"
|
||||
const RECENT_FACTS_PER_NPC := 3
|
||||
const STARTING_CLOCK_TICK := 50
|
||||
|
||||
@@ -43,6 +43,13 @@ func prepare_manager(
|
||||
npc.home_position = npc.position
|
||||
manager.npcs.append(npc)
|
||||
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()
|
||||
pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD))
|
||||
@@ -63,11 +70,15 @@ func measure_manager(
|
||||
or manager.npcs.size() != population
|
||||
or warmup_ticks < 0
|
||||
or measured_ticks <= 0
|
||||
or not _get_combatant_coverage(manager)["valid"]
|
||||
):
|
||||
return {}
|
||||
var warmup_arrivals := 0
|
||||
for _tick in warmup_ticks:
|
||||
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_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 end_state_bytes := end_json.to_utf8_buffer().size()
|
||||
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 {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"simulation_state_schema_version": SimulationStateRecord.SCHEMA_VERSION,
|
||||
"workload_id": String(WORKLOAD_ID),
|
||||
"seed": manager.simulation_seed,
|
||||
"population": population,
|
||||
@@ -98,6 +113,8 @@ func measure_manager(
|
||||
"warmup_ticks": warmup_ticks,
|
||||
"measured_ticks": 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,
|
||||
"arrivals_processed": measured_arrivals,
|
||||
"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:
|
||||
manager.simulate_tick()
|
||||
return _complete_headless_arrivals(manager)
|
||||
|
||||
@@ -23,6 +23,7 @@ const STRONG_STRENGTH := 5.0
|
||||
const DEFENSE_DECISION_INTERVAL := 200
|
||||
const TRIBE_FOOD_DECAY := 0.05
|
||||
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_SPEED := 1.2
|
||||
const RAID_SIZE := 4
|
||||
@@ -32,15 +33,23 @@ var combatants: Dictionary = {}
|
||||
var factions: Dictionary = {}
|
||||
var economy: RefCounted
|
||||
var npcs: Array[SimNPC] = []
|
||||
var _npcs_by_id: Dictionary = {}
|
||||
var next_combatant_id := 0
|
||||
var next_wolf_id := 0
|
||||
var current_tick := 0
|
||||
var village_center := Vector3.ZERO
|
||||
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
|
||||
tick_interval = maxf(simulation_tick_interval, 0.0001)
|
||||
|
||||
|
||||
func initialize_factions() -> void:
|
||||
@@ -69,6 +78,7 @@ func _ensure_player_combatant() -> void:
|
||||
sword.item_id if sword != null else SimulationIds.ITEM_SWORD,
|
||||
CombatantStateRecord.NO_NPC_ID
|
||||
)
|
||||
_combatant_ids_dirty = true
|
||||
|
||||
|
||||
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)
|
||||
if player_combatant == null:
|
||||
return
|
||||
if not downed and player_combatant.is_alive():
|
||||
var target := clampf(health, 0.0, player_combatant.get_max_health())
|
||||
var current := player_combatant.get_health()
|
||||
if target < current:
|
||||
player_combatant.take_damage(current - target)
|
||||
elif target > current:
|
||||
player_combatant.data["health"] = target
|
||||
player_combatant.set_health_and_alive(health, not downed)
|
||||
if downed:
|
||||
player_attack_cooldown_remaining = 0.0
|
||||
|
||||
|
||||
func is_player_downed() -> bool:
|
||||
@@ -101,8 +107,30 @@ func is_player_downed() -> bool:
|
||||
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:
|
||||
npcs = npc_list
|
||||
_npcs_by_id.clear()
|
||||
for npc in npcs:
|
||||
_npcs_by_id[npc.id] = npc
|
||||
var existing_ids := {}
|
||||
for combatant in combatants.values():
|
||||
var candidate := combatant as CombatantStateRecord
|
||||
@@ -133,6 +161,7 @@ func _create_npc_combatant(npc: SimNPC) -> CombatantStateRecord:
|
||||
npc.id
|
||||
)
|
||||
combatants[combatant.get_combatant_id()] = combatant
|
||||
_combatant_ids_dirty = true
|
||||
return combatant
|
||||
|
||||
|
||||
@@ -170,12 +199,26 @@ func _advance_raider_positions() -> void:
|
||||
|
||||
|
||||
func player_attack(combatant_id: StringName) -> float:
|
||||
var player_combatant := get_combatant(PLAYER_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
|
||||
var sword := SimulationItems.get_weapon(SimulationIds.ITEM_SWORD)
|
||||
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:
|
||||
@@ -194,6 +237,7 @@ func spawn_wolf(position: Vector3) -> StringName:
|
||||
true
|
||||
)
|
||||
combatants[wolf_id] = wolf
|
||||
_combatant_ids_dirty = true
|
||||
combatant_spawned.emit(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:
|
||||
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(
|
||||
SimulationIds.EVENT_COMBATANT_KILLED,
|
||||
killer_id,
|
||||
@@ -235,10 +284,6 @@ func _kill_combatant(target: CombatantStateRecord, killer_id: int) -> void:
|
||||
0.0
|
||||
)
|
||||
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:
|
||||
@@ -253,6 +298,17 @@ func _advance_wolves() -> void:
|
||||
continue
|
||||
if not combatant.is_hostile():
|
||||
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)
|
||||
|
||||
|
||||
@@ -386,6 +442,7 @@ func _start_raid(tribe: FactionStateRecord, simulation_tick: int) -> void:
|
||||
true
|
||||
)
|
||||
combatants[raider_id] = raider
|
||||
_combatant_ids_dirty = true
|
||||
raider_ids.append(raider_id)
|
||||
combatant_spawned.emit(raider_id)
|
||||
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_stance(SimulationIds.STANCE_NEUTRAL)
|
||||
if village != null and outcome == &"village_lost":
|
||||
var stolen := minf(village.get_food() * 0.6, 15.0)
|
||||
village.set_food(maxf(village.get_food() - stolen, 0.0))
|
||||
var 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:
|
||||
tribe.set_food(tribe.get_food() + stolen)
|
||||
elif tribe != null:
|
||||
@@ -486,7 +548,7 @@ func _attack_target(attacker: CombatantStateRecord, target: CombatantStateRecord
|
||||
var damage := _attack_damage(attacker)
|
||||
var applied := _apply_damage(target, damage, _actor_id_for(attacker))
|
||||
if applied > 0.0:
|
||||
attacker.mark_attacked()
|
||||
attacker.mark_attacked(tick_interval)
|
||||
|
||||
|
||||
func _attack_damage(attacker: CombatantStateRecord) -> float:
|
||||
@@ -540,6 +602,10 @@ func _reach(combatant: CombatantStateRecord) -> float:
|
||||
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:
|
||||
if combatant.get_npc_id() >= 0:
|
||||
return combatant.get_npc_id()
|
||||
@@ -607,22 +673,22 @@ func get_living_hostiles() -> Array[CombatantStateRecord]:
|
||||
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] = []
|
||||
for combatant_id in combatants.keys():
|
||||
keys.append(String(combatant_id))
|
||||
keys.sort()
|
||||
var ids: Array = []
|
||||
_sorted_combatant_ids_cache.clear()
|
||||
for key in keys:
|
||||
ids.append(StringName(key))
|
||||
return ids
|
||||
_sorted_combatant_ids_cache.append(StringName(key))
|
||||
_combatant_ids_dirty = false
|
||||
return _sorted_combatant_ids_cache
|
||||
|
||||
|
||||
func _find_npc(npc_id: int) -> SimNPC:
|
||||
for npc in npcs:
|
||||
if npc.id == npc_id:
|
||||
return npc
|
||||
return null
|
||||
return _npcs_by_id.get(npc_id) as SimNPC
|
||||
|
||||
|
||||
func _get_faction(faction_id: StringName) -> FactionStateRecord:
|
||||
@@ -668,9 +734,11 @@ func restore_state_records(
|
||||
restored_tick: int
|
||||
) -> void:
|
||||
current_tick = restored_tick
|
||||
player_attack_cooldown_remaining = 0.0
|
||||
combatants.clear()
|
||||
for combatant in combatant_records:
|
||||
combatants[combatant.get_combatant_id()] = combatant
|
||||
_combatant_ids_dirty = true
|
||||
factions.clear()
|
||||
for faction in faction_records:
|
||||
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:
|
||||
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.is_starving = npc.hunger >= 90.0
|
||||
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.starvation_ticks = 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(
|
||||
SimulationIds.EVENT_ITEM_CONSUMED,
|
||||
npc.id,
|
||||
|
||||
@@ -195,6 +195,18 @@ func set_hostile(value: bool) -> void:
|
||||
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:
|
||||
if get_attack_cooldown() > 0:
|
||||
data["attack_cooldown"] = get_attack_cooldown() - 1
|
||||
@@ -211,14 +223,14 @@ func take_damage(amount: float) -> float:
|
||||
return previous - get_health()
|
||||
|
||||
|
||||
func mark_attacked() -> void:
|
||||
data["attack_cooldown"] = _weapon_cooldown_ticks()
|
||||
func mark_attacked(tick_interval: float = 1.2) -> void:
|
||||
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 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:
|
||||
|
||||
@@ -130,6 +130,10 @@ func _run() -> void:
|
||||
)
|
||||
var reloaded_goat := load("res://world/animals/goat/cozy_goat.tscn").instantiate() as AnimalNode
|
||||
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
|
||||
goat = reloaded_goat
|
||||
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"
|
||||
)
|
||||
goat.set_physics_process(true)
|
||||
for _frame in 3:
|
||||
await physics_frame
|
||||
_check(
|
||||
|
||||
@@ -67,8 +67,23 @@ func _test_player_downing() -> void:
|
||||
for _tick in 22:
|
||||
manager.simulate_tick()
|
||||
_check(not player_state.is_downed(), "The downed player should recover and stand again")
|
||||
var player_combatant: CombatantStateRecord = manager.get_combatant(&"player_combatant")
|
||||
_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()
|
||||
|
||||
|
||||
@@ -10,8 +10,11 @@ func _initialize() -> void:
|
||||
|
||||
func _run() -> void:
|
||||
_test_combat_and_wolf()
|
||||
_test_wolf_closes_distance_before_striking()
|
||||
_test_npc_death_signals_observe_authoritative_death()
|
||||
_test_war_motivation()
|
||||
_test_player_combat_and_restore()
|
||||
_test_raid_plunder_uses_authoritative_pantry()
|
||||
_finish()
|
||||
|
||||
|
||||
@@ -98,15 +101,101 @@ func _test_war_motivation() -> void:
|
||||
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:
|
||||
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)
|
||||
wolf.set_position(Vector3.ZERO)
|
||||
_check(
|
||||
is_equal_approx(manager.player_attack(wolf_id), 24.0),
|
||||
"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(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)
|
||||
_check(not wolf.is_alive(), "A second sword strike should kill the wolf")
|
||||
_check(
|
||||
@@ -118,6 +207,8 @@ func _test_player_combat_and_restore() -> void:
|
||||
"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 restored := _create_manager(985)
|
||||
_check(
|
||||
@@ -129,13 +220,42 @@ func _test_player_combat_and_restore() -> void:
|
||||
restored.get_state_checksum() == manager.get_state_checksum()
|
||||
and restored.get_faction(SimulationIds.FACTION_TRIBE) != null
|
||||
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()
|
||||
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:
|
||||
for npc in manager.npcs:
|
||||
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 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.look_at(wolf_visual.global_position, Vector3.UP)
|
||||
player.rotation.x = 0.0
|
||||
simulation_manager.update_player_combatant(player.global_position)
|
||||
var wolf_health: float = wolf.get_health()
|
||||
controller.call("_perform_attack")
|
||||
_check(
|
||||
wolf.get_health() < wolf_health,
|
||||
"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
|
||||
for _attempt in 6:
|
||||
if not wolf.is_alive():
|
||||
break
|
||||
controller.call("_advance_timers", sword_definition.attack_cooldown)
|
||||
simulation_manager.advance_player_combat_time(sword_definition.attack_cooldown)
|
||||
controller.call("_perform_attack")
|
||||
struck += 1
|
||||
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)
|
||||
var saved_clock_ticks: int = simulation_manager.clock.elapsed_ticks
|
||||
simulation_manager.clock.elapsed_ticks = 0
|
||||
await process_frame
|
||||
await process_frame
|
||||
var day_night_cycle := main_scene.get_node("JajceWorld/DayNightCycle")
|
||||
day_night_cycle.call("_process", 0.11)
|
||||
var environment: 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_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)
|
||||
await process_frame
|
||||
for _frame in 10:
|
||||
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")
|
||||
_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",
|
||||
)
|
||||
var adapter: ActiveWorldAdapter = fixture["adapter"]
|
||||
var manager: Node = fixture["manager"]
|
||||
var stats := adapter.get_resource_index_stats()
|
||||
_check(
|
||||
int(stats["candidate_count"]) == 180 and int(stats["occupied_cell_count"]) > 1,
|
||||
"The adapter should index every loaded anchor across multiple horizontal cells",
|
||||
)
|
||||
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:
|
||||
|
||||
@@ -3,6 +3,23 @@ extends SceneTree
|
||||
const SimulationManagerScript := preload("res://simulation/SimulationManager.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] = []
|
||||
|
||||
|
||||
@@ -21,9 +38,22 @@ func _run() -> void:
|
||||
_check(first["fixture_valid"], "The prepared benchmark state should pass schema validation")
|
||||
_check(
|
||||
(
|
||||
int(first["schema_version"]) == SimulationScalingBenchmark.SCHEMA_VERSION
|
||||
and StringName(first["workload_id"]) == SimulationScalingBenchmark.WORKLOAD_ID
|
||||
int(first["schema_version"]) == 2
|
||||
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["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["measured_ticks"]) == 8
|
||||
and int(first["npc_updates"]) == 96
|
||||
@@ -59,6 +89,8 @@ func _run() -> void:
|
||||
"arrivals_processed",
|
||||
"warmup_arrivals",
|
||||
"tick_interval",
|
||||
"npc_combatant_count",
|
||||
"npc_combatant_coverage_valid",
|
||||
]:
|
||||
_check(
|
||||
first[deterministic_key] == repeated[deterministic_key],
|
||||
@@ -68,6 +100,14 @@ func _run() -> void:
|
||||
first["final_checksum"] != different_seed["final_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()
|
||||
|
||||
|
||||
@@ -82,12 +122,57 @@ func _run_case(seed_value: int) -> Dictionary:
|
||||
manager.free()
|
||||
return {}
|
||||
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)
|
||||
result["fixture_valid"] = fixture_valid
|
||||
result["fixture_npc_combatant_count"] = fixture_npc_combatant_count
|
||||
result["fixture_combatants_valid"] = fixture_combatants_valid
|
||||
manager.free()
|
||||
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:
|
||||
if not condition:
|
||||
failures.append(message)
|
||||
|
||||
@@ -7,6 +7,90 @@ const EventKnowledgeSystemScript := preload("res://simulation/knowledge/EventKno
|
||||
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:
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
var event_log := SimulationEventLogScript.new()
|
||||
event_log.record_narrative(10, SimulationIds.EVENT_TASK_STARTED, 4, &"", "Study")
|
||||
|
||||
@@ -74,6 +74,7 @@ func _run() -> void:
|
||||
|
||||
var report := {
|
||||
"schema_version": SimulationScalingBenchmark.SCHEMA_VERSION,
|
||||
"simulation_state_schema_version": SimulationStateRecord.SCHEMA_VERSION,
|
||||
"captured_utc": Time.get_datetime_string_from_system(true) + "Z",
|
||||
"engine_version": String(Engine.get_version_info().get("string", "unknown")),
|
||||
"platform": OS.get_name(),
|
||||
@@ -83,7 +84,8 @@ func _run() -> void:
|
||||
"workload_id": String(SimulationScalingBenchmark.WORKLOAD_ID),
|
||||
"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."
|
||||
),
|
||||
"timed_phases": ["simulation_tick", "headless_arrival_completion"],
|
||||
@@ -130,10 +132,18 @@ func _summarize_samples(case_id: String, samples: Array[Dictionary]) -> Dictiona
|
||||
"arrivals_processed",
|
||||
"warmup_arrivals",
|
||||
"npc_updates",
|
||||
"npc_combatant_count",
|
||||
"npc_combatant_coverage_valid",
|
||||
"start_tick",
|
||||
"end_tick",
|
||||
]
|
||||
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:
|
||||
push_error("Scaling benchmark case %s diverged across checksums" % case_id)
|
||||
return {}
|
||||
|
||||
@@ -25,6 +25,9 @@ $CROSS_PLATFORM_PLUGIN_FILES = @(
|
||||
"addons/terrain_3d/bin/libterrain.windows.release.x86_64.dll"
|
||||
)
|
||||
$REQUIRED_GODOT_SERIES = "4.7"
|
||||
$RUNNING_ON_MACOS = [Runtime.InteropServices.RuntimeInformation]::IsOSPlatform(
|
||||
[Runtime.InteropServices.OSPlatform]::OSX
|
||||
)
|
||||
|
||||
function Exit-Quality {
|
||||
param([int]$ExitCode)
|
||||
@@ -182,6 +185,31 @@ function To-ResPath($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 {
|
||||
param(
|
||||
[string]$ArgumentString,
|
||||
@@ -437,6 +465,36 @@ foreach ($test in $scenarioTests) {
|
||||
$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
|
||||
if ($scenarioLoadError) {
|
||||
$scenarioResult = "FAIL"
|
||||
|
||||
@@ -26,6 +26,8 @@ CROSS_PLATFORM_PLUGIN_FILES=(
|
||||
addons/terrain_3d/bin/libterrain.windows.release.x86_64.dll
|
||||
)
|
||||
REQUIRED_GODOT_SERIES="4.7"
|
||||
RUNNING_ON_MACOS=false
|
||||
[[ "${OSTYPE:-}" == darwin* ]] && RUNNING_ON_MACOS=true
|
||||
|
||||
# -- tool detection -----------------------------------------------------------
|
||||
console_godot_path() {
|
||||
@@ -143,6 +145,31 @@ contains_element() {
|
||||
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() {
|
||||
local seconds="$1"
|
||||
shift
|
||||
@@ -380,6 +407,40 @@ for test in tests/*_test.gd; do
|
||||
ERRORS+=("scenario: $(basename "$test") failed or timed out")
|
||||
fi
|
||||
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
|
||||
scenario_result="FAIL"
|
||||
ERRORS+=("scenario: Godot reported a script load or parse error")
|
||||
|
||||
@@ -32,11 +32,7 @@ func _ready() -> void:
|
||||
_all.append(self)
|
||||
add_to_group("activity_sites")
|
||||
_update_presentation()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if debug_label_enabled:
|
||||
_update_presentation()
|
||||
set_process(false)
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
|
||||
@@ -5,6 +5,7 @@ var combatant: CombatantStateRecord
|
||||
var simulation_manager: Node
|
||||
var _flash_tween: Tween
|
||||
var _spawn_tween: Tween
|
||||
var _last_health := 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -24,22 +25,21 @@ func bind(combatant_id_value: StringName, manager: Node) -> void:
|
||||
global_position = combatant.get_position()
|
||||
sim_position = combatant.get_position()
|
||||
_build_from_definition()
|
||||
_last_health = combatant.get_health()
|
||||
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):
|
||||
return
|
||||
if not combatant.is_alive():
|
||||
play_death()
|
||||
return
|
||||
set_sim_position(combatant.get_position())
|
||||
|
||||
|
||||
func _on_combatant_changed(_state: CombatantStateRecord) -> void:
|
||||
if combatant == null or combatant.get_health() >= combatant.get_max_health():
|
||||
return
|
||||
_flash_hit()
|
||||
var current_health := combatant.get_health()
|
||||
if current_health < _last_health:
|
||||
_flash_hit()
|
||||
_last_health = current_health
|
||||
|
||||
|
||||
func _flash_hit() -> void:
|
||||
@@ -51,6 +51,8 @@ func _flash_hit() -> void:
|
||||
|
||||
|
||||
func play_death() -> void:
|
||||
if is_dead_visual:
|
||||
return
|
||||
is_dead_visual = true
|
||||
super.play_death()
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ const HOSTILE_SCENE := preload("res://world/combat/HostileCombatant.tscn")
|
||||
@export var wolf_spawn_positions: PackedVector3Array = PackedVector3Array()
|
||||
|
||||
var hostiles: Dictionary = {}
|
||||
var _initialized := false
|
||||
var _wolves_spawned := false
|
||||
|
||||
|
||||
@@ -20,7 +19,6 @@ func _ready() -> void:
|
||||
simulation_manager.combatant_spawned.connect(_on_combatant_spawned)
|
||||
simulation_manager.combatant_died.connect(_on_combatant_died)
|
||||
simulation_manager.state_restored.connect(_on_state_restored)
|
||||
_initialized = true
|
||||
call_deferred("_spawn_wolves")
|
||||
_refresh_from_state()
|
||||
|
||||
@@ -34,14 +32,6 @@ func _spawn_wolves() -> void:
|
||||
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:
|
||||
var ids: Array[StringName] = []
|
||||
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 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.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_request_id := 0
|
||||
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:
|
||||
@@ -30,6 +35,10 @@ func _physics_process(delta: float) -> void:
|
||||
if _horizontal_distance_to(sim_position) <= ARRIVAL_DISTANCE:
|
||||
_stop_moving()
|
||||
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()
|
||||
if path_pending:
|
||||
return
|
||||
@@ -51,12 +60,17 @@ func _physics_process(delta: float) -> void:
|
||||
func set_sim_position(position: Vector3) -> void:
|
||||
if not position.is_finite():
|
||||
return
|
||||
var moved := (
|
||||
not path_target.is_finite()
|
||||
var target_changed := (
|
||||
not sim_position.is_finite()
|
||||
or _horizontal_distance_to_position(sim_position, position) > PATH_RETARGET_DISTANCE
|
||||
)
|
||||
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()
|
||||
|
||||
|
||||
@@ -104,17 +118,41 @@ func _request_path() -> void:
|
||||
current_path = NavigationServer3D.map_get_path(
|
||||
get_world_3d().navigation_map, global_position, sim_position, true
|
||||
)
|
||||
path_query_count += 1
|
||||
path_pending = false
|
||||
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:
|
||||
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
|
||||
current_path = PackedVector3Array()
|
||||
path_index = 0
|
||||
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:
|
||||
|
||||
@@ -173,6 +173,7 @@ material = SubResource("Material_site_canvas")
|
||||
size = Vector3(2.9, 0.08, 1.4)
|
||||
|
||||
[sub_resource type="ProceduralSkyMaterial" id="SkyMaterial_jajce"]
|
||||
resource_local_to_scene = true
|
||||
sky_top_color = Color(0.28, 0.52, 0.72, 1)
|
||||
sky_horizon_color = Color(0.88, 0.74, 0.52, 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
|
||||
|
||||
[sub_resource type="Sky" id="Sky_jajce"]
|
||||
resource_local_to_scene = true
|
||||
sky_material = SubResource("SkyMaterial_jajce")
|
||||
|
||||
[sub_resource type="Environment" id="Environment_greybox"]
|
||||
resource_local_to_scene = true
|
||||
background_mode = 2
|
||||
sky = SubResource("Sky_jajce")
|
||||
background_energy_multiplier = 0.9
|
||||
@@ -204,7 +207,7 @@ fog_height = -2.0
|
||||
fog_height_density = 0.08
|
||||
fog_aerial_perspective = 0.35
|
||||
fog_sky_affect = 0.28
|
||||
volumetric_fog_enabled = true
|
||||
volumetric_fog_enabled = false
|
||||
volumetric_fog_density = 0.0035
|
||||
volumetric_fog_albedo = Color(0.82, 0.9, 0.82, 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 SUNRISE_LIGHT_COLOR := Color(1, 0.6, 0.45, 1)
|
||||
const SUNRISE_SUNSET_ENERGY := 0.8
|
||||
const PRESENTATION_UPDATE_INTERVAL_SECONDS := 0.1
|
||||
|
||||
var elapsed := 0.0
|
||||
var _simulation_node: Node
|
||||
var _presentation_elapsed := 0.0
|
||||
var _last_applied_cycle := -1.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -47,17 +50,25 @@ func _ready() -> void:
|
||||
_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):
|
||||
_find_simulation_node()
|
||||
var cycle: float
|
||||
if _simulation_node != null and "clock" in _simulation_node and _simulation_node.clock != null:
|
||||
cycle = _simulation_node.clock.time_of_day()
|
||||
else:
|
||||
elapsed += _delta
|
||||
elapsed += update_delta
|
||||
if elapsed >= cycle_duration_seconds:
|
||||
elapsed = fmod(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_environment(cycle)
|
||||
|
||||
|
||||
@@ -1,12 +1,348 @@
|
||||
class_name JajceWorld
|
||||
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 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:
|
||||
_isolate_mutable_presentation_resources()
|
||||
_capture_authored_presentation()
|
||||
apply_presentation_quality(_resolve_requested_quality())
|
||||
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:
|
||||
var active_camera := get_viewport().get_camera_3d()
|
||||
if active_camera != null:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
[ext_resource type="Shader" path="res://world/jajce/materials/cozy_grass.gdshader" id="1_grass"]
|
||||
|
||||
[resource]
|
||||
resource_local_to_scene = true
|
||||
render_priority = 0
|
||||
shader = ExtResource("1_grass")
|
||||
shader_parameter/wind_direction = Vector2(1, 0.7)
|
||||
|
||||
@@ -12,6 +12,7 @@ seamless = true
|
||||
noise = SubResource("FastNoise_grass")
|
||||
|
||||
[resource]
|
||||
resource_local_to_scene = true
|
||||
shader = ExtResource("1_process_shader")
|
||||
shader_parameter/main_noise = SubResource("NoiseTexture_grass")
|
||||
shader_parameter/main_noise_scale = 0.012
|
||||
|
||||
@@ -10,8 +10,11 @@ static var _all: Array = []
|
||||
|
||||
@onready var interaction_point: Marker3D = $InteractionPoint
|
||||
|
||||
const PRESENTATION_UPDATE_INTERVAL_SECONDS := 0.25
|
||||
|
||||
var state: StorageStateRecord
|
||||
var _last_label_text := ""
|
||||
var _presentation_elapsed := 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -35,11 +38,15 @@ func _ready() -> void:
|
||||
add_to_group("storage_nodes")
|
||||
_try_register_with_simulation()
|
||||
_update_presentation()
|
||||
set_process(debug_label_enabled)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if debug_label_enabled:
|
||||
_update_presentation()
|
||||
func _process(delta: float) -> void:
|
||||
_presentation_elapsed += delta
|
||||
if _presentation_elapsed < PRESENTATION_UPDATE_INTERVAL_SECONDS:
|
||||
return
|
||||
_presentation_elapsed = fmod(_presentation_elapsed, PRESENTATION_UPDATE_INTERVAL_SECONDS)
|
||||
_update_presentation()
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
@@ -93,6 +100,8 @@ func _update_presentation() -> void:
|
||||
|
||||
func set_debug_label_enabled(is_enabled: bool) -> void:
|
||||
debug_label_enabled = is_enabled
|
||||
_presentation_elapsed = 0.0
|
||||
set_process(is_enabled)
|
||||
_update_presentation()
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ class_name PlayerInteractionHud
|
||||
extends Control
|
||||
|
||||
const FEEDBACK_SECONDS := 2.0
|
||||
const PROBE_INTERVAL_SECONDS := 0.1
|
||||
const ENTER_OFFSET := 7.0
|
||||
const ACCENT_READY := Color(0.9, 0.63, 0.28, 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_active := false
|
||||
var feedback_succeeded := false
|
||||
var _probe_elapsed := 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -34,12 +36,19 @@ func _ready() -> void:
|
||||
call_deferred("refresh_prompt", true)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if not feedback_active:
|
||||
refresh_prompt()
|
||||
func _process(delta: float) -> void:
|
||||
if feedback_active:
|
||||
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:
|
||||
if force:
|
||||
_probe_elapsed = 0.0
|
||||
if player == null or not player.has_method("get_interaction_context"):
|
||||
_hide_prompt()
|
||||
return
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
class_name PlayerStatusHud
|
||||
extends Control
|
||||
|
||||
const REFRESH_INTERVAL_SECONDS := 0.2
|
||||
|
||||
@export var simulation_manager: Node
|
||||
|
||||
@onready var status_label: Label = $StatusLabel
|
||||
|
||||
var cached_text := ""
|
||||
var _refresh_elapsed := 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -13,7 +16,11 @@ func _ready() -> void:
|
||||
_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)
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const SIZE_DIAL := 60.0
|
||||
const RING_RADIUS := 24.0
|
||||
const RING_THICKNESS := 4.0
|
||||
const GLOW_RADIUS := 2.5
|
||||
const REFRESH_INTERVAL_SECONDS := 0.1
|
||||
|
||||
const COLOR_NIGHT := Color(0.12, 0.16, 0.42, 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)
|
||||
|
||||
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()
|
||||
if not is_equal_approx(fraction, _last_fraction):
|
||||
_last_fraction = fraction
|
||||
|
||||
@@ -3,6 +3,7 @@ extends Control
|
||||
|
||||
const ENTER_OFFSET := 9.0
|
||||
const EXIT_OFFSET := 4.0
|
||||
const PROBE_INTERVAL_SECONDS := 0.1
|
||||
|
||||
@export var player: Node
|
||||
@export var simulation_manager: Node
|
||||
@@ -21,6 +22,7 @@ var current_context_key := ""
|
||||
var motion_tween: Tween
|
||||
var motion_version := 0
|
||||
var is_hiding := false
|
||||
var _probe_elapsed := 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -32,11 +34,17 @@ func _ready() -> void:
|
||||
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()
|
||||
|
||||
|
||||
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"):
|
||||
current_context_key = ""
|
||||
_hide_note()
|
||||
|
||||
Reference in New Issue
Block a user