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