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
- 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;
- consumes the NPC's deterministic decision RNG;
- 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
does not recompute decisions. At completion it atomically pays any
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
@@ -82,6 +88,27 @@ late shortfall suppresses the effect and records a `task_blocked` fact.
- answers recent-history, actor-history, and consumption-rate queries;
- 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
managers. Further decomposition should follow measured pressure and a proven
gameplay consumer.
+12 -2
View File
@@ -15,6 +15,8 @@ SimulationClock
-> ActionTargetResolver resolves a stable target ID
-> VillageEconomy performs inventory/storage transactions
-> SimulationEventLog records completed facts
-> EventKnowledgeSystem records actor/nearby knowledge
-> RelationshipSystem applies evidence-gated social consequences
-> WorldViewManager presents travel and NPC state
-> ActiveWorldAdapter supplies loaded-world positions/capacity
-> NpcVisual performs local navigation and animation
@@ -30,6 +32,10 @@ would otherwise obscure that lifecycle:
and keeps village resource summaries synchronized;
- `simulation/events/SimulationEventLog.gd` owns ordered event identity,
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/state/` owns versioned serialized record contracts;
- `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/economy/` | Authoritative inventory and storage transactions |
| `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/definitions/` | Stable IDs and immutable gameplay definitions |
| `simulation/persistence/` | Validated local save-file storage |
@@ -69,11 +77,13 @@ improving ownership.
- Persistent references are stable IDs, never `Node`, `NodePath`, or scene
ownership.
- 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
`VillageEconomy`; `village.food` and `village.wood` are synchronized views.
- 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
consumers.
+25 -5
View File
@@ -667,14 +667,34 @@ Completed:
logs. Waterfall mist is anchored to the waterfall instead of the village
origin, duplicate wind layers are separated, and Metal smoke/wind
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:
1. Replace the remaining player capsule with a matching stylized presentation,
while preserving the elevated top-down follow, gentle dead-zone lag, and
cozy farm-builder readability.
2. Continue with the relationship consequence work in `LEARNING_ROADMAP.md`
before expanding visual scope further.
1. Add one provenance-aware NPC-to-NPC fact transfer, then bounded
importance/retention rules, as sequenced in `LEARNING_ROADMAP.md`.
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
+31 -13
View File
@@ -2,7 +2,8 @@
## Current contract
Every successful food movement creates one `EconomicEventRecord`:
Every successful food or wood movement creates an `EconomicEventRecord` using
the applicable event type:
```text
resource_extracted
@@ -13,17 +14,21 @@ item_consumed
Each record contains a monotonically increasing event ID, event type,
simulation tick, actor ID, source ID, destination ID, item ID, and transferred
amount. Actor `-1` identifies a player-triggered extraction until persistent
player identity is introduced.
amount, plus the authoritative world position captured when the fact is
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
transaction and are not replayed to reconstruct current state. Resource,
inventory, and storage records remain authoritative.
`SimulationEventLog` owns ordered event identity, append/restore behavior, and
history/rate queries. `VillageEconomy` performs transactions and requests event
records only after state changes succeed; `SimulationManager` remains the
public signal boundary used by presentation.
history/rate queries, including exact lookup through `get_by_id()`.
`VillageEconomy` performs transactions and requests event records only after
state changes succeed; `SimulationManager` remains the public signal boundary
used by presentation.
An action whose definition-backed completion cost becomes unavailable records
a zero-amount `task_blocked` narrative fact with the action and shortfall
@@ -32,10 +37,21 @@ transfer occurred.
## Persistence and determinism
`SimulationStateRecord` schema v3 stores the ordered event stream and
`next_event_id`. Schema v1 and v2 saves migrate to an empty stream beginning at
ID zero. Parsing rejects duplicate event IDs and a next ID that could collide
with restored history.
`SimulationStateRecord` schema v5 stores the ordered event stream,
`next_event_id`, directed relationships that may reference an exact event, and
per-NPC known-event references. Schema v1 and v2 saves migrate to an empty
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:
@@ -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
large populations or long-running worlds, add measured retention,
archival/summary rules, and query indexes. General denied attempts, witnesses,
secrecy, causal links, and memories belong in later event/history slices; they
should extend this record family without making prose authoritative.
archival/summary rules, and query indexes. Proximity is the only current witness
rule; line of sight, hearing, acquisition provenance, communication, secrecy,
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,
lookdev scene, and tested temporary navigation loop.
The practical next sequence is:
Completed after the architecture gate:
- 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
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
dimension, such as trust or obligation, driven by structured events rather
than proximity alone.
2. Use that dimension in one real choice or response, then expose the cause in
the NPC inspector before expanding the relationship graph.
- directed familiarity/trust records now live in a top-level relationship
graph instead of NPC-local dictionaries;
- a successful known food-deposit event raises a hungry familiar NPC's trust
and is retained as the exact causal event ID;
- 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:
+69 -28
View File
@@ -2,8 +2,8 @@
> Agent-facing context for understanding the project quickly.
>
> Snapshot basis: repository state after commit `556d0fd`, July 2026. Treat the
> code as the source of truth when this document and the implementation differ.
> Snapshot basis: repository state on July 11, 2026. Treat the code as the
> source of truth when this document and the implementation differ.
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
- **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`
- **Terrain:** Terrain3D 1.0.2 is installed and enabled
- **Jajce runtime:** reusable 512 m Terrain3D seed, greybox landmarks, stable
@@ -215,8 +216,8 @@ plugin content, not game architecture.
### World and player
- `main.tscn` instances the reusable Jajce Terrain3D world.
- The player is a `CharacterBody3D` represented by placeholder primitive
geometry.
- The player is a `CharacterBody3D` with a compact multi-part stylized visual,
velocity-driven walk motion, and unchanged primitive collision.
- WASD movement is camera-relative.
- The elevated third-person camera rotates with the mouse, uses smoothed
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;
- task duration and progress;
- simulated position;
- home position and carried inventory;
- last action and mourning duration;
- starvation state and duration;
- death state.
@@ -280,9 +283,11 @@ NPCs currently choose among:
- gather food;
- gather wood;
- deposit carried food or wood;
- withdraw and eat food;
- patrol;
- study;
- eat;
- sleep;
- rest;
- 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;
- compact silhouette props for the ridge landmark, pantry, guard, study, and
rest sites;
- warm sky, fog, shadows, and wind-reactive foliage;
- eight ResourceNodes preserving stable food/wood discovery IDs;
- warm sky, fog, shadows, coherent wind-reactive foliage, and breeze-aligned
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;
- a Terrain3D-derived baked navigation mesh covering the current playable loop;
- base collision/navigation guardrails that keep Terrain3D collision enabled,
@@ -363,7 +371,10 @@ A small village panel displays:
- starving NPC count;
- selected village modifiers.
- 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
visuals, and compact task glyphs make active simulation state readable in the
@@ -374,8 +385,9 @@ glyphs.
### `simulation/SimNPC.gd`
`SimNPC` is a `RefCounted` simulation model. It owns needs, task selection,
task state, profession affinity, carried inventory, starvation, and death.
`SimNPC` is a `RefCounted` simulation model. It owns needs, task state,
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
should be preserved.
@@ -401,7 +413,11 @@ Focused `RefCounted` collaborators keep rule ownership visible:
- action systems own selection, execution progress, and target resolution;
- `VillageEconomy` owns storage/inventory transactions and synchronized
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
than duplicating these responsibilities across additional manager nodes.
@@ -425,7 +441,8 @@ than duplicating these responsibilities across additional manager nodes.
- obtains a navigation path;
- moves and rotates toward path points;
- 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`
@@ -435,7 +452,9 @@ follow camera.
### `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
@@ -465,6 +484,8 @@ NpcVisual navigates through the active world
| ResourceStateRecord.extract() -> NPC inventory
| -> VillageEconomy transfers inventory/storage as actions complete
| -> SimulationEventLog appends completed facts
| -> EventKnowledgeSystem captures actor/nearby knowledge
| -> RelationshipSystem applies evidence-gated social consequences
| |
| v
| village_changed signal updates the UI
@@ -485,7 +506,9 @@ NpcVisual navigates through the active world
├── docs/ Project context and plans
├── player/
│ ├── camera_rig.gd
│ ├── PlayerVisual.tscn
│ ├── player.gd
│ ├── player_visual.gd
│ └── npc/
│ ├── NpcVisual.gd
│ └── NpcVisual.tscn
@@ -498,7 +521,9 @@ NpcVisual navigates through the active world
│ ├── definitions/ Stable IDs and custom definition resources
│ ├── economy/ Inventory and storage transactions
│ ├── events/ Ordered event history and queries
│ ├── knowledge/ Per-NPC references to known objective events
│ ├── persistence/ Validated local save-slot storage
│ ├── relationships/ Directed social consequences and queries
│ └── state/ Versioned simulation-state records
├── tests/
│ ├── action_system_boundaries_test.gd
@@ -507,9 +532,11 @@ NpcVisual navigates through the active world
│ ├── jajce_world_scaffold_test.gd
│ ├── jajce_runtime_integration_test.gd
│ ├── npc_visual_lifecycle_test.gd
│ ├── relationship_consequence_test.gd
│ ├── resource_node_player_parity_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
├── tools/
│ └── generate_jajce_terrain_seed.gd
@@ -518,7 +545,8 @@ NpcVisual navigates through the active world
│ │ ├── JajceWorld.tscn
│ │ ├── JajceLookdev.tscn
│ │ ├── jajce_world.gd
│ │ ── beauty_camera.gd
│ │ ── beauty_camera.gd
│ │ └── vfx/WindGustField.tscn
│ ├── resource_nodes/
│ │ ├── ResourceNode.gd
│ │ ├── 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
gathering use `ResourceNode` instances with no fallback, food transfer uses
the typed pantry `StorageNode`, and patrol/study/rest use `ActivitySite`.
- Current NPC, village, resource, storage, event, clock, and RNG state serialize
through world schema v3. F5/F9 provide one validated local quicksave; a save
menu, metadata, and player-transform persistence remain deferred.
- Current NPC, village, resource, storage, event, knowledge, relationship,
clock, and RNG state serialize through world schema v5. F5/F9 provide one
validated local quicksave; a save menu, metadata, and player-transform
persistence remain deferred.
- Simulation-owned resource records retain live amounts, reservations, and
usage definitions while ResourceNode scenes are unloaded.
- 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
village storage. Other village metrics remain aggregate values rather than
located items.
- NPCs do not have homes, schedules, possessions, memories, relationships,
goals, or social knowledge.
- The reason inspector exposes current decisions, but deeper historical traces
and rejected preconditions are not yet retained.
- NPCs have home positions, schedule periods, carried food/wood, and directed
familiarity/trust. They can retain direct proximity-witness knowledge of a
food deposit, but do not yet have wider social dimensions, goals, line of
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.
- Unloaded traveling NPCs preserve their state but do not yet advance through
abstract travel time.
@@ -573,8 +605,9 @@ These are expected prototype constraints, not necessarily isolated bugs:
as reproducible presentation baselines.
- Combat, companions, factions, politics, trade, rumours, quests, persistence,
and regional travel do not yet exist.
- Placeholder geometry is sufficient for debugging but not for build-in-public
presentation.
- Stylized player/NPC silhouettes, water, foliage, and VFX support the current
build-in-public baseline, while blockout buildings and several work/resource
props remain visibly prototype-grade.
## Target simulation architecture
@@ -596,8 +629,9 @@ physics frames, or a currently loaded map.
The authoritative-boundary decision is recorded in
[ADR 0001](decisions/0001-simulation-authority-boundary.md). The current
`ResourceNode` state and `WorldViewManager` target selection are transitional,
not patterns to extend into inventories, schedules, or relationships.
`ResourceNode` binding and the `WorldViewManager` active-position bridge follow
this boundary; new inventories, schedules, relationships, and knowledge should
remain scene-independent records and systems.
### 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
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;
- six named villagers;
+49 -7
View File
@@ -3,7 +3,7 @@
## Current contract
`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
clock ticks;
@@ -12,16 +12,20 @@ simulation. The current world schema is v3 and captures:
- village resource counters;
- controlled per-NPC wander RNG streams;
- 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,
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:
```json
{
"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.
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.
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
[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
`SimulationManager` owns `ResourceStateRecord` instances independently of the
@@ -106,8 +145,11 @@ visuals from authoritative state.
This phase does not yet provide:
- a save-slot menu, metadata, thumbnails, autosaves, or multiple profiles;
- migrations older than the explicitly supported world schemas v1 and v2;
- player inventory, relationship, schedule, or social-memory records;
- migrations from any historical world schema other than the explicitly
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.
Those features should build on this boundary rather than inventing parallel
+49 -11
View File
@@ -1,6 +1,6 @@
# Simulation Garden 01
Captured: 2026-07-09
Captured: 2026-07-11
## Files
@@ -10,7 +10,17 @@ Captured: 2026-07-09
## 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
$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
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;
- placeholder characters, houses, and work props remain useful but visibly
- the elevated third-person/top-down camera is intentionally preserved; future
staging should improve composition within that cozy follow perspective;
- houses and several work/resource props remain useful but visibly
prototype-grade;
- terrain texture variation and path strips read better than the old flat map,
but landmarks, water, and foreground silhouettes need stronger first-read
staging;
- task glyphs and work-site props help, but the cinematic view still needs
richer water/foreground shapes and stronger authored silhouettes before it
becomes a strong public-facing shot.
but terrain/color hierarchy still flattens some village landmarks;
- task glyphs and work-site props help, but blockout buildings and resource
props need stronger authored silhouettes before this becomes a polished
public-facing shot.
Use this baseline as the first runtime comparison image before adding more
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
at the village origin, ridge/valley wind systems overlapped, and chimney smoke
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
Only runs `gdformat` / `gdlint` on `.gd` files modified since the last commit.
The Godot dependency check and all project scenarios still run.
Only runs `gdformat` / `gdlint` on tracked changes and new untracked `.gd`
files. The Godot dependency check and all project scenarios still run.
```bash
./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
`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
manual editor launch. Godot/GUT script parse or load markers are failures
because headless Godot can report those errors while returning a zero process
exit code.
GUT global classes whenever GDScript changes, so fresh, renamed, and deleted
`class_name` scripts cannot leave the ignored cache stale. Godot/GUT script
parse or load markers are failures because headless Godot can report those
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="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="PackedScene" path="res://player/PlayerVisual.tscn" id="14_player_visual"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_o5qli"]
radius = 0.4
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="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)
shape = SubResource("CapsuleShape3D_o5qli")
[node name="MeshInstance3D" type="MeshInstance3D" parent="Player" unique_id=935946019]
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="Visual" parent="Player" instance=ExtResource("14_player_visual")]
[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)
+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")
+8 -5
View File
@@ -351,11 +351,14 @@ func apply_dead_visual_state() -> void:
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]:
return false
return action_id not in [
SimulationIds.ACTION_IDLE,
SimulationIds.ACTION_DEAD,
SimulationIds.ACTION_WANDER,
]
return (
action_id
not in [
SimulationIds.ACTION_IDLE,
SimulationIds.ACTION_DEAD,
SimulationIds.ACTION_WANDER,
]
)
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:
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 last_task: StringName
var random_source: RandomNumberGenerator
var familiarity: Dictionary = {}
var mourning_ticks := 0
var debug_logs := false
+139 -53
View File
@@ -2,6 +2,8 @@ extends Node
const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.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 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 state_restored
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()
@@ -29,6 +33,8 @@ var wander_random_sources := {}
var resource_states: Dictionary = {}
var event_log := SimulationEventLogScript.new()
var economy := VillageEconomyScript.new()
var relationship_system := RelationshipSystemScript.new()
var event_knowledge_system := EventKnowledgeSystemScript.new()
var storage_states: Dictionary:
get:
return economy.storage_states
@@ -57,6 +63,7 @@ func _ready() -> void:
economy.inventory_changed.connect(_on_economy_inventory_changed)
economy.economic_event_requested.connect(_record_economic_event)
economy.narrative_event_requested.connect(record_narrative_event)
action_selector.relationship_system = relationship_system
var definition_errors := SimulationDefinitions.validate()
if not definition_errors.is_empty():
for error in definition_errors:
@@ -72,6 +79,7 @@ func _ready() -> void:
village.update_modifiers()
village.update_priorities()
generate_npcs()
relationship_system.initialize_households(npcs)
call_deferred("register_loaded_resource_nodes")
call_deferred("register_loaded_storage_nodes")
if debug_logs:
@@ -122,12 +130,6 @@ func generate_npcs() -> void:
for i in range(npcs.size()):
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:
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.set_task(selection.action_id, selection.duration_override)
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)
func _handle_npc_death(
npc: SimNPC, previous_task: StringName, previous_target: StringName
) -> void:
func _handle_npc_death(npc: SimNPC, previous_task: StringName, previous_target: StringName) -> void:
if not previous_target.is_empty():
release_npc_reservation(npc.id)
npc_died.emit(npc)
@@ -264,8 +266,10 @@ func _complete_resource_gather(npc: SimNPC, completed_task: StringName) -> void:
if resource_state == null or resource_state.get_reserved_by() != npc.id:
if debug_logs:
print(
"[SimulationManager] %s could not complete %s: invalid reservation"
% [npc.npc_name, completed_task]
(
"[SimulationManager] %s could not complete %s: invalid reservation"
% [npc.npc_name, completed_task]
)
)
release_npc_reservation(npc.id)
return
@@ -289,8 +293,10 @@ func _complete_resource_gather(npc: SimNPC, completed_task: StringName) -> void:
)
if debug_logs:
print(
"[SimulationManager] %s extracted %.1f %s from %s"
% [npc.npc_name, extracted, resource_id, resource_state.get_node_id()]
(
"[SimulationManager] %s extracted %.1f %s from %s"
% [npc.npc_name, extracted, resource_id, resource_state.get_node_id()]
)
)
release_npc_reservation(npc.id)
@@ -384,16 +390,12 @@ func notify_npc_arrived(npc_id: int) -> void:
continue
if (
other.target_id == npc.target_id
and other.task_state in [
SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING
]
and (
other.task_state
in [SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING]
)
):
npc.familiarity[other.id] = minf(
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
)
relationship_system.increase_shared_work_familiarity(npc.id, other.id)
if debug_logs:
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:
var best_id := -1
var best_score := -1.0
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:
var mourner := relationship_system.get_most_familiar_living_subject(dead_npc.id, npcs)
if mourner == null:
return
for npc in npcs:
if npc.id == best_id and not npc.is_dead:
npc.mourning_ticks = 100
if debug_logs:
print(
"[SimulationManager] ",
npc.npc_name,
" is mourning ",
dead_npc.npc_name
)
return
mourner.mourning_ticks = 100
if debug_logs:
print("[SimulationManager] ", mourner.npc_name, " is mourning ", dead_npc.npc_name)
func _record_economic_event(
@@ -521,19 +508,66 @@ func _record_economic_event(
destination_id: StringName,
item_id: StringName,
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:
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(
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,
actor_id: int,
source_id: StringName = &"",
action_display: String = ""
source_id: StringName,
action_display: String,
world_position: Vector3
) -> 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]:
@@ -545,9 +579,43 @@ func get_recent_events(max_count: int = 5) -> Array[EconomicEventRecord]:
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)
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:
return "%.2fx" % SPEED_LEVELS[speed_index]
@@ -576,8 +644,10 @@ func register_storage_node(node: StorageNode) -> bool:
var storage_state := storage_states.get(node.storage_id) as StorageStateRecord
if storage_state == null:
push_error(
"SimulationManager: StorageNode '%s' has no authoritative StorageStateRecord"
% node.storage_id
(
"SimulationManager: StorageNode '%s' has no authoritative StorageStateRecord"
% node.storage_id
)
)
return false
return node.bind_state(storage_state)
@@ -671,7 +741,12 @@ func harvest_resource_node(node: ResourceNode) -> float:
return 0.0
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,
-1,
resource_state.get_node_id(),
@@ -685,11 +760,16 @@ func harvest_resource_node(node: ResourceNode) -> float:
)
),
resource_state.get_resource_id(),
deposited
deposited,
event_position
)
if resource_state.get_amount_remaining() <= 0.0:
record_narrative_event(
SimulationIds.EVENT_RESOURCE_DEPLETED, -1, resource_state.get_node_id()
_record_narrative_event_at(
SimulationIds.EVENT_RESOURCE_DEPLETED,
-1,
resource_state.get_node_id(),
"",
event_position
)
village_changed.emit(village)
@@ -818,6 +898,10 @@ func create_state_record() -> SimulationStateRecord:
record.storages.append(storage_state)
for event in economic_events:
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
@@ -853,6 +937,8 @@ func restore_state(record: SimulationStateRecord) -> bool:
npcs.clear()
for npc_record in record.npcs:
npcs.append(npc_record.restore(debug_logs))
relationship_system.restore(record.relationships)
event_knowledge_system.restore(record.event_knowledge)
wander_random_sources.clear()
var wander_streams: Array = record.simulation["wander_random_streams"]
+28 -27
View File
@@ -1,12 +1,7 @@
class_name ActionSelectionSystem
extends RefCounted
enum SchedulePeriod {
WORK,
SLEEP,
MEAL,
DISCRETIONARY
}
enum SchedulePeriod { WORK, SLEEP, MEAL, DISCRETIONARY }
const SLEEP_BEGIN := 0.88
const SLEEP_END := 0.15
@@ -17,9 +12,14 @@ const DINNER_END := 0.8
const WORK_BEGIN := 0.28
const WORK_END := 0.85
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:
return null
@@ -70,9 +70,7 @@ func select_action(npc: SimNPC, village: SimVillage, time_of_day: float = 0.5, a
return ActionSelectionResult.new(
SimulationIds.ACTION_EAT, -1.0, "Night-time hunger; eating from inventory"
)
return ActionSelectionResult.new(
SimulationIds.ACTION_SLEEP, -1.0, "Night-time; going home"
)
return ActionSelectionResult.new(SimulationIds.ACTION_SLEEP, -1.0, "Night-time; going home")
if period == SchedulePeriod.MEAL:
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(
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:
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"
)
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 := {
SimulationIds.ACTION_GATHER_FOOD:
_calculate_score(
@@ -177,16 +185,6 @@ func _choose_best_work_action(npc: SimNPC, village: SimVillage, all_npcs: Array)
if not rejection.is_empty():
scores[action_id] = UNAVAILABLE_ACTION_SCORE
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_score: float = scores[best_action]
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)
if available >= definition.completion_cost_amount:
return ""
return "Needs %.0f %s (%.1f available)" % [
definition.completion_cost_amount,
String(definition.completion_cost_resource_id).capitalize(),
available,
]
return (
"Needs %.0f %s (%.1f available)"
% [
definition.completion_cost_amount,
String(definition.completion_cost_resource_id).capitalize(),
available,
]
)
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_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:
var target_id := StringName(candidate["target_id"])
var capacity := maxi(int(candidate.get("capacity", 1)), 1)
@@ -131,8 +131,10 @@ static func validate() -> Array[String]:
)
if (
not definition.completion_cost_resource_id.is_empty()
and definition.completion_cost_resource_id
not in [SimulationIds.RESOURCE_FOOD, SimulationIds.RESOURCE_WOOD]
and (
definition.completion_cost_resource_id
not in [SimulationIds.RESOURCE_FOOD, SimulationIds.RESOURCE_WOOD]
)
):
errors.append(
(
+14 -11
View File
@@ -144,9 +144,7 @@ func consume_npc_food(npc: SimNPC) -> bool:
npc.starvation_ticks = 0
npc.is_starving = npc.hunger >= 90.0
inventory_changed.emit(
npc,
SimulationIds.RESOURCE_FOOD,
npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD)
npc, SimulationIds.RESOURCE_FOOD, npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD)
)
economic_event_requested.emit(
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 available := storage.get_amount(resource_id) if storage != null else 0.0
if storage == null or available < required_amount:
var reason := "%s: needs %.0f %s (%.1f available)" % [
definition.display_name,
required_amount,
String(resource_id).capitalize(),
available,
]
var reason := (
"%s: needs %.0f %s (%.1f available)"
% [
definition.display_name,
required_amount,
String(resource_id).capitalize(),
available,
]
)
narrative_event_requested.emit(
SimulationIds.EVENT_TASK_BLOCKED,
npc.id,
@@ -194,8 +195,10 @@ func consume_completion_cost(npc: SimNPC, definition: ActionDefinition) -> bool:
)
if debug_logs:
print(
"[VillageEconomy] %s consumed %.1f %s for %s"
% [npc.npc_name, consumed, resource_id, definition.action_id]
(
"[VillageEconomy] %s consumed %.1f %s for %s"
% [npc.npc_name, consumed, resource_id, definition.action_id]
)
)
return true
+21 -4
View File
@@ -16,12 +16,21 @@ func record_economic(
source_id: StringName,
destination_id: StringName,
item_id: StringName,
amount: float
amount: float,
world_position: Vector3 = Vector3.ZERO
) -> EconomicEventRecord:
if amount <= 0.0:
return null
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)
return event
@@ -32,10 +41,11 @@ func record_narrative(
event_type: StringName,
actor_id: int,
source_id: StringName = &"",
action_display: String = ""
action_display: String = "",
world_position: Vector3 = Vector3.ZERO
) -> EconomicEventRecord:
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)
return event
@@ -62,6 +72,13 @@ func get_recent(max_count: int = 5) -> Array[EconomicEventRecord]:
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(
current_tick: int,
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
extends RefCounted
const SCHEMA_VERSION := 1
const SCHEMA_VERSION := 2
const LEGACY_SCHEMA_VERSION := 1
var data: Dictionary
@@ -18,7 +19,8 @@ static func create(
source_id: StringName,
destination_id: StringName,
item_id: StringName,
amount: float
amount: float,
world_position: Vector3 = Vector3.ZERO
) -> EconomicEventRecord:
return EconomicEventRecord.new(
{
@@ -30,7 +32,8 @@ static func create(
"source_id": String(source_id),
"destination_id": String(destination_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,
actor_id: int,
source_id: StringName,
action_display: String = ""
action_display: String = "",
world_position: Vector3 = Vector3.ZERO
) -> EconomicEventRecord:
return EconomicEventRecord.new(
{
@@ -54,13 +58,15 @@ static func create_narrative(
"destination_id": "",
"item_id": "",
"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:
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
if not record_data.has_all(
[
@@ -75,6 +81,8 @@ static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
]
):
return null
if version == SCHEMA_VERSION and not record_data.has("world_position"):
return null
var normalized := record_data.duplicate(true)
normalized["schema_version"] = SCHEMA_VERSION
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["item_id"] = String(record_data["item_id"])
normalized["amount"] = float(record_data["amount"])
if (
normalized["event_id"] < 0
or normalized["tick"] < 0
or normalized["event_type"].is_empty()
):
var saved_position = record_data.get("world_position", [0.0, 0.0, 0.0])
if not saved_position is Array or saved_position.size() != 3:
return null
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 EconomicEventRecord.new(normalized)
@@ -98,6 +108,11 @@ func to_dictionary() -> Dictionary:
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:
var actor_name: String = npc_names.get(int(data["actor_id"]), "Someone")
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
+10 -26
View File
@@ -1,9 +1,10 @@
class_name NPCStateRecord
extends RefCounted
const SCHEMA_VERSION := 3
const SCHEMA_VERSION := 4
const LEGACY_SCHEMA_VERSION := 1
const PREVIOUS_SCHEMA_VERSION := 2
const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3
var data: Dictionary
@@ -46,7 +47,6 @@ static func capture(npc: SimNPC) -> NPCStateRecord:
"last_task": String(npc.last_task),
"random_seed": str(npc.random_source.seed),
"random_state": str(npc.random_source.state),
"familiarity": _sorted_familiarity(npc.familiarity),
"mourning_ticks": npc.mourning_ticks
}
)
@@ -54,7 +54,10 @@ static func capture(npc: SimNPC) -> NPCStateRecord:
static func from_dictionary(record_data: Dictionary) -> NPCStateRecord:
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)
elif version != SCHEMA_VERSION:
return null
@@ -116,7 +119,9 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
if version == LEGACY_SCHEMA_VERSION:
migrated["travel_target_position"] = legacy_data.get("position", [0.0, 0.0, 0.0])
migrated["has_travel_target"] = false
migrated["inventory"] = {}
if version in [LEGACY_SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION]:
migrated["inventory"] = {}
migrated.erase("familiarity")
return migrated
@@ -141,9 +146,7 @@ func restore(debug_logs: bool) -> SimNPC:
float(saved_position[0]), float(saved_position[1]), float(saved_position[2])
)
var saved_home: Array = data.get("home_position", saved_position)
npc.home_position = Vector3(
float(saved_home[0]), float(saved_home[1]), float(saved_home[2])
)
npc.home_position = Vector3(float(saved_home[0]), float(saved_home[1]), float(saved_home[2]))
npc.is_starving = bool(data["is_starving"])
npc.starvation_ticks = int(data["starvation_ticks"])
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.random_source.state = String(data["random_state"]).to_int()
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))
return npc
func to_dictionary() -> Dictionary:
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
+186 -10
View File
@@ -2,9 +2,11 @@ class_name SimulationStateRecord
extends RefCounted
const SCHEMA_NAME := "the_steward.simulation"
const SCHEMA_VERSION := 3
const SCHEMA_VERSION := 5
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 village: VillageStateRecord
@@ -12,6 +14,8 @@ var npcs: Array[NPCStateRecord] = []
var resources: Array[ResourceStateRecord] = []
var storages: Array[StorageStateRecord] = []
var economic_events: Array[EconomicEventRecord] = []
var relationships: Array[RelationshipStateRecord] = []
var event_knowledge: Array[KnownEventStateRecord] = []
func to_dictionary() -> Dictionary:
@@ -28,6 +32,12 @@ func to_dictionary() -> Dictionary:
var event_data: Array[Dictionary] = []
for event_record in economic_events:
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 {
"schema": SCHEMA_NAME,
@@ -37,7 +47,9 @@ func to_dictionary() -> Dictionary:
"npcs": npc_data,
"resources": resource_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:
return null
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)
elif version != SCHEMA_VERSION:
return null
if not record_data.has_all(
["simulation", "village", "npcs", "resources", "storages", "economic_events"]
if not (
record_data
. has_all(
[
"simulation",
"village",
"npcs",
"resources",
"storages",
"economic_events",
"relationships",
"event_knowledge",
]
)
):
return null
@@ -100,11 +132,15 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
var resource_data = record_data["resources"]
var storage_data = record_data["storages"]
var event_data = record_data["economic_events"]
var relationship_data = record_data["relationships"]
var knowledge_data = record_data["event_knowledge"]
if (
not npc_data is Array
or not resource_data is Array
or not storage_data is Array
or not event_data is Array
or not relationship_data is Array
or not knowledge_data is Array
):
return null
@@ -152,6 +188,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
record.storages.append(storage_record)
var event_ids := {}
var event_records_by_id := {}
var highest_event_id := -1
for item in event_data:
if not item is Dictionary:
@@ -163,11 +200,69 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
if event_ids.has(event_id):
return null
event_ids[event_id] = true
event_records_by_id[event_id] = event_record
highest_event_id = maxi(highest_event_id, event_id)
record.economic_events.append(event_record)
if int(record.simulation["next_event_id"]) <= highest_event_id:
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
@@ -196,8 +291,89 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
. to_dictionary()
)
]
migrated["economic_events"] = []
var simulation_data: Dictionary = migrated.get("simulation", {})
simulation_data["next_event_id"] = 0
migrated["simulation"] = simulation_data
if version in [LEGACY_SCHEMA_VERSION, EVENT_LEGACY_SCHEMA_VERSION]:
migrated["economic_events"] = []
var simulation_data: Dictionary = migrated.get("simulation", {})
simulation_data["next_event_id"] = 0
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
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(
(
unfunded_selection.rejections.has(SimulationIds.ACTION_PATROL)
and "Needs 1 Wood" in String(
unfunded_selection.rejections[SimulationIds.ACTION_PATROL]
)
and "Needs 1 Wood" in String(unfunded_selection.rejections[SimulationIds.ACTION_PATROL])
),
"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(
(
not blocked_events.is_empty()
and StringName(blocked_events[0].data["event_type"])
== SimulationIds.EVENT_TASK_BLOCKED
and StringName(blocked_events[0].data["event_type"]) == SimulationIds.EVENT_TASK_BLOCKED
and "needs 1 Wood" in String(blocked_events[0].data.get("action_name", ""))
),
"An unpaid completion cost should create a readable blocked-task fact"
+93 -12
View File
@@ -16,8 +16,56 @@ func _run() -> void:
var simulation_manager: Node = main_scene.get_node("SimulationManager")
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")
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(
main_scene.has_node("JajceWorld/TerrainRoot/Terrain3D"),
"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"
)
_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(
not main_scene.has_node("JajceWorld/NavigationRegion3D/GreyboxGround"),
"Runtime should not keep the temporary greybox navigation ground"
@@ -53,7 +116,9 @@ func _run() -> void:
"Runtime should not retain duplicate flat-world objects"
)
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(
simulation_manager.resource_states.size() == 18,
"Simulation authority should bind all eighteen Jajce resources"
@@ -63,7 +128,9 @@ func _run() -> void:
for child in village_root.get_children():
if child.name.begins_with("Path_"):
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(
main_scene.has_node("JajceWorld/FortressBlockout/Keep/RidgeBanner"),
"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_region := main_scene.get_node("JajceWorld/NavigationRegion3D") as NavigationRegion3D
var navigation_region := (
main_scene.get_node("JajceWorld/NavigationRegion3D") as NavigationRegion3D
)
_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"
)
await _wait_for_navigation_map(navigation_map)
@@ -104,13 +176,17 @@ func _run() -> void:
not main_scene.has_node("JajceWorld/LegacyActivityMarkers/PantryMarker"),
"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())
_check(
main_scene.has_node("JajceWorld/WorldObjects/StorageSites/VillageWoodpile"),
"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())
var activity_root := main_scene.get_node("JajceWorld/WorldObjects/ActivitySites")
_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)
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():
return
@@ -204,13 +282,17 @@ func _check_path_tracks_terrain(terrain: Terrain3D, path: PackedVector3Array, me
return
if absf(point.y - terrain_height) > 0.85:
failures.append(
"%s: path point %s is too far from terrain height %.2f"
% [message, point, terrain_height]
(
"%s: path point %s is too far from terrain height %.2f"
% [message, point, terrain_height]
)
)
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():
return
@@ -219,6 +301,5 @@ func _check_path_reaches_destination(path: PackedVector3Array, destination: Vect
var destination_xz := Vector2(destination.x, destination.z)
if final_xz.distance_to(destination_xz) > 1.5:
failures.append(
"%s: final path point %s is too far from %s"
% [message, final_point, destination]
"%s: final path point %s is too far from %s" % [message, final_point, destination]
)
+158 -35
View File
@@ -67,6 +67,94 @@ func _run() -> void:
world.get_node("FoliageRoot").get_child_count() >= 10,
"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("WaterRoot/WaterfallBody"), "WaterRoot should include a waterfall drop")
_check(
@@ -91,9 +179,11 @@ func _run() -> void:
"Jajce environment should provide sky and restrained valley fog"
)
_check(
environment.glow_enabled
and environment.volumetric_fog_enabled
and environment.adjustment_enabled,
(
environment.glow_enabled
and environment.volumetric_fog_enabled
and environment.adjustment_enabled
),
"WorldEnvironment should provide bounded glow, depth haze, and color grading"
)
_check(
@@ -102,24 +192,47 @@ func _run() -> void:
)
var smoke_count := 0
var smoke_is_translucent := true
var smoke_follows_wind := true
var smoke_direction := Vector2.ZERO
for suffix in ["House_01", "House_02", "House_03"]:
if world.has_node("VillageRoot/%s/Smoke" % suffix):
var smoke: GPUParticles3D = world.get_node("VillageRoot/%s/Smoke" % suffix)
if smoke.draw_pass_1 == null:
continue
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
and smoke_material != null
and smoke_material.transparency != BaseMaterial3D.TRANSPARENCY_DISABLED
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
_check(
smoke_count >= 2,
"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_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()
_check(lookdev.has_node("JajceWorld"), "Lookdev should instance JajceWorld")
_check(lookdev.has_node("BeautyCamera"), "Lookdev should provide a beauty camera")
@@ -131,27 +244,29 @@ func _run() -> void:
ids.append(String((resource as ResourceNode).node_id))
ids.sort()
_check(
ids
== [
"animal_camp_01",
"animal_camp_river_01",
"berry_bush_01",
"berry_bush_02",
"berry_bush_river_02",
"berry_patch_mill_01",
"berry_patch_river_01",
"berry_patch_south_01",
"farm_crop_01",
"tree_01",
"tree_02",
"tree_forest_grove_01",
"tree_forest_grove_02",
"tree_north_outskirts_01",
"tree_river_resource_01",
"tree_south_edge_01",
"wood_pile_mill_01",
"wood_pile_village_01"
],
(
ids
== [
"animal_camp_01",
"animal_camp_river_01",
"berry_bush_01",
"berry_bush_02",
"berry_bush_river_02",
"berry_patch_mill_01",
"berry_patch_river_01",
"berry_patch_south_01",
"farm_crop_01",
"tree_01",
"tree_02",
"tree_forest_grove_01",
"tree_forest_grove_02",
"tree_north_outskirts_01",
"tree_river_resource_01",
"tree_south_edge_01",
"wood_pile_mill_01",
"wood_pile_village_01"
]
),
"Jajce resources should preserve all stable and discoverable IDs"
)
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(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(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(farming_context_count >= 1, "Jajce food discovery should include farming contexts")
_check(world.has_node("WorldObjects/StorageSites"), "JajceWorld should expose StorageSites")
@@ -232,7 +349,10 @@ func _run() -> void:
"Jajce navigation mesh should contain baked polygons"
)
_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"
)
await _wait_for_navigation_map(navigation_map)
@@ -329,9 +449,7 @@ func _run() -> void:
)
if failures.is_empty():
print(
"[TEST] Jajce scaffold passed: Terrain3D, scattered resources, typed sites"
)
print("[TEST] Jajce scaffold passed: Terrain3D, scattered resources, typed sites")
quit(0)
return
@@ -357,7 +475,9 @@ func _check(condition: bool, message: String) -> void:
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():
return
@@ -368,13 +488,17 @@ func _check_path_tracks_terrain(terrain: Terrain3D, path: PackedVector3Array, me
return
if absf(point.y - terrain_height) > 0.85:
failures.append(
"%s: path point %s is too far from terrain height %.2f"
% [message, point, terrain_height]
(
"%s: path point %s is too far from terrain height %.2f"
% [message, point, terrain_height]
)
)
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():
return
@@ -383,6 +507,5 @@ func _check_path_reaches_destination(path: PackedVector3Array, destination: Vect
var destination_xz := Vector2(destination.x, destination.z)
if final_xz.distance_to(destination_xz) > 1.5:
failures.append(
"%s: final path point %s is too far from %s"
% [message, final_point, destination]
"%s: final path point %s is too far from %s" % [message, final_point, destination]
)
+28 -27
View File
@@ -64,19 +64,14 @@ func _test_work_period_during_day() -> void:
manager.clock.cycle_duration_seconds = 240.0
manager.clock.elapsed_ticks = 100
var time_of_day: float = manager.clock.time_of_day()
_check(
time_of_day > 0.28 and time_of_day < 0.85,
"100 ticks should land in the work period"
)
_check(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]
npc.profession = SimulationIds.PROFESSION_WANDERER
npc.hunger = 20.0
npc.energy = 80.0
npc.home_position = npc.position
var selection := ActionSelectionSystem.new().select_action(
npc, manager.village, time_of_day
)
var selection := ActionSelectionSystem.new().select_action(npc, manager.village, time_of_day)
_check(selection != null, "Daytime should produce an action selection")
_check(
selection.action_id != SimulationIds.ACTION_SLEEP,
@@ -101,9 +96,7 @@ func _test_meal_period_hunger() -> void:
manager.village.food = 5.0
npc.home_position = npc.position
var selection := ActionSelectionSystem.new().select_action(
npc, manager.village, time_of_day
)
var selection := ActionSelectionSystem.new().select_action(npc, manager.village, time_of_day)
_check(selection != null, "Meal period should produce an action selection")
_check(
selection.action_id == SimulationIds.ACTION_WITHDRAW_FOOD,
@@ -129,10 +122,7 @@ func _test_sleep_restores_energy() -> void:
manager.simulate_tick()
_check(npc.task_complete, "Sleep should complete after its duration")
_check(
npc.energy > energy_before,
"Completing sleep should restore energy"
)
_check(npc.energy > energy_before, "Completing sleep should restore energy")
manager.free()
@@ -206,20 +196,26 @@ func _test_household_familiarity() -> void:
var d: SimNPC = manager.npcs[3]
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(
(
is_equal_approx(a_to_b.get_familiarity(), 0.5)
and is_equal_approx(a_to_b.get_trust(), RelationshipStateRecord.NEUTRAL_TRUST)
),
"Household familiarity should start at baseline 0.5"
)
_check(
a.familiarity.has(b.id) and b.familiarity.has(a.id),
"NPCs with nearby homes should be mutual household members"
)
_check(
is_equal_approx(float(a.familiarity[b.id]), 0.5),
"Household familiarity should start at baseline 0.5"
)
_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"
)
_check(
not a.familiarity.has(e.id),
manager.relationship_system.get_relationship(a.id, e.id) == null,
"Distant NPCs should not have household familiarity"
)
@@ -232,10 +228,15 @@ func _test_household_familiarity() -> void:
restored.restore_state_from_json(saved_json),
"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(
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()
+11 -11
View File
@@ -37,20 +37,23 @@ func _run() -> void:
"NPC presentation should use a readable multi-part villager silhouette"
)
_check(
visual.get_node("ProfessionProp").mesh != null
and profession_definition.display_name in visual.get_node("ProfessionLabel").text,
(
visual.get_node("ProfessionProp").mesh != null
and profession_definition.display_name in visual.get_node("ProfessionLabel").text
),
"NPC visual should expose a profession prop and readable label"
)
visual.set_task_presentation(SimulationIds.ACTION_GATHER_FOOD, SimNPC.TASK_STATE_TRAVELING)
_check(
visual.get_node("TaskGlyphRoot").visible
and visual.get_node("TaskGlyphRoot/TaskGlyph").mesh is SphereMesh,
(
visual.get_node("TaskGlyphRoot").visible
and visual.get_node("TaskGlyphRoot/TaskGlyph").mesh is SphereMesh
),
"NPC visual should expose a compact task glyph for cinematic readability"
)
visual.set_debug_overlay_visible(false)
_check(
not visual.get_node("ProfessionLabel").visible
and visual.get_node("TaskGlyphRoot").visible,
not visual.get_node("ProfessionLabel").visible and visual.get_node("TaskGlyphRoot").visible,
"Cinematic mode should hide debug labels while preserving task glyphs"
)
visual.set_debug_overlay_visible(true)
@@ -68,12 +71,9 @@ func _run() -> void:
)
manager.latest_decisions[npc.id] = test_decision
manager.emit_signal("npc_decision_recorded", npc, test_decision)
var inspector_label: Label = ui.get_node(
"NpcInspectorPanel/MarginContainer/NpcInspectorLabel"
)
var inspector_label: Label = ui.get_node("NpcInspectorPanel/MarginContainer/NpcInspectorLabel")
_check(
"Test shortage reason" in inspector_label.text
and npc.npc_name in inspector_label.text,
"Test shortage reason" in inspector_label.text and npc.npc_name in inspector_label.text,
"NPC inspector should show the selected villager's real decision reason"
)
_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
+33 -7
View File
@@ -42,6 +42,28 @@ func _run() -> void:
"Village should receive exactly the bush's remaining food"
)
_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
var wood_before: float = simulation_manager.village.wood
@@ -57,18 +79,20 @@ func _run() -> void:
"Village should receive exactly the extracted wood"
)
var pantry := main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") as StorageNode
var pantry_state: StorageStateRecord = simulation_manager.get_pantry()
pantry_state.deposit(
SimulationIds.RESOURCE_FOOD, pantry_state.get_available_capacity() - 0.5
var pantry := (
main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") as StorageNode
)
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)
bush_state.set_amount_remaining(1.0)
player.global_position = bush.interaction_point.global_position
player.try_interact()
_check(
is_equal_approx(bush_state.get_amount_remaining(), 0.5)
and is_equal_approx(pantry_state.get_available_capacity(), 0.0),
(
is_equal_approx(bush_state.get_amount_remaining(), 0.5)
and is_equal_approx(pantry_state.get_available_capacity(), 0.0)
),
"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,
"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()
var safety_before: float = simulation_manager.village.safety
player.try_interact()
+4 -5
View File
@@ -34,17 +34,16 @@ func _test_registry_integrity() -> void:
SimulationDefinitions.get_action(definition.action_id) == definition,
"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()
_check(profession_ids.size() == 5, "Registry should contain all five prototype professions")
var profession_colors := {}
for profession_id in profession_ids:
var profession := SimulationDefinitions.get_profession(profession_id)
_check(
profession != null,
"Profession lookup should return '%s'" % profession_id
)
_check(profession != null, "Profession lookup should return '%s'" % profession_id)
if profession != null:
profession_colors[profession.visual_color.to_html()] = true
_check(
+178 -8
View File
@@ -14,6 +14,9 @@ func _run() -> void:
_test_legacy_resource_migration()
_test_legacy_world_storage_migration()
_test_previous_world_event_migration()
_test_previous_world_relationship_migration()
_test_previous_world_knowledge_migration()
_test_relationship_schema_rejection()
_test_schema_rejection()
if failures.is_empty():
@@ -157,8 +160,10 @@ func _test_legacy_resource_migration() -> void:
"Resource migration should preserve mutable authority"
)
_check(
is_equal_approx(migrated.get_safety_risk(), 0.0)
and is_equal_approx(migrated.get_comfort_distance(), 18.0),
(
is_equal_approx(migrated.get_safety_risk(), 0.0)
and is_equal_approx(migrated.get_comfort_distance(), 18.0)
),
"Resource migration should default discovery metadata"
)
@@ -190,9 +195,7 @@ func _test_legacy_world_storage_migration() -> void:
var migrated_storages := {}
for storage in migrated.storages:
migrated_storages[storage.get_storage_id()] = storage
var pantry: StorageStateRecord = migrated_storages.get(
SimulationIds.STORAGE_VILLAGE_PANTRY
)
var pantry: StorageStateRecord = migrated_storages.get(SimulationIds.STORAGE_VILLAGE_PANTRY)
var woodpile: StorageStateRecord = migrated_storages.get(
SimulationIds.STORAGE_VILLAGE_WOODPILE
)
@@ -200,8 +203,12 @@ func _test_legacy_world_storage_migration() -> void:
(
pantry != null
and woodpile != null
and is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), manager.village.food)
and is_equal_approx(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), manager.village.wood)
and is_equal_approx(
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"
)
@@ -211,7 +218,7 @@ func _test_legacy_world_storage_migration() -> void:
func _test_previous_world_event_migration() -> void:
var manager := _create_manager(56)
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["simulation"].erase("next_event_id")
var migrated := SimulationStateRecord.from_dictionary(previous_data)
@@ -224,6 +231,169 @@ func _test_previous_world_event_migration() -> void:
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:
var manager: Node = load("res://simulation/SimulationManager.gd").new()
manager.simulation_seed = seed_value
+83
View File
@@ -2,6 +2,8 @@ extends GutTest
const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.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:
@@ -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(event_log.get_for_actor(4).size(), 2)
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
+8 -6
View File
@@ -32,7 +32,9 @@ func _run() -> void:
var bake_aabb: AABB = nav_mesh.filter_baking_aabb
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():
push_error("Terrain3D produced no navigation source faces")
quit(1)
@@ -54,8 +56,10 @@ func _run() -> void:
return
print(
"[TOOL] Saved %s with %d vertices and %d polygons"
% [OUTPUT_PATH, nav_mesh.get_vertices().size(), nav_mesh.get_polygon_count()]
(
"[TOOL] Saved %s with %d vertices and %d polygons"
% [OUTPUT_PATH, nav_mesh.get_vertices().size(), nav_mesh.get_polygon_count()]
)
)
quit(0)
@@ -68,7 +72,5 @@ func _create_navigation_mesh_template() -> NavigationMesh:
nav_mesh.agent_max_slope = 35.0
nav_mesh.cell_size = 0.25
nav_mesh.cell_height = 0.1
nav_mesh.filter_baking_aabb = AABB(
Vector3(-36.0, -8.0, -36.0), Vector3(72.0, 32.0, 72.0)
)
nav_mesh.filter_baking_aabb = AABB(Vector3(-36.0, -8.0, -36.0), Vector3(72.0, 32.0, 72.0))
return nav_mesh
+17 -5
View File
@@ -41,6 +41,7 @@ func _run() -> void:
if not demo_controller.debug_overlay_visible:
demo_controller.toggle_debug_overlay()
await _stage_wind_gust(main_scene)
var debug_analysis := _capture(DEBUG_OUTPUT_PATH)
if debug_analysis.is_empty():
quit(1)
@@ -48,9 +49,7 @@ func _run() -> void:
if demo_controller.has_method("toggle_debug_overlay") and demo_controller.debug_overlay_visible:
demo_controller.toggle_debug_overlay()
await process_frame
for _frame in 30:
await process_frame
await _stage_wind_gust(main_scene)
var cinematic_analysis := _capture(CINEMATIC_OUTPUT_PATH)
if cinematic_analysis.is_empty():
@@ -58,12 +57,25 @@ func _run() -> void:
return
print(
"[TOOL] Saved Simulation Garden captures | debug %s | cinematic %s"
% [debug_analysis, cinematic_analysis]
(
"[TOOL] Saved Simulation Garden captures | debug %s | cinematic %s"
% [debug_analysis, cinematic_analysis]
)
)
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:
var image := root.get_texture().get_image()
if image == null or image.is_empty():
Regular → Executable
+6 -6
View File
@@ -7,20 +7,20 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
# detect gdformat
FMT=""
FMT=()
if [[ -x "$ROOT/.venv/bin/gdformat" ]]; then
FMT="$ROOT/.venv/bin/gdformat"
FMT=("$ROOT/.venv/bin/gdformat")
elif command -v gdformat &>/dev/null; then
FMT="gdformat"
FMT=(gdformat)
elif python -m gdtoolkit.formatter --help &>/dev/null 2>&1; then
FMT="python -m gdtoolkit.formatter"
FMT=(python -m gdtoolkit.formatter)
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."
exit 1
fi
echo "Formatting all .gd files in $ROOT ..."
$FMT .
"${FMT[@]}" player simulation tests tools world
echo "Done."
+41 -7
View File
@@ -86,6 +86,8 @@ function Test-GdLint {
return $false
}
$script:GdToolExitCode = 0
function Invoke-GdFormat {
$args = $args
$localTool = "$ROOT/.venv/Scripts/gdformat.exe"
@@ -96,6 +98,7 @@ function Invoke-GdFormat {
} else {
& python -m gdtoolkit.formatter @args 2>&1
}
$script:GdToolExitCode = $LASTEXITCODE
}
function Invoke-GdLint {
@@ -108,11 +111,13 @@ function Invoke-GdLint {
} else {
& python -m gdtoolkit.linter @args 2>&1
}
$script:GdToolExitCode = $LASTEXITCODE
}
function Get-ChangedGd {
$f = git diff --name-only HEAD -- '*.gd' 2>$null
return @($f | Where-Object { $_ -ne '' })
$changed = @(git diff --name-only HEAD -- '*.gd' 2>$null)
$untracked = @(git ls-files --others --exclude-standard -- '*.gd' 2>$null)
return @((@($changed) + @($untracked)) | Where-Object { $_ -ne '' } | Sort-Object -Unique)
}
function To-ResPath($p) {
@@ -171,8 +176,10 @@ $hasProjectClasses = (Test-Path $classCache) -and
(Select-String -Path $classCache -SimpleMatch '"class": &"SimNPC"' -Quiet)
$hasGutClasses = (Test-Path $classCache) -and
(Select-String -Path $classCache -SimpleMatch '"class": &"GutTest"' -Quiet)
$changedGd = @(Get-ChangedGd)
$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) {
$importArgs = "--headless --path `"$ROOT`" --import"
$importOutput, $importExit = Invoke-GodotWithTimeout -ArgumentString $importArgs
@@ -200,6 +207,7 @@ if ($importResult -eq "FAIL") {
# -- 1. gdformat --------------------------------------------------------------
$fmtAvail = Test-GdFormat
$fmtResult = "PASS"
$fmtFailed = $false
Set-Content -Path "$LOG/gdformat.log" -Value "" -Encoding utf8
if (-not $fmtAvail) {
$fmtResult = "SKIPPED"
@@ -215,8 +223,9 @@ if (-not $fmtAvail) {
if (-not (Test-Path $f)) { continue }
$total++
$out = Invoke-GdFormat "--check" $f
$formatExit = $script:GdToolExitCode
$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)"
Add-Content -Path "$LOG/gdformat.log" -Value $msg -Encoding utf8
@@ -224,12 +233,15 @@ if (-not $fmtAvail) {
} else {
$formatArgs = @("--check") + $OWNED_GDSCRIPT_ROOTS
$out = Invoke-GdFormat @formatArgs
$formatExit = $script:GdToolExitCode
$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) {
$fmtResult = "FAIL"
if ($fmtFailed) { $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
foreach ($line in $lines) {
$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"
}
$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 ----------------------------------------------------------------
$lintAvail = Test-GdLint
$lintResult = "PASS"
$lintFailed = $false
Set-Content -Path "$LOG/gdlint.log" -Value "" -Encoding utf8
if (-not $lintAvail) {
$lintResult = "SKIPPED"
@@ -255,18 +271,22 @@ if (-not $lintAvail) {
foreach ($f in $files) {
if (-not (Test-Path $f)) { continue }
$out = Invoke-GdLint $f
$lintExit = $script:GdToolExitCode
$out | Add-Content -Path "$LOG/gdlint.log" -Encoding utf8
if ($lintExit -ne 0) { $lintFailed = $true }
}
}
} else {
$out = Invoke-GdLint @OWNED_GDSCRIPT_ROOTS
$lintExit = $script:GdToolExitCode
$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
if ($lintLines) {
$lintResult = "FAIL"
foreach ($line in $lintLines) {
$parts = $line -split ':', 4
if ($parts.Count -lt 4) { continue }
@@ -280,6 +300,9 @@ if ($lintLines) {
$rule = $parts[3]; $fname = Split-Path -Leaf $parts[0]
$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 --------------------------------------------------
@@ -300,6 +323,12 @@ if ($godotExit -eq $null) {
}
$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 ------------------------------------------------
$scenarioResult = "PASS"
@@ -315,6 +344,11 @@ foreach ($test in $scenarioTests) {
$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") {
$FIXES += "Fix failing project scenario tests"
}
+107 -60
View File
@@ -53,22 +53,43 @@ find_godot() {
return 1
}
find_gdformat() {
if [[ -x "$ROOT/.venv/bin/gdformat" ]]; then echo "$ROOT/.venv/bin/gdformat"; return 0; fi
if command -v gdformat &>/dev/null; then echo "gdformat"; return 0; fi
if python -m gdtoolkit.formatter --help &>/dev/null 2>&1; then echo "python -m gdtoolkit.formatter"; return 0; fi
return 1
has_gdformat() {
[[ -x "$ROOT/.venv/bin/gdformat" ]] && return 0
command -v gdformat &>/dev/null && return 0
python -m gdtoolkit.formatter --help &>/dev/null 2>&1
}
find_gdlint() {
if [[ -x "$ROOT/.venv/bin/gdlint" ]]; then echo "$ROOT/.venv/bin/gdlint"; return 0; fi
if command -v gdlint &>/dev/null; then echo "gdlint"; return 0; fi
if python -m gdtoolkit.linter --help &>/dev/null 2>&1; then echo "python -m gdtoolkit.linter"; return 0; fi
return 1
run_gdformat() {
if [[ -x "$ROOT/.venv/bin/gdformat" ]]; then
"$ROOT/.venv/bin/gdformat" "$@"
elif command -v gdformat &>/dev/null; then
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() {
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() {
@@ -83,8 +104,38 @@ contains_element() {
local e; for e in "${@:2}"; do [[ "$e" == "$1" ]] && return 0; done; return 1
}
need_timeout() {
command -v timeout &>/dev/null
run_with_timeout() {
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 --------------------------------------------------------------------
@@ -104,16 +155,15 @@ needs_import=false
if [[ -f "addons/gut/gut_cmdln.gd" ]] && ! grep -q '"class": &"GutTest"' "$class_cache" 2>/dev/null; then
needs_import=true
fi
[[ -n "$(changed_gd)" ]] && needs_import=true
if $needs_import; then
if need_timeout; then
timeout 60 "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --import >> "$LOG/godot-import.log" 2>&1
import_exit=$?
else
"${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --import >> "$LOG/godot-import.log" 2>&1
import_exit=$?
fi
run_with_timeout 60 "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --import >> "$LOG/godot-import.log" 2>&1
import_exit=$?
if [[ $import_exit -ne 0 ]]; then
import_result="FAIL"
if [[ $import_exit -eq 124 ]]; then
echo "[timed out after 60s]" >> "$LOG/godot-import.log"
fi
fi
fi
if [[ ! -f "$class_cache" ]] || ! grep -q '"class": &"SimNPC"' "$class_cache"; then
@@ -131,10 +181,10 @@ if [[ "$import_result" == "FAIL" ]]; then
fi
# -- 1. gdformat --------------------------------------------------------------
fmt_tool=$(find_gdformat) || true
fmt_result="PASS"
fmt_failed=false
: > "$LOG/gdformat.log"
if [[ -z "$fmt_tool" ]]; then
if ! has_gdformat; then
fmt_result="SKIPPED"
echo "gdformat not found — install requirements-dev.txt (see docs/local_quality_gate.md)" > "$LOG/gdformat.log"
else
@@ -147,32 +197,40 @@ else
while IFS= read -r f; do
[[ -f "$f" ]] || continue
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))
fmt_failed=true
fi
done <<< "$files"
echo "($bad/$total files need formatting)" >> "$LOG/gdformat.log"
fi
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
if grep -q "would reformat" "$LOG/gdformat.log" 2>/dev/null; then
if $fmt_failed; then
fmt_result="FAIL"
fi
if grep -q "would reformat" "$LOG/gdformat.log" 2>/dev/null; then
while IFS= read -r line; do
line="${line#would reformat }"
rp=$(res_path "$line")
ERRORS+=("$rp needs formatting")
done < <(grep "would reformat" "$LOG/gdformat.log")
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
# -- 2. gdlint ----------------------------------------------------------------
lint_tool=$(find_gdlint) || true
lint_result="PASS"
lint_failed=false
: > "$LOG/gdlint.log"
if [[ -z "$lint_tool" ]]; then
if ! has_gdlint; then
lint_result="SKIPPED"
echo "gdlint not found — install requirements-dev.txt (see docs/local_quality_gate.md)" > "$LOG/gdlint.log"
else
@@ -183,16 +241,22 @@ else
else
while IFS= read -r f; do
[[ -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"
fi
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
if grep -qE "^[^ ]+:[0-9]+:" "$LOG/gdlint.log" 2>/dev/null; then
if $lint_failed; then
lint_result="FAIL"
fi
if grep -qE "^[^ ]+:[0-9]+:" "$LOG/gdlint.log" 2>/dev/null; then
while IFS= read -r line; do
[[ "$line" =~ ^([^:]+):([0-9]+):([0-9]+):[^:]+:(.*) ]] || continue
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]}")
FIXES+=("Fix $rule in $fname")
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
# -- 3. godot headless check --------------------------------------------------
godot_result="PASS"
: > "$LOG/godot-check.log"
if need_timeout; 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"
else
ec=$?
if [[ $ec -eq 124 ]]; then
godot_result="FAIL"
echo "[timed out after 30s — project runs continuously]" >> "$LOG/godot-check.log"
else
godot_result="FAIL"
fi
fi
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
godot_result="PASS"
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"
ec=$?
godot_result="FAIL"
if [[ $ec -eq 124 ]]; then
echo "[timed out after 30s]" >> "$LOG/godot-check.log"
fi
fi
if grep -qE "SCRIPT ERROR:|Parse Error:|Failed to load script" "$LOG/godot-check.log"; then
@@ -248,14 +305,9 @@ scenario_result="PASS"
: > "$LOG/scenarios.log"
for test in tests/*_test.gd; do
echo "[RUN] $(basename "$test")" >> "$LOG/scenarios.log"
if need_timeout; then
if ! timeout 30 "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --script "res://$test" >> "$LOG/scenarios.log" 2>&1; then
scenario_result="FAIL"
ERRORS+=("scenario: $(basename "$test") failed or timed out")
fi
elif ! "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --script "res://$test" >> "$LOG/scenarios.log" 2>&1; then
if ! run_with_timeout 30 "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --script "res://$test" >> "$LOG/scenarios.log" 2>&1; then
scenario_result="FAIL"
ERRORS+=("scenario: $(basename "$test") failed")
ERRORS+=("scenario: $(basename "$test") failed or timed out")
fi
done
if grep -qE "SCRIPT ERROR:|Parse Error:|Failed to load script" "$LOG/scenarios.log"; then
@@ -278,13 +330,8 @@ else
"${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT"
-s addons/gut/gut_cmdln.gd -gexit -gdisable_colors
)
if need_timeout; then
timeout 30 "${gut_command[@]}" >> "$LOG/gut.log" 2>&1
gut_exit=$?
else
"${gut_command[@]}" >> "$LOG/gut.log" 2>&1
gut_exit=$?
fi
run_with_timeout 30 "${gut_command[@]}" >> "$LOG/gut.log" 2>&1
gut_exit=$?
if [[ $gut_exit -eq 0 ]]; then
gut_result="PASS"
else
+4 -2
View File
@@ -34,8 +34,10 @@ func toggle_debug_overlay() -> void:
debug_overlay_visible = not debug_overlay_visible
_apply_debug_overlay_visibility()
print(
"[DemoController] %s"
% ("Debug overlay visible" if debug_overlay_visible else "Cinematic overlay hidden")
(
"[DemoController] %s"
% ("Debug overlay visible" if debug_overlay_visible else "Cinematic overlay hidden")
)
)
+1 -1
View File
@@ -3,7 +3,7 @@
[sub_resource type="ParticleProcessMaterial" id="3"]
emission_shape = 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_max = 0.5
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="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="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="PackedScene" path="res://world/jajce/WindStrokes.tscn" id="19_windstrokes"]
[ext_resource type="PackedScene" path="res://world/jajce/WindSpecks.tscn" id="20_windspecks"]
[ext_resource type="PackedScene" path="res://world/jajce/vfx/WindGustField.tscn" id="17_wind_gust"]
[sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"]
_shader_parameters = {
@@ -593,15 +592,10 @@ light_energy = 1.45
shadow_enabled = true
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")]
position = Vector3(-25, 7, 9)
[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="WindGustField" parent="AtmosphereRoot" instance=ExtResource("17_wind_gust")]
position = Vector3(-2, 0, 0)
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("Environment_greybox")
+10 -5
View File
@@ -6,9 +6,6 @@ enum CanopyVariant {
YELLOW_GREEN,
}
@export var canopy_variant: CanopyVariant = CanopyVariant.GREEN
@export var canopy_radius: float = 1.35
const COLORS := {
CanopyVariant.GREEN: Color(0.19, 0.38, 0.17),
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_STRENGTH := 0.07
const WIND_SPEED := 0.55
func _ready() -> void:
@@ -25,8 +24,9 @@ func _ready() -> void:
var hash_seed := hash(global_position)
var rng := RandomNumberGenerator.new()
rng.seed = hash_seed
canopy_variant = rng.randi() % COLORS.size() as CanopyVariant
canopy_radius = 1.0 + rng.randf() * 0.8
var canopy_variant := rng.randi() % COLORS.size() as CanopyVariant
var canopy_radius := 1.0 + rng.randf() * 0.8
var wind_phase := rng.randf_range(0.0, TAU)
var trunk_mesh := CylinderMesh.new()
trunk_mesh.top_radius = 0.22
@@ -39,6 +39,7 @@ func _ready() -> void:
trunk_mesh.material = trunk_mat
var trunk := MeshInstance3D.new()
trunk.name = "Trunk"
trunk.mesh = trunk_mesh
trunk.position = Vector3(0, 1.4, 0)
add_child(trunk)
@@ -51,9 +52,13 @@ func _ready() -> void:
canopy_mat.shader = WIND_SHADER
canopy_mat.set_shader_parameter("albedo", COLORS[canopy_variant])
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
var canopy := MeshInstance3D.new()
canopy.name = "Canopy"
canopy.mesh = canopy_mesh
canopy.position = Vector3(0, 3.2 + (canopy_radius - 1.35) * 0.5, 0)
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:
_simulation_node = get_tree().get_first_node_in_group("simulation_manager")
_find_simulation_node()
if _simulation_node == null:
elapsed = cycle_duration_seconds * initial_cycle
_apply_light_rotation(initial_cycle)
@@ -48,8 +48,10 @@ func _ready() -> void:
func _process(_delta: float) -> void:
if _simulation_node == null or not is_instance_valid(_simulation_node):
_find_simulation_node()
var cycle: float
if _simulation_node != null and "clock" in _simulation_node:
if _simulation_node != null and "clock" in _simulation_node and _simulation_node.clock != null:
cycle = _simulation_node.clock.time_of_day()
else:
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_energy = lerpf(NIGHT_LIGHT_ENERGY, DAY_LIGHT_ENERGY, day_factor)
var sunset_peak := _sunrise_sunset_weight(cycle)
if sunset_peak > 0.0:
var sunset_color: Color
var transition_peak := _sunrise_sunset_weight(cycle)
if transition_peak > 0.0:
var transition_color: Color
if cycle < 0.5:
sunset_color = SUNSET_LIGHT_COLOR
transition_color = SUNRISE_LIGHT_COLOR
else:
sunset_color = SUNRISE_LIGHT_COLOR
transition_color = SUNSET_LIGHT_COLOR
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, SUNRISE_SUNSET_ENERGY * sunset_peak
directional_light.light_energy, SUNRISE_SUNSET_ENERGY * transition_peak
)
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)
func _find_simulation_node() -> void:
_simulation_node = get_tree().get_first_node_in_group("simulation_manager")
func _day_factor(cycle: float) -> float:
if cycle < 0.15:
return 0.0
+16 -6
View File
@@ -1,8 +1,13 @@
shader_type spatial;
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 float wind_speed : hint_range(0.0, 5.0) = 0.6;
uniform vec2 wind_direction = vec2(0.94, 0.34);
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 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);
height_factor = height_factor * height_factor;
float sway_x = sin(VERTEX.x * 0.3 + TIME * wind_speed) * wind_strength * height_factor;
float sway_z = cos(VERTEX.z * 0.3 + TIME * wind_speed * 0.7) * wind_strength * height_factor;
vec2 direction = normalize(wind_direction);
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.z += sway_z;
VERTEX.xz += direction * bend * height_factor;
VERTEX.xz += crosswind * flutter * height_factor;
}
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_x := (SIZE_DIAL - text_size.x) / 2.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:
+91 -41
View File
@@ -27,6 +27,10 @@ func _ready() -> void:
simulation_manager.state_restored.connect(_on_state_restored)
if simulation_manager.has_signal("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:
_on_village_changed(simulation_manager.village)
@@ -119,6 +123,16 @@ func _on_speed_changed(_multiplier: float) -> void:
_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:
if not debug_overlay_visible:
return
@@ -147,44 +161,58 @@ func _refresh_npc_inspector() -> void:
for action_id in score_keys:
if decision.rejections.has(action_id):
rejection_rows.append(
"%s%s"
% [_get_action_name(StringName(action_id)), String(decision.rejections[action_id])]
(
"%s%s"
% [
_get_action_name(StringName(action_id)),
String(decision.rejections[action_id])
]
)
)
continue
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():
score_text = "\n\nUtility\n" + "\n".join(score_rows)
if not rejection_rows.is_empty():
score_text += "\n\nUnavailable\n" + "\n".join(rejection_rows)
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 = (
"%s · %s\n"
+ "Task: %s (%s)\n"
+ "Destination: %s\n"
+ "Hunger: %.0f Energy: %.0f\n"
+ "Carrying food: %.0f wood: %.0f\n"
+ "%s\n"
+ "Why\n%s%s\n\n"
+ "Recent\n%s\n\n"
+ "[Tab] Next villager [F10] Cinematic/debug [F12] Demo reset"
) % [
npc.npc_name,
profession_name,
action_name,
npc.task_state,
destination_name,
npc.hunger,
npc.energy,
npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD),
npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD),
housemate_text,
reason_text,
score_text,
event_text
]
(
"%s · %s\n"
+ "Task: %s (%s)\n"
+ "Destination: %s\n"
+ "Hunger: %.0f Energy: %.0f\n"
+ "Carrying food: %.0f wood: %.0f\n"
+ "%s\n"
+ "%s\n"
+ "Why\n%s%s\n\n"
+ "Recent\n%s\n\n"
+ "[Tab] Next villager [F10] Cinematic/debug [F12] Demo reset"
)
% [
npc.npc_name,
profession_name,
action_name,
npc.task_state,
destination_name,
npc.hunger,
npc.energy,
npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD),
npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD),
relationship_text,
known_fact_text,
reason_text,
score_text,
event_text
]
)
func _get_action_name(action_id: StringName) -> String:
@@ -220,22 +248,44 @@ func _build_event_history(npc: SimNPC) -> String:
return "\n".join(lines)
func _build_housemate_display(npc: SimNPC) -> String:
if npc.familiarity.is_empty():
func _build_relationship_display(npc: SimNPC) -> String:
if not simulation_manager.has_method("get_primary_relationship"):
return ""
var highest_id: int = -1
var highest_score := -1.0
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:
var relationship: RelationshipStateRecord = simulation_manager.get_primary_relationship(npc.id)
if relationship == null:
return ""
var other_name := "Someone"
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
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)