perf: harden runtime for weaker hardware

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