feat: add witnessed knowledge and wind ambience

This commit is contained in:
Rijad Zuzo
2026-07-11 11:27:40 +02:00
parent 2ed93de6d1
commit 81409650df
72 changed files with 3001 additions and 606 deletions
+28 -1
View File
@@ -21,6 +21,9 @@ SimulationManager tick
### ActionSelectionSystem ### ActionSelectionSystem
- reads NPC and village state; - reads NPC and village state;
- queries `RelationshipSystem` after personal survival/schedule overrides, so a
trusted starving acquaintance can redirect ordinary work toward food
gathering while the pantry is low;
- evaluates current utility scores; - evaluates current utility scores;
- consumes the NPC's deterministic decision RNG; - consumes the NPC's deterministic decision RNG;
- returns an action ID, optional urgent-duration override, branch reason, - returns an action ID, optional urgent-duration override, branch reason,
@@ -67,7 +70,10 @@ targets, and translates presentation callbacks into simulation transitions.
It also publishes the latest `ActionSelectionResult` for presentation; the UI It also publishes the latest `ActionSelectionResult` for presentation; the UI
does not recompute decisions. At completion it atomically pays any does not recompute decisions. At completion it atomically pays any
definition-backed stored-resource cost before applying the action effect. A definition-backed stored-resource cost before applying the action effect. A
late shortfall suppresses the effect and records a `task_blocked` fact. late shortfall suppresses the effect and records a `task_blocked` fact. The
manager also captures actor/nearby knowledge before forwarding newly recorded
events into `RelationshipSystem`. It exposes knowledge, relationship, and cause
queries without moving social authority into UI.
### VillageEconomy ### VillageEconomy
@@ -82,6 +88,27 @@ late shortfall suppresses the effect and records a `task_blocked` fact.
- answers recent-history, actor-history, and consumption-rate queries; - answers recent-history, actor-history, and consumption-rate queries;
- restores persisted history without performing or replaying transactions. - restores persisted history without performing or replaying transactions.
### EventKnowledgeSystem
- stores stable `(knower_id, event_id)` references separately from objective
history;
- initially observes successful food-deposit events for the actor and living
NPCs within a fixed proximity radius;
- captures evidence only when the event happens, never from current positions
during restore;
- answers deterministic known-event and knower queries without formatting
prose or choosing actions.
### RelationshipSystem
- owns directed familiarity/trust records and stable relationship queries;
- applies a bounded trust gain when a hungry familiar NPC knows about a
contributor's successful food-deposit event;
- stores the exact event ID as the trust cause and ignores replayed or older
events;
- selects trusted starving subjects deterministically for action selection;
- does not choose actions, perform transactions, or format presentation text.
These collaborators are `RefCounted` rule services, not additional scene-tree These collaborators are `RefCounted` rule services, not additional scene-tree
managers. Further decomposition should follow measured pressure and a proven managers. Further decomposition should follow measured pressure and a proven
gameplay consumer. gameplay consumer.
+12 -2
View File
@@ -15,6 +15,8 @@ SimulationClock
-> ActionTargetResolver resolves a stable target ID -> ActionTargetResolver resolves a stable target ID
-> VillageEconomy performs inventory/storage transactions -> VillageEconomy performs inventory/storage transactions
-> SimulationEventLog records completed facts -> SimulationEventLog records completed facts
-> EventKnowledgeSystem records actor/nearby knowledge
-> RelationshipSystem applies evidence-gated social consequences
-> WorldViewManager presents travel and NPC state -> WorldViewManager presents travel and NPC state
-> ActiveWorldAdapter supplies loaded-world positions/capacity -> ActiveWorldAdapter supplies loaded-world positions/capacity
-> NpcVisual performs local navigation and animation -> NpcVisual performs local navigation and animation
@@ -30,6 +32,10 @@ would otherwise obscure that lifecycle:
and keeps village resource summaries synchronized; and keeps village resource summaries synchronized;
- `simulation/events/SimulationEventLog.gd` owns ordered event identity, - `simulation/events/SimulationEventLog.gd` owns ordered event identity,
history queries, and rate calculations; history queries, and rate calculations;
- `simulation/knowledge/EventKnowledgeSystem.gd` owns per-NPC references to
known objective events and captures proximity witnesses at record time;
- `simulation/relationships/RelationshipSystem.gd` owns directed relationship
queries, event-driven trust changes, and deterministic social tie-breaking;
- `simulation/persistence/` owns save-slot file safety; - `simulation/persistence/` owns save-slot file safety;
- `simulation/state/` owns versioned serialized record contracts; - `simulation/state/` owns versioned serialized record contracts;
- `simulation/definitions/` owns stable IDs and immutable action/profession - `simulation/definitions/` owns stable IDs and immutable action/profession
@@ -48,6 +54,8 @@ hard to read.
| `simulation/actions/` | Action decisions, execution, and target queries | | `simulation/actions/` | Action decisions, execution, and target queries |
| `simulation/economy/` | Authoritative inventory and storage transactions | | `simulation/economy/` | Authoritative inventory and storage transactions |
| `simulation/events/` | Immutable event history and derived event queries | | `simulation/events/` | Immutable event history and derived event queries |
| `simulation/knowledge/` | Per-NPC knowledge of objective event IDs |
| `simulation/relationships/` | Directed social consequences and relationship queries |
| `simulation/state/` | Versioned, serializable mutable records | | `simulation/state/` | Versioned, serializable mutable records |
| `simulation/definitions/` | Stable IDs and immutable gameplay definitions | | `simulation/definitions/` | Stable IDs and immutable gameplay definitions |
| `simulation/persistence/` | Validated local save-file storage | | `simulation/persistence/` | Validated local save-file storage |
@@ -69,11 +77,13 @@ improving ownership.
- Persistent references are stable IDs, never `Node`, `NodePath`, or scene - Persistent references are stable IDs, never `Node`, `NodePath`, or scene
ownership. ownership.
- Presentation may report facts and submit commands; it does not choose NPC - Presentation may report facts and submit commands; it does not choose NPC
actions or own resource, storage, inventory, event, or reservation state. actions or own resource, storage, inventory, event, knowledge, relationship,
or reservation state.
- Resource changes go through `ResourceStateRecord`, NPC inventory, and - Resource changes go through `ResourceStateRecord`, NPC inventory, and
`VillageEconomy`; `village.food` and `village.wood` are synchronized views. `VillageEconomy`; `village.food` and `village.wood` are synchronized views.
- New mutable features define serialization and deterministic continuation at - New mutable features define serialization and deterministic continuation at
the same time as their first gameplay use. the same time as their first gameplay use. Cross-record causes use stable
event IDs rather than object references or prose.
- Prefer one tested vertical behavior over a generic framework with no proven - Prefer one tested vertical behavior over a generic framework with no proven
consumers. consumers.
+25 -5
View File
@@ -667,14 +667,34 @@ Completed:
logs. Waterfall mist is anchored to the waterfall instead of the village logs. Waterfall mist is anchored to the waterfall instead of the village
origin, duplicate wind layers are separated, and Metal smoke/wind origin, duplicate wind layers are separated, and Metal smoke/wind
materials retain restrained transparency. materials retain restrained transparency.
17. Coherent breeze pass: overlapping valley-wide white stroke/speck emitters
were removed. Tree canopies now bend gently in one prevailing direction
with stable per-tree phase and slow gust variation, while chimney smoke
leans with the same breeze. Day/night lighting now follows the simulation
clock even when the world initializes first, with the sunrise/sunset tints
applied in the correct order. The gameplay camera remains unchanged.
18. Stylized player readability: the remaining gray player capsule is replaced
by a compact multi-part figure with a warm scarf/satchel accent and
velocity-driven walk motion. Collision, controls, and the elevated
staggered follow camera remain unchanged.
19. Event-caused trust consequence: hungry familiar NPCs can gain directed
trust from a real food-deposit event, retain that event as the inspectable
cause, and redirect ordinary work to help a starving acquaintance. The
state is versioned and deterministic rather than presentation-only flavor.
20. Calligraphic gust ambience: one terrain-aware controller now emits only
two or three tapered sage/amber/aqua ribbons at a time, with long quiet
intervals and a shared canopy/smoke direction. The shader draws each line
head-to-tail like a soft brush mark; repeated bursts cannot stack into the
old scratch/gnat field.
21. Witnessed event knowledge: objective food-deposit events create separate
known-event references for their actor and nearby living NPCs. Knowledge
gates trust, produces a real informed-versus-uninformed choice divergence,
survives schema-v5 save/load, and appears in the NPC inspector.
Next: Next:
1. Replace the remaining player capsule with a matching stylized presentation, 1. Add one provenance-aware NPC-to-NPC fact transfer, then bounded
while preserving the elevated top-down follow, gentle dead-zone lag, and importance/retention rules, as sequenced in `LEARNING_ROADMAP.md`.
cozy farm-builder readability.
2. Continue with the relationship consequence work in `LEARNING_ROADMAP.md`
before expanding visual scope further.
Do not start with GIS data, a full city, a large asset pack, or more NPC Do not start with GIS data, a full city, a large asset pack, or more NPC
mechanics. The next proof is a beautiful stage for the systems that already mechanics. The next proof is a beautiful stage for the systems that already
+31 -13
View File
@@ -2,7 +2,8 @@
## Current contract ## Current contract
Every successful food movement creates one `EconomicEventRecord`: Every successful food or wood movement creates an `EconomicEventRecord` using
the applicable event type:
```text ```text
resource_extracted resource_extracted
@@ -13,17 +14,21 @@ item_consumed
Each record contains a monotonically increasing event ID, event type, Each record contains a monotonically increasing event ID, event type,
simulation tick, actor ID, source ID, destination ID, item ID, and transferred simulation tick, actor ID, source ID, destination ID, item ID, and transferred
amount. Actor `-1` identifies a player-triggered extraction until persistent amount, plus the authoritative world position captured when the fact is
player identity is introduced. recorded. NPC events preserve their authoritative interaction target (or NPC
position when no target applies); player extraction/depletion events preserve
the ResourceNode interaction position. Actor `-1` identifies a player-triggered
extraction until persistent player identity is introduced.
Events are immutable facts about completed transfers. They do not perform the Events are immutable facts about completed transfers. They do not perform the
transaction and are not replayed to reconstruct current state. Resource, transaction and are not replayed to reconstruct current state. Resource,
inventory, and storage records remain authoritative. inventory, and storage records remain authoritative.
`SimulationEventLog` owns ordered event identity, append/restore behavior, and `SimulationEventLog` owns ordered event identity, append/restore behavior, and
history/rate queries. `VillageEconomy` performs transactions and requests event history/rate queries, including exact lookup through `get_by_id()`.
records only after state changes succeed; `SimulationManager` remains the `VillageEconomy` performs transactions and requests event records only after
public signal boundary used by presentation. state changes succeed; `SimulationManager` remains the public signal boundary
used by presentation.
An action whose definition-backed completion cost becomes unavailable records An action whose definition-backed completion cost becomes unavailable records
a zero-amount `task_blocked` narrative fact with the action and shortfall a zero-amount `task_blocked` narrative fact with the action and shortfall
@@ -32,10 +37,21 @@ transfer occurred.
## Persistence and determinism ## Persistence and determinism
`SimulationStateRecord` schema v3 stores the ordered event stream and `SimulationStateRecord` schema v5 stores the ordered event stream,
`next_event_id`. Schema v1 and v2 saves migrate to an empty stream beginning at `next_event_id`, directed relationships that may reference an exact event, and
ID zero. Parsing rejects duplicate event IDs and a next ID that could collide per-NPC known-event references. Schema v1 and v2 saves migrate to an empty
with restored history. stream beginning at ID zero. Parsing rejects duplicate event IDs, invalid or
duplicate knowledge references, relationship causes the observer does not
know, and a next ID that could collide with restored history.
A successful food deposit can currently raise a hungry familiar NPC's directed
trust in its contributor only when that NPC knows the event. The actor and
living NPCs within the bounded witness radius receive a `KnownEventStateRecord`
at record time. Witness distance uses the event's captured position, never the
actor's later location. The relationship stores the same deposit event ID
rather than copied prose, so the inspector can resolve and display the real
completed fact. This is a first evidence-gated causal consumer of the event
stream, not general event sourcing.
The food-loop regression verifies this chain: The food-loop regression verifies this chain:
@@ -57,6 +73,8 @@ NPC record, so unloading presentation does not lose the fact.
The stream is currently kept in full for the small simulation garden. Before The stream is currently kept in full for the small simulation garden. Before
large populations or long-running worlds, add measured retention, large populations or long-running worlds, add measured retention,
archival/summary rules, and query indexes. General denied attempts, witnesses, archival/summary rules, and query indexes. Proximity is the only current witness
secrecy, causal links, and memories belong in later event/history slices; they rule; line of sight, hearing, acquisition provenance, communication, secrecy,
should extend this record family without making prose authoritative. false beliefs, multi-event causal graphs, and memory retention belong in later
event/history slices. They should extend this record family without making
prose authoritative or recomputing old evidence from current positions.
+30 -8
View File
@@ -630,8 +630,6 @@ Completed foundations:
- 512 m Jajce Terrain3D seed, greybox landmarks, stable-ID resource placement, - 512 m Jajce Terrain3D seed, greybox landmarks, stable-ID resource placement,
lookdev scene, and tested temporary navigation loop. lookdev scene, and tested temporary navigation loop.
The practical next sequence is:
Completed after the architecture gate: Completed after the architecture gate:
- location-based pantry state, NPC-carried food, and explicit - location-based pantry state, NPC-carried food, and explicit
@@ -649,13 +647,37 @@ Completed after the architecture gate:
- definition-driven profession colors/props and a selected-NPC inspector fed - definition-driven profession colors/props and a selected-NPC inspector fed
by real decision branches, destinations, needs, and utility scores. by real decision branches, destinations, needs, and utility scores.
The practical next sequence is: The first relationship consequence slice is complete:
1. Grow the existing familiarity seed into one consequence-bearing relationship - directed familiarity/trust records now live in a top-level relationship
dimension, such as trust or obligation, driven by structured events rather graph instead of NPC-local dictionaries;
than proximity alone. - a successful known food-deposit event raises a hungry familiar NPC's trust
2. Use that dimension in one real choice or response, then expose the cause in and is retained as the exact causal event ID;
the NPC inspector before expanding the relationship graph. - that trust can redirect ordinary patrol work toward replenishing a low pantry
for a starving acquaintance;
- the inspector displays the relationship values and completed event cause;
- schema migration, save/restore checksums, idempotence, and a neutral-control
decision test cover the complete slice.
The first witnessed-knowledge slice is also complete:
- `KnownEventStateRecord` keeps per-NPC event references separate from
objective event history;
- successful food deposits are known by their actor and nearby living NPCs at
event-record time, while distant NPCs remain uninformed;
- two equally familiar guards with different evidence now gain different trust
and choose helping versus ordinary patrol;
- the inspector resolves and displays the selected NPC's latest known fact;
- schema v5 migration, invalid-reference rejection, deterministic save/load,
and proximity/idempotence tests cover the complete slice.
This proves the first Milestone 6 evidence-to-choice exit path without claiming
full memory or belief simulation. The practical next sequence is:
1. Add acquisition provenance and one bounded NPC-to-NPC fact transfer so
information can travel beyond direct witnesses without replaying events.
2. Add importance/retention rules for known facts before expanding into
distortion, secrecy, rumours, or opportunity generation.
Recently completed: Recently completed:
+69 -28
View File
@@ -2,8 +2,8 @@
> Agent-facing context for understanding the project quickly. > Agent-facing context for understanding the project quickly.
> >
> Snapshot basis: repository state after commit `556d0fd`, July 2026. Treat the > Snapshot basis: repository state on July 11, 2026. Treat the code as the
> code as the source of truth when this document and the implementation differ. > source of truth when this document and the implementation differ.
See the [documentation map](README.md) for the authority and scope of each plan. See the [documentation map](README.md) for the authority and scope of each plan.
@@ -189,7 +189,8 @@ study, and patrol target typed activity sites. The migration is documented in
- **Engine:** Godot 4.7 project configuration - **Engine:** Godot 4.7 project configuration
- **Renderer feature:** Forward Plus - **Renderer feature:** Forward Plus
- **Language:** GDScript (`odig` analysis with warnings treated as errors) - **Language:** GDScript (`gdformat`, `gdlint`, headless Godot 4.7 scenarios,
and GUT run through the local quality gate)
- **Main scene:** `res://main.tscn` - **Main scene:** `res://main.tscn`
- **Terrain:** Terrain3D 1.0.2 is installed and enabled - **Terrain:** Terrain3D 1.0.2 is installed and enabled
- **Jajce runtime:** reusable 512 m Terrain3D seed, greybox landmarks, stable - **Jajce runtime:** reusable 512 m Terrain3D seed, greybox landmarks, stable
@@ -215,8 +216,8 @@ plugin content, not game architecture.
### World and player ### World and player
- `main.tscn` instances the reusable Jajce Terrain3D world. - `main.tscn` instances the reusable Jajce Terrain3D world.
- The player is a `CharacterBody3D` represented by placeholder primitive - The player is a `CharacterBody3D` with a compact multi-part stylized visual,
geometry. velocity-driven walk motion, and unchanged primitive collision.
- WASD movement is camera-relative. - WASD movement is camera-relative.
- The elevated third-person camera rotates with the mouse, uses smoothed - The elevated third-person camera rotates with the mouse, uses smoothed
follow/focus behavior, and exposes an opt-in presentation preset for follow/focus behavior, and exposes an opt-in presentation preset for
@@ -254,6 +255,8 @@ Each simulated NPC currently stores:
- current task and task state; - current task and task state;
- task duration and progress; - task duration and progress;
- simulated position; - simulated position;
- home position and carried inventory;
- last action and mourning duration;
- starvation state and duration; - starvation state and duration;
- death state. - death state.
@@ -280,9 +283,11 @@ NPCs currently choose among:
- gather food; - gather food;
- gather wood; - gather wood;
- deposit carried food or wood;
- withdraw and eat food;
- patrol; - patrol;
- study; - study;
- eat; - sleep;
- rest; - rest;
- wander. - wander.
@@ -335,8 +340,11 @@ distance with resource `safety_risk`, `comfort_distance`, and
- authored dirt path strips that make the current village task loop readable; - authored dirt path strips that make the current village task loop readable;
- compact silhouette props for the ridge landmark, pantry, guard, study, and - compact silhouette props for the ridge landmark, pantry, guard, study, and
rest sites; rest sites;
- warm sky, fog, shadows, and wind-reactive foliage; - warm sky, fog, shadows, coherent wind-reactive foliage, and breeze-aligned
- eight ResourceNodes preserving stable food/wood discovery IDs; chimney smoke;
- one sparse terrain-aware calligraphic gust field with two or three soft
tapered strokes per burst;
- eighteen ResourceNodes preserving stable food/wood discovery IDs;
- typed village pantry storage and typed guard, study, and rest activity sites; - typed village pantry storage and typed guard, study, and rest activity sites;
- a Terrain3D-derived baked navigation mesh covering the current playable loop; - a Terrain3D-derived baked navigation mesh covering the current playable loop;
- base collision/navigation guardrails that keep Terrain3D collision enabled, - base collision/navigation guardrails that keep Terrain3D collision enabled,
@@ -363,7 +371,10 @@ A small village panel displays:
- starving NPC count; - starving NPC count;
- selected village modifiers. - selected village modifiers.
- a Tab-cycled NPC inspector with profession, needs, task state, destination, - a Tab-cycled NPC inspector with profession, needs, task state, destination,
decision reason, and utility scores. decision reason, utility scores, directed familiarity/trust, and the exact
completed event that last changed trust;
- the selected NPC's latest known objective fact, resolved from its own
persisted knowledge rather than omniscient event history.
NPC name/profession labels, definition-driven colors and props, carried-food NPC name/profession labels, definition-driven colors and props, carried-food
visuals, and compact task glyphs make active simulation state readable in the visuals, and compact task glyphs make active simulation state readable in the
@@ -374,8 +385,9 @@ glyphs.
### `simulation/SimNPC.gd` ### `simulation/SimNPC.gd`
`SimNPC` is a `RefCounted` simulation model. It owns needs, task selection, `SimNPC` is a `RefCounted` simulation model. It owns needs, task state,
task state, profession affinity, carried inventory, starvation, and death. profession identity, carried inventory, starvation, and death. Focused systems
select and execute actions against that state.
This separation from the visual node is an important architectural seed and This separation from the visual node is an important architectural seed and
should be preserved. should be preserved.
@@ -401,7 +413,11 @@ Focused `RefCounted` collaborators keep rule ownership visible:
- action systems own selection, execution progress, and target resolution; - action systems own selection, execution progress, and target resolution;
- `VillageEconomy` owns storage/inventory transactions and synchronized - `VillageEconomy` owns storage/inventory transactions and synchronized
village resource views; village resource views;
- `SimulationEventLog` owns deterministic event history and queries. - `SimulationEventLog` owns deterministic event history and queries;
- `EventKnowledgeSystem` owns per-NPC known-event references and captures
actor/nearby evidence when a witnessable event is recorded;
- `RelationshipSystem` owns directed relationship state, event-driven trust
consequences, and deterministic social queries.
`SimulationManager` remains the scene-tree façade and signal boundary rather `SimulationManager` remains the scene-tree façade and signal boundary rather
than duplicating these responsibilities across additional manager nodes. than duplicating these responsibilities across additional manager nodes.
@@ -425,7 +441,8 @@ than duplicating these responsibilities across additional manager nodes.
- obtains a navigation path; - obtains a navigation path;
- moves and rotates toward path points; - moves and rotates toward path points;
- reports arrival; - reports arrival;
- applies a simple death presentation. - presents definition-driven profession silhouettes, carried resources, task
glyphs, walk motion, and death state.
### `player/player.gd` and `player/camera_rig.gd` ### `player/player.gd` and `player/camera_rig.gd`
@@ -435,7 +452,9 @@ follow camera.
### `world/ui/ui.gd` ### `world/ui/ui.gd`
The UI subscribes to village changes and formats aggregate village state. The UI subscribes to village, task, event, knowledge, and relationship changes.
It formats aggregate state and resolves known facts/relationship causes through
the event log without owning or recomputing simulation facts.
## Current runtime flow ## Current runtime flow
@@ -465,6 +484,8 @@ NpcVisual navigates through the active world
| ResourceStateRecord.extract() -> NPC inventory | ResourceStateRecord.extract() -> NPC inventory
| -> VillageEconomy transfers inventory/storage as actions complete | -> VillageEconomy transfers inventory/storage as actions complete
| -> SimulationEventLog appends completed facts | -> SimulationEventLog appends completed facts
| -> EventKnowledgeSystem captures actor/nearby knowledge
| -> RelationshipSystem applies evidence-gated social consequences
| | | |
| v | v
| village_changed signal updates the UI | village_changed signal updates the UI
@@ -485,7 +506,9 @@ NpcVisual navigates through the active world
├── docs/ Project context and plans ├── docs/ Project context and plans
├── player/ ├── player/
│ ├── camera_rig.gd │ ├── camera_rig.gd
│ ├── PlayerVisual.tscn
│ ├── player.gd │ ├── player.gd
│ ├── player_visual.gd
│ └── npc/ │ └── npc/
│ ├── NpcVisual.gd │ ├── NpcVisual.gd
│ └── NpcVisual.tscn │ └── NpcVisual.tscn
@@ -498,7 +521,9 @@ NpcVisual navigates through the active world
│ ├── definitions/ Stable IDs and custom definition resources │ ├── definitions/ Stable IDs and custom definition resources
│ ├── economy/ Inventory and storage transactions │ ├── economy/ Inventory and storage transactions
│ ├── events/ Ordered event history and queries │ ├── events/ Ordered event history and queries
│ ├── knowledge/ Per-NPC references to known objective events
│ ├── persistence/ Validated local save-slot storage │ ├── persistence/ Validated local save-slot storage
│ ├── relationships/ Directed social consequences and queries
│ └── state/ Versioned simulation-state records │ └── state/ Versioned simulation-state records
├── tests/ ├── tests/
│ ├── action_system_boundaries_test.gd │ ├── action_system_boundaries_test.gd
@@ -507,9 +532,11 @@ NpcVisual navigates through the active world
│ ├── jajce_world_scaffold_test.gd │ ├── jajce_world_scaffold_test.gd
│ ├── jajce_runtime_integration_test.gd │ ├── jajce_runtime_integration_test.gd
│ ├── npc_visual_lifecycle_test.gd │ ├── npc_visual_lifecycle_test.gd
│ ├── relationship_consequence_test.gd
│ ├── resource_node_player_parity_test.gd │ ├── resource_node_player_parity_test.gd
│ ├── simulation_definitions_test.gd │ ├── simulation_definitions_test.gd
── simulation_state_serialization_test.gd ── simulation_state_serialization_test.gd
│ └── witnessed_knowledge_consequence_test.gd
├── terrain/jajce/ Dedicated Terrain3D seed data and assets ├── terrain/jajce/ Dedicated Terrain3D seed data and assets
├── tools/ ├── tools/
│ └── generate_jajce_terrain_seed.gd │ └── generate_jajce_terrain_seed.gd
@@ -518,7 +545,8 @@ NpcVisual navigates through the active world
│ │ ├── JajceWorld.tscn │ │ ├── JajceWorld.tscn
│ │ ├── JajceLookdev.tscn │ │ ├── JajceLookdev.tscn
│ │ ├── jajce_world.gd │ │ ├── jajce_world.gd
│ │ ── beauty_camera.gd │ │ ── beauty_camera.gd
│ │ └── vfx/WindGustField.tscn
│ ├── resource_nodes/ │ ├── resource_nodes/
│ │ ├── ResourceNode.gd │ │ ├── ResourceNode.gd
│ │ ├── ResourceNode.gd.uid │ │ ├── ResourceNode.gd.uid
@@ -539,9 +567,10 @@ These are expected prototype constraints, not necessarily isolated bugs:
- Temporary activity markers have been removed; NPC and player food/wood - Temporary activity markers have been removed; NPC and player food/wood
gathering use `ResourceNode` instances with no fallback, food transfer uses gathering use `ResourceNode` instances with no fallback, food transfer uses
the typed pantry `StorageNode`, and patrol/study/rest use `ActivitySite`. the typed pantry `StorageNode`, and patrol/study/rest use `ActivitySite`.
- Current NPC, village, resource, storage, event, clock, and RNG state serialize - Current NPC, village, resource, storage, event, knowledge, relationship,
through world schema v3. F5/F9 provide one validated local quicksave; a save clock, and RNG state serialize through world schema v5. F5/F9 provide one
menu, metadata, and player-transform persistence remain deferred. validated local quicksave; a save menu, metadata, and player-transform
persistence remain deferred.
- Simulation-owned resource records retain live amounts, reservations, and - Simulation-owned resource records retain live amounts, reservations, and
usage definitions while ResourceNode scenes are unloaded. usage definitions while ResourceNode scenes are unloaded.
- An explicit fixed-step clock converts frame delta into simulation ticks, but - An explicit fixed-step clock converts frame delta into simulation ticks, but
@@ -552,10 +581,13 @@ These are expected prototype constraints, not necessarily isolated bugs:
- Food and wood now move through finite sources, NPC inventory, and typed - Food and wood now move through finite sources, NPC inventory, and typed
village storage. Other village metrics remain aggregate values rather than village storage. Other village metrics remain aggregate values rather than
located items. located items.
- NPCs do not have homes, schedules, possessions, memories, relationships, - NPCs have home positions, schedule periods, carried food/wood, and directed
goals, or social knowledge. familiarity/trust. They can retain direct proximity-witness knowledge of a
- The reason inspector exposes current decisions, but deeper historical traces food deposit, but do not yet have wider social dimensions, goals, line of
and rejected preconditions are not yet retained. sight/hearing evidence, memory decay, false beliefs, or communication.
- The reason inspector exposes current decisions, utility rejections, and one
exact relationship cause plus the latest known fact, but deeper historical
decision traces are not yet retained.
- Active navigation is used as if all agents are local; no simulation LOD exists. - Active navigation is used as if all agents are local; no simulation LOD exists.
- Unloaded traveling NPCs preserve their state but do not yet advance through - Unloaded traveling NPCs preserve their state but do not yet advance through
abstract travel time. abstract travel time.
@@ -573,8 +605,9 @@ These are expected prototype constraints, not necessarily isolated bugs:
as reproducible presentation baselines. as reproducible presentation baselines.
- Combat, companions, factions, politics, trade, rumours, quests, persistence, - Combat, companions, factions, politics, trade, rumours, quests, persistence,
and regional travel do not yet exist. and regional travel do not yet exist.
- Placeholder geometry is sufficient for debugging but not for build-in-public - Stylized player/NPC silhouettes, water, foliage, and VFX support the current
presentation. build-in-public baseline, while blockout buildings and several work/resource
props remain visibly prototype-grade.
## Target simulation architecture ## Target simulation architecture
@@ -596,8 +629,9 @@ physics frames, or a currently loaded map.
The authoritative-boundary decision is recorded in The authoritative-boundary decision is recorded in
[ADR 0001](decisions/0001-simulation-authority-boundary.md). The current [ADR 0001](decisions/0001-simulation-authority-boundary.md). The current
`ResourceNode` state and `WorldViewManager` target selection are transitional, `ResourceNode` binding and the `WorldViewManager` active-position bridge follow
not patterns to extend into inventories, schedules, or relationships. this boundary; new inventories, schedules, relationships, and knowledge should
remain scene-independent records and systems.
### Authority and active-world adapters ### Authority and active-world adapters
@@ -791,7 +825,14 @@ validation, atomic replacement recovery, and active-visual rebuilding.
Terrain3D runtime integration, the bounded Jajce beauty pass, and the first Terrain3D runtime integration, the bounded Jajce beauty pass, and the first
simulation-garden runtime capture are complete. simulation-garden runtime capture are complete.
The coherent visual slice can then aim for: The first relationship and knowledge consequences are also complete:
food-deposit events become separate known facts for their actor and nearby
living NPCs; only an informed hungry familiar NPC gains directed trust. The
exact event remains visible as both known fact and relationship cause, and that
trust can redirect ordinary work toward helping a starving acquaintance. One
provenance-aware NPC-to-NPC fact transfer is the next systems slice.
The remaining simulation-garden target still aims for:
- one attractive valley section; - one attractive valley section;
- six named villagers; - six named villagers;
+49 -7
View File
@@ -3,7 +3,7 @@
## Current contract ## Current contract
`SimulationStateRecord` is the versioned JSON boundary for the current `SimulationStateRecord` is the versioned JSON boundary for the current
simulation. The current world schema is v3 and captures: simulation. The current world schema is v5 and captures:
- simulation seed, tick interval, tick count, clock remainder, and elapsed - simulation seed, tick interval, tick count, clock remainder, and elapsed
clock ticks; clock ticks;
@@ -12,16 +12,20 @@ simulation. The current world schema is v3 and captures:
- village resource counters; - village resource counters;
- controlled per-NPC wander RNG streams; - controlled per-NPC wander RNG streams;
- scene-independent resource state plus the definition facts needed while its - scene-independent resource state plus the definition facts needed while its
ResourceNode is unloaded. ResourceNode is unloaded;
- pantry contents, carried NPC inventory, the ordered economic event stream, - pantry contents, carried NPC inventory, the ordered economic event stream,
and its next stable event ID. and its next stable event ID;
- directed relationship records with familiarity, trust, and the stable event
ID that last changed trust;
- per-NPC known-event records that reference objective event history without
copying it.
The top-level identity is: The top-level identity is:
```json ```json
{ {
"schema": "the_steward.simulation", "schema": "the_steward.simulation",
"schema_version": 3 "schema_version": 5
} }
``` ```
@@ -51,7 +55,8 @@ its future random sequence.
5. requiring the complete final-state checksums to match. 5. requiring the complete final-state checksums to match.
It also verifies clock remainder, resource amount/reservation/enabled It also verifies clock remainder, resource amount/reservation/enabled
round-tripping, presentation unload/rebind, and rejection of unsupported round-tripping, presentation unload/rebind, directed relationship/cause
round-tripping, divergent known-event state, and rejection of unsupported
schemas. schemas.
NPCStateRecord v2 adds the resolved travel destination and whether it is NPCStateRecord v2 adds the resolved travel destination and whether it is
@@ -71,6 +76,40 @@ stream. Event records use stable source/destination/item IDs, reject invalid or
duplicate IDs, and preserve deterministic ordering across save/restore. See duplicate IDs, and preserve deterministic ordering across save/restore. See
[the economic event stream](ECONOMIC_EVENTS.md). [the economic event stream](ECONOMIC_EVENTS.md).
EconomicEventRecord v2 adds the authoritative world position captured when the
fact is recorded. Nested v1 events migrate with a zero-vector fallback; old
saves already encode their stable location IDs, and migrated knowledge is not
recomputed spatially.
SimulationStateRecord v4 moves social authority into top-level directed
`RelationshipStateRecord` entries. Each record contains `observer_id`,
`subject_id`, familiarity, trust, and `last_trust_cause_event_id` (`-1` when no
event has changed trust). Parsing requires unique ordered pairs, existing NPC
IDs, bounded relationship values, and a real event for every non-empty cause
ID.
The relationship system serializes records in stable observer/subject order so
they participate in deterministic checksums.
World schema v3 migrates its NPC-local familiarity pairs into directed
relationships with neutral trust. Nested NPC schema v4 removes that obsolete
duplicate field; v3 NPC records remain accepted only through the explicit
migration. World schemas v1v2 also derive the graph from any legacy
familiarity data (or initialize it empty) after their storage/event migrations.
SimulationStateRecord v5 adds top-level `KnownEventStateRecord` entries keyed
by `knower_id` and `event_id`. The objective `EconomicEventRecord` remains the
single completed fact; knowledge records only state which NPC knows it. Parsing
requires unique NPC/event pairs and valid references to both an existing NPC
and an event in the same record. A relationship's causal event must also be
known by that relationship's observer and, for the current trust contract, be
a food deposit performed by the relationship subject.
World schema v4 migrates relationship causes into known-event records. The old
schema used village-wide awareness, so this preserves the implied known fact
without falsely inventing spatial witness provenance. New v5 facts are created
only at event-record time from the actor and nearby NPC positions; historical
events are never re-evaluated against current positions.
## Resource authority ## Resource authority
`SimulationManager` owns `ResourceStateRecord` instances independently of the `SimulationManager` owns `ResourceStateRecord` instances independently of the
@@ -106,8 +145,11 @@ visuals from authoritative state.
This phase does not yet provide: This phase does not yet provide:
- a save-slot menu, metadata, thumbnails, autosaves, or multiple profiles; - a save-slot menu, metadata, thumbnails, autosaves, or multiple profiles;
- migrations older than the explicitly supported world schemas v1 and v2; - migrations from any historical world schema other than the explicitly
- player inventory, relationship, schedule, or social-memory records; supported v1v4 layouts;
- player inventory or player relationship records;
- broader relationship dimensions, line-of-sight/hearing evidence,
acquisition provenance, memory decay, or communication;
- persistence for the player transform or presentation-only scene state. - persistence for the player transform or presentation-only scene state.
Those features should build on this boundary rather than inventing parallel Those features should build on this boundary rather than inventing parallel
+49 -11
View File
@@ -1,6 +1,6 @@
# Simulation Garden 01 # Simulation Garden 01
Captured: 2026-07-09 Captured: 2026-07-11
## Files ## Files
@@ -10,7 +10,17 @@ Captured: 2026-07-09
## Capture Command ## Capture Command
Run from the project root with the normal renderer: Run from the project root with the normal renderer on macOS:
```bash
HOME="$PWD/logs/quality/godot_profile" \
APPDATA="$PWD/logs/quality/godot_profile" \
LOCALAPPDATA="$PWD/logs/quality/godot_profile" \
/Applications/Godot.app/Contents/MacOS/Godot \
--path "$PWD" --script res://tools/capture_simulation_garden.gd
```
On Windows:
```powershell ```powershell
$env:APPDATA = 'C:\Users\Rijad\Documents\Simulation Game\logs\quality\godot_profile' $env:APPDATA = 'C:\Users\Rijad\Documents\Simulation Game\logs\quality\godot_profile'
@@ -36,17 +46,17 @@ The next presentation pass adds compact silhouette props to the ridge landmark,
pantry, guard, study, and rest sites so work locations remain identifiable in pantry, guard, study, and rest sites so work locations remain identifiable in
cinematic mode. cinematic mode.
The frame also makes the next presentation risks plain: The frame also makes the remaining presentation risks plain:
- the camera still favors verification over composition; - the elevated third-person/top-down camera is intentionally preserved; future
- placeholder characters, houses, and work props remain useful but visibly staging should improve composition within that cozy follow perspective;
- houses and several work/resource props remain useful but visibly
prototype-grade; prototype-grade;
- terrain texture variation and path strips read better than the old flat map, - terrain texture variation and path strips read better than the old flat map,
but landmarks, water, and foreground silhouettes need stronger first-read but terrain/color hierarchy still flattens some village landmarks;
staging; - task glyphs and work-site props help, but blockout buildings and resource
- task glyphs and work-site props help, but the cinematic view still needs props need stronger authored silhouettes before this becomes a polished
richer water/foreground shapes and stronger authored silhouettes before it public-facing shot.
becomes a strong public-facing shot.
Use this baseline as the first runtime comparison image before adding more Use this baseline as the first runtime comparison image before adding more
systems or expanding the terrain. systems or expanding the terrain.
@@ -60,4 +70,32 @@ inventory now appears as carried logs. The capture also fixes three effects
that obscured the simulation on Metal: waterfall mist was incorrectly emitted that obscured the simulation on Metal: waterfall mist was incorrectly emitted
at the village origin, ridge/valley wind systems overlapped, and chimney smoke at the village origin, ridge/valley wind systems overlapped, and chimney smoke
ignored particle transparency. The player capsule and camera composition are ignored particle transparency. The player capsule and camera composition are
the next visible presentation gaps. the next visible presentation gaps at that checkpoint.
## July 11 visual follow-up
The implementation keeps the overlapping valley-wide white stroke/speck fields
removed: those read as constant scratches or insects. In their place, one
bounded controller produces sporadic groups of two or three terrain-aware
calligraphic ribbons. Their tapered sage, warm amber, and pale aqua strokes are
drawn head-to-tail by the shader, remain below 30% alpha, cannot stack, and
share the prevailing direction used by deterministic tree-canopy motion and
chimney smoke. The capture tool stages one gust so the next visual comparison
does not randomly land during the intended quiet interval.
The PNGs listed above still predate that gust-controller pass. A fresh Metal
capture was unavailable when the code and headless mesh/shader checks landed,
so do not use those images as evidence of the final ribbon opacity or terrain
contact. Replace both images after running the capture command on the normal
renderer and reviewing the staged mid-sweep gust.
The day/night controller also reconnects to the authoritative simulation clock
after scene startup and uses the correct sunrise/sunset color order, preventing
lighting drift during speed changes or restoration.
The player now uses a compact multi-part silhouette with a teal tunic, warm
scarf/satchel accents, and velocity-driven walk motion. Collision, controls,
and the elevated staggered follow camera are unchanged. The next visible gains
should come from replacing remaining blockout architecture, strengthening work
props, and improving terrain/color hierarchy—not from changing the camera
language again.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 419 KiB

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

After

Width:  |  Height:  |  Size: 426 KiB

+9 -6
View File
@@ -40,8 +40,8 @@ is optional.
### Fast changed-files mode ### Fast changed-files mode
Only runs `gdformat` / `gdlint` on `.gd` files modified since the last commit. Only runs `gdformat` / `gdlint` on tracked changes and new untracked `.gd`
The Godot dependency check and all project scenarios still run. files. The Godot dependency check and all project scenarios still run.
```bash ```bash
./tools/quality.sh --changed ./tools/quality.sh --changed
@@ -128,7 +128,10 @@ The script exits non-zero on any failure, so it will fail the CI step.
The shell gate isolates Godot's cross-platform user-data paths under The shell gate isolates Godot's cross-platform user-data paths under
`logs/quality/godot_profile`. Before running scenarios it imports project and `logs/quality/godot_profile`. Before running scenarios it imports project and
GUT global classes when the ignored cache is absent, so a fresh clone needs no GUT global classes whenever GDScript changes, so fresh, renamed, and deleted
manual editor launch. Godot/GUT script parse or load markers are failures `class_name` scripts cannot leave the ignored cache stale. Godot/GUT script
because headless Godot can report those errors while returning a zero process parse or load markers are failures because headless Godot can report those
exit code. errors while returning a zero process exit code. Every Godot subprocess has a
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.
+50
View File
@@ -0,0 +1,50 @@
class-definitions-order:
- tools
- classnames
- extends
- docstrings
- signals
- enums
- consts
- staticvars
- exports
- pubvars
- prvvars
- onreadypubvars
- onreadyprvvars
- others
class-load-variable-name: (([A-Z][a-z0-9]*)+|_?[a-z][a-z0-9]*(_[a-z0-9]+)*)
class-name: ([A-Z][a-z0-9]*)+
class-variable-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)*
comparison-with-itself: null
constant-name: _?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*
disable:
- class-definitions-order
- duplicated-load
- max-public-methods
- max-returns
duplicated-load: null
enum-element-name: '[A-Z][A-Z0-9]*(_[A-Z0-9]+)*'
enum-name: ([A-Z][a-z0-9]*)+
excluded_directories: !!set
.git: null
function-argument-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)*
function-arguments-number: 10
function-name: (_on_([A-Z][a-z0-9]*)+(_[a-z0-9]+)*|_?[a-z][a-z0-9]*(_[a-z0-9]+)*)
function-preload-variable-name: ([A-Z][a-z0-9]*)+
function-variable-name: '[a-z][a-z0-9]*(_[a-z0-9]+)*'
load-constant-name: (([A-Z][a-z0-9]*)+|_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*)
loop-variable-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)*
max-file-lines: 1200
max-line-length: 120
max-public-methods: 40
max-returns: 12
mixed-tabs-and-spaces: null
no-elif-return: null
no-else-return: null
signal-name: '[a-z][a-z0-9]*(_[a-z0-9]+)*'
sub-class-name: _?([A-Z][a-z0-9]*)+
tab-characters: 1
trailing-whitespace: null
unnecessary-pass: null
unused-argument: null
+2 -11
View File
@@ -11,15 +11,12 @@
[ext_resource type="PackedScene" path="res://world/jajce/JajceWorld.tscn" id="11_jajce"] [ext_resource type="PackedScene" path="res://world/jajce/JajceWorld.tscn" id="11_jajce"]
[ext_resource type="Script" uid="uid://cascjhf8lsvay" path="res://world/ui/time_dial.gd" id="12_timedial"] [ext_resource type="Script" uid="uid://cascjhf8lsvay" path="res://world/ui/time_dial.gd" id="12_timedial"]
[ext_resource type="Script" uid="uid://bivrsk5ukgnoo" path="res://world/demo/DemoController.gd" id="13_demo"] [ext_resource type="Script" uid="uid://bivrsk5ukgnoo" path="res://world/demo/DemoController.gd" id="13_demo"]
[ext_resource type="PackedScene" path="res://player/PlayerVisual.tscn" id="14_player_visual"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_o5qli"] [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_o5qli"]
radius = 0.4 radius = 0.4
height = 1.7 height = 1.7
[sub_resource type="CapsuleMesh" id="CapsuleMesh_sgp6g"]
[sub_resource type="BoxMesh" id="BoxMesh_0wfyh"]
[node name="Main" type="Node3D" unique_id=1850341560] [node name="Main" type="Node3D" unique_id=1850341560]
[node name="JajceWorld" parent="." unique_id=1023795383 instance=ExtResource("11_jajce")] [node name="JajceWorld" parent="." unique_id=1023795383 instance=ExtResource("11_jajce")]
@@ -38,13 +35,7 @@ stomach_capacity_for_food = 10
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.85, 0) transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.85, 0)
shape = SubResource("CapsuleShape3D_o5qli") shape = SubResource("CapsuleShape3D_o5qli")
[node name="MeshInstance3D" type="MeshInstance3D" parent="Player" unique_id=935946019] [node name="Visual" parent="Player" instance=ExtResource("14_player_visual")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.85, 0)
mesh = SubResource("CapsuleMesh_sgp6g")
[node name="FaceMarker" type="MeshInstance3D" parent="Player" unique_id=1558899356]
transform = Transform3D(0.15, 0, 0, 0, 0.15, 0, 0, 0, 0.4, 0, 1.2, 0.6266626)
mesh = SubResource("BoxMesh_0wfyh")
[node name="CameraRig" type="Node3D" parent="." unique_id=81378719 node_paths=PackedStringArray("target")] [node name="CameraRig" type="Node3D" parent="." unique_id=81378719 node_paths=PackedStringArray("target")]
transform = Transform3D(1, 0, 0, 0, 0.57357645, 0.81915206, 0, -0.81915206, 0.57357645, 0, 10, 8) transform = Transform3D(1, 0, 0, 0, 0.57357645, 0.81915206, 0, -0.81915206, 0.57357645, 0, 10, 8)
+130
View File
@@ -0,0 +1,130 @@
[gd_scene load_steps=17 format=3]
[ext_resource type="Script" path="res://player/player_visual.gd" id="1_visual"]
[sub_resource type="StandardMaterial3D" id="Material_tunic"]
albedo_color = Color(0.18, 0.46, 0.48, 1)
roughness = 0.84
[sub_resource type="CylinderMesh" id="Mesh_body"]
material = SubResource("Material_tunic")
top_radius = 0.28
bottom_radius = 0.42
height = 0.9
[sub_resource type="StandardMaterial3D" id="Material_skin"]
albedo_color = Color(0.84, 0.61, 0.39, 1)
roughness = 0.88
[sub_resource type="SphereMesh" id="Mesh_head"]
material = SubResource("Material_skin")
radius = 0.29
height = 0.56
[sub_resource type="StandardMaterial3D" id="Material_hair"]
albedo_color = Color(0.2, 0.105, 0.05, 1)
roughness = 0.92
[sub_resource type="CylinderMesh" id="Mesh_hair"]
material = SubResource("Material_hair")
top_radius = 0.24
bottom_radius = 0.29
height = 0.18
[sub_resource type="CylinderMesh" id="Mesh_arm"]
material = SubResource("Material_tunic")
top_radius = 0.095
bottom_radius = 0.11
height = 0.58
[sub_resource type="SphereMesh" id="Mesh_hand"]
material = SubResource("Material_skin")
radius = 0.12
height = 0.22
[sub_resource type="StandardMaterial3D" id="Material_pants"]
albedo_color = Color(0.15, 0.21, 0.24, 1)
roughness = 0.9
[sub_resource type="CapsuleMesh" id="Mesh_leg"]
material = SubResource("Material_pants")
radius = 0.115
height = 0.62
[sub_resource type="StandardMaterial3D" id="Material_boot"]
albedo_color = Color(0.13, 0.075, 0.04, 1)
roughness = 0.95
[sub_resource type="BoxMesh" id="Mesh_foot"]
material = SubResource("Material_boot")
size = Vector3(0.23, 0.15, 0.36)
[sub_resource type="StandardMaterial3D" id="Material_accent"]
albedo_color = Color(0.86, 0.4, 0.18, 1)
roughness = 0.82
[sub_resource type="BoxMesh" id="Mesh_scarf"]
material = SubResource("Material_accent")
size = Vector3(0.48, 0.1, 0.38)
[sub_resource type="SphereMesh" id="Mesh_satchel"]
material = SubResource("Material_accent")
radius = 0.22
height = 0.34
[node name="PlayerVisual" type="Node3D"]
script = ExtResource("1_visual")
[node name="Body" type="MeshInstance3D" parent="."]
position = Vector3(0, 1.05, 0)
mesh = SubResource("Mesh_body")
[node name="Scarf" type="MeshInstance3D" parent="."]
position = Vector3(0, 1.42, 0.02)
mesh = SubResource("Mesh_scarf")
[node name="Head" type="MeshInstance3D" parent="."]
position = Vector3(0, 1.68, 0)
mesh = SubResource("Mesh_head")
[node name="Hair" type="MeshInstance3D" parent="."]
position = Vector3(0, 1.9, -0.015)
mesh = SubResource("Mesh_hair")
[node name="ArmLeft" type="MeshInstance3D" parent="."]
position = Vector3(-0.39, 1.08, 0)
rotation = Vector3(0, 0, -0.139626)
mesh = SubResource("Mesh_arm")
[node name="Hand" type="MeshInstance3D" parent="ArmLeft"]
position = Vector3(0, -0.35, 0)
mesh = SubResource("Mesh_hand")
[node name="ArmRight" type="MeshInstance3D" parent="."]
position = Vector3(0.39, 1.08, 0)
rotation = Vector3(0, 0, 0.139626)
mesh = SubResource("Mesh_arm")
[node name="Hand" type="MeshInstance3D" parent="ArmRight"]
position = Vector3(0, -0.35, 0)
mesh = SubResource("Mesh_hand")
[node name="LegLeft" type="MeshInstance3D" parent="."]
position = Vector3(-0.18, 0.43, 0)
mesh = SubResource("Mesh_leg")
[node name="Foot" type="MeshInstance3D" parent="LegLeft"]
position = Vector3(0, -0.28, 0.09)
mesh = SubResource("Mesh_foot")
[node name="LegRight" type="MeshInstance3D" parent="."]
position = Vector3(0.18, 0.43, 0)
mesh = SubResource("Mesh_leg")
[node name="Foot" type="MeshInstance3D" parent="LegRight"]
position = Vector3(0, -0.28, 0.09)
mesh = SubResource("Mesh_foot")
[node name="Satchel" type="MeshInstance3D" parent="."]
position = Vector3(-0.43, 0.87, -0.04)
mesh = SubResource("Mesh_satchel")
+4 -1
View File
@@ -351,11 +351,14 @@ func apply_dead_visual_state() -> void:
func _should_show_task_glyph(action_id: StringName, task_state: StringName) -> bool: func _should_show_task_glyph(action_id: StringName, task_state: StringName) -> bool:
if task_state not in [SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING]: if task_state not in [SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING]:
return false return false
return action_id not in [ return (
action_id
not in [
SimulationIds.ACTION_IDLE, SimulationIds.ACTION_IDLE,
SimulationIds.ACTION_DEAD, SimulationIds.ACTION_DEAD,
SimulationIds.ACTION_WANDER, SimulationIds.ACTION_WANDER,
] ]
)
func _create_task_glyph_mesh(action_id: StringName) -> PrimitiveMesh: func _create_task_glyph_mesh(action_id: StringName) -> PrimitiveMesh:
+3 -1
View File
@@ -110,4 +110,6 @@ func is_near_activity_site(activity_site: ActivitySite) -> bool:
if activity_site == null: if activity_site == null:
return false return false
return global_position.distance_to(activity_site.get_interaction_position()) <= interaction_range return (
global_position.distance_to(activity_site.get_interaction_position()) <= interaction_range
)
+43
View File
@@ -0,0 +1,43 @@
extends Node3D
@onready var body_mesh: MeshInstance3D = $Body
@onready var head_mesh: MeshInstance3D = $Head
@onready var hair_mesh: MeshInstance3D = $Hair
@onready var left_arm: MeshInstance3D = $ArmLeft
@onready var right_arm: MeshInstance3D = $ArmRight
@onready var left_leg: MeshInstance3D = $LegLeft
@onready var right_leg: MeshInstance3D = $LegRight
var player_body: CharacterBody3D
var walk_phase := 0.0
func _ready() -> void:
player_body = get_parent() as CharacterBody3D
func _process(delta: float) -> void:
if player_body == null:
return
var horizontal_speed := Vector2(player_body.velocity.x, player_body.velocity.z).length()
var blend := minf(delta * 10.0, 1.0)
if horizontal_speed > 0.1:
walk_phase = fmod(walk_phase + delta * horizontal_speed * 3.4, TAU)
var swing := sin(walk_phase)
left_leg.rotation_degrees.x = lerpf(left_leg.rotation_degrees.x, swing * 30.0, blend)
right_leg.rotation_degrees.x = lerpf(right_leg.rotation_degrees.x, -swing * 30.0, blend)
left_arm.rotation_degrees.x = lerpf(left_arm.rotation_degrees.x, -swing * 22.0, blend)
right_arm.rotation_degrees.x = lerpf(right_arm.rotation_degrees.x, swing * 22.0, blend)
var step_bob := absf(sin(walk_phase * 2.0)) * 0.035
body_mesh.position.y = lerpf(body_mesh.position.y, 1.05 + step_bob, blend)
head_mesh.position.y = lerpf(head_mesh.position.y, 1.68 + step_bob, blend)
hair_mesh.position.y = lerpf(hair_mesh.position.y, 1.9 + step_bob, blend)
return
left_leg.rotation_degrees.x = lerpf(left_leg.rotation_degrees.x, 0.0, blend)
right_leg.rotation_degrees.x = lerpf(right_leg.rotation_degrees.x, 0.0, blend)
left_arm.rotation_degrees.x = lerpf(left_arm.rotation_degrees.x, 0.0, blend)
right_arm.rotation_degrees.x = lerpf(right_arm.rotation_degrees.x, 0.0, blend)
body_mesh.position.y = lerpf(body_mesh.position.y, 1.05, blend)
head_mesh.position.y = lerpf(head_mesh.position.y, 1.68, blend)
hair_mesh.position.y = lerpf(hair_mesh.position.y, 1.9, blend)
+1
View File
@@ -0,0 +1 @@
uid://bu752bd0occgc
-1
View File
@@ -32,7 +32,6 @@ var has_travel_target := false
var inventory: Dictionary = {} var inventory: Dictionary = {}
var last_task: StringName var last_task: StringName
var random_source: RandomNumberGenerator var random_source: RandomNumberGenerator
var familiarity: Dictionary = {}
var mourning_ticks := 0 var mourning_ticks := 0
var debug_logs := false var debug_logs := false
+132 -46
View File
@@ -2,6 +2,8 @@ extends Node
const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.gd") const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.gd")
const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd") const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd")
const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd")
const EventKnowledgeSystemScript := preload("res://simulation/knowledge/EventKnowledgeSystem.gd")
signal npc_task_changed(npc: SimNPC, old_task: StringName, new_task: StringName) signal npc_task_changed(npc: SimNPC, old_task: StringName, new_task: StringName)
signal village_changed(village: SimVillage) signal village_changed(village: SimVillage)
@@ -12,6 +14,8 @@ signal npc_inventory_changed(npc: SimNPC, item_id: StringName, amount: float)
signal economic_event_recorded(event: EconomicEventRecord) signal economic_event_recorded(event: EconomicEventRecord)
signal state_restored signal state_restored
signal npc_decision_recorded(npc: SimNPC, decision: ActionSelectionResult) signal npc_decision_recorded(npc: SimNPC, decision: ActionSelectionResult)
signal relationship_changed(relationship: RelationshipStateRecord, cause_event: EconomicEventRecord)
signal event_knowledge_changed(knower_id: int, event: EconomicEventRecord)
var village := SimVillage.new() var village := SimVillage.new()
@@ -29,6 +33,8 @@ var wander_random_sources := {}
var resource_states: Dictionary = {} var resource_states: Dictionary = {}
var event_log := SimulationEventLogScript.new() var event_log := SimulationEventLogScript.new()
var economy := VillageEconomyScript.new() var economy := VillageEconomyScript.new()
var relationship_system := RelationshipSystemScript.new()
var event_knowledge_system := EventKnowledgeSystemScript.new()
var storage_states: Dictionary: var storage_states: Dictionary:
get: get:
return economy.storage_states return economy.storage_states
@@ -57,6 +63,7 @@ func _ready() -> void:
economy.inventory_changed.connect(_on_economy_inventory_changed) economy.inventory_changed.connect(_on_economy_inventory_changed)
economy.economic_event_requested.connect(_record_economic_event) economy.economic_event_requested.connect(_record_economic_event)
economy.narrative_event_requested.connect(record_narrative_event) economy.narrative_event_requested.connect(record_narrative_event)
action_selector.relationship_system = relationship_system
var definition_errors := SimulationDefinitions.validate() var definition_errors := SimulationDefinitions.validate()
if not definition_errors.is_empty(): if not definition_errors.is_empty():
for error in definition_errors: for error in definition_errors:
@@ -72,6 +79,7 @@ func _ready() -> void:
village.update_modifiers() village.update_modifiers()
village.update_priorities() village.update_priorities()
generate_npcs() generate_npcs()
relationship_system.initialize_households(npcs)
call_deferred("register_loaded_resource_nodes") call_deferred("register_loaded_resource_nodes")
call_deferred("register_loaded_storage_nodes") call_deferred("register_loaded_storage_nodes")
if debug_logs: if debug_logs:
@@ -122,12 +130,6 @@ func generate_npcs() -> void:
for i in range(npcs.size()): for i in range(npcs.size()):
npcs[i].home_position = home_positions[i % home_count] npcs[i].home_position = home_positions[i % home_count]
for i in range(npcs.size()):
for j in range(i + 1, npcs.size()):
if npcs[i].home_position.distance_to(npcs[j].home_position) < 4.0:
npcs[i].familiarity[npcs[j].id] = 0.5
npcs[j].familiarity[npcs[i].id] = 0.5
func _create_random_source(npc_id: int, stream_id: int) -> RandomNumberGenerator: func _create_random_source(npc_id: int, stream_id: int) -> RandomNumberGenerator:
var source := RandomNumberGenerator.new() var source := RandomNumberGenerator.new()
@@ -200,13 +202,13 @@ func _select_action_if_idle(npc: SimNPC, previous_state: StringName) -> void:
npc_decision_recorded.emit(npc, selection) npc_decision_recorded.emit(npc, selection)
npc.set_task(selection.action_id, selection.duration_override) npc.set_task(selection.action_id, selection.duration_override)
var definition := SimulationDefinitions.get_action(selection.action_id) var definition := SimulationDefinitions.get_action(selection.action_id)
var display_name := definition.display_name if definition != null else String(selection.action_id) var display_name := (
definition.display_name if definition != null else String(selection.action_id)
)
record_narrative_event(SimulationIds.EVENT_TASK_STARTED, npc.id, &"", display_name) record_narrative_event(SimulationIds.EVENT_TASK_STARTED, npc.id, &"", display_name)
func _handle_npc_death( func _handle_npc_death(npc: SimNPC, previous_task: StringName, previous_target: StringName) -> void:
npc: SimNPC, previous_task: StringName, previous_target: StringName
) -> void:
if not previous_target.is_empty(): if not previous_target.is_empty():
release_npc_reservation(npc.id) release_npc_reservation(npc.id)
npc_died.emit(npc) npc_died.emit(npc)
@@ -264,9 +266,11 @@ func _complete_resource_gather(npc: SimNPC, completed_task: StringName) -> void:
if resource_state == null or resource_state.get_reserved_by() != npc.id: if resource_state == null or resource_state.get_reserved_by() != npc.id:
if debug_logs: if debug_logs:
print( print(
(
"[SimulationManager] %s could not complete %s: invalid reservation" "[SimulationManager] %s could not complete %s: invalid reservation"
% [npc.npc_name, completed_task] % [npc.npc_name, completed_task]
) )
)
release_npc_reservation(npc.id) release_npc_reservation(npc.id)
return return
@@ -289,9 +293,11 @@ func _complete_resource_gather(npc: SimNPC, completed_task: StringName) -> void:
) )
if debug_logs: if debug_logs:
print( print(
(
"[SimulationManager] %s extracted %.1f %s from %s" "[SimulationManager] %s extracted %.1f %s from %s"
% [npc.npc_name, extracted, resource_id, resource_state.get_node_id()] % [npc.npc_name, extracted, resource_id, resource_state.get_node_id()]
) )
)
release_npc_reservation(npc.id) release_npc_reservation(npc.id)
@@ -384,16 +390,12 @@ func notify_npc_arrived(npc_id: int) -> void:
continue continue
if ( if (
other.target_id == npc.target_id other.target_id == npc.target_id
and other.task_state in [ and (
SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING other.task_state
] in [SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING]
)
): ):
npc.familiarity[other.id] = minf( relationship_system.increase_shared_work_familiarity(npc.id, other.id)
float(npc.familiarity.get(other.id, 0.0)) + 0.1, 1.0
)
other.familiarity[npc.id] = minf(
float(other.familiarity.get(npc.id, 0.0)) + 0.1, 1.0
)
if debug_logs: if debug_logs:
print( print(
@@ -491,27 +493,12 @@ func get_activity_target_claim_count(target_id: StringName, except_npc_id: int =
func _notify_mourning(dead_npc: SimNPC) -> void: func _notify_mourning(dead_npc: SimNPC) -> void:
var best_id := -1 var mourner := relationship_system.get_most_familiar_living_subject(dead_npc.id, npcs)
var best_score := -1.0 if mourner == null:
for key in dead_npc.familiarity:
var other_id: int = int(key)
var score: float = float(dead_npc.familiarity[key])
if score > best_score:
best_score = score
best_id = other_id
if best_id < 0:
return return
for npc in npcs: mourner.mourning_ticks = 100
if npc.id == best_id and not npc.is_dead:
npc.mourning_ticks = 100
if debug_logs: if debug_logs:
print( print("[SimulationManager] ", mourner.npc_name, " is mourning ", dead_npc.npc_name)
"[SimulationManager] ",
npc.npc_name,
" is mourning ",
dead_npc.npc_name
)
return
func _record_economic_event( func _record_economic_event(
@@ -521,19 +508,66 @@ func _record_economic_event(
destination_id: StringName, destination_id: StringName,
item_id: StringName, item_id: StringName,
amount: float amount: float
) -> void:
_record_economic_event_at(
event_type,
actor_id,
source_id,
destination_id,
item_id,
amount,
_get_event_world_position(actor_id, source_id, destination_id)
)
func _record_economic_event_at(
event_type: StringName,
actor_id: int,
source_id: StringName,
destination_id: StringName,
item_id: StringName,
amount: float,
world_position: Vector3
) -> void: ) -> void:
event_log.record_economic( event_log.record_economic(
tick_count, event_type, actor_id, source_id, destination_id, item_id, amount tick_count, event_type, actor_id, source_id, destination_id, item_id, amount, world_position
) )
func record_narrative_event( func record_narrative_event(
event_type: StringName, actor_id: int, source_id: StringName = &"", action_display: String = ""
) -> void:
_record_narrative_event_at(
event_type,
actor_id,
source_id,
action_display,
_get_event_world_position(actor_id, source_id, &"")
)
func _record_narrative_event_at(
event_type: StringName, event_type: StringName,
actor_id: int, actor_id: int,
source_id: StringName = &"", source_id: StringName,
action_display: String = "" action_display: String,
world_position: Vector3
) -> void: ) -> void:
event_log.record_narrative(tick_count, event_type, actor_id, source_id, action_display) event_log.record_narrative(
tick_count, event_type, actor_id, source_id, action_display, world_position
)
func _get_event_world_position(
actor_id: int, source_id: StringName, destination_id: StringName
) -> Vector3:
for npc in npcs:
if npc.id != actor_id:
continue
if not npc.target_id.is_empty() and npc.target_id in [source_id, destination_id]:
return npc.travel_target_position
return npc.position
return Vector3.ZERO
func get_npc_events(npc_id: int, max_count: int = 8) -> Array[EconomicEventRecord]: func get_npc_events(npc_id: int, max_count: int = 8) -> Array[EconomicEventRecord]:
@@ -545,9 +579,43 @@ func get_recent_events(max_count: int = 5) -> Array[EconomicEventRecord]:
func _on_economic_event_recorded(event: EconomicEventRecord) -> void: func _on_economic_event_recorded(event: EconomicEventRecord) -> void:
var learned_records: Array[KnownEventStateRecord] = event_knowledge_system.observe_event(
event, npcs
)
for learned_record in learned_records:
event_knowledge_changed.emit(learned_record.get_knower_id(), event)
var knower_ids: Array[int] = event_knowledge_system.get_knowers(int(event.data["event_id"]))
var changed_relationships: Array[RelationshipStateRecord] = relationship_system.apply_event(
event, npcs, knower_ids
)
for relationship in changed_relationships:
relationship_changed.emit(relationship, event)
economic_event_recorded.emit(event) economic_event_recorded.emit(event)
func get_primary_relationship(npc_id: int) -> RelationshipStateRecord:
return relationship_system.get_primary_relationship(npc_id)
func get_relationship_cause(relationship: RelationshipStateRecord) -> EconomicEventRecord:
if relationship == null:
return null
return event_log.get_by_id(relationship.get_last_trust_cause_event_id())
func get_known_events(npc_id: int, max_count: int = 3) -> Array[EconomicEventRecord]:
var known: Array[EconomicEventRecord] = []
for event_id in event_knowledge_system.get_known_event_ids(npc_id, max_count):
var event := event_log.get_by_id(event_id)
if event != null:
known.append(event)
return known
func npc_knows_event(npc_id: int, event_id: int) -> bool:
return event_knowledge_system.knows_event(npc_id, event_id)
func get_current_speed() -> String: func get_current_speed() -> String:
return "%.2fx" % SPEED_LEVELS[speed_index] return "%.2fx" % SPEED_LEVELS[speed_index]
@@ -576,9 +644,11 @@ func register_storage_node(node: StorageNode) -> bool:
var storage_state := storage_states.get(node.storage_id) as StorageStateRecord var storage_state := storage_states.get(node.storage_id) as StorageStateRecord
if storage_state == null: if storage_state == null:
push_error( push_error(
(
"SimulationManager: StorageNode '%s' has no authoritative StorageStateRecord" "SimulationManager: StorageNode '%s' has no authoritative StorageStateRecord"
% node.storage_id % node.storage_id
) )
)
return false return false
return node.bind_state(storage_state) return node.bind_state(storage_state)
@@ -671,7 +741,12 @@ func harvest_resource_node(node: ResourceNode) -> float:
return 0.0 return 0.0
var deposited := economy.deposit_resource(resource_state.get_resource_id(), extracted) var deposited := economy.deposit_resource(resource_state.get_resource_id(), extracted)
_record_economic_event( var event_position := (
node.interaction_point.global_position
if node.interaction_point != null
else node.global_position
)
_record_economic_event_at(
SimulationIds.EVENT_RESOURCE_EXTRACTED, SimulationIds.EVENT_RESOURCE_EXTRACTED,
-1, -1,
resource_state.get_node_id(), resource_state.get_node_id(),
@@ -685,11 +760,16 @@ func harvest_resource_node(node: ResourceNode) -> float:
) )
), ),
resource_state.get_resource_id(), resource_state.get_resource_id(),
deposited deposited,
event_position
) )
if resource_state.get_amount_remaining() <= 0.0: if resource_state.get_amount_remaining() <= 0.0:
record_narrative_event( _record_narrative_event_at(
SimulationIds.EVENT_RESOURCE_DEPLETED, -1, resource_state.get_node_id() SimulationIds.EVENT_RESOURCE_DEPLETED,
-1,
resource_state.get_node_id(),
"",
event_position
) )
village_changed.emit(village) village_changed.emit(village)
@@ -818,6 +898,10 @@ func create_state_record() -> SimulationStateRecord:
record.storages.append(storage_state) record.storages.append(storage_state)
for event in economic_events: for event in economic_events:
record.economic_events.append(event) record.economic_events.append(event)
for relationship in relationship_system.get_all_sorted():
record.relationships.append(relationship)
for known_event in event_knowledge_system.get_all_sorted():
record.event_knowledge.append(known_event)
return record return record
@@ -853,6 +937,8 @@ func restore_state(record: SimulationStateRecord) -> bool:
npcs.clear() npcs.clear()
for npc_record in record.npcs: for npc_record in record.npcs:
npcs.append(npc_record.restore(debug_logs)) npcs.append(npc_record.restore(debug_logs))
relationship_system.restore(record.relationships)
event_knowledge_system.restore(record.event_knowledge)
wander_random_sources.clear() wander_random_sources.clear()
var wander_streams: Array = record.simulation["wander_random_streams"] var wander_streams: Array = record.simulation["wander_random_streams"]
+24 -23
View File
@@ -1,12 +1,7 @@
class_name ActionSelectionSystem class_name ActionSelectionSystem
extends RefCounted extends RefCounted
enum SchedulePeriod { enum SchedulePeriod { WORK, SLEEP, MEAL, DISCRETIONARY }
WORK,
SLEEP,
MEAL,
DISCRETIONARY
}
const SLEEP_BEGIN := 0.88 const SLEEP_BEGIN := 0.88
const SLEEP_END := 0.15 const SLEEP_END := 0.15
@@ -17,9 +12,14 @@ const DINNER_END := 0.8
const WORK_BEGIN := 0.28 const WORK_BEGIN := 0.28
const WORK_END := 0.85 const WORK_END := 0.85
const UNAVAILABLE_ACTION_SCORE := -1000000.0 const UNAVAILABLE_ACTION_SCORE := -1000000.0
const RELATIONSHIP_AID_PANTRY_THRESHOLD := 25.0
var relationship_system: RefCounted
func select_action(npc: SimNPC, village: SimVillage, time_of_day: float = 0.5, all_npcs: Array = []) -> ActionSelectionResult: func select_action(
npc: SimNPC, village: SimVillage, time_of_day: float = 0.5, all_npcs: Array = []
) -> ActionSelectionResult:
if npc.is_dead: if npc.is_dead:
return null return null
@@ -70,9 +70,7 @@ func select_action(npc: SimNPC, village: SimVillage, time_of_day: float = 0.5, a
return ActionSelectionResult.new( return ActionSelectionResult.new(
SimulationIds.ACTION_EAT, -1.0, "Night-time hunger; eating from inventory" SimulationIds.ACTION_EAT, -1.0, "Night-time hunger; eating from inventory"
) )
return ActionSelectionResult.new( return ActionSelectionResult.new(SimulationIds.ACTION_SLEEP, -1.0, "Night-time; going home")
SimulationIds.ACTION_SLEEP, -1.0, "Night-time; going home"
)
if period == SchedulePeriod.MEAL: if period == SchedulePeriod.MEAL:
if npc.hunger > 50.0: if npc.hunger > 50.0:
@@ -112,6 +110,16 @@ func select_action(npc: SimNPC, village: SimVillage, time_of_day: float = 0.5, a
return ActionSelectionResult.new( return ActionSelectionResult.new(
SimulationIds.ACTION_REST, -1.0, "Energy is below the rest threshold" SimulationIds.ACTION_REST, -1.0, "Energy is below the rest threshold"
) )
if village.food <= RELATIONSHIP_AID_PANTRY_THRESHOLD and relationship_system != null:
var trusted_starving_npc: SimNPC = relationship_system.get_trusted_starving_subject(
npc.id, all_npcs
)
if trusted_starving_npc != null:
return ActionSelectionResult.new(
SimulationIds.ACTION_GATHER_FOOD,
-1.0,
"Helping %s, a trusted acquaintance who is starving" % trusted_starving_npc.npc_name
)
if period == SchedulePeriod.DISCRETIONARY: if period == SchedulePeriod.DISCRETIONARY:
var roll := npc.random_source.randf() var roll := npc.random_source.randf()
@@ -120,10 +128,10 @@ func select_action(npc: SimNPC, village: SimVillage, time_of_day: float = 0.5, a
SimulationIds.ACTION_WANDER, -1.0, "Discretionary wander" SimulationIds.ACTION_WANDER, -1.0, "Discretionary wander"
) )
return _choose_best_work_action(npc, village, all_npcs) return _choose_best_work_action(npc, village)
func _choose_best_work_action(npc: SimNPC, village: SimVillage, all_npcs: Array) -> ActionSelectionResult: func _choose_best_work_action(npc: SimNPC, village: SimVillage) -> ActionSelectionResult:
var scores := { var scores := {
SimulationIds.ACTION_GATHER_FOOD: SimulationIds.ACTION_GATHER_FOOD:
_calculate_score( _calculate_score(
@@ -177,16 +185,6 @@ func _choose_best_work_action(npc: SimNPC, village: SimVillage, all_npcs: Array)
if not rejection.is_empty(): if not rejection.is_empty():
scores[action_id] = UNAVAILABLE_ACTION_SCORE scores[action_id] = UNAVAILABLE_ACTION_SCORE
rejections[action_id] = rejection rejections[action_id] = rejection
for familiar_id in npc.familiarity:
if not familiar_id is int:
continue
var other_task: StringName
for other in all_npcs:
if other.id == familiar_id and not other.is_dead:
other_task = other.current_task
break
if other_task in scores:
scores[other_task] = float(scores[other_task]) + 0.3
var best_action := SimulationIds.ACTION_GATHER_FOOD var best_action := SimulationIds.ACTION_GATHER_FOOD
var best_score: float = scores[best_action] var best_score: float = scores[best_action]
for action_id in [ for action_id in [
@@ -207,11 +205,14 @@ func _get_cost_rejection(definition: ActionDefinition, village: SimVillage) -> S
var available := _get_village_resource_amount(village, definition.completion_cost_resource_id) var available := _get_village_resource_amount(village, definition.completion_cost_resource_id)
if available >= definition.completion_cost_amount: if available >= definition.completion_cost_amount:
return "" return ""
return "Needs %.0f %s (%.1f available)" % [ return (
"Needs %.0f %s (%.1f available)"
% [
definition.completion_cost_amount, definition.completion_cost_amount,
String(definition.completion_cost_resource_id).capitalize(), String(definition.completion_cost_resource_id).capitalize(),
available, available,
] ]
)
func _get_village_resource_amount(village: SimVillage, resource_id: StringName) -> float: func _get_village_resource_amount(village: SimVillage, resource_id: StringName) -> float:
+3 -1
View File
@@ -57,7 +57,9 @@ func _resolve_activity(
var best: Dictionary = {} var best: Dictionary = {}
var best_distance := INF var best_distance := INF
var candidates: Array[Dictionary] = active_world_adapter.get_activity_candidates(npc.current_task) var candidates: Array[Dictionary] = active_world_adapter.get_activity_candidates(
npc.current_task
)
for candidate in candidates: for candidate in candidates:
var target_id := StringName(candidate["target_id"]) var target_id := StringName(candidate["target_id"])
var capacity := maxi(int(candidate.get("capacity", 1)), 1) var capacity := maxi(int(candidate.get("capacity", 1)), 1)
@@ -131,8 +131,10 @@ static func validate() -> Array[String]:
) )
if ( if (
not definition.completion_cost_resource_id.is_empty() not definition.completion_cost_resource_id.is_empty()
and definition.completion_cost_resource_id and (
definition.completion_cost_resource_id
not in [SimulationIds.RESOURCE_FOOD, SimulationIds.RESOURCE_WOOD] not in [SimulationIds.RESOURCE_FOOD, SimulationIds.RESOURCE_WOOD]
)
): ):
errors.append( errors.append(
( (
+7 -4
View File
@@ -144,9 +144,7 @@ func consume_npc_food(npc: SimNPC) -> bool:
npc.starvation_ticks = 0 npc.starvation_ticks = 0
npc.is_starving = npc.hunger >= 90.0 npc.is_starving = npc.hunger >= 90.0
inventory_changed.emit( inventory_changed.emit(
npc, npc, SimulationIds.RESOURCE_FOOD, npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD)
SimulationIds.RESOURCE_FOOD,
npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD)
) )
economic_event_requested.emit( economic_event_requested.emit(
SimulationIds.EVENT_ITEM_CONSUMED, SimulationIds.EVENT_ITEM_CONSUMED,
@@ -167,12 +165,15 @@ func consume_completion_cost(npc: SimNPC, definition: ActionDefinition) -> bool:
var storage := get_storage_for_resource(resource_id) var storage := get_storage_for_resource(resource_id)
var available := storage.get_amount(resource_id) if storage != null else 0.0 var available := storage.get_amount(resource_id) if storage != null else 0.0
if storage == null or available < required_amount: if storage == null or available < required_amount:
var reason := "%s: needs %.0f %s (%.1f available)" % [ var reason := (
"%s: needs %.0f %s (%.1f available)"
% [
definition.display_name, definition.display_name,
required_amount, required_amount,
String(resource_id).capitalize(), String(resource_id).capitalize(),
available, available,
] ]
)
narrative_event_requested.emit( narrative_event_requested.emit(
SimulationIds.EVENT_TASK_BLOCKED, SimulationIds.EVENT_TASK_BLOCKED,
npc.id, npc.id,
@@ -194,9 +195,11 @@ func consume_completion_cost(npc: SimNPC, definition: ActionDefinition) -> bool:
) )
if debug_logs: if debug_logs:
print( print(
(
"[VillageEconomy] %s consumed %.1f %s for %s" "[VillageEconomy] %s consumed %.1f %s for %s"
% [npc.npc_name, consumed, resource_id, definition.action_id] % [npc.npc_name, consumed, resource_id, definition.action_id]
) )
)
return true return true
+21 -4
View File
@@ -16,12 +16,21 @@ func record_economic(
source_id: StringName, source_id: StringName,
destination_id: StringName, destination_id: StringName,
item_id: StringName, item_id: StringName,
amount: float amount: float,
world_position: Vector3 = Vector3.ZERO
) -> EconomicEventRecord: ) -> EconomicEventRecord:
if amount <= 0.0: if amount <= 0.0:
return null return null
var event := EconomicEventRecord.create( var event := EconomicEventRecord.create(
next_event_id, event_type, tick, actor_id, source_id, destination_id, item_id, amount next_event_id,
event_type,
tick,
actor_id,
source_id,
destination_id,
item_id,
amount,
world_position
) )
_append(event) _append(event)
return event return event
@@ -32,10 +41,11 @@ func record_narrative(
event_type: StringName, event_type: StringName,
actor_id: int, actor_id: int,
source_id: StringName = &"", source_id: StringName = &"",
action_display: String = "" action_display: String = "",
world_position: Vector3 = Vector3.ZERO
) -> EconomicEventRecord: ) -> EconomicEventRecord:
var event := EconomicEventRecord.create_narrative( var event := EconomicEventRecord.create_narrative(
next_event_id, event_type, tick, actor_id, source_id, action_display next_event_id, event_type, tick, actor_id, source_id, action_display, world_position
) )
_append(event) _append(event)
return event return event
@@ -62,6 +72,13 @@ func get_recent(max_count: int = 5) -> Array[EconomicEventRecord]:
return results return results
func get_by_id(event_id: int) -> EconomicEventRecord:
for event in events:
if int(event.data["event_id"]) == event_id:
return event
return null
func get_consumption_rates( func get_consumption_rates(
current_tick: int, current_tick: int,
window_ticks: int = DEFAULT_RATE_WINDOW_TICKS, window_ticks: int = DEFAULT_RATE_WINDOW_TICKS,
@@ -0,0 +1,105 @@
class_name EventKnowledgeSystem
extends RefCounted
const WITNESS_RADIUS := 10.0
var known_events: Dictionary = {}
func observe_event(event: EconomicEventRecord, npcs: Array[SimNPC]) -> Array[KnownEventStateRecord]:
var learned: Array[KnownEventStateRecord] = []
if not _is_witnessable(event):
return learned
var actor := _find_npc(int(event.data["actor_id"]), npcs)
if actor == null:
return learned
var actor_record := _remember(actor.id, int(event.data["event_id"]))
if actor_record != null:
learned.append(actor_record)
var witness_radius_squared := WITNESS_RADIUS * WITNESS_RADIUS
var event_position := event.get_world_position()
for npc in npcs:
if npc.id == actor.id or npc.is_dead:
continue
if npc.position.distance_squared_to(event_position) > witness_radius_squared:
continue
var witness_record := _remember(npc.id, int(event.data["event_id"]))
if witness_record != null:
learned.append(witness_record)
learned.sort_custom(_sort_records)
return learned
func knows_event(knower_id: int, event_id: int) -> bool:
return known_events.has(_key(knower_id, event_id))
func get_knowers(event_id: int) -> Array[int]:
var knower_ids: Array[int] = []
for record in known_events.values():
if record.get_event_id() == event_id:
knower_ids.append(record.get_knower_id())
knower_ids.sort()
return knower_ids
func get_known_event_ids(knower_id: int, max_count: int = 3) -> Array[int]:
var event_ids: Array[int] = []
for record in known_events.values():
if record.get_knower_id() == knower_id:
event_ids.append(record.get_event_id())
event_ids.sort()
if max_count <= 0 or event_ids.size() <= max_count:
return event_ids
var recent_ids: Array[int] = []
for index in range(event_ids.size() - max_count, event_ids.size()):
recent_ids.append(event_ids[index])
return recent_ids
func get_all_sorted() -> Array[KnownEventStateRecord]:
var records: Array[KnownEventStateRecord] = []
for record in known_events.values():
records.append(record)
records.sort_custom(_sort_records)
return records
func restore(records: Array[KnownEventStateRecord]) -> void:
known_events.clear()
for record in records:
known_events[_key(record.get_knower_id(), record.get_event_id())] = record
func _remember(knower_id: int, event_id: int) -> KnownEventStateRecord:
var key := _key(knower_id, event_id)
if known_events.has(key):
return null
var record := KnownEventStateRecord.create(knower_id, event_id)
known_events[key] = record
return record
func _is_witnessable(event: EconomicEventRecord) -> bool:
return (
StringName(event.data["event_type"]) == SimulationIds.EVENT_STORAGE_DEPOSITED
and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
and float(event.data["amount"]) > 0.0
)
static func _find_npc(npc_id: int, npcs: Array[SimNPC]) -> SimNPC:
for npc in npcs:
if npc.id == npc_id:
return npc
return null
static func _key(knower_id: int, event_id: int) -> String:
return "%d:%d" % [knower_id, event_id]
static func _sort_records(first: KnownEventStateRecord, second: KnownEventStateRecord) -> bool:
if first.get_knower_id() != second.get_knower_id():
return first.get_knower_id() < second.get_knower_id()
return first.get_event_id() < second.get_event_id()
@@ -0,0 +1 @@
uid://8qgicjoe2gft
@@ -0,0 +1,173 @@
extends RefCounted
const AID_HUNGER_THRESHOLD := 80.0
const TRUSTED_HELP_THRESHOLD := 0.6
const FOOD_AID_TRUST_GAIN := 0.15
const SHARED_WORK_FAMILIARITY_GAIN := 0.1
var relationships: Dictionary = {}
func initialize_households(npcs: Array[SimNPC]) -> void:
relationships.clear()
for first_index in range(npcs.size()):
for second_index in range(first_index + 1, npcs.size()):
var first := npcs[first_index]
var second := npcs[second_index]
if first.home_position.distance_to(second.home_position) >= 4.0:
continue
_add(RelationshipStateRecord.create(first.id, second.id, 0.5))
_add(RelationshipStateRecord.create(second.id, first.id, 0.5))
func restore(records: Array[RelationshipStateRecord]) -> void:
relationships.clear()
for record in records:
_add(record)
func get_all_sorted() -> Array[RelationshipStateRecord]:
var records: Array[RelationshipStateRecord] = []
for record in relationships.values():
records.append(record)
records.sort_custom(_sort_relationships)
return records
func get_relationship(observer_id: int, subject_id: int) -> RelationshipStateRecord:
return relationships.get(_key(observer_id, subject_id)) as RelationshipStateRecord
func increase_shared_work_familiarity(first_id: int, second_id: int) -> void:
var first_to_second := _get_or_create(first_id, second_id)
var second_to_first := _get_or_create(second_id, first_id)
first_to_second.increase_familiarity(SHARED_WORK_FAMILIARITY_GAIN)
second_to_first.increase_familiarity(SHARED_WORK_FAMILIARITY_GAIN)
func apply_event(
event: EconomicEventRecord, npcs: Array[SimNPC], knower_ids: Array[int]
) -> Array[RelationshipStateRecord]:
var changed: Array[RelationshipStateRecord] = []
if StringName(event.data["event_type"]) != SimulationIds.EVENT_STORAGE_DEPOSITED:
return changed
if StringName(event.data["item_id"]) != SimulationIds.RESOURCE_FOOD:
return changed
var contributor_id := int(event.data["actor_id"])
var event_id := int(event.data["event_id"])
if contributor_id < 0 or event_id < 0 or float(event.data["amount"]) <= 0.0:
return changed
for observer in npcs:
if observer.id == contributor_id or observer.is_dead:
continue
if observer.id not in knower_ids:
continue
if observer.hunger < AID_HUNGER_THRESHOLD:
continue
var relationship := get_relationship(observer.id, contributor_id)
if relationship == null or relationship.get_familiarity() <= 0.0:
continue
if event_id <= relationship.get_last_trust_cause_event_id():
continue
var applied := relationship.increase_trust(FOOD_AID_TRUST_GAIN, event_id)
if applied <= 0.0:
continue
changed.append(relationship)
return changed
func get_primary_relationship(observer_id: int) -> RelationshipStateRecord:
var best: RelationshipStateRecord
for relationship in relationships.values():
if relationship.get_observer_id() != observer_id:
continue
if best == null or _is_stronger(relationship, best):
best = relationship
return best
func get_most_familiar_living_subject(observer_id: int, npcs: Array[SimNPC]) -> SimNPC:
var living_by_id := {}
for npc in npcs:
if not npc.is_dead:
living_by_id[npc.id] = npc
var best_relationship: RelationshipStateRecord
for relationship in relationships.values():
if relationship.get_observer_id() != observer_id:
continue
if not living_by_id.has(relationship.get_subject_id()):
continue
if (
best_relationship == null
or relationship.get_familiarity() > best_relationship.get_familiarity()
or (
is_equal_approx(relationship.get_familiarity(), best_relationship.get_familiarity())
and relationship.get_subject_id() < best_relationship.get_subject_id()
)
):
best_relationship = relationship
if best_relationship == null:
return null
return living_by_id[best_relationship.get_subject_id()] as SimNPC
func get_trusted_starving_subject(observer_id: int, npcs: Array[SimNPC]) -> SimNPC:
var starving_by_id := {}
for npc in npcs:
if not npc.is_dead and npc.is_starving:
starving_by_id[npc.id] = npc
var best_relationship: RelationshipStateRecord
for relationship in relationships.values():
if relationship.get_observer_id() != observer_id:
continue
if relationship.get_trust() < TRUSTED_HELP_THRESHOLD:
continue
if not starving_by_id.has(relationship.get_subject_id()):
continue
if (
best_relationship == null
or relationship.get_trust() > best_relationship.get_trust()
or (
is_equal_approx(relationship.get_trust(), best_relationship.get_trust())
and relationship.get_subject_id() < best_relationship.get_subject_id()
)
):
best_relationship = relationship
if best_relationship == null:
return null
return starving_by_id[best_relationship.get_subject_id()] as SimNPC
func _get_or_create(observer_id: int, subject_id: int) -> RelationshipStateRecord:
var relationship := get_relationship(observer_id, subject_id)
if relationship != null:
return relationship
relationship = RelationshipStateRecord.create(observer_id, subject_id, 0.0)
_add(relationship)
return relationship
func _add(relationship: RelationshipStateRecord) -> void:
relationships[_key(relationship.get_observer_id(), relationship.get_subject_id())] = relationship
static func _key(observer_id: int, subject_id: int) -> String:
return "%d:%d" % [observer_id, subject_id]
static func _sort_relationships(
first: RelationshipStateRecord, second: RelationshipStateRecord
) -> bool:
if first.get_observer_id() != second.get_observer_id():
return first.get_observer_id() < second.get_observer_id()
return first.get_subject_id() < second.get_subject_id()
static func _is_stronger(
candidate: RelationshipStateRecord, current: RelationshipStateRecord
) -> bool:
if not is_equal_approx(candidate.get_trust(), current.get_trust()):
return candidate.get_trust() > current.get_trust()
if not is_equal_approx(candidate.get_familiarity(), current.get_familiarity()):
return candidate.get_familiarity() > current.get_familiarity()
return candidate.get_subject_id() < current.get_subject_id()
@@ -0,0 +1 @@
uid://d4hehra0mfvdf
+26 -11
View File
@@ -1,7 +1,8 @@
class_name EconomicEventRecord class_name EconomicEventRecord
extends RefCounted extends RefCounted
const SCHEMA_VERSION := 1 const SCHEMA_VERSION := 2
const LEGACY_SCHEMA_VERSION := 1
var data: Dictionary var data: Dictionary
@@ -18,7 +19,8 @@ static func create(
source_id: StringName, source_id: StringName,
destination_id: StringName, destination_id: StringName,
item_id: StringName, item_id: StringName,
amount: float amount: float,
world_position: Vector3 = Vector3.ZERO
) -> EconomicEventRecord: ) -> EconomicEventRecord:
return EconomicEventRecord.new( return EconomicEventRecord.new(
{ {
@@ -30,7 +32,8 @@ static func create(
"source_id": String(source_id), "source_id": String(source_id),
"destination_id": String(destination_id), "destination_id": String(destination_id),
"item_id": String(item_id), "item_id": String(item_id),
"amount": amount "amount": amount,
"world_position": [world_position.x, world_position.y, world_position.z]
} }
) )
@@ -41,7 +44,8 @@ static func create_narrative(
tick: int, tick: int,
actor_id: int, actor_id: int,
source_id: StringName, source_id: StringName,
action_display: String = "" action_display: String = "",
world_position: Vector3 = Vector3.ZERO
) -> EconomicEventRecord: ) -> EconomicEventRecord:
return EconomicEventRecord.new( return EconomicEventRecord.new(
{ {
@@ -54,13 +58,15 @@ static func create_narrative(
"destination_id": "", "destination_id": "",
"item_id": "", "item_id": "",
"amount": 0.0, "amount": 0.0,
"action_name": action_display "action_name": action_display,
"world_position": [world_position.x, world_position.y, world_position.z]
} }
) )
static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord: static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: var version := int(record_data.get("schema_version", -1))
if version not in [LEGACY_SCHEMA_VERSION, SCHEMA_VERSION]:
return null return null
if not record_data.has_all( if not record_data.has_all(
[ [
@@ -75,6 +81,8 @@ static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
] ]
): ):
return null return null
if version == SCHEMA_VERSION and not record_data.has("world_position"):
return null
var normalized := record_data.duplicate(true) var normalized := record_data.duplicate(true)
normalized["schema_version"] = SCHEMA_VERSION normalized["schema_version"] = SCHEMA_VERSION
normalized["event_id"] = int(record_data["event_id"]) normalized["event_id"] = int(record_data["event_id"])
@@ -85,11 +93,13 @@ static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
normalized["destination_id"] = String(record_data["destination_id"]) normalized["destination_id"] = String(record_data["destination_id"])
normalized["item_id"] = String(record_data["item_id"]) normalized["item_id"] = String(record_data["item_id"])
normalized["amount"] = float(record_data["amount"]) normalized["amount"] = float(record_data["amount"])
if ( var saved_position = record_data.get("world_position", [0.0, 0.0, 0.0])
normalized["event_id"] < 0 if not saved_position is Array or saved_position.size() != 3:
or normalized["tick"] < 0 return null
or normalized["event_type"].is_empty() normalized["world_position"] = [
): float(saved_position[0]), float(saved_position[1]), float(saved_position[2])
]
if normalized["event_id"] < 0 or normalized["tick"] < 0 or normalized["event_type"].is_empty():
return null return null
return EconomicEventRecord.new(normalized) return EconomicEventRecord.new(normalized)
@@ -98,6 +108,11 @@ func to_dictionary() -> Dictionary:
return data.duplicate(true) return data.duplicate(true)
func get_world_position() -> Vector3:
var saved_position: Array = data["world_position"]
return Vector3(float(saved_position[0]), float(saved_position[1]), float(saved_position[2]))
func description(npc_names: Dictionary = {}) -> String: func description(npc_names: Dictionary = {}) -> String:
var actor_name: String = npc_names.get(int(data["actor_id"]), "Someone") var actor_name: String = npc_names.get(int(data["actor_id"]), "Someone")
var item: String = str(data["item_id"]) var item: String = str(data["item_id"])
+47
View File
@@ -0,0 +1,47 @@
class_name KnownEventStateRecord
extends RefCounted
const SCHEMA_VERSION := 1
var data: Dictionary
func _init(record_data: Dictionary = {}) -> void:
data = record_data.duplicate(true)
static func create(knower_id: int, event_id: int) -> KnownEventStateRecord:
return (
KnownEventStateRecord
. new(
{
"schema_version": SCHEMA_VERSION,
"knower_id": knower_id,
"event_id": event_id,
}
)
)
static func from_dictionary(record_data: Dictionary) -> KnownEventStateRecord:
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION:
return null
if not record_data.has_all(["knower_id", "event_id"]):
return null
var knower_id := int(record_data["knower_id"])
var event_id := int(record_data["event_id"])
if knower_id < 0 or event_id < 0:
return null
return create(knower_id, event_id)
func get_knower_id() -> int:
return int(data["knower_id"])
func get_event_id() -> int:
return int(data["event_id"])
func to_dictionary() -> Dictionary:
return data.duplicate(true)
@@ -0,0 +1 @@
uid://n65oueyvbeoi
+9 -25
View File
@@ -1,9 +1,10 @@
class_name NPCStateRecord class_name NPCStateRecord
extends RefCounted extends RefCounted
const SCHEMA_VERSION := 3 const SCHEMA_VERSION := 4
const LEGACY_SCHEMA_VERSION := 1 const LEGACY_SCHEMA_VERSION := 1
const PREVIOUS_SCHEMA_VERSION := 2 const PREVIOUS_SCHEMA_VERSION := 2
const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3
var data: Dictionary var data: Dictionary
@@ -46,7 +47,6 @@ static func capture(npc: SimNPC) -> NPCStateRecord:
"last_task": String(npc.last_task), "last_task": String(npc.last_task),
"random_seed": str(npc.random_source.seed), "random_seed": str(npc.random_source.seed),
"random_state": str(npc.random_source.state), "random_state": str(npc.random_source.state),
"familiarity": _sorted_familiarity(npc.familiarity),
"mourning_ticks": npc.mourning_ticks "mourning_ticks": npc.mourning_ticks
} }
) )
@@ -54,7 +54,10 @@ static func capture(npc: SimNPC) -> NPCStateRecord:
static func from_dictionary(record_data: Dictionary) -> NPCStateRecord: static func from_dictionary(record_data: Dictionary) -> NPCStateRecord:
var version := int(record_data.get("schema_version", -1)) var version := int(record_data.get("schema_version", -1))
if version in [LEGACY_SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION]: if (
version
in [LEGACY_SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, RELATIONSHIP_LEGACY_SCHEMA_VERSION]
):
record_data = _migrate_legacy(record_data, version) record_data = _migrate_legacy(record_data, version)
elif version != SCHEMA_VERSION: elif version != SCHEMA_VERSION:
return null return null
@@ -116,7 +119,9 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
if version == LEGACY_SCHEMA_VERSION: if version == LEGACY_SCHEMA_VERSION:
migrated["travel_target_position"] = legacy_data.get("position", [0.0, 0.0, 0.0]) migrated["travel_target_position"] = legacy_data.get("position", [0.0, 0.0, 0.0])
migrated["has_travel_target"] = false migrated["has_travel_target"] = false
if version in [LEGACY_SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION]:
migrated["inventory"] = {} migrated["inventory"] = {}
migrated.erase("familiarity")
return migrated return migrated
@@ -141,9 +146,7 @@ func restore(debug_logs: bool) -> SimNPC:
float(saved_position[0]), float(saved_position[1]), float(saved_position[2]) float(saved_position[0]), float(saved_position[1]), float(saved_position[2])
) )
var saved_home: Array = data.get("home_position", saved_position) var saved_home: Array = data.get("home_position", saved_position)
npc.home_position = Vector3( npc.home_position = Vector3(float(saved_home[0]), float(saved_home[1]), float(saved_home[2]))
float(saved_home[0]), float(saved_home[1]), float(saved_home[2])
)
npc.is_starving = bool(data["is_starving"]) npc.is_starving = bool(data["is_starving"])
npc.starvation_ticks = int(data["starvation_ticks"]) npc.starvation_ticks = int(data["starvation_ticks"])
npc.starvation_death_threshold = int(data["starvation_death_threshold"]) npc.starvation_death_threshold = int(data["starvation_death_threshold"])
@@ -162,28 +165,9 @@ func restore(debug_logs: bool) -> SimNPC:
npc.last_task = StringName(data["last_task"]) npc.last_task = StringName(data["last_task"])
npc.random_source.state = String(data["random_state"]).to_int() npc.random_source.state = String(data["random_state"]).to_int()
npc.debug_logs = debug_logs npc.debug_logs = debug_logs
var saved_familiarity = data.get("familiarity", {})
if saved_familiarity is Array:
for pair in saved_familiarity:
var pair_dict: Dictionary = pair
npc.familiarity[int(pair_dict["id"])] = float(pair_dict["score"])
elif saved_familiarity is Dictionary:
npc.familiarity = saved_familiarity.duplicate(true)
npc.mourning_ticks = int(data.get("mourning_ticks", 0)) npc.mourning_ticks = int(data.get("mourning_ticks", 0))
return npc return npc
func to_dictionary() -> Dictionary: func to_dictionary() -> Dictionary:
return data.duplicate(true) return data.duplicate(true)
static func _sorted_familiarity(familiarity: Dictionary) -> Array:
var pairs: Array[Dictionary] = []
for key in familiarity:
pairs.append({"id": int(key), "score": float(familiarity[key])})
pairs.sort_custom(_familiarity_sort)
return pairs
static func _familiarity_sort(a: Dictionary, b: Dictionary) -> bool:
return int(a["id"]) < int(b["id"])
@@ -0,0 +1,94 @@
class_name RelationshipStateRecord
extends RefCounted
const SCHEMA_VERSION := 1
const NO_CAUSE_EVENT := -1
const NEUTRAL_TRUST := 0.5
var data: Dictionary
func _init(record_data: Dictionary = {}) -> void:
data = record_data.duplicate(true)
static func create(
observer_id: int,
subject_id: int,
familiarity: float,
trust: float = NEUTRAL_TRUST,
last_trust_cause_event_id: int = NO_CAUSE_EVENT
) -> RelationshipStateRecord:
return (
RelationshipStateRecord
. new(
{
"schema_version": SCHEMA_VERSION,
"observer_id": observer_id,
"subject_id": subject_id,
"familiarity": clampf(familiarity, 0.0, 1.0),
"trust": clampf(trust, 0.0, 1.0),
"last_trust_cause_event_id": last_trust_cause_event_id,
}
)
)
static func from_dictionary(record_data: Dictionary) -> RelationshipStateRecord:
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION:
return null
if not record_data.has_all(
["observer_id", "subject_id", "familiarity", "trust", "last_trust_cause_event_id"]
):
return null
var observer_id := int(record_data["observer_id"])
var subject_id := int(record_data["subject_id"])
var familiarity := float(record_data["familiarity"])
var trust := float(record_data["trust"])
var cause_event_id := int(record_data["last_trust_cause_event_id"])
if observer_id < 0 or subject_id < 0 or observer_id == subject_id:
return null
if familiarity < 0.0 or familiarity > 1.0 or trust < 0.0 or trust > 1.0:
return null
if cause_event_id < NO_CAUSE_EVENT:
return null
return create(observer_id, subject_id, familiarity, trust, cause_event_id)
func get_observer_id() -> int:
return int(data["observer_id"])
func get_subject_id() -> int:
return int(data["subject_id"])
func get_familiarity() -> float:
return float(data["familiarity"])
func get_trust() -> float:
return float(data["trust"])
func get_last_trust_cause_event_id() -> int:
return int(data["last_trust_cause_event_id"])
func increase_familiarity(amount: float) -> float:
var previous := get_familiarity()
data["familiarity"] = clampf(previous + maxf(amount, 0.0), 0.0, 1.0)
return get_familiarity() - previous
func increase_trust(amount: float, cause_event_id: int) -> float:
var previous := get_trust()
data["trust"] = clampf(previous + maxf(amount, 0.0), 0.0, 1.0)
var applied := get_trust() - previous
if applied > 0.0:
data["last_trust_cause_event_id"] = cause_event_id
return applied
func to_dictionary() -> Dictionary:
return data.duplicate(true)
@@ -0,0 +1 @@
uid://bxvc6flu3fdka
+182 -6
View File
@@ -2,9 +2,11 @@ class_name SimulationStateRecord
extends RefCounted extends RefCounted
const SCHEMA_NAME := "the_steward.simulation" const SCHEMA_NAME := "the_steward.simulation"
const SCHEMA_VERSION := 3 const SCHEMA_VERSION := 5
const LEGACY_SCHEMA_VERSION := 1 const LEGACY_SCHEMA_VERSION := 1
const PREVIOUS_SCHEMA_VERSION := 2 const EVENT_LEGACY_SCHEMA_VERSION := 2
const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3
const PREVIOUS_SCHEMA_VERSION := 4
var simulation: Dictionary var simulation: Dictionary
var village: VillageStateRecord var village: VillageStateRecord
@@ -12,6 +14,8 @@ var npcs: Array[NPCStateRecord] = []
var resources: Array[ResourceStateRecord] = [] var resources: Array[ResourceStateRecord] = []
var storages: Array[StorageStateRecord] = [] var storages: Array[StorageStateRecord] = []
var economic_events: Array[EconomicEventRecord] = [] var economic_events: Array[EconomicEventRecord] = []
var relationships: Array[RelationshipStateRecord] = []
var event_knowledge: Array[KnownEventStateRecord] = []
func to_dictionary() -> Dictionary: func to_dictionary() -> Dictionary:
@@ -28,6 +32,12 @@ func to_dictionary() -> Dictionary:
var event_data: Array[Dictionary] = [] var event_data: Array[Dictionary] = []
for event_record in economic_events: for event_record in economic_events:
event_data.append(event_record.to_dictionary()) event_data.append(event_record.to_dictionary())
var relationship_data: Array[Dictionary] = []
for relationship_record in relationships:
relationship_data.append(relationship_record.to_dictionary())
var knowledge_data: Array[Dictionary] = []
for known_event_record in event_knowledge:
knowledge_data.append(known_event_record.to_dictionary())
return { return {
"schema": SCHEMA_NAME, "schema": SCHEMA_NAME,
@@ -37,7 +47,9 @@ func to_dictionary() -> Dictionary:
"npcs": npc_data, "npcs": npc_data,
"resources": resource_data, "resources": resource_data,
"storages": storage_data, "storages": storage_data,
"economic_events": event_data "economic_events": event_data,
"relationships": relationship_data,
"event_knowledge": knowledge_data
} }
@@ -56,12 +68,32 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
if record_data.get("schema", "") != SCHEMA_NAME: if record_data.get("schema", "") != SCHEMA_NAME:
return null return null
var version := int(record_data.get("schema_version", -1)) var version := int(record_data.get("schema_version", -1))
if version in [LEGACY_SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION]: if (
version
in [
LEGACY_SCHEMA_VERSION,
EVENT_LEGACY_SCHEMA_VERSION,
RELATIONSHIP_LEGACY_SCHEMA_VERSION,
PREVIOUS_SCHEMA_VERSION,
]
):
record_data = _migrate_legacy(record_data, version) record_data = _migrate_legacy(record_data, version)
elif version != SCHEMA_VERSION: elif version != SCHEMA_VERSION:
return null return null
if not record_data.has_all( if not (
["simulation", "village", "npcs", "resources", "storages", "economic_events"] record_data
. has_all(
[
"simulation",
"village",
"npcs",
"resources",
"storages",
"economic_events",
"relationships",
"event_knowledge",
]
)
): ):
return null return null
@@ -100,11 +132,15 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
var resource_data = record_data["resources"] var resource_data = record_data["resources"]
var storage_data = record_data["storages"] var storage_data = record_data["storages"]
var event_data = record_data["economic_events"] var event_data = record_data["economic_events"]
var relationship_data = record_data["relationships"]
var knowledge_data = record_data["event_knowledge"]
if ( if (
not npc_data is Array not npc_data is Array
or not resource_data is Array or not resource_data is Array
or not storage_data is Array or not storage_data is Array
or not event_data is Array or not event_data is Array
or not relationship_data is Array
or not knowledge_data is Array
): ):
return null return null
@@ -152,6 +188,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
record.storages.append(storage_record) record.storages.append(storage_record)
var event_ids := {} var event_ids := {}
var event_records_by_id := {}
var highest_event_id := -1 var highest_event_id := -1
for item in event_data: for item in event_data:
if not item is Dictionary: if not item is Dictionary:
@@ -163,11 +200,69 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
if event_ids.has(event_id): if event_ids.has(event_id):
return null return null
event_ids[event_id] = true event_ids[event_id] = true
event_records_by_id[event_id] = event_record
highest_event_id = maxi(highest_event_id, event_id) highest_event_id = maxi(highest_event_id, event_id)
record.economic_events.append(event_record) record.economic_events.append(event_record)
if int(record.simulation["next_event_id"]) <= highest_event_id: if int(record.simulation["next_event_id"]) <= highest_event_id:
return null return null
var knowledge_keys := {}
for item in knowledge_data:
if not item is Dictionary:
return null
var known_event_record := KnownEventStateRecord.from_dictionary(item)
if known_event_record == null:
return null
var knower_id := known_event_record.get_knower_id()
var known_event_id := known_event_record.get_event_id()
if not npc_ids.has(knower_id) or not event_ids.has(known_event_id):
return null
var knowledge_key := "%d:%d" % [knower_id, known_event_id]
if knowledge_keys.has(knowledge_key):
return null
knowledge_keys[knowledge_key] = true
record.event_knowledge.append(known_event_record)
var relationship_keys := {}
for item in relationship_data:
if not item is Dictionary:
return null
var relationship_record := RelationshipStateRecord.from_dictionary(item)
if relationship_record == null:
return null
var observer_id := relationship_record.get_observer_id()
var subject_id := relationship_record.get_subject_id()
if not npc_ids.has(observer_id) or not npc_ids.has(subject_id):
return null
var relationship_key := "%d:%d" % [observer_id, subject_id]
if relationship_keys.has(relationship_key):
return null
var cause_event_id := relationship_record.get_last_trust_cause_event_id()
if (
cause_event_id != RelationshipStateRecord.NO_CAUSE_EVENT
and not event_ids.has(cause_event_id)
):
return null
if (
cause_event_id != RelationshipStateRecord.NO_CAUSE_EVENT
and not knowledge_keys.has("%d:%d" % [observer_id, cause_event_id])
):
return null
if cause_event_id != RelationshipStateRecord.NO_CAUSE_EVENT:
var cause_event := event_records_by_id[cause_event_id] as EconomicEventRecord
if (
int(cause_event.data["actor_id"]) != subject_id
or (
StringName(cause_event.data["event_type"])
!= SimulationIds.EVENT_STORAGE_DEPOSITED
)
or StringName(cause_event.data["item_id"]) != SimulationIds.RESOURCE_FOOD
or float(cause_event.data["amount"]) <= 0.0
):
return null
relationship_keys[relationship_key] = true
record.relationships.append(relationship_record)
return record return record
@@ -196,8 +291,89 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
. to_dictionary() . to_dictionary()
) )
] ]
if version in [LEGACY_SCHEMA_VERSION, EVENT_LEGACY_SCHEMA_VERSION]:
migrated["economic_events"] = [] migrated["economic_events"] = []
var simulation_data: Dictionary = migrated.get("simulation", {}) var simulation_data: Dictionary = migrated.get("simulation", {})
simulation_data["next_event_id"] = 0 simulation_data["next_event_id"] = 0
migrated["simulation"] = simulation_data migrated["simulation"] = simulation_data
if (
version
in [LEGACY_SCHEMA_VERSION, EVENT_LEGACY_SCHEMA_VERSION, RELATIONSHIP_LEGACY_SCHEMA_VERSION]
):
migrated["relationships"] = _migrate_npc_familiarity(legacy_data.get("npcs", []))
migrated["event_knowledge"] = _migrate_relationship_causes(migrated.get("relationships", []))
return migrated return migrated
static func _migrate_npc_familiarity(npc_data: Variant) -> Array[Dictionary]:
var migrated_relationships: Array[Dictionary] = []
if not npc_data is Array:
return migrated_relationships
for npc_item in npc_data:
if not npc_item is Dictionary:
continue
var observer_id := int(npc_item.get("id", -1))
var familiarity = npc_item.get("familiarity", [])
if familiarity is Array:
for pair in familiarity:
if not pair is Dictionary:
continue
migrated_relationships.append(
(
RelationshipStateRecord
. create(
observer_id, int(pair.get("id", -1)), float(pair.get("score", 0.0))
)
. to_dictionary()
)
)
elif familiarity is Dictionary:
for subject_id in familiarity:
migrated_relationships.append(
(
RelationshipStateRecord
. create(observer_id, int(subject_id), float(familiarity[subject_id]))
. to_dictionary()
)
)
migrated_relationships.sort_custom(_relationship_dictionary_sort)
return migrated_relationships
static func _relationship_dictionary_sort(first: Dictionary, second: Dictionary) -> bool:
if int(first["observer_id"]) != int(second["observer_id"]):
return int(first["observer_id"]) < int(second["observer_id"])
return int(first["subject_id"]) < int(second["subject_id"])
static func _migrate_relationship_causes(relationship_data: Variant) -> Array[Dictionary]:
var migrated_knowledge: Array[Dictionary] = []
var known_keys := {}
if not relationship_data is Array:
return migrated_knowledge
for relationship_item in relationship_data:
if not relationship_item is Dictionary:
continue
var observer_id := int(relationship_item.get("observer_id", -1))
var cause_event_id := int(
relationship_item.get(
"last_trust_cause_event_id", RelationshipStateRecord.NO_CAUSE_EVENT
)
)
if observer_id < 0 or cause_event_id < 0:
continue
var knowledge_key := "%d:%d" % [observer_id, cause_event_id]
if known_keys.has(knowledge_key):
continue
known_keys[knowledge_key] = true
migrated_knowledge.append(
KnownEventStateRecord.create(observer_id, cause_event_id).to_dictionary()
)
migrated_knowledge.sort_custom(_knowledge_dictionary_sort)
return migrated_knowledge
static func _knowledge_dictionary_sort(first: Dictionary, second: Dictionary) -> bool:
if int(first["knower_id"]) != int(second["knower_id"]):
return int(first["knower_id"]) < int(second["knower_id"])
return int(first["event_id"]) < int(second["event_id"])
+1 -3
View File
@@ -85,9 +85,7 @@ func _test_selection_and_execution_are_separate() -> void:
_check( _check(
( (
unfunded_selection.rejections.has(SimulationIds.ACTION_PATROL) unfunded_selection.rejections.has(SimulationIds.ACTION_PATROL)
and "Needs 1 Wood" in String( and "Needs 1 Wood" in String(unfunded_selection.rejections[SimulationIds.ACTION_PATROL])
unfunded_selection.rejections[SimulationIds.ACTION_PATROL]
)
), ),
"Selection should expose a readable definition-backed rejection reason" "Selection should expose a readable definition-backed rejection reason"
) )
+1 -2
View File
@@ -208,8 +208,7 @@ func _test_wood_work_requires_material() -> void:
_check( _check(
( (
not blocked_events.is_empty() not blocked_events.is_empty()
and StringName(blocked_events[0].data["event_type"]) and StringName(blocked_events[0].data["event_type"]) == SimulationIds.EVENT_TASK_BLOCKED
== SimulationIds.EVENT_TASK_BLOCKED
and "needs 1 Wood" in String(blocked_events[0].data.get("action_name", "")) and "needs 1 Wood" in String(blocked_events[0].data.get("action_name", ""))
), ),
"An unpaid completion cost should create a readable blocked-task fact" "An unpaid completion cost should create a readable blocked-task fact"
+91 -10
View File
@@ -16,8 +16,56 @@ func _run() -> void:
var simulation_manager: Node = main_scene.get_node("SimulationManager") var simulation_manager: Node = main_scene.get_node("SimulationManager")
simulation_manager.set_process(false) 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 environment: Environment = (
(main_scene.get_node("JajceWorld/WorldEnvironment") as WorldEnvironment).environment
)
_check(
is_equal_approx(environment.ambient_light_energy, 0.25),
"Day/night presentation should follow the authoritative simulation clock"
)
simulation_manager.clock.elapsed_ticks = saved_clock_ticks
await process_frame
_check(simulation_manager.npcs.size() == 6, "Baseline should create six NPCs") _check(simulation_manager.npcs.size() == 6, "Baseline should create six NPCs")
var inspector_label := (
main_scene.get_node("UI/NpcInspectorPanel/MarginContainer/NpcInspectorLabel") as Label
)
_check(
(
"Relationship:" in inspector_label.text
and "familiar 50%" in inspector_label.text
and "trust 50%" in inspector_label.text
),
"Runtime inspector should render authoritative directed relationship state"
)
var contributor: SimNPC = simulation_manager.npcs[0]
var witness: SimNPC = simulation_manager.npcs[1]
contributor.position = Vector3.ZERO
witness.position = Vector3(4.0, 0.0, 0.0)
witness.hunger = 85.0
contributor.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0)
_check(
is_equal_approx(
simulation_manager.economy.deposit_inventory(contributor, SimulationIds.RESOURCE_FOOD),
1.0
),
"Runtime knowledge UI check should use a completed simulation transaction"
)
var village_ui := main_scene.get_node("UI")
village_ui.selected_npc_index = witness.id
village_ui.call("_refresh_npc_inspector")
_check(
(
"Known fact: %s deposited" % contributor.npc_name in inspector_label.text
and "Relationship: %s" % contributor.npc_name in inspector_label.text
and "Because:" in inspector_label.text
),
"NPC inspector should show the witnessed fact and its relationship consequence"
)
_check( _check(
main_scene.has_node("JajceWorld/TerrainRoot/Terrain3D"), main_scene.has_node("JajceWorld/TerrainRoot/Terrain3D"),
"Playable runtime should instance the Jajce Terrain3D world" "Playable runtime should instance the Jajce Terrain3D world"
@@ -39,6 +87,21 @@ func _run() -> void:
"Presentation preset should pull the camera back from the village focus" "Presentation preset should pull the camera back from the village focus"
) )
_check(terrain.collision_mode != 0, "Runtime Terrain3D collision should be enabled") _check(terrain.collision_mode != 0, "Runtime Terrain3D collision should be enabled")
_check(
(
main_scene.has_node("Player/Visual/Body")
and main_scene.has_node("Player/Visual/Head")
and main_scene.has_node("Player/Visual/Scarf")
),
"Player should use the same readable multi-part visual language as villagers"
)
_check(
(
not main_scene.has_node("Player/MeshInstance3D")
and not main_scene.has_node("Player/FaceMarker")
),
"Runtime should not retain the placeholder player capsule presentation"
)
_check( _check(
not main_scene.has_node("JajceWorld/NavigationRegion3D/GreyboxGround"), not main_scene.has_node("JajceWorld/NavigationRegion3D/GreyboxGround"),
"Runtime should not keep the temporary greybox navigation ground" "Runtime should not keep the temporary greybox navigation ground"
@@ -53,7 +116,9 @@ func _run() -> void:
"Runtime should not retain duplicate flat-world objects" "Runtime should not retain duplicate flat-world objects"
) )
var resource_root := main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes") var resource_root := main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes")
_check(resource_root.get_child_count() == 18, "Runtime should contain eighteen Jajce ResourceNodes") _check(
resource_root.get_child_count() == 18, "Runtime should contain eighteen Jajce ResourceNodes"
)
_check( _check(
simulation_manager.resource_states.size() == 18, simulation_manager.resource_states.size() == 18,
"Simulation authority should bind all eighteen Jajce resources" "Simulation authority should bind all eighteen Jajce resources"
@@ -63,7 +128,9 @@ func _run() -> void:
for child in village_root.get_children(): for child in village_root.get_children():
if child.name.begins_with("Path_"): if child.name.begins_with("Path_"):
path_strips += 1 path_strips += 1
_check(path_strips >= 4, "Runtime should include authored path strips for first-read composition") _check(
path_strips >= 4, "Runtime should include authored path strips for first-read composition"
)
_check( _check(
main_scene.has_node("JajceWorld/FortressBlockout/Keep/RidgeBanner"), main_scene.has_node("JajceWorld/FortressBlockout/Keep/RidgeBanner"),
"Runtime should include a readable ridge landmark banner" "Runtime should include a readable ridge landmark banner"
@@ -86,9 +153,14 @@ func _run() -> void:
) )
var navigation_map: RID = main_scene.get_world_3d().navigation_map var navigation_map: RID = main_scene.get_world_3d().navigation_map
var navigation_region := main_scene.get_node("JajceWorld/NavigationRegion3D") as NavigationRegion3D var navigation_region := (
main_scene.get_node("JajceWorld/NavigationRegion3D") as NavigationRegion3D
)
_check( _check(
navigation_region.navigation_mesh.resource_path == "res://world/jajce/JajceNavigationMesh.tres", (
navigation_region.navigation_mesh.resource_path
== "res://world/jajce/JajceNavigationMesh.tres"
),
"Runtime navigation should use the Terrain3D-derived baked mesh resource" "Runtime navigation should use the Terrain3D-derived baked mesh resource"
) )
await _wait_for_navigation_map(navigation_map) await _wait_for_navigation_map(navigation_map)
@@ -104,13 +176,17 @@ func _run() -> void:
not main_scene.has_node("JajceWorld/LegacyActivityMarkers/PantryMarker"), not main_scene.has_node("JajceWorld/LegacyActivityMarkers/PantryMarker"),
"Runtime should not keep the pantry as a legacy activity marker" "Runtime should not keep the pantry as a legacy activity marker"
) )
var pantry := main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") as StorageNode var pantry := (
main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") as StorageNode
)
destinations.append(pantry.get_interaction_position()) destinations.append(pantry.get_interaction_position())
_check( _check(
main_scene.has_node("JajceWorld/WorldObjects/StorageSites/VillageWoodpile"), main_scene.has_node("JajceWorld/WorldObjects/StorageSites/VillageWoodpile"),
"Runtime should expose a typed VillageWoodpile StorageNode" "Runtime should expose a typed VillageWoodpile StorageNode"
) )
var woodpile := main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillageWoodpile") as StorageNode var woodpile := (
main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillageWoodpile") as StorageNode
)
destinations.append(woodpile.get_interaction_position()) destinations.append(woodpile.get_interaction_position())
var activity_root := main_scene.get_node("JajceWorld/WorldObjects/ActivitySites") var activity_root := main_scene.get_node("JajceWorld/WorldObjects/ActivitySites")
_check(activity_root.get_child_count() == 3, "Runtime should expose three typed activity sites") _check(activity_root.get_child_count() == 3, "Runtime should expose three typed activity sites")
@@ -193,7 +269,9 @@ func _check(condition: bool, message: String) -> void:
failures.append(message) failures.append(message)
func _check_path_tracks_terrain(terrain: Terrain3D, path: PackedVector3Array, message: String) -> void: func _check_path_tracks_terrain(
terrain: Terrain3D, path: PackedVector3Array, message: String
) -> void:
if path.is_empty(): if path.is_empty():
return return
@@ -204,13 +282,17 @@ func _check_path_tracks_terrain(terrain: Terrain3D, path: PackedVector3Array, me
return return
if absf(point.y - terrain_height) > 0.85: if absf(point.y - terrain_height) > 0.85:
failures.append( failures.append(
(
"%s: path point %s is too far from terrain height %.2f" "%s: path point %s is too far from terrain height %.2f"
% [message, point, terrain_height] % [message, point, terrain_height]
) )
)
return return
func _check_path_reaches_destination(path: PackedVector3Array, destination: Vector3, message: String) -> void: func _check_path_reaches_destination(
path: PackedVector3Array, destination: Vector3, message: String
) -> void:
if path.is_empty(): if path.is_empty():
return return
@@ -219,6 +301,5 @@ func _check_path_reaches_destination(path: PackedVector3Array, destination: Vect
var destination_xz := Vector2(destination.x, destination.z) var destination_xz := Vector2(destination.x, destination.z)
if final_xz.distance_to(destination_xz) > 1.5: if final_xz.distance_to(destination_xz) > 1.5:
failures.append( failures.append(
"%s: final path point %s is too far from %s" "%s: final path point %s is too far from %s" % [message, final_point, destination]
% [message, final_point, destination]
) )
+134 -11
View File
@@ -67,6 +67,94 @@ func _run() -> void:
world.get_node("FoliageRoot").get_child_count() >= 10, world.get_node("FoliageRoot").get_child_count() >= 10,
"JajceWorld should include restrained authored foliage clusters" "JajceWorld should include restrained authored foliage clusters"
) )
_check(
(
not world.has_node("WindStrokes_Valley")
and not world.has_node("WindStrokes_Ridge")
and not world.has_node("WindSpecks_Valley")
and not world.has_node("WindSpecks_Ridge")
),
"Legacy overlapping valley and ridge wind fields should remain removed"
)
var wind_gust_field := world.get_node("AtmosphereRoot/WindGustField") as WindGustField
_check(wind_gust_field != null, "Jajce should expose one bounded wind-gust controller")
if wind_gust_field != null:
_check(
(
wind_gust_field.interval_min >= 5.0
and wind_gust_field.interval_max <= 12.0
and wind_gust_field.duration_max <= 2.5
and wind_gust_field.stroke_count_max <= 3
),
"Calligraphic wind should remain sporadic, brief, and sparse"
)
_check(
wind_gust_field.trigger_gust_at(Vector3.ZERO, true, 0.5),
"Wind controller should support a deterministic staged gust"
)
var active_stroke_count := wind_gust_field.get_active_stroke_count()
_check(
active_stroke_count in [2, 3],
"One gust should contain only two or three related brush strokes"
)
var gust_burst := wind_gust_field.get_node_or_null("WindGustBurst") as Node3D
var gust_geometry_valid := gust_burst != null
if gust_burst != null:
for stroke in gust_burst.get_children():
var mesh_instance := stroke as MeshInstance3D
if mesh_instance == null:
gust_geometry_valid = false
continue
var material := mesh_instance.material_override as ShaderMaterial
var tint: Color = (
material.get_shader_parameter("tint") if material != null else Color.WHITE
)
var progress := (
float(material.get_shader_parameter("progress")) if material != null else 0.0
)
gust_geometry_valid = (
gust_geometry_valid
and mesh_instance.mesh is ArrayMesh
and material != null
and material.shader == load("res://world/jajce/materials/wind_gust.gdshader")
and tint.a >= 0.18
and tint.a <= 0.3
and progress >= 0.4
and progress <= 0.6
and (mesh_instance.cast_shadow == GeometryInstance3D.SHADOW_CASTING_SETTING_OFF)
)
_check(
gust_geometry_valid,
"Gust strokes should use soft tapered meshes without bright alpha or shadows"
)
_check(
(
not wind_gust_field.trigger_gust_at(Vector3.ZERO)
and wind_gust_field.get_active_stroke_count() == active_stroke_count
),
"Repeated gust triggers should not stack visual fields"
)
var tree_phases: Array[float] = []
var tree_wind_is_bounded := true
for tree in world.get_node("FoliageRoot").get_children():
if not tree.has_node("Canopy"):
continue
var canopy := tree.get_node("Canopy") as MeshInstance3D
var material := canopy.mesh.material as ShaderMaterial
tree_wind_is_bounded = (
tree_wind_is_bounded
and material != null
and material.shader == load("res://world/jajce/materials/wind.gdshader")
and float(material.get_shader_parameter("wind_strength")) <= 0.08
and float(material.get_shader_parameter("wind_speed")) <= 0.65
)
tree_phases.append(float(material.get_shader_parameter("wind_phase")))
_check(tree_phases.size() >= 2, "Decorative trees should expose named wind-reactive canopies")
_check(
tree_phases.size() >= 2 and not is_equal_approx(tree_phases[0], tree_phases[1]),
"Tree wind should use stable per-tree phase instead of moving every canopy in lockstep"
)
_check(tree_wind_is_bounded, "Tree wind should stay within the restrained cozy-motion budget")
_check(world.has_node("FortressBlockout"), "JajceWorld should include the fortress landmark") _check(world.has_node("FortressBlockout"), "JajceWorld should include the fortress landmark")
_check(world.has_node("WaterRoot/WaterfallBody"), "WaterRoot should include a waterfall drop") _check(world.has_node("WaterRoot/WaterfallBody"), "WaterRoot should include a waterfall drop")
_check( _check(
@@ -91,9 +179,11 @@ func _run() -> void:
"Jajce environment should provide sky and restrained valley fog" "Jajce environment should provide sky and restrained valley fog"
) )
_check( _check(
(
environment.glow_enabled environment.glow_enabled
and environment.volumetric_fog_enabled and environment.volumetric_fog_enabled
and environment.adjustment_enabled, and environment.adjustment_enabled
),
"WorldEnvironment should provide bounded glow, depth haze, and color grading" "WorldEnvironment should provide bounded glow, depth haze, and color grading"
) )
_check( _check(
@@ -102,24 +192,47 @@ func _run() -> void:
) )
var smoke_count := 0 var smoke_count := 0
var smoke_is_translucent := true var smoke_is_translucent := true
var smoke_follows_wind := true
var smoke_direction := Vector2.ZERO
for suffix in ["House_01", "House_02", "House_03"]: for suffix in ["House_01", "House_02", "House_03"]:
if world.has_node("VillageRoot/%s/Smoke" % suffix): if world.has_node("VillageRoot/%s/Smoke" % suffix):
var smoke: GPUParticles3D = world.get_node("VillageRoot/%s/Smoke" % suffix) var smoke: GPUParticles3D = world.get_node("VillageRoot/%s/Smoke" % suffix)
if smoke.draw_pass_1 == null: if smoke.draw_pass_1 == null:
continue continue
var smoke_material := (smoke.draw_pass_1 as QuadMesh).material as StandardMaterial3D var smoke_material := (smoke.draw_pass_1 as QuadMesh).material as StandardMaterial3D
var smoke_process := smoke.process_material as ParticleProcessMaterial
if smoke_process != null and smoke_direction.is_zero_approx():
smoke_direction = (
Vector2(smoke_process.gravity.x, smoke_process.gravity.z).normalized()
)
smoke_is_translucent = ( smoke_is_translucent = (
smoke_is_translucent smoke_is_translucent
and smoke_material != null and smoke_material != null
and smoke_material.transparency != BaseMaterial3D.TRANSPARENCY_DISABLED and smoke_material.transparency != BaseMaterial3D.TRANSPARENCY_DISABLED
and smoke_material.albedo_color.a < 0.5 and smoke_material.albedo_color.a < 0.5
) )
smoke_follows_wind = (
smoke_follows_wind
and smoke_process != null
and smoke_process.gravity.x > 0.0
and smoke_process.gravity.z > 0.0
)
smoke_count += 1 smoke_count += 1
_check( _check(
smoke_count >= 2, smoke_count >= 2,
"At least two houses should have chimney smoke particles (found %s)" % smoke_count "At least two houses should have chimney smoke particles (found %s)" % smoke_count
) )
_check(smoke_is_translucent, "Chimney smoke should not render as opaque white quads") _check(smoke_is_translucent, "Chimney smoke should not render as opaque white quads")
_check(smoke_follows_wind, "Chimney smoke should lean with the prevailing canopy wind")
var gust_direction := Vector2.ZERO
if wind_gust_field != null:
gust_direction = (
Vector2(wind_gust_field.wind_direction.x, wind_gust_field.wind_direction.z).normalized()
)
_check(
gust_direction.dot(smoke_direction) >= 0.98,
"Calligraphic gusts, canopy bend, and chimney smoke should share one wind direction"
)
var lookdev: Node3D = load("res://world/jajce/JajceLookdev.tscn").instantiate() var lookdev: Node3D = load("res://world/jajce/JajceLookdev.tscn").instantiate()
_check(lookdev.has_node("JajceWorld"), "Lookdev should instance JajceWorld") _check(lookdev.has_node("JajceWorld"), "Lookdev should instance JajceWorld")
_check(lookdev.has_node("BeautyCamera"), "Lookdev should provide a beauty camera") _check(lookdev.has_node("BeautyCamera"), "Lookdev should provide a beauty camera")
@@ -131,6 +244,7 @@ func _run() -> void:
ids.append(String((resource as ResourceNode).node_id)) ids.append(String((resource as ResourceNode).node_id))
ids.sort() ids.sort()
_check( _check(
(
ids ids
== [ == [
"animal_camp_01", "animal_camp_01",
@@ -151,7 +265,8 @@ func _run() -> void:
"tree_south_edge_01", "tree_south_edge_01",
"wood_pile_mill_01", "wood_pile_mill_01",
"wood_pile_village_01" "wood_pile_village_01"
], ]
),
"Jajce resources should preserve all stable and discoverable IDs" "Jajce resources should preserve all stable and discoverable IDs"
) )
var metadata_valid := true var metadata_valid := true
@@ -190,7 +305,9 @@ func _run() -> void:
_check(wood_count >= 9, "Jajce should expose at least nine finite wood resources") _check(wood_count >= 9, "Jajce should expose at least nine finite wood resources")
_check(animal_context_count >= 2, "Jajce food discovery should include animal-camp contexts") _check(animal_context_count >= 2, "Jajce food discovery should include animal-camp contexts")
_check(berry_context_count >= 6, "Jajce food discovery should include berry contexts") _check(berry_context_count >= 6, "Jajce food discovery should include berry contexts")
_check(village_context_count >= 1, "Jajce discovery should include at least one village stockpile") _check(
village_context_count >= 1, "Jajce discovery should include at least one village stockpile"
)
_check(outskirt_context_count >= 4, "Jajce discovery should include outskirts/river contexts") _check(outskirt_context_count >= 4, "Jajce discovery should include outskirts/river contexts")
_check(farming_context_count >= 1, "Jajce food discovery should include farming contexts") _check(farming_context_count >= 1, "Jajce food discovery should include farming contexts")
_check(world.has_node("WorldObjects/StorageSites"), "JajceWorld should expose StorageSites") _check(world.has_node("WorldObjects/StorageSites"), "JajceWorld should expose StorageSites")
@@ -232,7 +349,10 @@ func _run() -> void:
"Jajce navigation mesh should contain baked polygons" "Jajce navigation mesh should contain baked polygons"
) )
_check( _check(
navigation_region.navigation_mesh.resource_path == "res://world/jajce/JajceNavigationMesh.tres", (
navigation_region.navigation_mesh.resource_path
== "res://world/jajce/JajceNavigationMesh.tres"
),
"Jajce navigation should use the Terrain3D-derived baked mesh resource" "Jajce navigation should use the Terrain3D-derived baked mesh resource"
) )
await _wait_for_navigation_map(navigation_map) await _wait_for_navigation_map(navigation_map)
@@ -329,9 +449,7 @@ func _run() -> void:
) )
if failures.is_empty(): if failures.is_empty():
print( print("[TEST] Jajce scaffold passed: Terrain3D, scattered resources, typed sites")
"[TEST] Jajce scaffold passed: Terrain3D, scattered resources, typed sites"
)
quit(0) quit(0)
return return
@@ -357,7 +475,9 @@ func _check(condition: bool, message: String) -> void:
failures.append(message) failures.append(message)
func _check_path_tracks_terrain(terrain: Terrain3D, path: PackedVector3Array, message: String) -> void: func _check_path_tracks_terrain(
terrain: Terrain3D, path: PackedVector3Array, message: String
) -> void:
if path.is_empty(): if path.is_empty():
return return
@@ -368,13 +488,17 @@ func _check_path_tracks_terrain(terrain: Terrain3D, path: PackedVector3Array, me
return return
if absf(point.y - terrain_height) > 0.85: if absf(point.y - terrain_height) > 0.85:
failures.append( failures.append(
(
"%s: path point %s is too far from terrain height %.2f" "%s: path point %s is too far from terrain height %.2f"
% [message, point, terrain_height] % [message, point, terrain_height]
) )
)
return return
func _check_path_reaches_destination(path: PackedVector3Array, destination: Vector3, message: String) -> void: func _check_path_reaches_destination(
path: PackedVector3Array, destination: Vector3, message: String
) -> void:
if path.is_empty(): if path.is_empty():
return return
@@ -383,6 +507,5 @@ func _check_path_reaches_destination(path: PackedVector3Array, destination: Vect
var destination_xz := Vector2(destination.x, destination.z) var destination_xz := Vector2(destination.x, destination.z)
if final_xz.distance_to(destination_xz) > 1.5: if final_xz.distance_to(destination_xz) > 1.5:
failures.append( failures.append(
"%s: final path point %s is too far from %s" "%s: final path point %s is too far from %s" % [message, final_point, destination]
% [message, final_point, destination]
) )
+25 -24
View File
@@ -64,19 +64,14 @@ func _test_work_period_during_day() -> void:
manager.clock.cycle_duration_seconds = 240.0 manager.clock.cycle_duration_seconds = 240.0
manager.clock.elapsed_ticks = 100 manager.clock.elapsed_ticks = 100
var time_of_day: float = manager.clock.time_of_day() var time_of_day: float = manager.clock.time_of_day()
_check( _check(time_of_day > 0.28 and time_of_day < 0.85, "100 ticks should land in the work period")
time_of_day > 0.28 and time_of_day < 0.85,
"100 ticks should land in the work period"
)
var npc: SimNPC = manager.npcs[0] var npc: SimNPC = manager.npcs[0]
npc.profession = SimulationIds.PROFESSION_WANDERER npc.profession = SimulationIds.PROFESSION_WANDERER
npc.hunger = 20.0 npc.hunger = 20.0
npc.energy = 80.0 npc.energy = 80.0
npc.home_position = npc.position npc.home_position = npc.position
var selection := ActionSelectionSystem.new().select_action( var selection := ActionSelectionSystem.new().select_action(npc, manager.village, time_of_day)
npc, manager.village, time_of_day
)
_check(selection != null, "Daytime should produce an action selection") _check(selection != null, "Daytime should produce an action selection")
_check( _check(
selection.action_id != SimulationIds.ACTION_SLEEP, selection.action_id != SimulationIds.ACTION_SLEEP,
@@ -101,9 +96,7 @@ func _test_meal_period_hunger() -> void:
manager.village.food = 5.0 manager.village.food = 5.0
npc.home_position = npc.position npc.home_position = npc.position
var selection := ActionSelectionSystem.new().select_action( var selection := ActionSelectionSystem.new().select_action(npc, manager.village, time_of_day)
npc, manager.village, time_of_day
)
_check(selection != null, "Meal period should produce an action selection") _check(selection != null, "Meal period should produce an action selection")
_check( _check(
selection.action_id == SimulationIds.ACTION_WITHDRAW_FOOD, selection.action_id == SimulationIds.ACTION_WITHDRAW_FOOD,
@@ -129,10 +122,7 @@ func _test_sleep_restores_energy() -> void:
manager.simulate_tick() manager.simulate_tick()
_check(npc.task_complete, "Sleep should complete after its duration") _check(npc.task_complete, "Sleep should complete after its duration")
_check( _check(npc.energy > energy_before, "Completing sleep should restore energy")
npc.energy > energy_before,
"Completing sleep should restore energy"
)
manager.free() manager.free()
@@ -206,20 +196,26 @@ func _test_household_familiarity() -> void:
var d: SimNPC = manager.npcs[3] var d: SimNPC = manager.npcs[3]
var e: SimNPC = manager.npcs[4] var e: SimNPC = manager.npcs[4]
var a_to_b: RelationshipStateRecord = manager.relationship_system.get_relationship(a.id, b.id)
var b_to_a: RelationshipStateRecord = manager.relationship_system.get_relationship(b.id, a.id)
_check(a_to_b != null and b_to_a != null, "Nearby homes should create directed household ties")
if a_to_b != null:
_check( _check(
a.familiarity.has(b.id) and b.familiarity.has(a.id), (
"NPCs with nearby homes should be mutual household members" is_equal_approx(a_to_b.get_familiarity(), 0.5)
) and is_equal_approx(a_to_b.get_trust(), RelationshipStateRecord.NEUTRAL_TRUST)
_check( ),
is_equal_approx(float(a.familiarity[b.id]), 0.5),
"Household familiarity should start at baseline 0.5" "Household familiarity should start at baseline 0.5"
) )
_check( _check(
c.familiarity.has(d.id) and d.familiarity.has(c.id), (
manager.relationship_system.get_relationship(c.id, d.id) != null
and manager.relationship_system.get_relationship(d.id, c.id) != null
),
"Second household pair should also be familiar" "Second household pair should also be familiar"
) )
_check( _check(
not a.familiarity.has(e.id), manager.relationship_system.get_relationship(a.id, e.id) == null,
"Distant NPCs should not have household familiarity" "Distant NPCs should not have household familiarity"
) )
@@ -232,10 +228,15 @@ func _test_household_familiarity() -> void:
restored.restore_state_from_json(saved_json), restored.restore_state_from_json(saved_json),
"Familiarity should survive save/load round-trip" "Familiarity should survive save/load round-trip"
) )
var ra: SimNPC = restored.npcs[0] var restored_relationship: RelationshipStateRecord = (
restored.relationship_system.get_relationship(a.id, b.id)
)
_check( _check(
ra.familiarity.size() > 0, (
"Restored NPC should retain household familiarity" restored_relationship != null
and is_equal_approx(restored_relationship.get_familiarity(), 0.5)
),
"Restored simulation should retain household familiarity"
) )
manager.free() manager.free()
+9 -9
View File
@@ -37,20 +37,23 @@ func _run() -> void:
"NPC presentation should use a readable multi-part villager silhouette" "NPC presentation should use a readable multi-part villager silhouette"
) )
_check( _check(
(
visual.get_node("ProfessionProp").mesh != null visual.get_node("ProfessionProp").mesh != null
and profession_definition.display_name in visual.get_node("ProfessionLabel").text, and profession_definition.display_name in visual.get_node("ProfessionLabel").text
),
"NPC visual should expose a profession prop and readable label" "NPC visual should expose a profession prop and readable label"
) )
visual.set_task_presentation(SimulationIds.ACTION_GATHER_FOOD, SimNPC.TASK_STATE_TRAVELING) visual.set_task_presentation(SimulationIds.ACTION_GATHER_FOOD, SimNPC.TASK_STATE_TRAVELING)
_check( _check(
(
visual.get_node("TaskGlyphRoot").visible visual.get_node("TaskGlyphRoot").visible
and visual.get_node("TaskGlyphRoot/TaskGlyph").mesh is SphereMesh, and visual.get_node("TaskGlyphRoot/TaskGlyph").mesh is SphereMesh
),
"NPC visual should expose a compact task glyph for cinematic readability" "NPC visual should expose a compact task glyph for cinematic readability"
) )
visual.set_debug_overlay_visible(false) visual.set_debug_overlay_visible(false)
_check( _check(
not visual.get_node("ProfessionLabel").visible not visual.get_node("ProfessionLabel").visible and visual.get_node("TaskGlyphRoot").visible,
and visual.get_node("TaskGlyphRoot").visible,
"Cinematic mode should hide debug labels while preserving task glyphs" "Cinematic mode should hide debug labels while preserving task glyphs"
) )
visual.set_debug_overlay_visible(true) visual.set_debug_overlay_visible(true)
@@ -68,12 +71,9 @@ func _run() -> void:
) )
manager.latest_decisions[npc.id] = test_decision manager.latest_decisions[npc.id] = test_decision
manager.emit_signal("npc_decision_recorded", npc, test_decision) manager.emit_signal("npc_decision_recorded", npc, test_decision)
var inspector_label: Label = ui.get_node( var inspector_label: Label = ui.get_node("NpcInspectorPanel/MarginContainer/NpcInspectorLabel")
"NpcInspectorPanel/MarginContainer/NpcInspectorLabel"
)
_check( _check(
"Test shortage reason" in inspector_label.text "Test shortage reason" in inspector_label.text and npc.npc_name in inspector_label.text,
and npc.npc_name in inspector_label.text,
"NPC inspector should show the selected villager's real decision reason" "NPC inspector should show the selected villager's real decision reason"
) )
_check( _check(
+173
View File
@@ -0,0 +1,173 @@
extends SceneTree
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var manager := _create_manager()
var contributor: SimNPC = manager.npcs[0]
var observer: SimNPC = manager.npcs[1]
var stranger: SimNPC = manager.npcs[2]
var relationship: RelationshipStateRecord = manager.relationship_system.get_relationship(
observer.id, contributor.id
)
_check(relationship != null, "Nearby household NPCs should begin with a directed relationship")
if relationship == null:
manager.free()
_finish()
return
observer.hunger = 85.0
stranger.hunger = 85.0
contributor.position = Vector3.ZERO
observer.position = Vector3(4.0, 0.0, 0.0)
stranger.position = Vector3(20.0, 0.0, 0.0)
contributor.add_inventory(SimulationIds.RESOURCE_FOOD, 2.0)
var deposited: float = manager.economy.deposit_inventory(
contributor, SimulationIds.RESOURCE_FOOD
)
_check(is_equal_approx(deposited, 2.0), "The real economy path should deposit contributed food")
var cause_event: EconomicEventRecord = manager.get_relationship_cause(relationship)
var cause_event_id := int(cause_event.data["event_id"]) if cause_event != null else -1
_check(
is_equal_approx(relationship.get_trust(), 0.65),
"Food aid should raise the hungry familiar NPC's directed trust"
)
_check(
(
cause_event != null
and StringName(cause_event.data["event_type"]) == SimulationIds.EVENT_STORAGE_DEPOSITED
and int(cause_event.data["actor_id"]) == contributor.id
),
"Trust should reference the exact structured deposit event that caused it"
)
_check(
(
manager.npc_knows_event(contributor.id, cause_event_id)
and manager.npc_knows_event(observer.id, cause_event_id)
and not manager.npc_knows_event(stranger.id, cause_event_id)
),
"Only the contributor and nearby observer should know the deposit fact"
)
var reverse: RelationshipStateRecord = manager.relationship_system.get_relationship(
contributor.id, observer.id
)
_check(
(
reverse != null
and is_equal_approx(reverse.get_trust(), RelationshipStateRecord.NEUTRAL_TRUST)
),
"Food aid trust should remain directed instead of changing both people implicitly"
)
_check(
manager.relationship_system.get_relationship(stranger.id, contributor.id) == null,
"A distant NPC should not gain trust without an existing familiar relationship"
)
var pantry: StorageStateRecord = manager.get_pantry()
pantry.withdraw(
SimulationIds.RESOURCE_FOOD,
maxf(
(
pantry.get_amount(SimulationIds.RESOURCE_FOOD)
- (ActionSelectionSystem.RELATIONSHIP_AID_PANTRY_THRESHOLD - 5.0)
),
0.0
)
)
manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
contributor.hunger = 95.0
contributor.is_starving = true
observer.profession = SimulationIds.PROFESSION_GUARD
observer.hunger = 20.0
observer.energy = 80.0
observer.inventory.clear()
observer.last_task = &""
manager.economy.deposit_resource(SimulationIds.RESOURCE_WOOD, 10.0)
manager.village.safety = 50.0
manager.village.knowledge = 20.0
manager.village.update_priorities()
var neutral_choice: ActionSelectionResult = ActionSelectionSystem.new().select_action(
observer, manager.village, 0.5, manager.npcs
)
var helping_choice: ActionSelectionResult = manager.action_selector.select_action(
observer, manager.village, 0.5, manager.npcs
)
_check(
neutral_choice.action_id == SimulationIds.ACTION_PATROL,
"Without relationship context, the guard should keep doing ordinary patrol work"
)
_check(
(
helping_choice.action_id == SimulationIds.ACTION_GATHER_FOOD
and contributor.npc_name in helping_choice.reason
),
"Trust should visibly redirect ordinary work toward helping a starving acquaintance"
)
var saved_json: String = manager.serialize_state()
var restored := _create_manager(999)
_check(
restored.restore_state_from_json(saved_json),
"Relationship state and its event cause should restore through the world schema"
)
var restored_relationship: RelationshipStateRecord = (
restored.relationship_system.get_relationship(observer.id, contributor.id)
)
_check(
(
restored_relationship != null
and is_equal_approx(restored_relationship.get_trust(), relationship.get_trust())
and (
restored_relationship.get_last_trust_cause_event_id()
== relationship.get_last_trust_cause_event_id()
)
),
"Save/load should preserve directed trust and its causal event identity"
)
_check(
restored.get_state_checksum() == manager.get_state_checksum(),
"Restored relationship state should retain the complete deterministic checksum"
)
manager.free()
restored.free()
_finish()
func _finish() -> void:
if failures.is_empty():
print("[TEST] Relationship consequence passed: food aid -> trust -> helping choice")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
func _create_manager(seed_value: int = 801) -> Node:
var manager: Node = load("res://simulation/SimulationManager.gd").new()
manager.simulation_seed = seed_value
manager.debug_logs = false
var home_positions: Array[Vector3] = [
Vector3(0.0, 0.0, 0.0),
Vector3(1.0, 0.0, 0.0),
Vector3(20.0, 0.0, 20.0),
Vector3(30.0, 0.0, 30.0),
Vector3(40.0, 0.0, 40.0),
Vector3(50.0, 0.0, 50.0),
]
manager.home_positions = home_positions
root.add_child(manager)
manager.set_process(false)
return manager
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
@@ -0,0 +1 @@
uid://bt4ajli015mp6
+32 -6
View File
@@ -42,6 +42,28 @@ func _run() -> void:
"Village should receive exactly the bush's remaining food" "Village should receive exactly the bush's remaining food"
) )
_check(bush_state.get_reserved_by() == -1, "Depletion should release the NPC reservation") _check(bush_state.get_reserved_by() == -1, "Depletion should release the NPC reservation")
var player_extraction: EconomicEventRecord
var player_depletion: EconomicEventRecord
for event in simulation_manager.economic_events:
if int(event.data["actor_id"]) != -1 or StringName(event.data["source_id"]) != bush.node_id:
continue
if StringName(event.data["event_type"]) == SimulationIds.EVENT_RESOURCE_EXTRACTED:
player_extraction = event
elif StringName(event.data["event_type"]) == SimulationIds.EVENT_RESOURCE_DEPLETED:
player_depletion = event
var bush_event_position := bush.interaction_point.global_position
_check(player_extraction != null, "Player extraction should create a structured event")
_check(player_depletion != null, "Player depletion should create a structured event")
if player_extraction != null:
_check(
player_extraction.get_world_position().is_equal_approx(bush_event_position),
"Player extraction should preserve the resource interaction position"
)
if player_depletion != null:
_check(
player_depletion.get_world_position().is_equal_approx(bush_event_position),
"Player depletion should preserve the resource interaction position"
)
player.global_position = tree.interaction_point.global_position player.global_position = tree.interaction_point.global_position
var wood_before: float = simulation_manager.village.wood var wood_before: float = simulation_manager.village.wood
@@ -57,18 +79,20 @@ func _run() -> void:
"Village should receive exactly the extracted wood" "Village should receive exactly the extracted wood"
) )
var pantry := main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") as StorageNode var pantry := (
var pantry_state: StorageStateRecord = simulation_manager.get_pantry() main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") as StorageNode
pantry_state.deposit(
SimulationIds.RESOURCE_FOOD, pantry_state.get_available_capacity() - 0.5
) )
var pantry_state: StorageStateRecord = simulation_manager.get_pantry()
pantry_state.deposit(SimulationIds.RESOURCE_FOOD, pantry_state.get_available_capacity() - 0.5)
simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD) simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
bush_state.set_amount_remaining(1.0) bush_state.set_amount_remaining(1.0)
player.global_position = bush.interaction_point.global_position player.global_position = bush.interaction_point.global_position
player.try_interact() player.try_interact()
_check( _check(
(
is_equal_approx(bush_state.get_amount_remaining(), 0.5) is_equal_approx(bush_state.get_amount_remaining(), 0.5)
and is_equal_approx(pantry_state.get_available_capacity(), 0.0), and is_equal_approx(pantry_state.get_available_capacity(), 0.0)
),
"Player harvesting should leave overflow at its source when storage fills" "Player harvesting should leave overflow at its source when storage fills"
) )
@@ -81,7 +105,9 @@ func _run() -> void:
pantry_state.get_amount(SimulationIds.RESOURCE_FOOD) < pantry_food_before, pantry_state.get_amount(SimulationIds.RESOURCE_FOOD) < pantry_food_before,
"Player should eat from the typed pantry StorageNode" "Player should eat from the typed pantry StorageNode"
) )
var guard_site := main_scene.get_node("JajceWorld/WorldObjects/ActivitySites/GuardPost") as ActivitySite var guard_site := (
main_scene.get_node("JajceWorld/WorldObjects/ActivitySites/GuardPost") as ActivitySite
)
player.global_position = guard_site.get_interaction_position() player.global_position = guard_site.get_interaction_position()
var safety_before: float = simulation_manager.village.safety var safety_before: float = simulation_manager.village.safety
player.try_interact() player.try_interact()
+4 -5
View File
@@ -34,17 +34,16 @@ func _test_registry_integrity() -> void:
SimulationDefinitions.get_action(definition.action_id) == definition, SimulationDefinitions.get_action(definition.action_id) == definition,
"Action lookup should return '%s'" % definition.action_id "Action lookup should return '%s'" % definition.action_id
) )
_check(action_ids.size() == 11, "Registry should contain all eleven executable prototype actions") _check(
action_ids.size() == 11, "Registry should contain all eleven executable prototype actions"
)
var profession_ids := SimulationDefinitions.get_profession_ids() var profession_ids := SimulationDefinitions.get_profession_ids()
_check(profession_ids.size() == 5, "Registry should contain all five prototype professions") _check(profession_ids.size() == 5, "Registry should contain all five prototype professions")
var profession_colors := {} var profession_colors := {}
for profession_id in profession_ids: for profession_id in profession_ids:
var profession := SimulationDefinitions.get_profession(profession_id) var profession := SimulationDefinitions.get_profession(profession_id)
_check( _check(profession != null, "Profession lookup should return '%s'" % profession_id)
profession != null,
"Profession lookup should return '%s'" % profession_id
)
if profession != null: if profession != null:
profession_colors[profession.visual_color.to_html()] = true profession_colors[profession.visual_color.to_html()] = true
_check( _check(
+177 -7
View File
@@ -14,6 +14,9 @@ func _run() -> void:
_test_legacy_resource_migration() _test_legacy_resource_migration()
_test_legacy_world_storage_migration() _test_legacy_world_storage_migration()
_test_previous_world_event_migration() _test_previous_world_event_migration()
_test_previous_world_relationship_migration()
_test_previous_world_knowledge_migration()
_test_relationship_schema_rejection()
_test_schema_rejection() _test_schema_rejection()
if failures.is_empty(): if failures.is_empty():
@@ -157,8 +160,10 @@ func _test_legacy_resource_migration() -> void:
"Resource migration should preserve mutable authority" "Resource migration should preserve mutable authority"
) )
_check( _check(
(
is_equal_approx(migrated.get_safety_risk(), 0.0) is_equal_approx(migrated.get_safety_risk(), 0.0)
and is_equal_approx(migrated.get_comfort_distance(), 18.0), and is_equal_approx(migrated.get_comfort_distance(), 18.0)
),
"Resource migration should default discovery metadata" "Resource migration should default discovery metadata"
) )
@@ -190,9 +195,7 @@ func _test_legacy_world_storage_migration() -> void:
var migrated_storages := {} var migrated_storages := {}
for storage in migrated.storages: for storage in migrated.storages:
migrated_storages[storage.get_storage_id()] = storage migrated_storages[storage.get_storage_id()] = storage
var pantry: StorageStateRecord = migrated_storages.get( var pantry: StorageStateRecord = migrated_storages.get(SimulationIds.STORAGE_VILLAGE_PANTRY)
SimulationIds.STORAGE_VILLAGE_PANTRY
)
var woodpile: StorageStateRecord = migrated_storages.get( var woodpile: StorageStateRecord = migrated_storages.get(
SimulationIds.STORAGE_VILLAGE_WOODPILE SimulationIds.STORAGE_VILLAGE_WOODPILE
) )
@@ -200,8 +203,12 @@ func _test_legacy_world_storage_migration() -> void:
( (
pantry != null pantry != null
and woodpile != null and woodpile != null
and is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), manager.village.food) and is_equal_approx(
and is_equal_approx(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), manager.village.wood) pantry.get_amount(SimulationIds.RESOURCE_FOOD), manager.village.food
)
and is_equal_approx(
woodpile.get_amount(SimulationIds.RESOURCE_WOOD), manager.village.wood
)
), ),
"World migration should preserve legacy village resources in their storage" "World migration should preserve legacy village resources in their storage"
) )
@@ -211,7 +218,7 @@ func _test_legacy_world_storage_migration() -> void:
func _test_previous_world_event_migration() -> void: func _test_previous_world_event_migration() -> void:
var manager := _create_manager(56) var manager := _create_manager(56)
var previous_data: Dictionary = manager.create_state_record().to_dictionary() var previous_data: Dictionary = manager.create_state_record().to_dictionary()
previous_data["schema_version"] = (SimulationStateRecord.PREVIOUS_SCHEMA_VERSION) previous_data["schema_version"] = SimulationStateRecord.EVENT_LEGACY_SCHEMA_VERSION
previous_data.erase("economic_events") previous_data.erase("economic_events")
previous_data["simulation"].erase("next_event_id") previous_data["simulation"].erase("next_event_id")
var migrated := SimulationStateRecord.from_dictionary(previous_data) var migrated := SimulationStateRecord.from_dictionary(previous_data)
@@ -224,6 +231,169 @@ func _test_previous_world_event_migration() -> void:
manager.free() manager.free()
func _test_previous_world_relationship_migration() -> void:
var manager := _create_manager(57)
var previous_data: Dictionary = manager.create_state_record().to_dictionary()
previous_data["schema_version"] = SimulationStateRecord.RELATIONSHIP_LEGACY_SCHEMA_VERSION
previous_data.erase("relationships")
previous_data.erase("event_knowledge")
for npc_data in previous_data["npcs"]:
npc_data["schema_version"] = NPCStateRecord.RELATIONSHIP_LEGACY_SCHEMA_VERSION
npc_data["familiarity"] = []
previous_data["npcs"][0]["familiarity"] = [{"id": 1, "score": 0.75}]
var migrated := SimulationStateRecord.from_dictionary(previous_data)
_check(migrated != null, "World schema v3 should migrate NPC familiarity into relationships")
if migrated != null:
_check(
(
migrated.relationships.size() == 1
and migrated.relationships[0].get_observer_id() == 0
and migrated.relationships[0].get_subject_id() == 1
and is_equal_approx(migrated.relationships[0].get_familiarity(), 0.75)
and is_equal_approx(
migrated.relationships[0].get_trust(), RelationshipStateRecord.NEUTRAL_TRUST
)
),
"World v3 migration should preserve familiarity and initialize neutral trust"
)
_check(
not migrated.npcs[0].data.has("familiarity"),
"World v3 migration should remove obsolete NPC-local familiarity data"
)
manager.free()
func _test_previous_world_knowledge_migration() -> void:
var manager := _create_manager(59)
var previous_data: Dictionary = manager.create_state_record().to_dictionary()
previous_data["schema_version"] = SimulationStateRecord.PREVIOUS_SCHEMA_VERSION
previous_data.erase("event_knowledge")
var deposit_event := EconomicEventRecord.create(
0,
SimulationIds.EVENT_STORAGE_DEPOSITED,
12,
1,
SimulationIds.npc_inventory_id(1),
SimulationIds.STORAGE_VILLAGE_PANTRY,
SimulationIds.RESOURCE_FOOD,
2.0
)
previous_data["economic_events"] = [deposit_event.to_dictionary()]
previous_data["simulation"]["next_event_id"] = 1
previous_data["relationships"] = [
RelationshipStateRecord.create(0, 1, 0.5, 0.65, 0).to_dictionary()
]
var migrated := SimulationStateRecord.from_dictionary(previous_data)
_check(migrated != null, "World schema v4 should migrate causal facts into NPC knowledge")
if migrated != null:
_check(
(
migrated.event_knowledge.size() == 1
and migrated.event_knowledge[0].get_knower_id() == 0
and migrated.event_knowledge[0].get_event_id() == 0
),
"World v4 migration should preserve the fact implied by a relationship cause"
)
manager.free()
func _test_relationship_schema_rejection() -> void:
var manager := _create_manager(58)
var missing_cause_data: Dictionary = manager.create_state_record().to_dictionary()
missing_cause_data["relationships"] = [
RelationshipStateRecord.create(0, 1, 0.5, 0.65, 999).to_dictionary()
]
_check(
SimulationStateRecord.from_dictionary(missing_cause_data) == null,
"Relationship causes must reference an event in the same world record"
)
var duplicate_pair_data: Dictionary = manager.create_state_record().to_dictionary()
var relationship_data := RelationshipStateRecord.create(0, 1, 0.5).to_dictionary()
duplicate_pair_data["relationships"] = [relationship_data, relationship_data.duplicate(true)]
_check(
SimulationStateRecord.from_dictionary(duplicate_pair_data) == null,
"World state should reject duplicate directed relationship pairs"
)
var unknown_knowledge_data: Dictionary = manager.create_state_record().to_dictionary()
unknown_knowledge_data["event_knowledge"] = [
KnownEventStateRecord.create(0, 999).to_dictionary()
]
_check(
SimulationStateRecord.from_dictionary(unknown_knowledge_data) == null,
"Known-event records must reference an event in the same world record"
)
var duplicate_knowledge_data: Dictionary = manager.create_state_record().to_dictionary()
var event := EconomicEventRecord.create(
0,
SimulationIds.EVENT_STORAGE_DEPOSITED,
1,
0,
SimulationIds.npc_inventory_id(0),
SimulationIds.STORAGE_VILLAGE_PANTRY,
SimulationIds.RESOURCE_FOOD,
1.0
)
duplicate_knowledge_data["economic_events"] = [event.to_dictionary()]
duplicate_knowledge_data["simulation"]["next_event_id"] = 1
var known_event_data := KnownEventStateRecord.create(0, 0).to_dictionary()
duplicate_knowledge_data["event_knowledge"] = [
known_event_data, known_event_data.duplicate(true)
]
_check(
SimulationStateRecord.from_dictionary(duplicate_knowledge_data) == null,
"World state should reject duplicate NPC knowledge pairs"
)
var wrong_subject_cause_data: Dictionary = manager.create_state_record().to_dictionary()
var unrelated_deposit := EconomicEventRecord.create(
0,
SimulationIds.EVENT_STORAGE_DEPOSITED,
1,
2,
SimulationIds.npc_inventory_id(2),
SimulationIds.STORAGE_VILLAGE_PANTRY,
SimulationIds.RESOURCE_FOOD,
1.0
)
wrong_subject_cause_data["economic_events"] = [unrelated_deposit.to_dictionary()]
wrong_subject_cause_data["simulation"]["next_event_id"] = 1
wrong_subject_cause_data["event_knowledge"] = [
KnownEventStateRecord.create(0, 0).to_dictionary()
]
wrong_subject_cause_data["relationships"] = [
RelationshipStateRecord.create(0, 1, 0.5, 0.65, 0).to_dictionary()
]
_check(
SimulationStateRecord.from_dictionary(wrong_subject_cause_data) == null,
"A trust cause must be a known food deposit performed by the relationship subject"
)
var nonpositive_cause_data: Dictionary = manager.create_state_record().to_dictionary()
var nonpositive_deposit := EconomicEventRecord.create(
0,
SimulationIds.EVENT_STORAGE_DEPOSITED,
1,
1,
SimulationIds.npc_inventory_id(1),
SimulationIds.STORAGE_VILLAGE_PANTRY,
SimulationIds.RESOURCE_FOOD,
0.0
)
nonpositive_cause_data["economic_events"] = [nonpositive_deposit.to_dictionary()]
nonpositive_cause_data["simulation"]["next_event_id"] = 1
nonpositive_cause_data["event_knowledge"] = [KnownEventStateRecord.create(0, 0).to_dictionary()]
nonpositive_cause_data["relationships"] = [
RelationshipStateRecord.create(0, 1, 0.5, 0.65, 0).to_dictionary()
]
_check(
SimulationStateRecord.from_dictionary(nonpositive_cause_data) == null,
"A trust cause must describe a successful positive food deposit"
)
manager.free()
func _create_manager(seed_value: int) -> Node: func _create_manager(seed_value: int) -> Node:
var manager: Node = load("res://simulation/SimulationManager.gd").new() var manager: Node = load("res://simulation/SimulationManager.gd").new()
manager.simulation_seed = seed_value manager.simulation_seed = seed_value
+83
View File
@@ -2,6 +2,8 @@ extends GutTest
const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.gd") const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.gd")
const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd") const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd")
const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd")
const EventKnowledgeSystemScript := preload("res://simulation/knowledge/EventKnowledgeSystem.gd")
func test_storage_never_accepts_more_than_its_capacity() -> void: func test_storage_never_accepts_more_than_its_capacity() -> void:
@@ -45,3 +47,84 @@ func test_event_log_preserves_order_and_derives_consumption_rate() -> void:
assert_eq(int(event_log.events[1].data["event_id"]), 1) assert_eq(int(event_log.events[1].data["event_id"]), 1)
assert_eq(event_log.get_for_actor(4).size(), 2) assert_eq(event_log.get_for_actor(4).size(), 2)
assert_eq(float(event_log.get_consumption_rates(200)["food_per_day"]), 1.0) assert_eq(float(event_log.get_consumption_rates(200)["food_per_day"]), 1.0)
var legacy_event_data := event_log.events[1].to_dictionary()
legacy_event_data["schema_version"] = EconomicEventRecord.LEGACY_SCHEMA_VERSION
legacy_event_data.erase("world_position")
var migrated_event := EconomicEventRecord.from_dictionary(legacy_event_data)
assert_not_null(migrated_event)
assert_eq(migrated_event.get_world_position(), Vector3.ZERO)
func test_food_aid_builds_directed_trust_once_per_event() -> void:
var contributor := SimNPC.new(0, "Amina", SimulationIds.PROFESSION_FARMER, 5.0, 5.0)
var observer := SimNPC.new(1, "Tarik", SimulationIds.PROFESSION_GUARD, 5.0, 5.0)
contributor.home_position = Vector3.ZERO
observer.home_position = Vector3(1.0, 0.0, 0.0)
observer.hunger = 85.0
var villagers: Array[SimNPC] = [contributor, observer]
var relationship_system := RelationshipSystemScript.new()
relationship_system.initialize_households(villagers)
var deposit_event := EconomicEventRecord.create(
7,
SimulationIds.EVENT_STORAGE_DEPOSITED,
12,
contributor.id,
SimulationIds.npc_inventory_id(contributor.id),
SimulationIds.STORAGE_VILLAGE_PANTRY,
SimulationIds.RESOURCE_FOOD,
2.0
)
var later_deposit_event := EconomicEventRecord.create(
8,
SimulationIds.EVENT_STORAGE_DEPOSITED,
13,
contributor.id,
SimulationIds.npc_inventory_id(contributor.id),
SimulationIds.STORAGE_VILLAGE_PANTRY,
SimulationIds.RESOURCE_FOOD,
1.0
)
var knower_ids: Array[int] = [contributor.id, observer.id]
relationship_system.apply_event(deposit_event, villagers, knower_ids)
relationship_system.apply_event(later_deposit_event, villagers, knower_ids)
relationship_system.apply_event(deposit_event, villagers, knower_ids)
var relationship: RelationshipStateRecord = relationship_system.get_relationship(
observer.id, contributor.id
)
assert_eq(relationship.get_trust(), 0.8)
assert_eq(relationship.get_last_trust_cause_event_id(), 8)
assert_eq(
relationship_system.get_relationship(contributor.id, observer.id).get_trust(),
RelationshipStateRecord.NEUTRAL_TRUST
)
func test_event_knowledge_is_spatial_directed_and_idempotent() -> void:
var actor := SimNPC.new(0, "Amina", SimulationIds.PROFESSION_FARMER, 5.0, 5.0)
var nearby := SimNPC.new(1, "Tarik", SimulationIds.PROFESSION_GUARD, 5.0, 5.0)
var distant := SimNPC.new(2, "Jasmin", SimulationIds.PROFESSION_GUARD, 5.0, 5.0)
actor.position = Vector3.ZERO
nearby.position = Vector3(4.0, 0.0, 0.0)
distant.position = Vector3(20.0, 0.0, 0.0)
var villagers: Array[SimNPC] = [actor, nearby, distant]
var event := EconomicEventRecord.create(
12,
SimulationIds.EVENT_STORAGE_DEPOSITED,
30,
actor.id,
SimulationIds.npc_inventory_id(actor.id),
SimulationIds.STORAGE_VILLAGE_PANTRY,
SimulationIds.RESOURCE_FOOD,
2.0,
Vector3.ZERO
)
actor.position = Vector3(100.0, 0.0, 0.0)
var knowledge_system := EventKnowledgeSystemScript.new()
assert_eq(knowledge_system.observe_event(event, villagers).size(), 2)
assert_eq(knowledge_system.observe_event(event, villagers).size(), 0)
assert_true(knowledge_system.knows_event(actor.id, 12))
assert_true(knowledge_system.knows_event(nearby.id, 12))
assert_false(knowledge_system.knows_event(distant.id, 12))
assert_eq(knowledge_system.get_knowers(12), [actor.id, nearby.id])
@@ -0,0 +1,212 @@
extends SceneTree
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var manager := _create_manager()
var contributor: SimNPC = manager.npcs[0]
var nearby_observer: SimNPC = manager.npcs[1]
var distant_observer: SimNPC = manager.npcs[2]
contributor.position = Vector3.ZERO
nearby_observer.position = Vector3(4.0, 0.0, 0.0)
distant_observer.position = Vector3(20.0, 0.0, 0.0)
nearby_observer.hunger = 85.0
distant_observer.hunger = 85.0
contributor.add_inventory(SimulationIds.RESOURCE_FOOD, 2.0)
_check(
is_equal_approx(
manager.economy.deposit_inventory(contributor, SimulationIds.RESOURCE_FOOD), 2.0
),
"The witnessed fact should originate from a real successful food deposit"
)
var deposit_events: Array = manager.get_npc_events(contributor.id, 3)
var deposit_event: EconomicEventRecord
for event in deposit_events:
if StringName(event.data["event_type"]) == SimulationIds.EVENT_STORAGE_DEPOSITED:
deposit_event = event
break
_check(deposit_event != null, "The objective event stream should contain the deposit fact")
if deposit_event == null:
manager.free()
_finish()
return
var event_id := int(deposit_event.data["event_id"])
_check(
(
manager.npc_knows_event(contributor.id, event_id)
and manager.npc_knows_event(nearby_observer.id, event_id)
and not manager.npc_knows_event(distant_observer.id, event_id)
),
"Nearby and distant familiar NPCs should retain different evidence about one event"
)
var nearby_relationship: RelationshipStateRecord = manager.relationship_system.get_relationship(
nearby_observer.id, contributor.id
)
var distant_relationship: RelationshipStateRecord = (
manager.relationship_system.get_relationship(distant_observer.id, contributor.id)
)
_check(
(
nearby_relationship != null
and is_equal_approx(nearby_relationship.get_trust(), 0.65)
and nearby_relationship.get_last_trust_cause_event_id() == event_id
),
"Witnessed food aid should raise directed trust with the known fact as its cause"
)
_check(
(
distant_relationship != null
and is_equal_approx(
distant_relationship.get_trust(), RelationshipStateRecord.NEUTRAL_TRUST
)
and (
distant_relationship.get_last_trust_cause_event_id()
== RelationshipStateRecord.NO_CAUSE_EVENT
)
),
"Familiarity alone should not grant trust without evidence"
)
_prepare_choice_divergence(manager, contributor, nearby_observer, distant_observer)
var nearby_choice: ActionSelectionResult = manager.action_selector.select_action(
nearby_observer, manager.village, 0.5, manager.npcs
)
var distant_choice: ActionSelectionResult = manager.action_selector.select_action(
distant_observer, manager.village, 0.5, manager.npcs
)
_check(
(
nearby_choice.action_id == SimulationIds.ACTION_GATHER_FOOD
and contributor.npc_name in nearby_choice.reason
),
"The informed trusted observer should leave ordinary work to help"
)
_check(
distant_choice.action_id == SimulationIds.ACTION_PATROL,
"The uninformed neutral observer should continue ordinary guard work"
)
var saved_json: String = manager.serialize_state()
var restored := _create_manager(999)
_check(
restored.restore_state_from_json(saved_json),
"Witnessed knowledge should restore through the versioned world schema"
)
_check(
(
restored.npc_knows_event(nearby_observer.id, event_id)
and not restored.npc_knows_event(distant_observer.id, event_id)
and restored.get_known_events(nearby_observer.id, 1).size() == 1
),
"Save/load should preserve different per-NPC knowledge of the same objective event"
)
_check(
restored.get_state_checksum() == manager.get_state_checksum(),
"Restored knowledge should retain the complete deterministic checksum"
)
var current_nearby_choice: ActionSelectionResult = manager.action_selector.select_action(
nearby_observer, manager.village, 0.5, manager.npcs
)
var restored_nearby: SimNPC = restored.npcs[nearby_observer.id]
var restored_nearby_choice: ActionSelectionResult = restored.action_selector.select_action(
restored_nearby, restored.village, 0.5, restored.npcs
)
_check(
(
current_nearby_choice.action_id == SimulationIds.ACTION_GATHER_FOOD
and restored_nearby_choice.action_id == SimulationIds.ACTION_GATHER_FOOD
and contributor.npc_name in current_nearby_choice.reason
and contributor.npc_name in restored_nearby_choice.reason
),
"The restored informed NPC should retain the named helping branch"
)
var current_future_choice: ActionSelectionResult = manager.action_selector.select_action(
distant_observer, manager.village, 0.5, manager.npcs
)
var restored_distant: SimNPC = restored.npcs[distant_observer.id]
var restored_future_choice: ActionSelectionResult = restored.action_selector.select_action(
restored_distant, restored.village, 0.5, restored.npcs
)
_check(
current_future_choice.action_id == restored_future_choice.action_id,
"Knowledge restoration should preserve the next deterministic decision"
)
_check(
restored.get_state_checksum() == manager.get_state_checksum(),
"Equivalent future decisions should advance restored RNG state identically"
)
manager.free()
restored.free()
_finish()
func _prepare_choice_divergence(
manager: Node, contributor: SimNPC, nearby: SimNPC, distant: SimNPC
) -> void:
var pantry: StorageStateRecord = manager.get_pantry()
pantry.withdraw(
SimulationIds.RESOURCE_FOOD,
maxf(
(
pantry.get_amount(SimulationIds.RESOURCE_FOOD)
- (ActionSelectionSystem.RELATIONSHIP_AID_PANTRY_THRESHOLD - 5.0)
),
0.0
)
)
manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
manager.economy.deposit_resource(SimulationIds.RESOURCE_WOOD, 10.0)
manager.village.safety = 50.0
manager.village.knowledge = 20.0
manager.village.update_priorities()
contributor.hunger = 95.0
contributor.is_starving = true
for observer in [nearby, distant]:
observer.profession = SimulationIds.PROFESSION_GUARD
observer.hunger = 20.0
observer.energy = 80.0
observer.inventory.clear()
observer.last_task = &""
func _create_manager(seed_value: int = 811) -> Node:
var manager: Node = load("res://simulation/SimulationManager.gd").new()
manager.simulation_seed = seed_value
manager.debug_logs = false
var home_positions: Array[Vector3] = [
Vector3(0.0, 0.0, 0.0),
Vector3(1.0, 0.0, 0.0),
Vector3(2.5, 0.0, 0.0),
Vector3(20.0, 0.0, 20.0),
Vector3(30.0, 0.0, 30.0),
Vector3(40.0, 0.0, 40.0),
]
manager.home_positions = home_positions
root.add_child(manager)
manager.set_process(false)
return manager
func _finish() -> void:
if failures.is_empty():
print("[TEST] Witnessed knowledge passed: evidence -> trust -> divergent choices")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
@@ -0,0 +1 @@
uid://m0x2wooal8pa
+6 -4
View File
@@ -32,7 +32,9 @@ func _run() -> void:
var bake_aabb: AABB = nav_mesh.filter_baking_aabb var bake_aabb: AABB = nav_mesh.filter_baking_aabb
bake_aabb.position += nav_mesh.filter_baking_aabb_offset bake_aabb.position += nav_mesh.filter_baking_aabb_offset
var terrain_faces: PackedVector3Array = terrain.generate_nav_mesh_source_geometry(bake_aabb, false) var terrain_faces: PackedVector3Array = terrain.generate_nav_mesh_source_geometry(
bake_aabb, false
)
if terrain_faces.is_empty(): if terrain_faces.is_empty():
push_error("Terrain3D produced no navigation source faces") push_error("Terrain3D produced no navigation source faces")
quit(1) quit(1)
@@ -54,9 +56,11 @@ func _run() -> void:
return return
print( print(
(
"[TOOL] Saved %s with %d vertices and %d polygons" "[TOOL] Saved %s with %d vertices and %d polygons"
% [OUTPUT_PATH, nav_mesh.get_vertices().size(), nav_mesh.get_polygon_count()] % [OUTPUT_PATH, nav_mesh.get_vertices().size(), nav_mesh.get_polygon_count()]
) )
)
quit(0) quit(0)
@@ -68,7 +72,5 @@ func _create_navigation_mesh_template() -> NavigationMesh:
nav_mesh.agent_max_slope = 35.0 nav_mesh.agent_max_slope = 35.0
nav_mesh.cell_size = 0.25 nav_mesh.cell_size = 0.25
nav_mesh.cell_height = 0.1 nav_mesh.cell_height = 0.1
nav_mesh.filter_baking_aabb = AABB( nav_mesh.filter_baking_aabb = AABB(Vector3(-36.0, -8.0, -36.0), Vector3(72.0, 32.0, 72.0))
Vector3(-36.0, -8.0, -36.0), Vector3(72.0, 32.0, 72.0)
)
return nav_mesh return nav_mesh
+15 -3
View File
@@ -41,6 +41,7 @@ func _run() -> void:
if not demo_controller.debug_overlay_visible: if not demo_controller.debug_overlay_visible:
demo_controller.toggle_debug_overlay() demo_controller.toggle_debug_overlay()
await _stage_wind_gust(main_scene)
var debug_analysis := _capture(DEBUG_OUTPUT_PATH) var debug_analysis := _capture(DEBUG_OUTPUT_PATH)
if debug_analysis.is_empty(): if debug_analysis.is_empty():
quit(1) quit(1)
@@ -48,9 +49,7 @@ func _run() -> void:
if demo_controller.has_method("toggle_debug_overlay") and demo_controller.debug_overlay_visible: if demo_controller.has_method("toggle_debug_overlay") and demo_controller.debug_overlay_visible:
demo_controller.toggle_debug_overlay() demo_controller.toggle_debug_overlay()
await process_frame await _stage_wind_gust(main_scene)
for _frame in 30:
await process_frame
var cinematic_analysis := _capture(CINEMATIC_OUTPUT_PATH) var cinematic_analysis := _capture(CINEMATIC_OUTPUT_PATH)
if cinematic_analysis.is_empty(): if cinematic_analysis.is_empty():
@@ -58,12 +57,25 @@ func _run() -> void:
return return
print( print(
(
"[TOOL] Saved Simulation Garden captures | debug %s | cinematic %s" "[TOOL] Saved Simulation Garden captures | debug %s | cinematic %s"
% [debug_analysis, cinematic_analysis] % [debug_analysis, cinematic_analysis]
) )
)
quit(0) quit(0)
func _stage_wind_gust(main_scene: Node) -> void:
var gust_field := (
main_scene.get_node_or_null("JajceWorld/AtmosphereRoot/WindGustField") as WindGustField
)
if gust_field == null:
return
gust_field.trigger_gust_at(Vector3(1.5, 0.0, -1.0), true, 0.5)
for _frame in 2:
await process_frame
func _capture(output_path: String) -> Dictionary: func _capture(output_path: String) -> Dictionary:
var image := root.get_texture().get_image() var image := root.get_texture().get_image()
if image == null or image.is_empty(): if image == null or image.is_empty():
Regular → Executable
+6 -6
View File
@@ -7,20 +7,20 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT" cd "$ROOT"
# detect gdformat # detect gdformat
FMT="" FMT=()
if [[ -x "$ROOT/.venv/bin/gdformat" ]]; then if [[ -x "$ROOT/.venv/bin/gdformat" ]]; then
FMT="$ROOT/.venv/bin/gdformat" FMT=("$ROOT/.venv/bin/gdformat")
elif command -v gdformat &>/dev/null; then elif command -v gdformat &>/dev/null; then
FMT="gdformat" FMT=(gdformat)
elif python -m gdtoolkit.formatter --help &>/dev/null 2>&1; then elif python -m gdtoolkit.formatter --help &>/dev/null 2>&1; then
FMT="python -m gdtoolkit.formatter" FMT=(python -m gdtoolkit.formatter)
fi fi
if [[ -z "$FMT" ]]; then if [[ ${#FMT[@]} -eq 0 ]]; then
echo "ERROR: gdformat not found. Install requirements-dev.txt; see docs/local_quality_gate.md." echo "ERROR: gdformat not found. Install requirements-dev.txt; see docs/local_quality_gate.md."
exit 1 exit 1
fi fi
echo "Formatting all .gd files in $ROOT ..." echo "Formatting all .gd files in $ROOT ..."
$FMT . "${FMT[@]}" player simulation tests tools world
echo "Done." echo "Done."
+41 -7
View File
@@ -86,6 +86,8 @@ function Test-GdLint {
return $false return $false
} }
$script:GdToolExitCode = 0
function Invoke-GdFormat { function Invoke-GdFormat {
$args = $args $args = $args
$localTool = "$ROOT/.venv/Scripts/gdformat.exe" $localTool = "$ROOT/.venv/Scripts/gdformat.exe"
@@ -96,6 +98,7 @@ function Invoke-GdFormat {
} else { } else {
& python -m gdtoolkit.formatter @args 2>&1 & python -m gdtoolkit.formatter @args 2>&1
} }
$script:GdToolExitCode = $LASTEXITCODE
} }
function Invoke-GdLint { function Invoke-GdLint {
@@ -108,11 +111,13 @@ function Invoke-GdLint {
} else { } else {
& python -m gdtoolkit.linter @args 2>&1 & python -m gdtoolkit.linter @args 2>&1
} }
$script:GdToolExitCode = $LASTEXITCODE
} }
function Get-ChangedGd { function Get-ChangedGd {
$f = git diff --name-only HEAD -- '*.gd' 2>$null $changed = @(git diff --name-only HEAD -- '*.gd' 2>$null)
return @($f | Where-Object { $_ -ne '' }) $untracked = @(git ls-files --others --exclude-standard -- '*.gd' 2>$null)
return @((@($changed) + @($untracked)) | Where-Object { $_ -ne '' } | Sort-Object -Unique)
} }
function To-ResPath($p) { function To-ResPath($p) {
@@ -171,8 +176,10 @@ $hasProjectClasses = (Test-Path $classCache) -and
(Select-String -Path $classCache -SimpleMatch '"class": &"SimNPC"' -Quiet) (Select-String -Path $classCache -SimpleMatch '"class": &"SimNPC"' -Quiet)
$hasGutClasses = (Test-Path $classCache) -and $hasGutClasses = (Test-Path $classCache) -and
(Select-String -Path $classCache -SimpleMatch '"class": &"GutTest"' -Quiet) (Select-String -Path $classCache -SimpleMatch '"class": &"GutTest"' -Quiet)
$changedGd = @(Get-ChangedGd)
$needsImport = (-not $hasProjectClasses) -or $needsImport = (-not $hasProjectClasses) -or
((Test-Path "addons/gut/gut_cmdln.gd") -and (-not $hasGutClasses)) ((Test-Path "addons/gut/gut_cmdln.gd") -and (-not $hasGutClasses)) -or
($changedGd.Count -gt 0)
if ($needsImport) { if ($needsImport) {
$importArgs = "--headless --path `"$ROOT`" --import" $importArgs = "--headless --path `"$ROOT`" --import"
$importOutput, $importExit = Invoke-GodotWithTimeout -ArgumentString $importArgs $importOutput, $importExit = Invoke-GodotWithTimeout -ArgumentString $importArgs
@@ -200,6 +207,7 @@ if ($importResult -eq "FAIL") {
# -- 1. gdformat -------------------------------------------------------------- # -- 1. gdformat --------------------------------------------------------------
$fmtAvail = Test-GdFormat $fmtAvail = Test-GdFormat
$fmtResult = "PASS" $fmtResult = "PASS"
$fmtFailed = $false
Set-Content -Path "$LOG/gdformat.log" -Value "" -Encoding utf8 Set-Content -Path "$LOG/gdformat.log" -Value "" -Encoding utf8
if (-not $fmtAvail) { if (-not $fmtAvail) {
$fmtResult = "SKIPPED" $fmtResult = "SKIPPED"
@@ -215,8 +223,9 @@ if (-not $fmtAvail) {
if (-not (Test-Path $f)) { continue } if (-not (Test-Path $f)) { continue }
$total++ $total++
$out = Invoke-GdFormat "--check" $f $out = Invoke-GdFormat "--check" $f
$formatExit = $script:GdToolExitCode
$out | Add-Content -Path "$LOG/gdformat.log" -Encoding utf8 $out | Add-Content -Path "$LOG/gdformat.log" -Encoding utf8
if ($LASTEXITCODE -ne 0) { $bad++ } if ($formatExit -ne 0) { $bad++; $fmtFailed = $true }
} }
$msg = "($bad/$total files need formatting)" $msg = "($bad/$total files need formatting)"
Add-Content -Path "$LOG/gdformat.log" -Value $msg -Encoding utf8 Add-Content -Path "$LOG/gdformat.log" -Value $msg -Encoding utf8
@@ -224,12 +233,15 @@ if (-not $fmtAvail) {
} else { } else {
$formatArgs = @("--check") + $OWNED_GDSCRIPT_ROOTS $formatArgs = @("--check") + $OWNED_GDSCRIPT_ROOTS
$out = Invoke-GdFormat @formatArgs $out = Invoke-GdFormat @formatArgs
$formatExit = $script:GdToolExitCode
$out | Add-Content -Path "$LOG/gdformat.log" -Encoding utf8 $out | Add-Content -Path "$LOG/gdformat.log" -Encoding utf8
if ($formatExit -ne 0) { $fmtFailed = $true }
} }
} }
if (Select-String -SimpleMatch "would reformat" -Path "$LOG/gdformat.log" -Quiet) { if ($fmtFailed) { $fmtResult = "FAIL" }
$fmtResult = "FAIL" $formatDiagnostics = Select-String -SimpleMatch "would reformat" -Path "$LOG/gdformat.log" -Quiet
if ($formatDiagnostics) {
$lines = Select-String -SimpleMatch "would reformat" -Path "$LOG/gdformat.log" | Select-Object -ExpandProperty Line $lines = Select-String -SimpleMatch "would reformat" -Path "$LOG/gdformat.log" | Select-Object -ExpandProperty Line
foreach ($line in $lines) { foreach ($line in $lines) {
$p = ($line -replace '^would reformat ', '').Trim() $p = ($line -replace '^would reformat ', '').Trim()
@@ -237,11 +249,15 @@ if (Select-String -SimpleMatch "would reformat" -Path "$LOG/gdformat.log" -Quiet
$ERRORS += "$rp needs formatting" $ERRORS += "$rp needs formatting"
} }
$FIXES += "Run gdformat to auto-format files" $FIXES += "Run gdformat to auto-format files"
} elseif ($fmtFailed) {
$ERRORS += "gdformat: formatter exited non-zero; see $LOG/gdformat.log"
$FIXES += "Fix the gdformat error before continuing"
} }
# -- 2. gdlint ---------------------------------------------------------------- # -- 2. gdlint ----------------------------------------------------------------
$lintAvail = Test-GdLint $lintAvail = Test-GdLint
$lintResult = "PASS" $lintResult = "PASS"
$lintFailed = $false
Set-Content -Path "$LOG/gdlint.log" -Value "" -Encoding utf8 Set-Content -Path "$LOG/gdlint.log" -Value "" -Encoding utf8
if (-not $lintAvail) { if (-not $lintAvail) {
$lintResult = "SKIPPED" $lintResult = "SKIPPED"
@@ -255,18 +271,22 @@ if (-not $lintAvail) {
foreach ($f in $files) { foreach ($f in $files) {
if (-not (Test-Path $f)) { continue } if (-not (Test-Path $f)) { continue }
$out = Invoke-GdLint $f $out = Invoke-GdLint $f
$lintExit = $script:GdToolExitCode
$out | Add-Content -Path "$LOG/gdlint.log" -Encoding utf8 $out | Add-Content -Path "$LOG/gdlint.log" -Encoding utf8
if ($lintExit -ne 0) { $lintFailed = $true }
} }
} }
} else { } else {
$out = Invoke-GdLint @OWNED_GDSCRIPT_ROOTS $out = Invoke-GdLint @OWNED_GDSCRIPT_ROOTS
$lintExit = $script:GdToolExitCode
$out | Add-Content -Path "$LOG/gdlint.log" -Encoding utf8 $out | Add-Content -Path "$LOG/gdlint.log" -Encoding utf8
if ($lintExit -ne 0) { $lintFailed = $true }
} }
} }
if ($lintFailed) { $lintResult = "FAIL" }
$lintLines = Select-String -Pattern '^[^ ]+:\d+:\d+:' -Path "$LOG/gdlint.log" | Select-Object -ExpandProperty Line $lintLines = Select-String -Pattern '^[^ ]+:\d+:\d+:' -Path "$LOG/gdlint.log" | Select-Object -ExpandProperty Line
if ($lintLines) { if ($lintLines) {
$lintResult = "FAIL"
foreach ($line in $lintLines) { foreach ($line in $lintLines) {
$parts = $line -split ':', 4 $parts = $line -split ':', 4
if ($parts.Count -lt 4) { continue } if ($parts.Count -lt 4) { continue }
@@ -280,6 +300,9 @@ if ($lintLines) {
$rule = $parts[3]; $fname = Split-Path -Leaf $parts[0] $rule = $parts[3]; $fname = Split-Path -Leaf $parts[0]
$FIXES += "Fix $rule in $fname" $FIXES += "Fix $rule in $fname"
} }
} elseif ($lintFailed) {
$ERRORS += "gdlint: linter exited non-zero; see $LOG/gdlint.log"
$FIXES += "Fix the gdlint error before continuing"
} }
# -- 3. godot headless check -------------------------------------------------- # -- 3. godot headless check --------------------------------------------------
@@ -300,6 +323,12 @@ if ($godotExit -eq $null) {
} }
$FIXES += "Fix Godot parser errors" $FIXES += "Fix Godot parser errors"
} }
$godotLoadError = Select-String -Path "$LOG/godot-check.log" -Pattern 'SCRIPT ERROR:|Parse Error:|Failed to load script' -Quiet
if ($godotLoadError) {
$godotResult = "FAIL"
$ERRORS += "godot: project check reported a script load or parse error"
$FIXES += "Fix Godot parser errors"
}
# -- 4. project scenario tests ------------------------------------------------ # -- 4. project scenario tests ------------------------------------------------
$scenarioResult = "PASS" $scenarioResult = "PASS"
@@ -315,6 +344,11 @@ foreach ($test in $scenarioTests) {
$ERRORS += "scenario: $($test.Name) failed or timed out" $ERRORS += "scenario: $($test.Name) failed or timed out"
} }
} }
$scenarioLoadError = Select-String -Path "$LOG/scenarios.log" -Pattern 'SCRIPT ERROR:|Parse Error:|Failed to load script' -Quiet
if ($scenarioLoadError) {
$scenarioResult = "FAIL"
$ERRORS += "scenario: Godot reported a script load or parse error"
}
if ($scenarioResult -eq "FAIL") { if ($scenarioResult -eq "FAIL") {
$FIXES += "Fix failing project scenario tests" $FIXES += "Fix failing project scenario tests"
} }
+101 -54
View File
@@ -53,22 +53,43 @@ find_godot() {
return 1 return 1
} }
find_gdformat() { has_gdformat() {
if [[ -x "$ROOT/.venv/bin/gdformat" ]]; then echo "$ROOT/.venv/bin/gdformat"; return 0; fi [[ -x "$ROOT/.venv/bin/gdformat" ]] && return 0
if command -v gdformat &>/dev/null; then echo "gdformat"; return 0; fi command -v gdformat &>/dev/null && return 0
if python -m gdtoolkit.formatter --help &>/dev/null 2>&1; then echo "python -m gdtoolkit.formatter"; return 0; fi python -m gdtoolkit.formatter --help &>/dev/null 2>&1
return 1
} }
find_gdlint() { run_gdformat() {
if [[ -x "$ROOT/.venv/bin/gdlint" ]]; then echo "$ROOT/.venv/bin/gdlint"; return 0; fi if [[ -x "$ROOT/.venv/bin/gdformat" ]]; then
if command -v gdlint &>/dev/null; then echo "gdlint"; return 0; fi "$ROOT/.venv/bin/gdformat" "$@"
if python -m gdtoolkit.linter --help &>/dev/null 2>&1; then echo "python -m gdtoolkit.linter"; return 0; fi elif command -v gdformat &>/dev/null; then
return 1 gdformat "$@"
else
python -m gdtoolkit.formatter "$@"
fi
}
has_gdlint() {
[[ -x "$ROOT/.venv/bin/gdlint" ]] && return 0
command -v gdlint &>/dev/null && return 0
python -m gdtoolkit.linter --help &>/dev/null 2>&1
}
run_gdlint() {
if [[ -x "$ROOT/.venv/bin/gdlint" ]]; then
"$ROOT/.venv/bin/gdlint" "$@"
elif command -v gdlint &>/dev/null; then
gdlint "$@"
else
python -m gdtoolkit.linter "$@"
fi
} }
changed_gd() { changed_gd() {
git diff --name-only HEAD -- '*.gd' 2>/dev/null || true {
git diff --name-only HEAD -- '*.gd' 2>/dev/null
git ls-files --others --exclude-standard -- '*.gd' 2>/dev/null
} | sort -u || true
} }
res_path() { res_path() {
@@ -83,8 +104,38 @@ contains_element() {
local e; for e in "${@:2}"; do [[ "$e" == "$1" ]] && return 0; done; return 1 local e; for e in "${@:2}"; do [[ "$e" == "$1" ]] && return 0; done; return 1
} }
need_timeout() { run_with_timeout() {
command -v timeout &>/dev/null local seconds="$1"
shift
if command -v timeout &>/dev/null; then
timeout "$seconds" "$@"
return $?
fi
local marker="$LOG/.timeout-$$-${RANDOM}"
: > "$marker"
"$@" &
local command_pid=$!
(
sleep "$seconds"
if kill -0 "$command_pid" 2>/dev/null; then
echo "timeout" > "$marker"
kill "$command_pid" 2>/dev/null || true
sleep 2
kill -KILL "$command_pid" 2>/dev/null || true
fi
) &
local watchdog_pid=$!
wait "$command_pid"
local status=$?
kill "$watchdog_pid" 2>/dev/null || true
wait "$watchdog_pid" 2>/dev/null || true
if [[ -s "$marker" ]]; then
status=124
fi
rm -f "$marker"
return "$status"
} }
# -- godot -------------------------------------------------------------------- # -- godot --------------------------------------------------------------------
@@ -104,16 +155,15 @@ needs_import=false
if [[ -f "addons/gut/gut_cmdln.gd" ]] && ! grep -q '"class": &"GutTest"' "$class_cache" 2>/dev/null; then if [[ -f "addons/gut/gut_cmdln.gd" ]] && ! grep -q '"class": &"GutTest"' "$class_cache" 2>/dev/null; then
needs_import=true needs_import=true
fi fi
[[ -n "$(changed_gd)" ]] && needs_import=true
if $needs_import; then if $needs_import; then
if need_timeout; then run_with_timeout 60 "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --import >> "$LOG/godot-import.log" 2>&1
timeout 60 "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --import >> "$LOG/godot-import.log" 2>&1
import_exit=$? import_exit=$?
else
"${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --import >> "$LOG/godot-import.log" 2>&1
import_exit=$?
fi
if [[ $import_exit -ne 0 ]]; then if [[ $import_exit -ne 0 ]]; then
import_result="FAIL" import_result="FAIL"
if [[ $import_exit -eq 124 ]]; then
echo "[timed out after 60s]" >> "$LOG/godot-import.log"
fi
fi fi
fi fi
if [[ ! -f "$class_cache" ]] || ! grep -q '"class": &"SimNPC"' "$class_cache"; then if [[ ! -f "$class_cache" ]] || ! grep -q '"class": &"SimNPC"' "$class_cache"; then
@@ -131,10 +181,10 @@ if [[ "$import_result" == "FAIL" ]]; then
fi fi
# -- 1. gdformat -------------------------------------------------------------- # -- 1. gdformat --------------------------------------------------------------
fmt_tool=$(find_gdformat) || true
fmt_result="PASS" fmt_result="PASS"
fmt_failed=false
: > "$LOG/gdformat.log" : > "$LOG/gdformat.log"
if [[ -z "$fmt_tool" ]]; then if ! has_gdformat; then
fmt_result="SKIPPED" fmt_result="SKIPPED"
echo "gdformat not found — install requirements-dev.txt (see docs/local_quality_gate.md)" > "$LOG/gdformat.log" echo "gdformat not found — install requirements-dev.txt (see docs/local_quality_gate.md)" > "$LOG/gdformat.log"
else else
@@ -147,32 +197,40 @@ else
while IFS= read -r f; do while IFS= read -r f; do
[[ -f "$f" ]] || continue [[ -f "$f" ]] || continue
total=$((total+1)) total=$((total+1))
if ! $fmt_tool --check "$f" >> "$LOG/gdformat.log" 2>&1; then if ! run_gdformat --check "$f" >> "$LOG/gdformat.log" 2>&1; then
bad=$((bad+1)) bad=$((bad+1))
fmt_failed=true
fi fi
done <<< "$files" done <<< "$files"
echo "($bad/$total files need formatting)" >> "$LOG/gdformat.log" echo "($bad/$total files need formatting)" >> "$LOG/gdformat.log"
fi fi
else else
$fmt_tool --check "${OWNED_GDSCRIPT_ROOTS[@]}" >> "$LOG/gdformat.log" 2>&1 || true if ! run_gdformat --check "${OWNED_GDSCRIPT_ROOTS[@]}" >> "$LOG/gdformat.log" 2>&1; then
fmt_failed=true
fi
fi fi
fi fi
if grep -q "would reformat" "$LOG/gdformat.log" 2>/dev/null; then if $fmt_failed; then
fmt_result="FAIL" fmt_result="FAIL"
fi
if grep -q "would reformat" "$LOG/gdformat.log" 2>/dev/null; then
while IFS= read -r line; do while IFS= read -r line; do
line="${line#would reformat }" line="${line#would reformat }"
rp=$(res_path "$line") rp=$(res_path "$line")
ERRORS+=("$rp needs formatting") ERRORS+=("$rp needs formatting")
done < <(grep "would reformat" "$LOG/gdformat.log") done < <(grep "would reformat" "$LOG/gdformat.log")
FIXES+=("Run gdformat to auto-format files") FIXES+=("Run gdformat to auto-format files")
elif $fmt_failed; then
ERRORS+=("gdformat: formatter exited non-zero; see $LOG/gdformat.log")
FIXES+=("Fix the gdformat error before continuing")
fi fi
# -- 2. gdlint ---------------------------------------------------------------- # -- 2. gdlint ----------------------------------------------------------------
lint_tool=$(find_gdlint) || true
lint_result="PASS" lint_result="PASS"
lint_failed=false
: > "$LOG/gdlint.log" : > "$LOG/gdlint.log"
if [[ -z "$lint_tool" ]]; then if ! has_gdlint; then
lint_result="SKIPPED" lint_result="SKIPPED"
echo "gdlint not found — install requirements-dev.txt (see docs/local_quality_gate.md)" > "$LOG/gdlint.log" echo "gdlint not found — install requirements-dev.txt (see docs/local_quality_gate.md)" > "$LOG/gdlint.log"
else else
@@ -183,16 +241,22 @@ else
else else
while IFS= read -r f; do while IFS= read -r f; do
[[ -f "$f" ]] || continue [[ -f "$f" ]] || continue
$lint_tool "$f" >> "$LOG/gdlint.log" 2>&1 || true if ! run_gdlint "$f" >> "$LOG/gdlint.log" 2>&1; then
lint_failed=true
fi
done <<< "$files" done <<< "$files"
fi fi
else else
$lint_tool "${OWNED_GDSCRIPT_ROOTS[@]}" >> "$LOG/gdlint.log" 2>&1 || true if ! run_gdlint "${OWNED_GDSCRIPT_ROOTS[@]}" >> "$LOG/gdlint.log" 2>&1; then
lint_failed=true
fi
fi fi
fi fi
if grep -qE "^[^ ]+:[0-9]+:" "$LOG/gdlint.log" 2>/dev/null; then if $lint_failed; then
lint_result="FAIL" lint_result="FAIL"
fi
if grep -qE "^[^ ]+:[0-9]+:" "$LOG/gdlint.log" 2>/dev/null; then
while IFS= read -r line; do while IFS= read -r line; do
[[ "$line" =~ ^([^:]+):([0-9]+):([0-9]+):[^:]+:(.*) ]] || continue [[ "$line" =~ ^([^:]+):([0-9]+):([0-9]+):[^:]+:(.*) ]] || continue
file="${BASH_REMATCH[1]}" file="${BASH_REMATCH[1]}"
@@ -209,28 +273,21 @@ if grep -qE "^[^ ]+:[0-9]+:" "$LOG/gdlint.log" 2>/dev/null; then
fname=$(basename "${BASH_REMATCH[1]}") fname=$(basename "${BASH_REMATCH[1]}")
FIXES+=("Fix $rule in $fname") FIXES+=("Fix $rule in $fname")
done < <(grep -E "^[^ ]+:[0-9]+:" "$LOG/gdlint.log") done < <(grep -E "^[^ ]+:[0-9]+:" "$LOG/gdlint.log")
elif $lint_failed; then
ERRORS+=("gdlint: linter exited non-zero; see $LOG/gdlint.log")
FIXES+=("Fix the gdlint error before continuing")
fi fi
# -- 3. godot headless check -------------------------------------------------- # -- 3. godot headless check --------------------------------------------------
godot_result="PASS" godot_result="PASS"
: > "$LOG/godot-check.log" : > "$LOG/godot-check.log"
if need_timeout; then if run_with_timeout 30 "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --script res://tests/simulation_definitions_test.gd >> "$LOG/godot-check.log" 2>&1; then
if timeout 30 "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --script res://tests/simulation_definitions_test.gd >> "$LOG/godot-check.log" 2>&1; then
godot_result="PASS" godot_result="PASS"
else else
ec=$? ec=$?
godot_result="FAIL"
if [[ $ec -eq 124 ]]; then if [[ $ec -eq 124 ]]; then
godot_result="FAIL" echo "[timed out after 30s]" >> "$LOG/godot-check.log"
echo "[timed out after 30s — project runs continuously]" >> "$LOG/godot-check.log"
else
godot_result="FAIL"
fi
fi
else
if "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --script res://tests/simulation_definitions_test.gd >> "$LOG/godot-check.log" 2>&1; then
godot_result="PASS"
else
godot_result="FAIL"
fi fi
fi fi
if grep -qE "SCRIPT ERROR:|Parse Error:|Failed to load script" "$LOG/godot-check.log"; then if grep -qE "SCRIPT ERROR:|Parse Error:|Failed to load script" "$LOG/godot-check.log"; then
@@ -248,15 +305,10 @@ scenario_result="PASS"
: > "$LOG/scenarios.log" : > "$LOG/scenarios.log"
for test in tests/*_test.gd; do for test in tests/*_test.gd; do
echo "[RUN] $(basename "$test")" >> "$LOG/scenarios.log" echo "[RUN] $(basename "$test")" >> "$LOG/scenarios.log"
if need_timeout; then if ! run_with_timeout 30 "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --script "res://$test" >> "$LOG/scenarios.log" 2>&1; then
if ! timeout 30 "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --script "res://$test" >> "$LOG/scenarios.log" 2>&1; then
scenario_result="FAIL" scenario_result="FAIL"
ERRORS+=("scenario: $(basename "$test") failed or timed out") ERRORS+=("scenario: $(basename "$test") failed or timed out")
fi fi
elif ! "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --script "res://$test" >> "$LOG/scenarios.log" 2>&1; then
scenario_result="FAIL"
ERRORS+=("scenario: $(basename "$test") failed")
fi
done done
if grep -qE "SCRIPT ERROR:|Parse Error:|Failed to load script" "$LOG/scenarios.log"; then if grep -qE "SCRIPT ERROR:|Parse Error:|Failed to load script" "$LOG/scenarios.log"; then
scenario_result="FAIL" scenario_result="FAIL"
@@ -278,13 +330,8 @@ else
"${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT"
-s addons/gut/gut_cmdln.gd -gexit -gdisable_colors -s addons/gut/gut_cmdln.gd -gexit -gdisable_colors
) )
if need_timeout; then run_with_timeout 30 "${gut_command[@]}" >> "$LOG/gut.log" 2>&1
timeout 30 "${gut_command[@]}" >> "$LOG/gut.log" 2>&1
gut_exit=$? gut_exit=$?
else
"${gut_command[@]}" >> "$LOG/gut.log" 2>&1
gut_exit=$?
fi
if [[ $gut_exit -eq 0 ]]; then if [[ $gut_exit -eq 0 ]]; then
gut_result="PASS" gut_result="PASS"
else else
+2
View File
@@ -34,9 +34,11 @@ func toggle_debug_overlay() -> void:
debug_overlay_visible = not debug_overlay_visible debug_overlay_visible = not debug_overlay_visible
_apply_debug_overlay_visibility() _apply_debug_overlay_visibility()
print( print(
(
"[DemoController] %s" "[DemoController] %s"
% ("Debug overlay visible" if debug_overlay_visible else "Cinematic overlay hidden") % ("Debug overlay visible" if debug_overlay_visible else "Cinematic overlay hidden")
) )
)
func reset_demo_scene() -> void: func reset_demo_scene() -> void:
+1 -1
View File
@@ -3,7 +3,7 @@
[sub_resource type="ParticleProcessMaterial" id="3"] [sub_resource type="ParticleProcessMaterial" id="3"]
emission_shape = 1 emission_shape = 1
emission_box_extents = Vector3(0.1, 0.025, 0.1) emission_box_extents = Vector3(0.1, 0.025, 0.1)
gravity = Vector3(0, 0.3, 0) gravity = Vector3(0.065, 0.3, 0.025)
velocity_min = 0.0 velocity_min = 0.0
velocity_max = 0.5 velocity_max = 0.5
scale_min = 0.05 scale_min = 0.05
+5 -11
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=37 format=3] [gd_scene load_steps=36 format=3]
[ext_resource type="Terrain3DAssets" path="res://terrain/jajce/assets.tres" id="1_assets"] [ext_resource type="Terrain3DAssets" path="res://terrain/jajce/assets.tres" id="1_assets"]
[ext_resource type="PackedScene" path="res://world/resource_nodes/ResourceNode.tscn" id="2_resource"] [ext_resource type="PackedScene" path="res://world/resource_nodes/ResourceNode.tscn" id="2_resource"]
@@ -16,8 +16,7 @@
[ext_resource type="PackedScene" path="res://world/storage/StorageNode.tscn" id="14_storage"] [ext_resource type="PackedScene" path="res://world/storage/StorageNode.tscn" id="14_storage"]
[ext_resource type="Script" path="res://world/activity/ActivitySite.gd" id="15_activity"] [ext_resource type="Script" path="res://world/activity/ActivitySite.gd" id="15_activity"]
[ext_resource type="NavigationMesh" path="res://world/jajce/JajceNavigationMesh.tres" id="16_nav"] [ext_resource type="NavigationMesh" path="res://world/jajce/JajceNavigationMesh.tres" id="16_nav"]
[ext_resource type="PackedScene" path="res://world/jajce/WindStrokes.tscn" id="19_windstrokes"] [ext_resource type="PackedScene" path="res://world/jajce/vfx/WindGustField.tscn" id="17_wind_gust"]
[ext_resource type="PackedScene" path="res://world/jajce/WindSpecks.tscn" id="20_windspecks"]
[sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"] [sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"]
_shader_parameters = { _shader_parameters = {
@@ -593,15 +592,10 @@ light_energy = 1.45
shadow_enabled = true shadow_enabled = true
directional_shadow_max_distance = 180.0 directional_shadow_max_distance = 180.0
[node name="WindStrokes_Valley" parent="." instance=ExtResource("19_windstrokes")] [node name="AtmosphereRoot" type="Node3D" parent="."]
[node name="WindStrokes_Ridge" parent="." instance=ExtResource("19_windstrokes")] [node name="WindGustField" parent="AtmosphereRoot" instance=ExtResource("17_wind_gust")]
position = Vector3(-25, 7, 9) position = Vector3(-2, 0, 0)
[node name="WindSpecks_Valley" parent="." instance=ExtResource("20_windspecks")]
[node name="WindSpecks_Ridge" parent="." instance=ExtResource("20_windspecks")]
position = Vector3(-25, 7, 9)
[node name="WorldEnvironment" type="WorldEnvironment" parent="."] [node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("Environment_greybox") environment = SubResource("Environment_greybox")
+10 -5
View File
@@ -6,9 +6,6 @@ enum CanopyVariant {
YELLOW_GREEN, YELLOW_GREEN,
} }
@export var canopy_variant: CanopyVariant = CanopyVariant.GREEN
@export var canopy_radius: float = 1.35
const COLORS := { const COLORS := {
CanopyVariant.GREEN: Color(0.19, 0.38, 0.17), CanopyVariant.GREEN: Color(0.19, 0.38, 0.17),
CanopyVariant.DARK_GREEN: Color(0.12, 0.30, 0.12), CanopyVariant.DARK_GREEN: Color(0.12, 0.30, 0.12),
@@ -16,6 +13,8 @@ const COLORS := {
} }
const WIND_SHADER := preload("res://world/jajce/materials/wind.gdshader") const WIND_SHADER := preload("res://world/jajce/materials/wind.gdshader")
const WIND_STRENGTH := 0.07
const WIND_SPEED := 0.55
func _ready() -> void: func _ready() -> void:
@@ -25,8 +24,9 @@ func _ready() -> void:
var hash_seed := hash(global_position) var hash_seed := hash(global_position)
var rng := RandomNumberGenerator.new() var rng := RandomNumberGenerator.new()
rng.seed = hash_seed rng.seed = hash_seed
canopy_variant = rng.randi() % COLORS.size() as CanopyVariant var canopy_variant := rng.randi() % COLORS.size() as CanopyVariant
canopy_radius = 1.0 + rng.randf() * 0.8 var canopy_radius := 1.0 + rng.randf() * 0.8
var wind_phase := rng.randf_range(0.0, TAU)
var trunk_mesh := CylinderMesh.new() var trunk_mesh := CylinderMesh.new()
trunk_mesh.top_radius = 0.22 trunk_mesh.top_radius = 0.22
@@ -39,6 +39,7 @@ func _ready() -> void:
trunk_mesh.material = trunk_mat trunk_mesh.material = trunk_mat
var trunk := MeshInstance3D.new() var trunk := MeshInstance3D.new()
trunk.name = "Trunk"
trunk.mesh = trunk_mesh trunk.mesh = trunk_mesh
trunk.position = Vector3(0, 1.4, 0) trunk.position = Vector3(0, 1.4, 0)
add_child(trunk) add_child(trunk)
@@ -51,9 +52,13 @@ func _ready() -> void:
canopy_mat.shader = WIND_SHADER canopy_mat.shader = WIND_SHADER
canopy_mat.set_shader_parameter("albedo", COLORS[canopy_variant]) canopy_mat.set_shader_parameter("albedo", COLORS[canopy_variant])
canopy_mat.set_shader_parameter("roughness", 0.9) canopy_mat.set_shader_parameter("roughness", 0.9)
canopy_mat.set_shader_parameter("wind_strength", WIND_STRENGTH)
canopy_mat.set_shader_parameter("wind_speed", WIND_SPEED)
canopy_mat.set_shader_parameter("wind_phase", wind_phase)
canopy_mesh.material = canopy_mat canopy_mesh.material = canopy_mat
var canopy := MeshInstance3D.new() var canopy := MeshInstance3D.new()
canopy.name = "Canopy"
canopy.mesh = canopy_mesh canopy.mesh = canopy_mesh
canopy.position = Vector3(0, 3.2 + (canopy_radius - 1.35) * 0.5, 0) canopy.position = Vector3(0, 3.2 + (canopy_radius - 1.35) * 0.5, 0)
add_child(canopy) add_child(canopy)
-37
View File
@@ -1,37 +0,0 @@
[gd_scene load_steps=4 format=3]
[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_specks"]
lifetime_randomness = 0.8
spread = 120.0
flatness = 0.5
gravity = Vector3(-0.35, 0.25, 0.0)
initial_velocity_min = 0.2
initial_velocity_max = 1.2
scale_min = 0.06
scale_max = 0.28
color = Color(1, 0.98, 0.9, 0.3)
color_ramp = 1
emission_shape = 1
emission_box_extents = Vector3(32.0, 14.0, 32.0)
[sub_resource type="StandardMaterial3D" id="Material_speck"]
albedo_color = Color(1, 0.98, 0.92, 0.3)
transparency = 1
shading_mode = 0
billboard_mode = 1
cull_mode = 0
[sub_resource type="QuadMesh" id="Mesh_speck"]
material = SubResource("Material_speck")
size = Vector2(0.12, 0.12)
[node name="WindSpecks" type="GPUParticles3D"]
emitting = true
amount = 40
lifetime = 6.0
one_shot = false
preprocess = 4.0
speed_scale = 1.0
local_coords = false
process_material = SubResource("ParticleProcessMaterial_specks")
draw_pass_1 = SubResource("Mesh_speck")
-40
View File
@@ -1,40 +0,0 @@
[gd_scene load_steps=5 format=3]
[ext_resource type="Shader" path="res://world/jajce/materials/wind_stroke.gdshader" id="1_shader"]
[sub_resource type="ShaderMaterial" id="ShaderMaterial_stroke"]
shader = ExtResource("1_shader")
[sub_resource type="QuadMesh" id="Mesh_stroke"]
material = SubResource("ShaderMaterial_stroke")
size = Vector2(1.5, 0.07)
[sub_resource type="ParticleProcessMaterial" id="ParticleProcessMaterial_wind"]
lifetime_randomness = 0.6
spread = 55.0
flatness = 0.7
gravity = Vector3(-0.6, 0.35, 0.0)
initial_velocity_min = 0.8
initial_velocity_max = 2.8
angular_velocity_min = -0.25
angular_velocity_max = 0.25
radial_accel_min = 0.0
radial_accel_max = 0.0
tangential_accel_min = -0.08
tangential_accel_max = 0.08
scale_min = 0.3
scale_max = 1.2
color = Color(1, 1, 1, 0.35)
emission_shape = 1
emission_box_extents = Vector3(28.0, 8.0, 28.0)
[node name="WindStrokes" type="GPUParticles3D"]
emitting = true
amount = 10
lifetime = 5.0
one_shot = false
preprocess = 3.0
speed_scale = 1.0
local_coords = false
process_material = SubResource("ParticleProcessMaterial_wind")
draw_pass_1 = SubResource("Mesh_stroke")
+15 -9
View File
@@ -40,7 +40,7 @@ var _simulation_node: Node
func _ready() -> void: func _ready() -> void:
_simulation_node = get_tree().get_first_node_in_group("simulation_manager") _find_simulation_node()
if _simulation_node == null: if _simulation_node == null:
elapsed = cycle_duration_seconds * initial_cycle elapsed = cycle_duration_seconds * initial_cycle
_apply_light_rotation(initial_cycle) _apply_light_rotation(initial_cycle)
@@ -48,8 +48,10 @@ func _ready() -> void:
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
if _simulation_node == null or not is_instance_valid(_simulation_node):
_find_simulation_node()
var cycle: float var cycle: float
if _simulation_node != null and "clock" in _simulation_node: if _simulation_node != null and "clock" in _simulation_node and _simulation_node.clock != null:
cycle = _simulation_node.clock.time_of_day() cycle = _simulation_node.clock.time_of_day()
else: else:
elapsed += _delta elapsed += _delta
@@ -77,18 +79,18 @@ func _apply_environment(cycle: float) -> void:
directional_light.light_color = _lerp_color(NIGHT_LIGHT_COLOR, DAY_LIGHT_COLOR, day_factor) directional_light.light_color = _lerp_color(NIGHT_LIGHT_COLOR, DAY_LIGHT_COLOR, day_factor)
directional_light.light_energy = lerpf(NIGHT_LIGHT_ENERGY, DAY_LIGHT_ENERGY, day_factor) directional_light.light_energy = lerpf(NIGHT_LIGHT_ENERGY, DAY_LIGHT_ENERGY, day_factor)
var sunset_peak := _sunrise_sunset_weight(cycle) var transition_peak := _sunrise_sunset_weight(cycle)
if sunset_peak > 0.0: if transition_peak > 0.0:
var sunset_color: Color var transition_color: Color
if cycle < 0.5: if cycle < 0.5:
sunset_color = SUNSET_LIGHT_COLOR transition_color = SUNRISE_LIGHT_COLOR
else: else:
sunset_color = SUNRISE_LIGHT_COLOR transition_color = SUNSET_LIGHT_COLOR
directional_light.light_color = directional_light.light_color.lerp( directional_light.light_color = directional_light.light_color.lerp(
sunset_color, sunset_peak * 0.85 transition_color, transition_peak * 0.85
) )
directional_light.light_energy = maxf( directional_light.light_energy = maxf(
directional_light.light_energy, SUNRISE_SUNSET_ENERGY * sunset_peak directional_light.light_energy, SUNRISE_SUNSET_ENERGY * transition_peak
) )
if world_environment == null or world_environment.environment == null: if world_environment == null or world_environment.environment == null:
@@ -112,6 +114,10 @@ func _apply_environment(cycle: float) -> void:
mat.ground_horizon_color = _lerp_color(NIGHT_GROUND_HORIZON, DAY_GROUND_HORIZON, day_factor) mat.ground_horizon_color = _lerp_color(NIGHT_GROUND_HORIZON, DAY_GROUND_HORIZON, day_factor)
func _find_simulation_node() -> void:
_simulation_node = get_tree().get_first_node_in_group("simulation_manager")
func _day_factor(cycle: float) -> float: func _day_factor(cycle: float) -> float:
if cycle < 0.15: if cycle < 0.15:
return 0.0 return 0.0
+16 -6
View File
@@ -1,8 +1,13 @@
shader_type spatial; shader_type spatial;
render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_burley, specular_schlick_ggx; render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_burley, specular_schlick_ggx;
uniform float wind_strength : hint_range(0.0, 0.5) = 0.08; uniform vec2 wind_direction = vec2(0.94, 0.34);
uniform float wind_speed : hint_range(0.0, 5.0) = 0.6; uniform float wind_strength : hint_range(0.0, 0.2) = 0.07;
uniform float wind_speed : hint_range(0.0, 2.0) = 0.55;
uniform float gust_speed : hint_range(0.0, 1.0) = 0.12;
uniform float gust_strength : hint_range(0.0, 0.8) = 0.35;
uniform float flutter_strength : hint_range(0.0, 0.05) = 0.012;
uniform float wind_phase : hint_range(0.0, 6.2832) = 0.0;
uniform vec3 albedo : source_color = vec3(0.19, 0.38, 0.17); uniform vec3 albedo : source_color = vec3(0.19, 0.38, 0.17);
uniform float roughness : hint_range(0.0, 1.0) = 0.9; uniform float roughness : hint_range(0.0, 1.0) = 0.9;
@@ -11,11 +16,16 @@ void vertex() {
float height_factor = clamp((VERTEX.y + half_height) / (half_height * 2.0), 0.0, 1.0); float height_factor = clamp((VERTEX.y + half_height) / (half_height * 2.0), 0.0, 1.0);
height_factor = height_factor * height_factor; height_factor = height_factor * height_factor;
float sway_x = sin(VERTEX.x * 0.3 + TIME * wind_speed) * wind_strength * height_factor; vec2 direction = normalize(wind_direction);
float sway_z = cos(VERTEX.z * 0.3 + TIME * wind_speed * 0.7) * wind_strength * height_factor; vec2 crosswind = vec2(-direction.y, direction.x);
float world_phase = wind_phase + dot(MODEL_MATRIX[3].xz, vec2(0.07, 0.05));
float gust_wave = 0.5 + 0.5 * sin(TIME * gust_speed + world_phase * 0.7);
float gust = mix(1.0 - gust_strength, 1.0, gust_wave);
float bend = sin(TIME * wind_speed + world_phase) * wind_strength * gust;
float flutter = sin(TIME * 1.7 + world_phase * 1.9 + VERTEX.y * 2.0) * flutter_strength;
VERTEX.x += sway_x; VERTEX.xz += direction * bend * height_factor;
VERTEX.z += sway_z; VERTEX.xz += crosswind * flutter * height_factor;
} }
void fragment() { void fragment() {
+22
View File
@@ -0,0 +1,22 @@
shader_type spatial;
render_mode blend_mix, depth_draw_never, cull_disabled, unshaded;
uniform vec4 tint : source_color = vec4(0.76, 0.91, 0.75, 0.2);
uniform float progress : hint_range(0.0, 1.0) = 0.0;
uniform float glow_strength : hint_range(0.0, 1.0) = 0.38;
void fragment() {
float edge_softness = smoothstep(0.0, 0.24, UV.y) * smoothstep(0.0, 0.24, 1.0 - UV.y);
float head = progress * 1.18 - 0.08;
float tail = head - 0.52;
float head_mask = 1.0 - smoothstep(head, head + 0.07, UV.x);
float tail_mask = smoothstep(tail - 0.08, tail + 0.03, UV.x);
float life_fade = smoothstep(0.0, 0.12, progress) * (1.0 - smoothstep(0.82, 1.0, progress));
float brush_texture = 0.88 + 0.12 * sin(UV.x * 31.0 + UV.y * 5.0);
float stroke_alpha = edge_softness * head_mask * tail_mask * life_fade * brush_texture;
float glint = exp(-pow((UV.x - head) * 20.0, 2.0)) * edge_softness * life_fade;
ALBEDO = tint.rgb;
EMISSION = tint.rgb * (glow_strength * stroke_alpha + glint * 0.28);
ALPHA = tint.a * stroke_alpha;
}
@@ -0,0 +1 @@
uid://bjj5oxdmfpgqf
@@ -1,14 +0,0 @@
shader_type spatial;
render_mode blend_mix, depth_draw_never, cull_disabled, unshaded;
uniform float softness : hint_range(0.0, 1.0) = 0.45;
uniform vec3 tint : source_color = vec3(0.95, 0.93, 0.88);
void fragment() {
float edge_dist = abs(UV.y - 0.5) * 2.0;
float alpha = 1.0 - smoothstep(0.0, 1.0, edge_dist);
alpha *= smoothstep(0.0, 0.2, UV.x) * smoothstep(0.0, 0.2, 1.0 - UV.x);
alpha *= softness * 0.1;
ALBEDO = tint;
ALPHA = alpha;
}
@@ -1 +0,0 @@
uid://b8dxberetmchf
+271
View File
@@ -0,0 +1,271 @@
class_name WindGustField
extends Node3D
const GUST_SHADER := preload("res://world/jajce/materials/wind_gust.gdshader")
const RIBBON_SEGMENTS := 18
const STROKE_COLORS := [
Color(0.76, 0.93, 0.74, 0.28),
Color(1.0, 0.82, 0.52, 0.22),
Color(0.64, 0.88, 0.92, 0.2),
]
@export var terrain_path: NodePath = ^"../../TerrainRoot/Terrain3D"
@export var wind_direction := Vector3(0.94, 0.0, 0.34)
@export var view_spawn_extents := Vector2(8.5, 5.5)
@export_range(5.0, 12.0, 0.1) var interval_min := 6.0
@export_range(5.0, 12.0, 0.1) var interval_max := 10.0
@export_range(1.0, 3.0, 0.1) var duration_min := 1.8
@export_range(1.0, 3.0, 0.1) var duration_max := 2.35
@export_range(1, 3, 1) var stroke_count_min := 2
@export_range(1, 3, 1) var stroke_count_max := 3
@export_range(0.5, 3.0, 0.1) var hover_height := 1.25
@export var deterministic_seed := 20260711
var _random := RandomNumberGenerator.new()
var _terrain: Node
var _time_until_next_gust := 0.0
var _active_burst: Dictionary = {}
func _ready() -> void:
_random.seed = deterministic_seed
_terrain = get_node_or_null(terrain_path)
_time_until_next_gust = _random.randf_range(2.0, 4.0)
func _process(delta: float) -> void:
_time_until_next_gust -= delta
if not _active_burst.is_empty():
_update_active_burst(delta)
if _active_burst.is_empty() and _time_until_next_gust <= 0.0:
_trigger_gust_at_world(_random_world_origin())
func trigger_gust_at(
local_origin: Vector3, replace_active: bool = false, initial_progress: float = 0.0
) -> bool:
return _trigger_gust_at_world(to_global(local_origin), replace_active, initial_progress)
func _trigger_gust_at_world(
world_origin: Vector3, replace_active: bool = false, initial_progress: float = 0.0
) -> bool:
if not _active_burst.is_empty():
if not replace_active:
return false
_clear_active_burst(true)
var burst := Node3D.new()
burst.name = "WindGustBurst"
add_child(burst)
var origin := world_origin
var origin_ground_height := _sample_terrain_height(origin)
origin.y = origin_ground_height + hover_height + _random.randf_range(0.15, 0.55)
burst.global_position = origin
var duration := _random.randf_range(duration_min, duration_max)
var stroke_count := _random.randi_range(stroke_count_min, stroke_count_max)
var strokes: Array[Dictionary] = []
var direction := wind_direction.normalized()
var crosswind := Vector3.UP.cross(direction).normalized()
for stroke_index in stroke_count:
var stroke := MeshInstance3D.new()
stroke.name = "Stroke_%02d" % (stroke_index + 1)
stroke.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
var length := _random.randf_range(5.0, 7.8) * (1.0 - stroke_index * 0.08)
var width := _random.randf_range(0.2, 0.32)
var curve := _random.randf_range(0.18, 0.42)
var phase := _random.randf_range(-0.7, 0.7)
stroke.mesh = _create_ribbon_mesh(
length, width, curve, phase, direction, origin, origin_ground_height
)
stroke.position = (
crosswind * (float(stroke_index) - float(stroke_count - 1) * 0.5) * 0.62
+ Vector3.UP * stroke_index * 0.12
- direction * stroke_index * 0.25
)
var material := ShaderMaterial.new()
material.shader = GUST_SHADER
material.set_shader_parameter("tint", STROKE_COLORS[stroke_index])
material.set_shader_parameter("progress", 0.0)
stroke.material_override = material
burst.add_child(stroke)
(
strokes
. append(
{
"material": material,
"delay": float(stroke_index) * 0.11,
}
)
)
_active_burst = {
"node": burst,
"age": duration * clampf(initial_progress, 0.0, 0.95),
"duration": duration,
"drift_speed": _random.randf_range(0.35, 0.65),
"strokes": strokes,
}
_apply_stroke_progress()
_time_until_next_gust = _random.randf_range(interval_min, interval_max)
return true
func get_active_stroke_count() -> int:
if _active_burst.is_empty():
return 0
return (_active_burst["strokes"] as Array).size()
func _update_active_burst(delta: float) -> void:
var burst := _active_burst["node"] as Node3D
if burst == null or not is_instance_valid(burst):
_active_burst.clear()
return
var age := float(_active_burst["age"]) + delta
var duration := float(_active_burst["duration"])
_active_burst["age"] = age
burst.global_position += (
wind_direction.normalized() * float(_active_burst["drift_speed"]) * delta
)
var desired_height := _sample_terrain_height(burst.global_position) + hover_height
burst.global_position.y = lerpf(burst.global_position.y, desired_height, minf(delta * 2.0, 1.0))
_apply_stroke_progress()
if age >= duration:
_clear_active_burst()
func _apply_stroke_progress() -> void:
var age := float(_active_burst["age"])
var duration := float(_active_burst["duration"])
for stroke_data in _active_burst["strokes"]:
var delay := float(stroke_data["delay"])
var stroke_progress := clampf((age - delay) / maxf(duration - delay, 0.01), 0.0, 1.0)
var material := stroke_data["material"] as ShaderMaterial
material.set_shader_parameter("progress", stroke_progress)
func _clear_active_burst(free_immediately: bool = false) -> void:
if _active_burst.is_empty():
return
var burst := _active_burst.get("node") as Node3D
if burst != null and is_instance_valid(burst):
if free_immediately:
burst.free()
else:
burst.queue_free()
_active_burst.clear()
func _random_world_origin() -> Vector3:
var camera := get_viewport().get_camera_3d()
if camera == null:
return to_global(
Vector3(
_random.randf_range(-view_spawn_extents.x, view_spawn_extents.x),
0.0,
_random.randf_range(-view_spawn_extents.y, view_spawn_extents.y),
)
)
var center := _camera_ground_focus(camera)
var camera_right := Vector3(camera.global_basis.x.x, 0.0, camera.global_basis.x.z).normalized()
var camera_forward := (
Vector3(-camera.global_basis.z.x, 0.0, -camera.global_basis.z.z).normalized()
)
return (
center
+ camera_right * _random.randf_range(-view_spawn_extents.x, view_spawn_extents.x)
+ camera_forward * _random.randf_range(-view_spawn_extents.y, view_spawn_extents.y)
)
func _camera_ground_focus(camera: Camera3D) -> Vector3:
var ray_origin := camera.global_position
var ray_direction := -camera.global_basis.z.normalized()
if ray_direction.y >= -0.01:
return Vector3(ray_origin.x, _sample_terrain_height(ray_origin), ray_origin.z)
var ground_height := _sample_terrain_height(global_position)
var focus := global_position
for _iteration in 3:
var distance := (ground_height - ray_origin.y) / ray_direction.y
if distance <= 0.0:
break
focus = ray_origin + ray_direction * distance
ground_height = _sample_terrain_height(focus)
focus.y = ground_height
return focus
func _sample_terrain_height(world_position: Vector3) -> float:
if _terrain == null or not is_instance_valid(_terrain) or _terrain.get("data") == null:
return global_position.y
var terrain_height: float = _terrain.data.get_height(world_position)
return global_position.y if is_nan(terrain_height) else terrain_height
func _create_ribbon_mesh(
length: float,
base_width: float,
curve_amount: float,
phase: float,
direction: Vector3,
world_origin: Vector3,
origin_ground_height: float
) -> ArrayMesh:
var centers := PackedVector3Array()
var crosswind := Vector3.UP.cross(direction).normalized()
for segment_index in range(RIBBON_SEGMENTS + 1):
var t := float(segment_index) / RIBBON_SEGMENTS
var envelope := sin(t * PI)
var sweep := sin(t * TAU * 1.15 + phase) * curve_amount * envelope
var lift := envelope * 0.12 + sin(t * TAU + phase) * envelope * 0.035
var horizontal_offset := direction * ((t - 0.5) * length) + crosswind * sweep
var terrain_offset := (
_sample_terrain_height(world_origin + horizontal_offset) - origin_ground_height
)
centers.append(horizontal_offset + Vector3.UP * (terrain_offset + lift))
var vertices := PackedVector3Array()
var uvs := PackedVector2Array()
var indices := PackedInt32Array()
for segment_index in range(RIBBON_SEGMENTS + 1):
var t := float(segment_index) / RIBBON_SEGMENTS
var previous := centers[maxi(segment_index - 1, 0)]
var following := centers[mini(segment_index + 1, RIBBON_SEGMENTS)]
var tangent := (following - previous).normalized()
var side := Vector3.UP.cross(tangent).normalized()
var taper := pow(maxf(sin(t * PI), 0.0), 0.72)
var brush_belly := 1.0 + maxf(1.0 - absf(t - 0.34) / 0.34, 0.0) * 0.28
var half_width := base_width * taper * brush_belly * 0.5
vertices.append(centers[segment_index] - side * half_width)
vertices.append(centers[segment_index] + side * half_width)
uvs.append(Vector2(t, 0.0))
uvs.append(Vector2(t, 1.0))
if segment_index >= RIBBON_SEGMENTS:
continue
var vertex_index := segment_index * 2
(
indices
. append_array(
PackedInt32Array(
[
vertex_index,
vertex_index + 1,
vertex_index + 2,
vertex_index + 1,
vertex_index + 3,
vertex_index + 2,
]
)
)
)
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = vertices
arrays[Mesh.ARRAY_TEX_UV] = uvs
arrays[Mesh.ARRAY_INDEX] = indices
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
return mesh
+1
View File
@@ -0,0 +1 @@
uid://bceyn2pnyjfo7
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://world/jajce/vfx/WindGustField.gd" id="1_gust"]
[node name="WindGustField" type="Node3D"]
script = ExtResource("1_gust")
+9 -1
View File
@@ -52,7 +52,15 @@ func _draw() -> void:
var text_size := font.get_string_size(time_label, HORIZONTAL_ALIGNMENT_CENTER, -1, font_size) var text_size := font.get_string_size(time_label, HORIZONTAL_ALIGNMENT_CENTER, -1, font_size)
var text_x := (SIZE_DIAL - text_size.x) / 2.0 var text_x := (SIZE_DIAL - text_size.x) / 2.0
var text_y := SIZE_DIAL - text_size.y + 4.0 var text_y := SIZE_DIAL - text_size.y + 4.0
draw_string(font, Vector2(text_x, text_y), time_label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, Color.WHITE) draw_string(
font,
Vector2(text_x, text_y),
time_label,
HORIZONTAL_ALIGNMENT_LEFT,
-1,
font_size,
Color.WHITE
)
func _cycle_fraction() -> float: func _cycle_fraction() -> float:
+68 -18
View File
@@ -27,6 +27,10 @@ func _ready() -> void:
simulation_manager.state_restored.connect(_on_state_restored) simulation_manager.state_restored.connect(_on_state_restored)
if simulation_manager.has_signal("speed_changed"): if simulation_manager.has_signal("speed_changed"):
simulation_manager.speed_changed.connect(_on_speed_changed) simulation_manager.speed_changed.connect(_on_speed_changed)
if simulation_manager.has_signal("relationship_changed"):
simulation_manager.relationship_changed.connect(_on_relationship_changed)
if simulation_manager.has_signal("event_knowledge_changed"):
simulation_manager.event_knowledge_changed.connect(_on_event_knowledge_changed)
if "village" in simulation_manager: if "village" in simulation_manager:
_on_village_changed(simulation_manager.village) _on_village_changed(simulation_manager.village)
@@ -119,6 +123,16 @@ func _on_speed_changed(_multiplier: float) -> void:
_on_village_changed(simulation_manager.village) _on_village_changed(simulation_manager.village)
func _on_relationship_changed(
_relationship: RelationshipStateRecord, _cause_event: EconomicEventRecord
) -> void:
_refresh_npc_inspector()
func _on_event_knowledge_changed(_knower_id: int, _event: EconomicEventRecord) -> void:
_refresh_npc_inspector()
func _refresh_npc_inspector() -> void: func _refresh_npc_inspector() -> void:
if not debug_overlay_visible: if not debug_overlay_visible:
return return
@@ -147,30 +161,42 @@ func _refresh_npc_inspector() -> void:
for action_id in score_keys: for action_id in score_keys:
if decision.rejections.has(action_id): if decision.rejections.has(action_id):
rejection_rows.append( rejection_rows.append(
(
"%s%s" "%s%s"
% [_get_action_name(StringName(action_id)), String(decision.rejections[action_id])] % [
_get_action_name(StringName(action_id)),
String(decision.rejections[action_id])
]
)
) )
continue continue
score_rows.append( score_rows.append(
"%s %.2f" % [_get_action_name(StringName(action_id)), float(decision.scores[action_id])] (
"%s %.2f"
% [_get_action_name(StringName(action_id)), float(decision.scores[action_id])]
)
) )
if not score_rows.is_empty(): if not score_rows.is_empty():
score_text = "\n\nUtility\n" + "\n".join(score_rows) score_text = "\n\nUtility\n" + "\n".join(score_rows)
if not rejection_rows.is_empty(): if not rejection_rows.is_empty():
score_text += "\n\nUnavailable\n" + "\n".join(rejection_rows) score_text += "\n\nUnavailable\n" + "\n".join(rejection_rows)
var event_text := _build_event_history(npc) var event_text := _build_event_history(npc)
var housemate_text := _build_housemate_display(npc) var relationship_text := _build_relationship_display(npc)
var known_fact_text := _build_known_fact_display(npc)
npc_inspector_label.text = ( npc_inspector_label.text = (
(
"%s · %s\n" "%s · %s\n"
+ "Task: %s (%s)\n" + "Task: %s (%s)\n"
+ "Destination: %s\n" + "Destination: %s\n"
+ "Hunger: %.0f Energy: %.0f\n" + "Hunger: %.0f Energy: %.0f\n"
+ "Carrying food: %.0f wood: %.0f\n" + "Carrying food: %.0f wood: %.0f\n"
+ "%s\n" + "%s\n"
+ "%s\n"
+ "Why\n%s%s\n\n" + "Why\n%s%s\n\n"
+ "Recent\n%s\n\n" + "Recent\n%s\n\n"
+ "[Tab] Next villager [F10] Cinematic/debug [F12] Demo reset" + "[Tab] Next villager [F10] Cinematic/debug [F12] Demo reset"
) % [ )
% [
npc.npc_name, npc.npc_name,
profession_name, profession_name,
action_name, action_name,
@@ -180,11 +206,13 @@ func _refresh_npc_inspector() -> void:
npc.energy, npc.energy,
npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD), npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD),
npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD), npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD),
housemate_text, relationship_text,
known_fact_text,
reason_text, reason_text,
score_text, score_text,
event_text event_text
] ]
)
func _get_action_name(action_id: StringName) -> String: func _get_action_name(action_id: StringName) -> String:
@@ -220,22 +248,44 @@ func _build_event_history(npc: SimNPC) -> String:
return "\n".join(lines) return "\n".join(lines)
func _build_housemate_display(npc: SimNPC) -> String: func _build_relationship_display(npc: SimNPC) -> String:
if npc.familiarity.is_empty(): if not simulation_manager.has_method("get_primary_relationship"):
return "" return ""
var highest_id: int = -1 var relationship: RelationshipStateRecord = simulation_manager.get_primary_relationship(npc.id)
var highest_score := -1.0 if relationship == null:
for key in npc.familiarity:
var other_id: int = int(key)
var score: float = float(npc.familiarity[key])
if score > highest_score:
highest_score = score
highest_id = other_id
if highest_id < 0:
return "" return ""
var other_name := "Someone" var other_name := "Someone"
for other_npc in simulation_manager.npcs: for other_npc in simulation_manager.npcs:
if other_npc.id == highest_id: if other_npc.id == relationship.get_subject_id():
other_name = other_npc.npc_name other_name = other_npc.npc_name
break break
return "Familiar: %s (%.0f%%)" % [other_name, highest_score * 100.0] var display := (
"Relationship: %s — familiar %.0f%%, trust %.0f%%"
% [
other_name,
relationship.get_familiarity() * 100.0,
relationship.get_trust() * 100.0,
]
)
if not simulation_manager.has_method("get_relationship_cause"):
return display
var cause: EconomicEventRecord = simulation_manager.get_relationship_cause(relationship)
if cause == null:
return display
var name_map := {}
for other_npc in simulation_manager.npcs:
name_map[other_npc.id] = other_npc.npc_name
return "%s\nBecause: %s" % [display, cause.description(name_map)]
func _build_known_fact_display(npc: SimNPC) -> String:
if not simulation_manager.has_method("get_known_events"):
return ""
var known_events: Array = simulation_manager.get_known_events(npc.id, 1)
if known_events.is_empty():
return "Known fact: none yet"
var name_map := {}
for other_npc in simulation_manager.npcs:
name_map[other_npc.id] = other_npc.npc_name
var latest_event := known_events[known_events.size() - 1] as EconomicEventRecord
return "Known fact: %s" % latest_event.description(name_map)