feat: add witnessed knowledge and wind ambience
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 v1–v2 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 v1–v4 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
|
||||
|
||||
@@ -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 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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 [
|
||||
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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bu752bd0occgc
|
||||
@@ -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
|
||||
|
||||
|
||||
+132
-46
@@ -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,9 +266,11 @@ func _complete_resource_gather(npc: SimNPC, completed_task: StringName) -> void:
|
||||
if resource_state == null or resource_state.get_reserved_by() != npc.id:
|
||||
if debug_logs:
|
||||
print(
|
||||
(
|
||||
"[SimulationManager] %s could not complete %s: invalid reservation"
|
||||
% [npc.npc_name, completed_task]
|
||||
)
|
||||
)
|
||||
release_npc_reservation(npc.id)
|
||||
return
|
||||
|
||||
@@ -289,9 +293,11 @@ 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()]
|
||||
)
|
||||
)
|
||||
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
|
||||
mourner.mourning_ticks = 100
|
||||
if debug_logs:
|
||||
print(
|
||||
"[SimulationManager] ",
|
||||
npc.npc_name,
|
||||
" is mourning ",
|
||||
dead_npc.npc_name
|
||||
)
|
||||
return
|
||||
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,9 +644,11 @@ 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
|
||||
)
|
||||
)
|
||||
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"]
|
||||
|
||||
@@ -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)" % [
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
and (
|
||||
definition.completion_cost_resource_id
|
||||
not in [SimulationIds.RESOURCE_FOOD, SimulationIds.RESOURCE_WOOD]
|
||||
)
|
||||
):
|
||||
errors.append(
|
||||
(
|
||||
|
||||
@@ -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)" % [
|
||||
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,9 +195,11 @@ 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]
|
||||
)
|
||||
)
|
||||
return true
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
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
|
||||
@@ -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()
|
||||
)
|
||||
]
|
||||
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"])
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
)
|
||||
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]
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
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,6 +244,7 @@ func _run() -> void:
|
||||
ids.append(String((resource as ResourceNode).node_id))
|
||||
ids.sort()
|
||||
_check(
|
||||
(
|
||||
ids
|
||||
== [
|
||||
"animal_camp_01",
|
||||
@@ -151,7 +265,8 @@ func _run() -> void:
|
||||
"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]
|
||||
)
|
||||
)
|
||||
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]
|
||||
)
|
||||
|
||||
+25
-24
@@ -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(
|
||||
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),
|
||||
(
|
||||
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(
|
||||
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()
|
||||
|
||||
@@ -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,
|
||||
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,
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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,9 +56,11 @@ 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()]
|
||||
)
|
||||
)
|
||||
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
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
)
|
||||
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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
+102
-55
@@ -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
|
||||
run_with_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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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,15 +305,10 @@ 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
|
||||
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 or timed out")
|
||||
fi
|
||||
elif ! "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --script "res://$test" >> "$LOG/scenarios.log" 2>&1; then
|
||||
scenario_result="FAIL"
|
||||
ERRORS+=("scenario: $(basename "$test") failed")
|
||||
fi
|
||||
done
|
||||
if grep -qE "SCRIPT ERROR:|Parse Error:|Failed to load script" "$LOG/scenarios.log"; then
|
||||
scenario_result="FAIL"
|
||||
@@ -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
|
||||
run_with_timeout 30 "${gut_command[@]}" >> "$LOG/gut.log" 2>&1
|
||||
gut_exit=$?
|
||||
else
|
||||
"${gut_command[@]}" >> "$LOG/gut.log" 2>&1
|
||||
gut_exit=$?
|
||||
fi
|
||||
if [[ $gut_exit -eq 0 ]]; then
|
||||
gut_result="PASS"
|
||||
else
|
||||
|
||||
@@ -34,9 +34,11 @@ 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")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
func reset_demo_scene() -> void:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bceyn2pnyjfo7
|
||||
@@ -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")
|
||||
@@ -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:
|
||||
|
||||
+68
-18
@@ -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,30 +161,42 @@ 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])]
|
||||
% [
|
||||
_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"
|
||||
+ "%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,
|
||||
@@ -180,11 +206,13 @@ func _refresh_npc_inspector() -> void:
|
||||
npc.energy,
|
||||
npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD),
|
||||
npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD),
|
||||
housemate_text,
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user