feat: add witnessed knowledge and wind ambience
This commit is contained in:
@@ -21,6 +21,9 @@ SimulationManager tick
|
|||||||
### ActionSelectionSystem
|
### ActionSelectionSystem
|
||||||
|
|
||||||
- reads NPC and village state;
|
- reads NPC and village state;
|
||||||
|
- queries `RelationshipSystem` after personal survival/schedule overrides, so a
|
||||||
|
trusted starving acquaintance can redirect ordinary work toward food
|
||||||
|
gathering while the pantry is low;
|
||||||
- evaluates current utility scores;
|
- evaluates current utility scores;
|
||||||
- consumes the NPC's deterministic decision RNG;
|
- consumes the NPC's deterministic decision RNG;
|
||||||
- returns an action ID, optional urgent-duration override, branch reason,
|
- returns an action ID, optional urgent-duration override, branch reason,
|
||||||
@@ -67,7 +70,10 @@ targets, and translates presentation callbacks into simulation transitions.
|
|||||||
It also publishes the latest `ActionSelectionResult` for presentation; the UI
|
It also publishes the latest `ActionSelectionResult` for presentation; the UI
|
||||||
does not recompute decisions. At completion it atomically pays any
|
does not recompute decisions. At completion it atomically pays any
|
||||||
definition-backed stored-resource cost before applying the action effect. A
|
definition-backed stored-resource cost before applying the action effect. A
|
||||||
late shortfall suppresses the effect and records a `task_blocked` fact.
|
late shortfall suppresses the effect and records a `task_blocked` fact. The
|
||||||
|
manager also captures actor/nearby knowledge before forwarding newly recorded
|
||||||
|
events into `RelationshipSystem`. It exposes knowledge, relationship, and cause
|
||||||
|
queries without moving social authority into UI.
|
||||||
|
|
||||||
### VillageEconomy
|
### VillageEconomy
|
||||||
|
|
||||||
@@ -82,6 +88,27 @@ late shortfall suppresses the effect and records a `task_blocked` fact.
|
|||||||
- answers recent-history, actor-history, and consumption-rate queries;
|
- answers recent-history, actor-history, and consumption-rate queries;
|
||||||
- restores persisted history without performing or replaying transactions.
|
- restores persisted history without performing or replaying transactions.
|
||||||
|
|
||||||
|
### EventKnowledgeSystem
|
||||||
|
|
||||||
|
- stores stable `(knower_id, event_id)` references separately from objective
|
||||||
|
history;
|
||||||
|
- initially observes successful food-deposit events for the actor and living
|
||||||
|
NPCs within a fixed proximity radius;
|
||||||
|
- captures evidence only when the event happens, never from current positions
|
||||||
|
during restore;
|
||||||
|
- answers deterministic known-event and knower queries without formatting
|
||||||
|
prose or choosing actions.
|
||||||
|
|
||||||
|
### RelationshipSystem
|
||||||
|
|
||||||
|
- owns directed familiarity/trust records and stable relationship queries;
|
||||||
|
- applies a bounded trust gain when a hungry familiar NPC knows about a
|
||||||
|
contributor's successful food-deposit event;
|
||||||
|
- stores the exact event ID as the trust cause and ignores replayed or older
|
||||||
|
events;
|
||||||
|
- selects trusted starving subjects deterministically for action selection;
|
||||||
|
- does not choose actions, perform transactions, or format presentation text.
|
||||||
|
|
||||||
These collaborators are `RefCounted` rule services, not additional scene-tree
|
These collaborators are `RefCounted` rule services, not additional scene-tree
|
||||||
managers. Further decomposition should follow measured pressure and a proven
|
managers. Further decomposition should follow measured pressure and a proven
|
||||||
gameplay consumer.
|
gameplay consumer.
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ SimulationClock
|
|||||||
-> ActionTargetResolver resolves a stable target ID
|
-> ActionTargetResolver resolves a stable target ID
|
||||||
-> VillageEconomy performs inventory/storage transactions
|
-> VillageEconomy performs inventory/storage transactions
|
||||||
-> SimulationEventLog records completed facts
|
-> SimulationEventLog records completed facts
|
||||||
|
-> EventKnowledgeSystem records actor/nearby knowledge
|
||||||
|
-> RelationshipSystem applies evidence-gated social consequences
|
||||||
-> WorldViewManager presents travel and NPC state
|
-> WorldViewManager presents travel and NPC state
|
||||||
-> ActiveWorldAdapter supplies loaded-world positions/capacity
|
-> ActiveWorldAdapter supplies loaded-world positions/capacity
|
||||||
-> NpcVisual performs local navigation and animation
|
-> NpcVisual performs local navigation and animation
|
||||||
@@ -30,6 +32,10 @@ would otherwise obscure that lifecycle:
|
|||||||
and keeps village resource summaries synchronized;
|
and keeps village resource summaries synchronized;
|
||||||
- `simulation/events/SimulationEventLog.gd` owns ordered event identity,
|
- `simulation/events/SimulationEventLog.gd` owns ordered event identity,
|
||||||
history queries, and rate calculations;
|
history queries, and rate calculations;
|
||||||
|
- `simulation/knowledge/EventKnowledgeSystem.gd` owns per-NPC references to
|
||||||
|
known objective events and captures proximity witnesses at record time;
|
||||||
|
- `simulation/relationships/RelationshipSystem.gd` owns directed relationship
|
||||||
|
queries, event-driven trust changes, and deterministic social tie-breaking;
|
||||||
- `simulation/persistence/` owns save-slot file safety;
|
- `simulation/persistence/` owns save-slot file safety;
|
||||||
- `simulation/state/` owns versioned serialized record contracts;
|
- `simulation/state/` owns versioned serialized record contracts;
|
||||||
- `simulation/definitions/` owns stable IDs and immutable action/profession
|
- `simulation/definitions/` owns stable IDs and immutable action/profession
|
||||||
@@ -48,6 +54,8 @@ hard to read.
|
|||||||
| `simulation/actions/` | Action decisions, execution, and target queries |
|
| `simulation/actions/` | Action decisions, execution, and target queries |
|
||||||
| `simulation/economy/` | Authoritative inventory and storage transactions |
|
| `simulation/economy/` | Authoritative inventory and storage transactions |
|
||||||
| `simulation/events/` | Immutable event history and derived event queries |
|
| `simulation/events/` | Immutable event history and derived event queries |
|
||||||
|
| `simulation/knowledge/` | Per-NPC knowledge of objective event IDs |
|
||||||
|
| `simulation/relationships/` | Directed social consequences and relationship queries |
|
||||||
| `simulation/state/` | Versioned, serializable mutable records |
|
| `simulation/state/` | Versioned, serializable mutable records |
|
||||||
| `simulation/definitions/` | Stable IDs and immutable gameplay definitions |
|
| `simulation/definitions/` | Stable IDs and immutable gameplay definitions |
|
||||||
| `simulation/persistence/` | Validated local save-file storage |
|
| `simulation/persistence/` | Validated local save-file storage |
|
||||||
@@ -69,11 +77,13 @@ improving ownership.
|
|||||||
- Persistent references are stable IDs, never `Node`, `NodePath`, or scene
|
- Persistent references are stable IDs, never `Node`, `NodePath`, or scene
|
||||||
ownership.
|
ownership.
|
||||||
- Presentation may report facts and submit commands; it does not choose NPC
|
- Presentation may report facts and submit commands; it does not choose NPC
|
||||||
actions or own resource, storage, inventory, event, or reservation state.
|
actions or own resource, storage, inventory, event, knowledge, relationship,
|
||||||
|
or reservation state.
|
||||||
- Resource changes go through `ResourceStateRecord`, NPC inventory, and
|
- Resource changes go through `ResourceStateRecord`, NPC inventory, and
|
||||||
`VillageEconomy`; `village.food` and `village.wood` are synchronized views.
|
`VillageEconomy`; `village.food` and `village.wood` are synchronized views.
|
||||||
- New mutable features define serialization and deterministic continuation at
|
- New mutable features define serialization and deterministic continuation at
|
||||||
the same time as their first gameplay use.
|
the same time as their first gameplay use. Cross-record causes use stable
|
||||||
|
event IDs rather than object references or prose.
|
||||||
- Prefer one tested vertical behavior over a generic framework with no proven
|
- Prefer one tested vertical behavior over a generic framework with no proven
|
||||||
consumers.
|
consumers.
|
||||||
|
|
||||||
|
|||||||
@@ -667,14 +667,34 @@ Completed:
|
|||||||
logs. Waterfall mist is anchored to the waterfall instead of the village
|
logs. Waterfall mist is anchored to the waterfall instead of the village
|
||||||
origin, duplicate wind layers are separated, and Metal smoke/wind
|
origin, duplicate wind layers are separated, and Metal smoke/wind
|
||||||
materials retain restrained transparency.
|
materials retain restrained transparency.
|
||||||
|
17. Coherent breeze pass: overlapping valley-wide white stroke/speck emitters
|
||||||
|
were removed. Tree canopies now bend gently in one prevailing direction
|
||||||
|
with stable per-tree phase and slow gust variation, while chimney smoke
|
||||||
|
leans with the same breeze. Day/night lighting now follows the simulation
|
||||||
|
clock even when the world initializes first, with the sunrise/sunset tints
|
||||||
|
applied in the correct order. The gameplay camera remains unchanged.
|
||||||
|
18. Stylized player readability: the remaining gray player capsule is replaced
|
||||||
|
by a compact multi-part figure with a warm scarf/satchel accent and
|
||||||
|
velocity-driven walk motion. Collision, controls, and the elevated
|
||||||
|
staggered follow camera remain unchanged.
|
||||||
|
19. Event-caused trust consequence: hungry familiar NPCs can gain directed
|
||||||
|
trust from a real food-deposit event, retain that event as the inspectable
|
||||||
|
cause, and redirect ordinary work to help a starving acquaintance. The
|
||||||
|
state is versioned and deterministic rather than presentation-only flavor.
|
||||||
|
20. Calligraphic gust ambience: one terrain-aware controller now emits only
|
||||||
|
two or three tapered sage/amber/aqua ribbons at a time, with long quiet
|
||||||
|
intervals and a shared canopy/smoke direction. The shader draws each line
|
||||||
|
head-to-tail like a soft brush mark; repeated bursts cannot stack into the
|
||||||
|
old scratch/gnat field.
|
||||||
|
21. Witnessed event knowledge: objective food-deposit events create separate
|
||||||
|
known-event references for their actor and nearby living NPCs. Knowledge
|
||||||
|
gates trust, produces a real informed-versus-uninformed choice divergence,
|
||||||
|
survives schema-v5 save/load, and appears in the NPC inspector.
|
||||||
|
|
||||||
Next:
|
Next:
|
||||||
|
|
||||||
1. Replace the remaining player capsule with a matching stylized presentation,
|
1. Add one provenance-aware NPC-to-NPC fact transfer, then bounded
|
||||||
while preserving the elevated top-down follow, gentle dead-zone lag, and
|
importance/retention rules, as sequenced in `LEARNING_ROADMAP.md`.
|
||||||
cozy farm-builder readability.
|
|
||||||
2. Continue with the relationship consequence work in `LEARNING_ROADMAP.md`
|
|
||||||
before expanding visual scope further.
|
|
||||||
|
|
||||||
Do not start with GIS data, a full city, a large asset pack, or more NPC
|
Do not start with GIS data, a full city, a large asset pack, or more NPC
|
||||||
mechanics. The next proof is a beautiful stage for the systems that already
|
mechanics. The next proof is a beautiful stage for the systems that already
|
||||||
|
|||||||
+31
-13
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
## Current contract
|
## Current contract
|
||||||
|
|
||||||
Every successful food movement creates one `EconomicEventRecord`:
|
Every successful food or wood movement creates an `EconomicEventRecord` using
|
||||||
|
the applicable event type:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
resource_extracted
|
resource_extracted
|
||||||
@@ -13,17 +14,21 @@ item_consumed
|
|||||||
|
|
||||||
Each record contains a monotonically increasing event ID, event type,
|
Each record contains a monotonically increasing event ID, event type,
|
||||||
simulation tick, actor ID, source ID, destination ID, item ID, and transferred
|
simulation tick, actor ID, source ID, destination ID, item ID, and transferred
|
||||||
amount. Actor `-1` identifies a player-triggered extraction until persistent
|
amount, plus the authoritative world position captured when the fact is
|
||||||
player identity is introduced.
|
recorded. NPC events preserve their authoritative interaction target (or NPC
|
||||||
|
position when no target applies); player extraction/depletion events preserve
|
||||||
|
the ResourceNode interaction position. Actor `-1` identifies a player-triggered
|
||||||
|
extraction until persistent player identity is introduced.
|
||||||
|
|
||||||
Events are immutable facts about completed transfers. They do not perform the
|
Events are immutable facts about completed transfers. They do not perform the
|
||||||
transaction and are not replayed to reconstruct current state. Resource,
|
transaction and are not replayed to reconstruct current state. Resource,
|
||||||
inventory, and storage records remain authoritative.
|
inventory, and storage records remain authoritative.
|
||||||
|
|
||||||
`SimulationEventLog` owns ordered event identity, append/restore behavior, and
|
`SimulationEventLog` owns ordered event identity, append/restore behavior, and
|
||||||
history/rate queries. `VillageEconomy` performs transactions and requests event
|
history/rate queries, including exact lookup through `get_by_id()`.
|
||||||
records only after state changes succeed; `SimulationManager` remains the
|
`VillageEconomy` performs transactions and requests event records only after
|
||||||
public signal boundary used by presentation.
|
state changes succeed; `SimulationManager` remains the public signal boundary
|
||||||
|
used by presentation.
|
||||||
|
|
||||||
An action whose definition-backed completion cost becomes unavailable records
|
An action whose definition-backed completion cost becomes unavailable records
|
||||||
a zero-amount `task_blocked` narrative fact with the action and shortfall
|
a zero-amount `task_blocked` narrative fact with the action and shortfall
|
||||||
@@ -32,10 +37,21 @@ transfer occurred.
|
|||||||
|
|
||||||
## Persistence and determinism
|
## Persistence and determinism
|
||||||
|
|
||||||
`SimulationStateRecord` schema v3 stores the ordered event stream and
|
`SimulationStateRecord` schema v5 stores the ordered event stream,
|
||||||
`next_event_id`. Schema v1 and v2 saves migrate to an empty stream beginning at
|
`next_event_id`, directed relationships that may reference an exact event, and
|
||||||
ID zero. Parsing rejects duplicate event IDs and a next ID that could collide
|
per-NPC known-event references. Schema v1 and v2 saves migrate to an empty
|
||||||
with restored history.
|
stream beginning at ID zero. Parsing rejects duplicate event IDs, invalid or
|
||||||
|
duplicate knowledge references, relationship causes the observer does not
|
||||||
|
know, and a next ID that could collide with restored history.
|
||||||
|
|
||||||
|
A successful food deposit can currently raise a hungry familiar NPC's directed
|
||||||
|
trust in its contributor only when that NPC knows the event. The actor and
|
||||||
|
living NPCs within the bounded witness radius receive a `KnownEventStateRecord`
|
||||||
|
at record time. Witness distance uses the event's captured position, never the
|
||||||
|
actor's later location. The relationship stores the same deposit event ID
|
||||||
|
rather than copied prose, so the inspector can resolve and display the real
|
||||||
|
completed fact. This is a first evidence-gated causal consumer of the event
|
||||||
|
stream, not general event sourcing.
|
||||||
|
|
||||||
The food-loop regression verifies this chain:
|
The food-loop regression verifies this chain:
|
||||||
|
|
||||||
@@ -57,6 +73,8 @@ NPC record, so unloading presentation does not lose the fact.
|
|||||||
|
|
||||||
The stream is currently kept in full for the small simulation garden. Before
|
The stream is currently kept in full for the small simulation garden. Before
|
||||||
large populations or long-running worlds, add measured retention,
|
large populations or long-running worlds, add measured retention,
|
||||||
archival/summary rules, and query indexes. General denied attempts, witnesses,
|
archival/summary rules, and query indexes. Proximity is the only current witness
|
||||||
secrecy, causal links, and memories belong in later event/history slices; they
|
rule; line of sight, hearing, acquisition provenance, communication, secrecy,
|
||||||
should extend this record family without making prose authoritative.
|
false beliefs, multi-event causal graphs, and memory retention belong in later
|
||||||
|
event/history slices. They should extend this record family without making
|
||||||
|
prose authoritative or recomputing old evidence from current positions.
|
||||||
|
|||||||
@@ -630,8 +630,6 @@ Completed foundations:
|
|||||||
- 512 m Jajce Terrain3D seed, greybox landmarks, stable-ID resource placement,
|
- 512 m Jajce Terrain3D seed, greybox landmarks, stable-ID resource placement,
|
||||||
lookdev scene, and tested temporary navigation loop.
|
lookdev scene, and tested temporary navigation loop.
|
||||||
|
|
||||||
The practical next sequence is:
|
|
||||||
|
|
||||||
Completed after the architecture gate:
|
Completed after the architecture gate:
|
||||||
|
|
||||||
- location-based pantry state, NPC-carried food, and explicit
|
- location-based pantry state, NPC-carried food, and explicit
|
||||||
@@ -649,13 +647,37 @@ Completed after the architecture gate:
|
|||||||
- definition-driven profession colors/props and a selected-NPC inspector fed
|
- definition-driven profession colors/props and a selected-NPC inspector fed
|
||||||
by real decision branches, destinations, needs, and utility scores.
|
by real decision branches, destinations, needs, and utility scores.
|
||||||
|
|
||||||
The practical next sequence is:
|
The first relationship consequence slice is complete:
|
||||||
|
|
||||||
1. Grow the existing familiarity seed into one consequence-bearing relationship
|
- directed familiarity/trust records now live in a top-level relationship
|
||||||
dimension, such as trust or obligation, driven by structured events rather
|
graph instead of NPC-local dictionaries;
|
||||||
than proximity alone.
|
- a successful known food-deposit event raises a hungry familiar NPC's trust
|
||||||
2. Use that dimension in one real choice or response, then expose the cause in
|
and is retained as the exact causal event ID;
|
||||||
the NPC inspector before expanding the relationship graph.
|
- that trust can redirect ordinary patrol work toward replenishing a low pantry
|
||||||
|
for a starving acquaintance;
|
||||||
|
- the inspector displays the relationship values and completed event cause;
|
||||||
|
- schema migration, save/restore checksums, idempotence, and a neutral-control
|
||||||
|
decision test cover the complete slice.
|
||||||
|
|
||||||
|
The first witnessed-knowledge slice is also complete:
|
||||||
|
|
||||||
|
- `KnownEventStateRecord` keeps per-NPC event references separate from
|
||||||
|
objective event history;
|
||||||
|
- successful food deposits are known by their actor and nearby living NPCs at
|
||||||
|
event-record time, while distant NPCs remain uninformed;
|
||||||
|
- two equally familiar guards with different evidence now gain different trust
|
||||||
|
and choose helping versus ordinary patrol;
|
||||||
|
- the inspector resolves and displays the selected NPC's latest known fact;
|
||||||
|
- schema v5 migration, invalid-reference rejection, deterministic save/load,
|
||||||
|
and proximity/idempotence tests cover the complete slice.
|
||||||
|
|
||||||
|
This proves the first Milestone 6 evidence-to-choice exit path without claiming
|
||||||
|
full memory or belief simulation. The practical next sequence is:
|
||||||
|
|
||||||
|
1. Add acquisition provenance and one bounded NPC-to-NPC fact transfer so
|
||||||
|
information can travel beyond direct witnesses without replaying events.
|
||||||
|
2. Add importance/retention rules for known facts before expanding into
|
||||||
|
distortion, secrecy, rumours, or opportunity generation.
|
||||||
|
|
||||||
Recently completed:
|
Recently completed:
|
||||||
|
|
||||||
|
|||||||
+69
-28
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
> Agent-facing context for understanding the project quickly.
|
> Agent-facing context for understanding the project quickly.
|
||||||
>
|
>
|
||||||
> Snapshot basis: repository state after commit `556d0fd`, July 2026. Treat the
|
> Snapshot basis: repository state on July 11, 2026. Treat the code as the
|
||||||
> code as the source of truth when this document and the implementation differ.
|
> source of truth when this document and the implementation differ.
|
||||||
|
|
||||||
See the [documentation map](README.md) for the authority and scope of each plan.
|
See the [documentation map](README.md) for the authority and scope of each plan.
|
||||||
|
|
||||||
@@ -189,7 +189,8 @@ study, and patrol target typed activity sites. The migration is documented in
|
|||||||
|
|
||||||
- **Engine:** Godot 4.7 project configuration
|
- **Engine:** Godot 4.7 project configuration
|
||||||
- **Renderer feature:** Forward Plus
|
- **Renderer feature:** Forward Plus
|
||||||
- **Language:** GDScript (`odig` analysis with warnings treated as errors)
|
- **Language:** GDScript (`gdformat`, `gdlint`, headless Godot 4.7 scenarios,
|
||||||
|
and GUT run through the local quality gate)
|
||||||
- **Main scene:** `res://main.tscn`
|
- **Main scene:** `res://main.tscn`
|
||||||
- **Terrain:** Terrain3D 1.0.2 is installed and enabled
|
- **Terrain:** Terrain3D 1.0.2 is installed and enabled
|
||||||
- **Jajce runtime:** reusable 512 m Terrain3D seed, greybox landmarks, stable
|
- **Jajce runtime:** reusable 512 m Terrain3D seed, greybox landmarks, stable
|
||||||
@@ -215,8 +216,8 @@ plugin content, not game architecture.
|
|||||||
### World and player
|
### World and player
|
||||||
|
|
||||||
- `main.tscn` instances the reusable Jajce Terrain3D world.
|
- `main.tscn` instances the reusable Jajce Terrain3D world.
|
||||||
- The player is a `CharacterBody3D` represented by placeholder primitive
|
- The player is a `CharacterBody3D` with a compact multi-part stylized visual,
|
||||||
geometry.
|
velocity-driven walk motion, and unchanged primitive collision.
|
||||||
- WASD movement is camera-relative.
|
- WASD movement is camera-relative.
|
||||||
- The elevated third-person camera rotates with the mouse, uses smoothed
|
- The elevated third-person camera rotates with the mouse, uses smoothed
|
||||||
follow/focus behavior, and exposes an opt-in presentation preset for
|
follow/focus behavior, and exposes an opt-in presentation preset for
|
||||||
@@ -254,6 +255,8 @@ Each simulated NPC currently stores:
|
|||||||
- current task and task state;
|
- current task and task state;
|
||||||
- task duration and progress;
|
- task duration and progress;
|
||||||
- simulated position;
|
- simulated position;
|
||||||
|
- home position and carried inventory;
|
||||||
|
- last action and mourning duration;
|
||||||
- starvation state and duration;
|
- starvation state and duration;
|
||||||
- death state.
|
- death state.
|
||||||
|
|
||||||
@@ -280,9 +283,11 @@ NPCs currently choose among:
|
|||||||
|
|
||||||
- gather food;
|
- gather food;
|
||||||
- gather wood;
|
- gather wood;
|
||||||
|
- deposit carried food or wood;
|
||||||
|
- withdraw and eat food;
|
||||||
- patrol;
|
- patrol;
|
||||||
- study;
|
- study;
|
||||||
- eat;
|
- sleep;
|
||||||
- rest;
|
- rest;
|
||||||
- wander.
|
- wander.
|
||||||
|
|
||||||
@@ -335,8 +340,11 @@ distance with resource `safety_risk`, `comfort_distance`, and
|
|||||||
- authored dirt path strips that make the current village task loop readable;
|
- authored dirt path strips that make the current village task loop readable;
|
||||||
- compact silhouette props for the ridge landmark, pantry, guard, study, and
|
- compact silhouette props for the ridge landmark, pantry, guard, study, and
|
||||||
rest sites;
|
rest sites;
|
||||||
- warm sky, fog, shadows, and wind-reactive foliage;
|
- warm sky, fog, shadows, coherent wind-reactive foliage, and breeze-aligned
|
||||||
- eight ResourceNodes preserving stable food/wood discovery IDs;
|
chimney smoke;
|
||||||
|
- one sparse terrain-aware calligraphic gust field with two or three soft
|
||||||
|
tapered strokes per burst;
|
||||||
|
- eighteen ResourceNodes preserving stable food/wood discovery IDs;
|
||||||
- typed village pantry storage and typed guard, study, and rest activity sites;
|
- typed village pantry storage and typed guard, study, and rest activity sites;
|
||||||
- a Terrain3D-derived baked navigation mesh covering the current playable loop;
|
- a Terrain3D-derived baked navigation mesh covering the current playable loop;
|
||||||
- base collision/navigation guardrails that keep Terrain3D collision enabled,
|
- base collision/navigation guardrails that keep Terrain3D collision enabled,
|
||||||
@@ -363,7 +371,10 @@ A small village panel displays:
|
|||||||
- starving NPC count;
|
- starving NPC count;
|
||||||
- selected village modifiers.
|
- selected village modifiers.
|
||||||
- a Tab-cycled NPC inspector with profession, needs, task state, destination,
|
- a Tab-cycled NPC inspector with profession, needs, task state, destination,
|
||||||
decision reason, and utility scores.
|
decision reason, utility scores, directed familiarity/trust, and the exact
|
||||||
|
completed event that last changed trust;
|
||||||
|
- the selected NPC's latest known objective fact, resolved from its own
|
||||||
|
persisted knowledge rather than omniscient event history.
|
||||||
|
|
||||||
NPC name/profession labels, definition-driven colors and props, carried-food
|
NPC name/profession labels, definition-driven colors and props, carried-food
|
||||||
visuals, and compact task glyphs make active simulation state readable in the
|
visuals, and compact task glyphs make active simulation state readable in the
|
||||||
@@ -374,8 +385,9 @@ glyphs.
|
|||||||
|
|
||||||
### `simulation/SimNPC.gd`
|
### `simulation/SimNPC.gd`
|
||||||
|
|
||||||
`SimNPC` is a `RefCounted` simulation model. It owns needs, task selection,
|
`SimNPC` is a `RefCounted` simulation model. It owns needs, task state,
|
||||||
task state, profession affinity, carried inventory, starvation, and death.
|
profession identity, carried inventory, starvation, and death. Focused systems
|
||||||
|
select and execute actions against that state.
|
||||||
|
|
||||||
This separation from the visual node is an important architectural seed and
|
This separation from the visual node is an important architectural seed and
|
||||||
should be preserved.
|
should be preserved.
|
||||||
@@ -401,7 +413,11 @@ Focused `RefCounted` collaborators keep rule ownership visible:
|
|||||||
- action systems own selection, execution progress, and target resolution;
|
- action systems own selection, execution progress, and target resolution;
|
||||||
- `VillageEconomy` owns storage/inventory transactions and synchronized
|
- `VillageEconomy` owns storage/inventory transactions and synchronized
|
||||||
village resource views;
|
village resource views;
|
||||||
- `SimulationEventLog` owns deterministic event history and queries.
|
- `SimulationEventLog` owns deterministic event history and queries;
|
||||||
|
- `EventKnowledgeSystem` owns per-NPC known-event references and captures
|
||||||
|
actor/nearby evidence when a witnessable event is recorded;
|
||||||
|
- `RelationshipSystem` owns directed relationship state, event-driven trust
|
||||||
|
consequences, and deterministic social queries.
|
||||||
|
|
||||||
`SimulationManager` remains the scene-tree façade and signal boundary rather
|
`SimulationManager` remains the scene-tree façade and signal boundary rather
|
||||||
than duplicating these responsibilities across additional manager nodes.
|
than duplicating these responsibilities across additional manager nodes.
|
||||||
@@ -425,7 +441,8 @@ than duplicating these responsibilities across additional manager nodes.
|
|||||||
- obtains a navigation path;
|
- obtains a navigation path;
|
||||||
- moves and rotates toward path points;
|
- moves and rotates toward path points;
|
||||||
- reports arrival;
|
- reports arrival;
|
||||||
- applies a simple death presentation.
|
- presents definition-driven profession silhouettes, carried resources, task
|
||||||
|
glyphs, walk motion, and death state.
|
||||||
|
|
||||||
### `player/player.gd` and `player/camera_rig.gd`
|
### `player/player.gd` and `player/camera_rig.gd`
|
||||||
|
|
||||||
@@ -435,7 +452,9 @@ follow camera.
|
|||||||
|
|
||||||
### `world/ui/ui.gd`
|
### `world/ui/ui.gd`
|
||||||
|
|
||||||
The UI subscribes to village changes and formats aggregate village state.
|
The UI subscribes to village, task, event, knowledge, and relationship changes.
|
||||||
|
It formats aggregate state and resolves known facts/relationship causes through
|
||||||
|
the event log without owning or recomputing simulation facts.
|
||||||
|
|
||||||
## Current runtime flow
|
## Current runtime flow
|
||||||
|
|
||||||
@@ -465,6 +484,8 @@ NpcVisual navigates through the active world
|
|||||||
| ResourceStateRecord.extract() -> NPC inventory
|
| ResourceStateRecord.extract() -> NPC inventory
|
||||||
| -> VillageEconomy transfers inventory/storage as actions complete
|
| -> VillageEconomy transfers inventory/storage as actions complete
|
||||||
| -> SimulationEventLog appends completed facts
|
| -> SimulationEventLog appends completed facts
|
||||||
|
| -> EventKnowledgeSystem captures actor/nearby knowledge
|
||||||
|
| -> RelationshipSystem applies evidence-gated social consequences
|
||||||
| |
|
| |
|
||||||
| v
|
| v
|
||||||
| village_changed signal updates the UI
|
| village_changed signal updates the UI
|
||||||
@@ -485,7 +506,9 @@ NpcVisual navigates through the active world
|
|||||||
├── docs/ Project context and plans
|
├── docs/ Project context and plans
|
||||||
├── player/
|
├── player/
|
||||||
│ ├── camera_rig.gd
|
│ ├── camera_rig.gd
|
||||||
|
│ ├── PlayerVisual.tscn
|
||||||
│ ├── player.gd
|
│ ├── player.gd
|
||||||
|
│ ├── player_visual.gd
|
||||||
│ └── npc/
|
│ └── npc/
|
||||||
│ ├── NpcVisual.gd
|
│ ├── NpcVisual.gd
|
||||||
│ └── NpcVisual.tscn
|
│ └── NpcVisual.tscn
|
||||||
@@ -498,7 +521,9 @@ NpcVisual navigates through the active world
|
|||||||
│ ├── definitions/ Stable IDs and custom definition resources
|
│ ├── definitions/ Stable IDs and custom definition resources
|
||||||
│ ├── economy/ Inventory and storage transactions
|
│ ├── economy/ Inventory and storage transactions
|
||||||
│ ├── events/ Ordered event history and queries
|
│ ├── events/ Ordered event history and queries
|
||||||
|
│ ├── knowledge/ Per-NPC references to known objective events
|
||||||
│ ├── persistence/ Validated local save-slot storage
|
│ ├── persistence/ Validated local save-slot storage
|
||||||
|
│ ├── relationships/ Directed social consequences and queries
|
||||||
│ └── state/ Versioned simulation-state records
|
│ └── state/ Versioned simulation-state records
|
||||||
├── tests/
|
├── tests/
|
||||||
│ ├── action_system_boundaries_test.gd
|
│ ├── action_system_boundaries_test.gd
|
||||||
@@ -507,9 +532,11 @@ NpcVisual navigates through the active world
|
|||||||
│ ├── jajce_world_scaffold_test.gd
|
│ ├── jajce_world_scaffold_test.gd
|
||||||
│ ├── jajce_runtime_integration_test.gd
|
│ ├── jajce_runtime_integration_test.gd
|
||||||
│ ├── npc_visual_lifecycle_test.gd
|
│ ├── npc_visual_lifecycle_test.gd
|
||||||
|
│ ├── relationship_consequence_test.gd
|
||||||
│ ├── resource_node_player_parity_test.gd
|
│ ├── resource_node_player_parity_test.gd
|
||||||
│ ├── simulation_definitions_test.gd
|
│ ├── simulation_definitions_test.gd
|
||||||
│ └── simulation_state_serialization_test.gd
|
│ ├── simulation_state_serialization_test.gd
|
||||||
|
│ └── witnessed_knowledge_consequence_test.gd
|
||||||
├── terrain/jajce/ Dedicated Terrain3D seed data and assets
|
├── terrain/jajce/ Dedicated Terrain3D seed data and assets
|
||||||
├── tools/
|
├── tools/
|
||||||
│ └── generate_jajce_terrain_seed.gd
|
│ └── generate_jajce_terrain_seed.gd
|
||||||
@@ -518,7 +545,8 @@ NpcVisual navigates through the active world
|
|||||||
│ │ ├── JajceWorld.tscn
|
│ │ ├── JajceWorld.tscn
|
||||||
│ │ ├── JajceLookdev.tscn
|
│ │ ├── JajceLookdev.tscn
|
||||||
│ │ ├── jajce_world.gd
|
│ │ ├── jajce_world.gd
|
||||||
│ │ └── beauty_camera.gd
|
│ │ ├── beauty_camera.gd
|
||||||
|
│ │ └── vfx/WindGustField.tscn
|
||||||
│ ├── resource_nodes/
|
│ ├── resource_nodes/
|
||||||
│ │ ├── ResourceNode.gd
|
│ │ ├── ResourceNode.gd
|
||||||
│ │ ├── ResourceNode.gd.uid
|
│ │ ├── ResourceNode.gd.uid
|
||||||
@@ -539,9 +567,10 @@ These are expected prototype constraints, not necessarily isolated bugs:
|
|||||||
- Temporary activity markers have been removed; NPC and player food/wood
|
- Temporary activity markers have been removed; NPC and player food/wood
|
||||||
gathering use `ResourceNode` instances with no fallback, food transfer uses
|
gathering use `ResourceNode` instances with no fallback, food transfer uses
|
||||||
the typed pantry `StorageNode`, and patrol/study/rest use `ActivitySite`.
|
the typed pantry `StorageNode`, and patrol/study/rest use `ActivitySite`.
|
||||||
- Current NPC, village, resource, storage, event, clock, and RNG state serialize
|
- Current NPC, village, resource, storage, event, knowledge, relationship,
|
||||||
through world schema v3. F5/F9 provide one validated local quicksave; a save
|
clock, and RNG state serialize through world schema v5. F5/F9 provide one
|
||||||
menu, metadata, and player-transform persistence remain deferred.
|
validated local quicksave; a save menu, metadata, and player-transform
|
||||||
|
persistence remain deferred.
|
||||||
- Simulation-owned resource records retain live amounts, reservations, and
|
- Simulation-owned resource records retain live amounts, reservations, and
|
||||||
usage definitions while ResourceNode scenes are unloaded.
|
usage definitions while ResourceNode scenes are unloaded.
|
||||||
- An explicit fixed-step clock converts frame delta into simulation ticks, but
|
- An explicit fixed-step clock converts frame delta into simulation ticks, but
|
||||||
@@ -552,10 +581,13 @@ These are expected prototype constraints, not necessarily isolated bugs:
|
|||||||
- Food and wood now move through finite sources, NPC inventory, and typed
|
- Food and wood now move through finite sources, NPC inventory, and typed
|
||||||
village storage. Other village metrics remain aggregate values rather than
|
village storage. Other village metrics remain aggregate values rather than
|
||||||
located items.
|
located items.
|
||||||
- NPCs do not have homes, schedules, possessions, memories, relationships,
|
- NPCs have home positions, schedule periods, carried food/wood, and directed
|
||||||
goals, or social knowledge.
|
familiarity/trust. They can retain direct proximity-witness knowledge of a
|
||||||
- The reason inspector exposes current decisions, but deeper historical traces
|
food deposit, but do not yet have wider social dimensions, goals, line of
|
||||||
and rejected preconditions are not yet retained.
|
sight/hearing evidence, memory decay, false beliefs, or communication.
|
||||||
|
- The reason inspector exposes current decisions, utility rejections, and one
|
||||||
|
exact relationship cause plus the latest known fact, but deeper historical
|
||||||
|
decision traces are not yet retained.
|
||||||
- Active navigation is used as if all agents are local; no simulation LOD exists.
|
- Active navigation is used as if all agents are local; no simulation LOD exists.
|
||||||
- Unloaded traveling NPCs preserve their state but do not yet advance through
|
- Unloaded traveling NPCs preserve their state but do not yet advance through
|
||||||
abstract travel time.
|
abstract travel time.
|
||||||
@@ -573,8 +605,9 @@ These are expected prototype constraints, not necessarily isolated bugs:
|
|||||||
as reproducible presentation baselines.
|
as reproducible presentation baselines.
|
||||||
- Combat, companions, factions, politics, trade, rumours, quests, persistence,
|
- Combat, companions, factions, politics, trade, rumours, quests, persistence,
|
||||||
and regional travel do not yet exist.
|
and regional travel do not yet exist.
|
||||||
- Placeholder geometry is sufficient for debugging but not for build-in-public
|
- Stylized player/NPC silhouettes, water, foliage, and VFX support the current
|
||||||
presentation.
|
build-in-public baseline, while blockout buildings and several work/resource
|
||||||
|
props remain visibly prototype-grade.
|
||||||
|
|
||||||
## Target simulation architecture
|
## Target simulation architecture
|
||||||
|
|
||||||
@@ -596,8 +629,9 @@ physics frames, or a currently loaded map.
|
|||||||
|
|
||||||
The authoritative-boundary decision is recorded in
|
The authoritative-boundary decision is recorded in
|
||||||
[ADR 0001](decisions/0001-simulation-authority-boundary.md). The current
|
[ADR 0001](decisions/0001-simulation-authority-boundary.md). The current
|
||||||
`ResourceNode` state and `WorldViewManager` target selection are transitional,
|
`ResourceNode` binding and the `WorldViewManager` active-position bridge follow
|
||||||
not patterns to extend into inventories, schedules, or relationships.
|
this boundary; new inventories, schedules, relationships, and knowledge should
|
||||||
|
remain scene-independent records and systems.
|
||||||
|
|
||||||
### Authority and active-world adapters
|
### Authority and active-world adapters
|
||||||
|
|
||||||
@@ -791,7 +825,14 @@ validation, atomic replacement recovery, and active-visual rebuilding.
|
|||||||
Terrain3D runtime integration, the bounded Jajce beauty pass, and the first
|
Terrain3D runtime integration, the bounded Jajce beauty pass, and the first
|
||||||
simulation-garden runtime capture are complete.
|
simulation-garden runtime capture are complete.
|
||||||
|
|
||||||
The coherent visual slice can then aim for:
|
The first relationship and knowledge consequences are also complete:
|
||||||
|
food-deposit events become separate known facts for their actor and nearby
|
||||||
|
living NPCs; only an informed hungry familiar NPC gains directed trust. The
|
||||||
|
exact event remains visible as both known fact and relationship cause, and that
|
||||||
|
trust can redirect ordinary work toward helping a starving acquaintance. One
|
||||||
|
provenance-aware NPC-to-NPC fact transfer is the next systems slice.
|
||||||
|
|
||||||
|
The remaining simulation-garden target still aims for:
|
||||||
|
|
||||||
- one attractive valley section;
|
- one attractive valley section;
|
||||||
- six named villagers;
|
- six named villagers;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## Current contract
|
## Current contract
|
||||||
|
|
||||||
`SimulationStateRecord` is the versioned JSON boundary for the current
|
`SimulationStateRecord` is the versioned JSON boundary for the current
|
||||||
simulation. The current world schema is v3 and captures:
|
simulation. The current world schema is v5 and captures:
|
||||||
|
|
||||||
- simulation seed, tick interval, tick count, clock remainder, and elapsed
|
- simulation seed, tick interval, tick count, clock remainder, and elapsed
|
||||||
clock ticks;
|
clock ticks;
|
||||||
@@ -12,16 +12,20 @@ simulation. The current world schema is v3 and captures:
|
|||||||
- village resource counters;
|
- village resource counters;
|
||||||
- controlled per-NPC wander RNG streams;
|
- controlled per-NPC wander RNG streams;
|
||||||
- scene-independent resource state plus the definition facts needed while its
|
- scene-independent resource state plus the definition facts needed while its
|
||||||
ResourceNode is unloaded.
|
ResourceNode is unloaded;
|
||||||
- pantry contents, carried NPC inventory, the ordered economic event stream,
|
- pantry contents, carried NPC inventory, the ordered economic event stream,
|
||||||
and its next stable event ID.
|
and its next stable event ID;
|
||||||
|
- directed relationship records with familiarity, trust, and the stable event
|
||||||
|
ID that last changed trust;
|
||||||
|
- per-NPC known-event records that reference objective event history without
|
||||||
|
copying it.
|
||||||
|
|
||||||
The top-level identity is:
|
The top-level identity is:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"schema": "the_steward.simulation",
|
"schema": "the_steward.simulation",
|
||||||
"schema_version": 3
|
"schema_version": 5
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -51,7 +55,8 @@ its future random sequence.
|
|||||||
5. requiring the complete final-state checksums to match.
|
5. requiring the complete final-state checksums to match.
|
||||||
|
|
||||||
It also verifies clock remainder, resource amount/reservation/enabled
|
It also verifies clock remainder, resource amount/reservation/enabled
|
||||||
round-tripping, presentation unload/rebind, and rejection of unsupported
|
round-tripping, presentation unload/rebind, directed relationship/cause
|
||||||
|
round-tripping, divergent known-event state, and rejection of unsupported
|
||||||
schemas.
|
schemas.
|
||||||
|
|
||||||
NPCStateRecord v2 adds the resolved travel destination and whether it is
|
NPCStateRecord v2 adds the resolved travel destination and whether it is
|
||||||
@@ -71,6 +76,40 @@ stream. Event records use stable source/destination/item IDs, reject invalid or
|
|||||||
duplicate IDs, and preserve deterministic ordering across save/restore. See
|
duplicate IDs, and preserve deterministic ordering across save/restore. See
|
||||||
[the economic event stream](ECONOMIC_EVENTS.md).
|
[the economic event stream](ECONOMIC_EVENTS.md).
|
||||||
|
|
||||||
|
EconomicEventRecord v2 adds the authoritative world position captured when the
|
||||||
|
fact is recorded. Nested v1 events migrate with a zero-vector fallback; old
|
||||||
|
saves already encode their stable location IDs, and migrated knowledge is not
|
||||||
|
recomputed spatially.
|
||||||
|
|
||||||
|
SimulationStateRecord v4 moves social authority into top-level directed
|
||||||
|
`RelationshipStateRecord` entries. Each record contains `observer_id`,
|
||||||
|
`subject_id`, familiarity, trust, and `last_trust_cause_event_id` (`-1` when no
|
||||||
|
event has changed trust). Parsing requires unique ordered pairs, existing NPC
|
||||||
|
IDs, bounded relationship values, and a real event for every non-empty cause
|
||||||
|
ID.
|
||||||
|
The relationship system serializes records in stable observer/subject order so
|
||||||
|
they participate in deterministic checksums.
|
||||||
|
|
||||||
|
World schema v3 migrates its NPC-local familiarity pairs into directed
|
||||||
|
relationships with neutral trust. Nested NPC schema v4 removes that obsolete
|
||||||
|
duplicate field; v3 NPC records remain accepted only through the explicit
|
||||||
|
migration. World schemas 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
|
## Resource authority
|
||||||
|
|
||||||
`SimulationManager` owns `ResourceStateRecord` instances independently of the
|
`SimulationManager` owns `ResourceStateRecord` instances independently of the
|
||||||
@@ -106,8 +145,11 @@ visuals from authoritative state.
|
|||||||
This phase does not yet provide:
|
This phase does not yet provide:
|
||||||
|
|
||||||
- a save-slot menu, metadata, thumbnails, autosaves, or multiple profiles;
|
- a save-slot menu, metadata, thumbnails, autosaves, or multiple profiles;
|
||||||
- migrations older than the explicitly supported world schemas v1 and v2;
|
- migrations from any historical world schema other than the explicitly
|
||||||
- player inventory, relationship, schedule, or social-memory records;
|
supported 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.
|
- persistence for the player transform or presentation-only scene state.
|
||||||
|
|
||||||
Those features should build on this boundary rather than inventing parallel
|
Those features should build on this boundary rather than inventing parallel
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Simulation Garden 01
|
# Simulation Garden 01
|
||||||
|
|
||||||
Captured: 2026-07-09
|
Captured: 2026-07-11
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
@@ -10,7 +10,17 @@ Captured: 2026-07-09
|
|||||||
|
|
||||||
## Capture Command
|
## Capture Command
|
||||||
|
|
||||||
Run from the project root with the normal renderer:
|
Run from the project root with the normal renderer on macOS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HOME="$PWD/logs/quality/godot_profile" \
|
||||||
|
APPDATA="$PWD/logs/quality/godot_profile" \
|
||||||
|
LOCALAPPDATA="$PWD/logs/quality/godot_profile" \
|
||||||
|
/Applications/Godot.app/Contents/MacOS/Godot \
|
||||||
|
--path "$PWD" --script res://tools/capture_simulation_garden.gd
|
||||||
|
```
|
||||||
|
|
||||||
|
On Windows:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:APPDATA = 'C:\Users\Rijad\Documents\Simulation Game\logs\quality\godot_profile'
|
$env:APPDATA = 'C:\Users\Rijad\Documents\Simulation Game\logs\quality\godot_profile'
|
||||||
@@ -36,17 +46,17 @@ The next presentation pass adds compact silhouette props to the ridge landmark,
|
|||||||
pantry, guard, study, and rest sites so work locations remain identifiable in
|
pantry, guard, study, and rest sites so work locations remain identifiable in
|
||||||
cinematic mode.
|
cinematic mode.
|
||||||
|
|
||||||
The frame also makes the next presentation risks plain:
|
The frame also makes the remaining presentation risks plain:
|
||||||
|
|
||||||
- the camera still favors verification over composition;
|
- the elevated third-person/top-down camera is intentionally preserved; future
|
||||||
- placeholder characters, houses, and work props remain useful but visibly
|
staging should improve composition within that cozy follow perspective;
|
||||||
|
- houses and several work/resource props remain useful but visibly
|
||||||
prototype-grade;
|
prototype-grade;
|
||||||
- terrain texture variation and path strips read better than the old flat map,
|
- terrain texture variation and path strips read better than the old flat map,
|
||||||
but landmarks, water, and foreground silhouettes need stronger first-read
|
but terrain/color hierarchy still flattens some village landmarks;
|
||||||
staging;
|
- task glyphs and work-site props help, but blockout buildings and resource
|
||||||
- task glyphs and work-site props help, but the cinematic view still needs
|
props need stronger authored silhouettes before this becomes a polished
|
||||||
richer water/foreground shapes and stronger authored silhouettes before it
|
public-facing shot.
|
||||||
becomes a strong public-facing shot.
|
|
||||||
|
|
||||||
Use this baseline as the first runtime comparison image before adding more
|
Use this baseline as the first runtime comparison image before adding more
|
||||||
systems or expanding the terrain.
|
systems or expanding the terrain.
|
||||||
@@ -60,4 +70,32 @@ inventory now appears as carried logs. The capture also fixes three effects
|
|||||||
that obscured the simulation on Metal: waterfall mist was incorrectly emitted
|
that obscured the simulation on Metal: waterfall mist was incorrectly emitted
|
||||||
at the village origin, ridge/valley wind systems overlapped, and chimney smoke
|
at the village origin, ridge/valley wind systems overlapped, and chimney smoke
|
||||||
ignored particle transparency. The player capsule and camera composition are
|
ignored particle transparency. The player capsule and camera composition are
|
||||||
the next visible presentation gaps.
|
the next visible presentation gaps at that checkpoint.
|
||||||
|
|
||||||
|
## July 11 visual follow-up
|
||||||
|
|
||||||
|
The implementation keeps the overlapping valley-wide white stroke/speck fields
|
||||||
|
removed: those read as constant scratches or insects. In their place, one
|
||||||
|
bounded controller produces sporadic groups of two or three terrain-aware
|
||||||
|
calligraphic ribbons. Their tapered sage, warm amber, and pale aqua strokes are
|
||||||
|
drawn head-to-tail by the shader, remain below 30% alpha, cannot stack, and
|
||||||
|
share the prevailing direction used by deterministic tree-canopy motion and
|
||||||
|
chimney smoke. The capture tool stages one gust so the next visual comparison
|
||||||
|
does not randomly land during the intended quiet interval.
|
||||||
|
|
||||||
|
The PNGs listed above still predate that gust-controller pass. A fresh Metal
|
||||||
|
capture was unavailable when the code and headless mesh/shader checks landed,
|
||||||
|
so do not use those images as evidence of the final ribbon opacity or terrain
|
||||||
|
contact. Replace both images after running the capture command on the normal
|
||||||
|
renderer and reviewing the staged mid-sweep gust.
|
||||||
|
|
||||||
|
The day/night controller also reconnects to the authoritative simulation clock
|
||||||
|
after scene startup and uses the correct sunrise/sunset color order, preventing
|
||||||
|
lighting drift during speed changes or restoration.
|
||||||
|
|
||||||
|
The player now uses a compact multi-part silhouette with a teal tunic, warm
|
||||||
|
scarf/satchel accents, and velocity-driven walk motion. Collision, controls,
|
||||||
|
and the elevated staggered follow camera are unchanged. The next visible gains
|
||||||
|
should come from replacing remaining blockout architecture, strengthening work
|
||||||
|
props, and improving terrain/color hierarchy—not from changing the camera
|
||||||
|
language again.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 419 KiB After Width: | Height: | Size: 418 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 428 KiB After Width: | Height: | Size: 426 KiB |
@@ -40,8 +40,8 @@ is optional.
|
|||||||
|
|
||||||
### Fast changed-files mode
|
### Fast changed-files mode
|
||||||
|
|
||||||
Only runs `gdformat` / `gdlint` on `.gd` files modified since the last commit.
|
Only runs `gdformat` / `gdlint` on tracked changes and new untracked `.gd`
|
||||||
The Godot dependency check and all project scenarios still run.
|
files. The Godot dependency check and all project scenarios still run.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./tools/quality.sh --changed
|
./tools/quality.sh --changed
|
||||||
@@ -128,7 +128,10 @@ The script exits non-zero on any failure, so it will fail the CI step.
|
|||||||
|
|
||||||
The shell gate isolates Godot's cross-platform user-data paths under
|
The shell gate isolates Godot's cross-platform user-data paths under
|
||||||
`logs/quality/godot_profile`. Before running scenarios it imports project and
|
`logs/quality/godot_profile`. Before running scenarios it imports project and
|
||||||
GUT global classes when the ignored cache is absent, so a fresh clone needs no
|
GUT global classes whenever GDScript changes, so fresh, renamed, and deleted
|
||||||
manual editor launch. Godot/GUT script parse or load markers are failures
|
`class_name` scripts cannot leave the ignored cache stale. Godot/GUT script
|
||||||
because headless Godot can report those errors while returning a zero process
|
parse or load markers are failures because headless Godot can report those
|
||||||
exit code.
|
errors while returning a zero process exit code. Every Godot subprocess has a
|
||||||
|
portable watchdog on stock macOS as well as Linux/Windows, and nonzero
|
||||||
|
`gdformat`/`gdlint` exits fail the gate even when their output is not a familiar
|
||||||
|
diagnostic string.
|
||||||
|
|||||||
@@ -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="PackedScene" path="res://world/jajce/JajceWorld.tscn" id="11_jajce"]
|
||||||
[ext_resource type="Script" uid="uid://cascjhf8lsvay" path="res://world/ui/time_dial.gd" id="12_timedial"]
|
[ext_resource type="Script" uid="uid://cascjhf8lsvay" path="res://world/ui/time_dial.gd" id="12_timedial"]
|
||||||
[ext_resource type="Script" uid="uid://bivrsk5ukgnoo" path="res://world/demo/DemoController.gd" id="13_demo"]
|
[ext_resource type="Script" uid="uid://bivrsk5ukgnoo" path="res://world/demo/DemoController.gd" id="13_demo"]
|
||||||
|
[ext_resource type="PackedScene" path="res://player/PlayerVisual.tscn" id="14_player_visual"]
|
||||||
|
|
||||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_o5qli"]
|
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_o5qli"]
|
||||||
radius = 0.4
|
radius = 0.4
|
||||||
height = 1.7
|
height = 1.7
|
||||||
|
|
||||||
[sub_resource type="CapsuleMesh" id="CapsuleMesh_sgp6g"]
|
|
||||||
|
|
||||||
[sub_resource type="BoxMesh" id="BoxMesh_0wfyh"]
|
|
||||||
|
|
||||||
[node name="Main" type="Node3D" unique_id=1850341560]
|
[node name="Main" type="Node3D" unique_id=1850341560]
|
||||||
|
|
||||||
[node name="JajceWorld" parent="." unique_id=1023795383 instance=ExtResource("11_jajce")]
|
[node name="JajceWorld" parent="." unique_id=1023795383 instance=ExtResource("11_jajce")]
|
||||||
@@ -38,13 +35,7 @@ stomach_capacity_for_food = 10
|
|||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.85, 0)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.85, 0)
|
||||||
shape = SubResource("CapsuleShape3D_o5qli")
|
shape = SubResource("CapsuleShape3D_o5qli")
|
||||||
|
|
||||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="Player" unique_id=935946019]
|
[node name="Visual" parent="Player" instance=ExtResource("14_player_visual")]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.85, 0)
|
|
||||||
mesh = SubResource("CapsuleMesh_sgp6g")
|
|
||||||
|
|
||||||
[node name="FaceMarker" type="MeshInstance3D" parent="Player" unique_id=1558899356]
|
|
||||||
transform = Transform3D(0.15, 0, 0, 0, 0.15, 0, 0, 0, 0.4, 0, 1.2, 0.6266626)
|
|
||||||
mesh = SubResource("BoxMesh_0wfyh")
|
|
||||||
|
|
||||||
[node name="CameraRig" type="Node3D" parent="." unique_id=81378719 node_paths=PackedStringArray("target")]
|
[node name="CameraRig" type="Node3D" parent="." unique_id=81378719 node_paths=PackedStringArray("target")]
|
||||||
transform = Transform3D(1, 0, 0, 0, 0.57357645, 0.81915206, 0, -0.81915206, 0.57357645, 0, 10, 8)
|
transform = Transform3D(1, 0, 0, 0, 0.57357645, 0.81915206, 0, -0.81915206, 0.57357645, 0, 10, 8)
|
||||||
|
|||||||
@@ -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:
|
func _should_show_task_glyph(action_id: StringName, task_state: StringName) -> bool:
|
||||||
if task_state not in [SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING]:
|
if task_state not in [SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING]:
|
||||||
return false
|
return false
|
||||||
return action_id not in [
|
return (
|
||||||
|
action_id
|
||||||
|
not in [
|
||||||
SimulationIds.ACTION_IDLE,
|
SimulationIds.ACTION_IDLE,
|
||||||
SimulationIds.ACTION_DEAD,
|
SimulationIds.ACTION_DEAD,
|
||||||
SimulationIds.ACTION_WANDER,
|
SimulationIds.ACTION_WANDER,
|
||||||
]
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
func _create_task_glyph_mesh(action_id: StringName) -> PrimitiveMesh:
|
func _create_task_glyph_mesh(action_id: StringName) -> PrimitiveMesh:
|
||||||
|
|||||||
+3
-1
@@ -110,4 +110,6 @@ func is_near_activity_site(activity_site: ActivitySite) -> bool:
|
|||||||
if activity_site == null:
|
if activity_site == null:
|
||||||
return false
|
return false
|
||||||
|
|
||||||
return global_position.distance_to(activity_site.get_interaction_position()) <= interaction_range
|
return (
|
||||||
|
global_position.distance_to(activity_site.get_interaction_position()) <= interaction_range
|
||||||
|
)
|
||||||
|
|||||||
@@ -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 inventory: Dictionary = {}
|
||||||
var last_task: StringName
|
var last_task: StringName
|
||||||
var random_source: RandomNumberGenerator
|
var random_source: RandomNumberGenerator
|
||||||
var familiarity: Dictionary = {}
|
|
||||||
var mourning_ticks := 0
|
var mourning_ticks := 0
|
||||||
var debug_logs := false
|
var debug_logs := false
|
||||||
|
|
||||||
|
|||||||
+132
-46
@@ -2,6 +2,8 @@ extends Node
|
|||||||
|
|
||||||
const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.gd")
|
const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.gd")
|
||||||
const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd")
|
const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd")
|
||||||
|
const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd")
|
||||||
|
const EventKnowledgeSystemScript := preload("res://simulation/knowledge/EventKnowledgeSystem.gd")
|
||||||
|
|
||||||
signal npc_task_changed(npc: SimNPC, old_task: StringName, new_task: StringName)
|
signal npc_task_changed(npc: SimNPC, old_task: StringName, new_task: StringName)
|
||||||
signal village_changed(village: SimVillage)
|
signal village_changed(village: SimVillage)
|
||||||
@@ -12,6 +14,8 @@ signal npc_inventory_changed(npc: SimNPC, item_id: StringName, amount: float)
|
|||||||
signal economic_event_recorded(event: EconomicEventRecord)
|
signal economic_event_recorded(event: EconomicEventRecord)
|
||||||
signal state_restored
|
signal state_restored
|
||||||
signal npc_decision_recorded(npc: SimNPC, decision: ActionSelectionResult)
|
signal npc_decision_recorded(npc: SimNPC, decision: ActionSelectionResult)
|
||||||
|
signal relationship_changed(relationship: RelationshipStateRecord, cause_event: EconomicEventRecord)
|
||||||
|
signal event_knowledge_changed(knower_id: int, event: EconomicEventRecord)
|
||||||
|
|
||||||
var village := SimVillage.new()
|
var village := SimVillage.new()
|
||||||
|
|
||||||
@@ -29,6 +33,8 @@ var wander_random_sources := {}
|
|||||||
var resource_states: Dictionary = {}
|
var resource_states: Dictionary = {}
|
||||||
var event_log := SimulationEventLogScript.new()
|
var event_log := SimulationEventLogScript.new()
|
||||||
var economy := VillageEconomyScript.new()
|
var economy := VillageEconomyScript.new()
|
||||||
|
var relationship_system := RelationshipSystemScript.new()
|
||||||
|
var event_knowledge_system := EventKnowledgeSystemScript.new()
|
||||||
var storage_states: Dictionary:
|
var storage_states: Dictionary:
|
||||||
get:
|
get:
|
||||||
return economy.storage_states
|
return economy.storage_states
|
||||||
@@ -57,6 +63,7 @@ func _ready() -> void:
|
|||||||
economy.inventory_changed.connect(_on_economy_inventory_changed)
|
economy.inventory_changed.connect(_on_economy_inventory_changed)
|
||||||
economy.economic_event_requested.connect(_record_economic_event)
|
economy.economic_event_requested.connect(_record_economic_event)
|
||||||
economy.narrative_event_requested.connect(record_narrative_event)
|
economy.narrative_event_requested.connect(record_narrative_event)
|
||||||
|
action_selector.relationship_system = relationship_system
|
||||||
var definition_errors := SimulationDefinitions.validate()
|
var definition_errors := SimulationDefinitions.validate()
|
||||||
if not definition_errors.is_empty():
|
if not definition_errors.is_empty():
|
||||||
for error in definition_errors:
|
for error in definition_errors:
|
||||||
@@ -72,6 +79,7 @@ func _ready() -> void:
|
|||||||
village.update_modifiers()
|
village.update_modifiers()
|
||||||
village.update_priorities()
|
village.update_priorities()
|
||||||
generate_npcs()
|
generate_npcs()
|
||||||
|
relationship_system.initialize_households(npcs)
|
||||||
call_deferred("register_loaded_resource_nodes")
|
call_deferred("register_loaded_resource_nodes")
|
||||||
call_deferred("register_loaded_storage_nodes")
|
call_deferred("register_loaded_storage_nodes")
|
||||||
if debug_logs:
|
if debug_logs:
|
||||||
@@ -122,12 +130,6 @@ func generate_npcs() -> void:
|
|||||||
for i in range(npcs.size()):
|
for i in range(npcs.size()):
|
||||||
npcs[i].home_position = home_positions[i % home_count]
|
npcs[i].home_position = home_positions[i % home_count]
|
||||||
|
|
||||||
for i in range(npcs.size()):
|
|
||||||
for j in range(i + 1, npcs.size()):
|
|
||||||
if npcs[i].home_position.distance_to(npcs[j].home_position) < 4.0:
|
|
||||||
npcs[i].familiarity[npcs[j].id] = 0.5
|
|
||||||
npcs[j].familiarity[npcs[i].id] = 0.5
|
|
||||||
|
|
||||||
|
|
||||||
func _create_random_source(npc_id: int, stream_id: int) -> RandomNumberGenerator:
|
func _create_random_source(npc_id: int, stream_id: int) -> RandomNumberGenerator:
|
||||||
var source := RandomNumberGenerator.new()
|
var source := RandomNumberGenerator.new()
|
||||||
@@ -200,13 +202,13 @@ func _select_action_if_idle(npc: SimNPC, previous_state: StringName) -> void:
|
|||||||
npc_decision_recorded.emit(npc, selection)
|
npc_decision_recorded.emit(npc, selection)
|
||||||
npc.set_task(selection.action_id, selection.duration_override)
|
npc.set_task(selection.action_id, selection.duration_override)
|
||||||
var definition := SimulationDefinitions.get_action(selection.action_id)
|
var definition := SimulationDefinitions.get_action(selection.action_id)
|
||||||
var display_name := definition.display_name if definition != null else String(selection.action_id)
|
var display_name := (
|
||||||
|
definition.display_name if definition != null else String(selection.action_id)
|
||||||
|
)
|
||||||
record_narrative_event(SimulationIds.EVENT_TASK_STARTED, npc.id, &"", display_name)
|
record_narrative_event(SimulationIds.EVENT_TASK_STARTED, npc.id, &"", display_name)
|
||||||
|
|
||||||
|
|
||||||
func _handle_npc_death(
|
func _handle_npc_death(npc: SimNPC, previous_task: StringName, previous_target: StringName) -> void:
|
||||||
npc: SimNPC, previous_task: StringName, previous_target: StringName
|
|
||||||
) -> void:
|
|
||||||
if not previous_target.is_empty():
|
if not previous_target.is_empty():
|
||||||
release_npc_reservation(npc.id)
|
release_npc_reservation(npc.id)
|
||||||
npc_died.emit(npc)
|
npc_died.emit(npc)
|
||||||
@@ -264,9 +266,11 @@ func _complete_resource_gather(npc: SimNPC, completed_task: StringName) -> void:
|
|||||||
if resource_state == null or resource_state.get_reserved_by() != npc.id:
|
if resource_state == null or resource_state.get_reserved_by() != npc.id:
|
||||||
if debug_logs:
|
if debug_logs:
|
||||||
print(
|
print(
|
||||||
|
(
|
||||||
"[SimulationManager] %s could not complete %s: invalid reservation"
|
"[SimulationManager] %s could not complete %s: invalid reservation"
|
||||||
% [npc.npc_name, completed_task]
|
% [npc.npc_name, completed_task]
|
||||||
)
|
)
|
||||||
|
)
|
||||||
release_npc_reservation(npc.id)
|
release_npc_reservation(npc.id)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -289,9 +293,11 @@ func _complete_resource_gather(npc: SimNPC, completed_task: StringName) -> void:
|
|||||||
)
|
)
|
||||||
if debug_logs:
|
if debug_logs:
|
||||||
print(
|
print(
|
||||||
|
(
|
||||||
"[SimulationManager] %s extracted %.1f %s from %s"
|
"[SimulationManager] %s extracted %.1f %s from %s"
|
||||||
% [npc.npc_name, extracted, resource_id, resource_state.get_node_id()]
|
% [npc.npc_name, extracted, resource_id, resource_state.get_node_id()]
|
||||||
)
|
)
|
||||||
|
)
|
||||||
release_npc_reservation(npc.id)
|
release_npc_reservation(npc.id)
|
||||||
|
|
||||||
|
|
||||||
@@ -384,16 +390,12 @@ func notify_npc_arrived(npc_id: int) -> void:
|
|||||||
continue
|
continue
|
||||||
if (
|
if (
|
||||||
other.target_id == npc.target_id
|
other.target_id == npc.target_id
|
||||||
and other.task_state in [
|
and (
|
||||||
SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING
|
other.task_state
|
||||||
]
|
in [SimNPC.TASK_STATE_TRAVELING, SimNPC.TASK_STATE_WORKING]
|
||||||
|
)
|
||||||
):
|
):
|
||||||
npc.familiarity[other.id] = minf(
|
relationship_system.increase_shared_work_familiarity(npc.id, other.id)
|
||||||
float(npc.familiarity.get(other.id, 0.0)) + 0.1, 1.0
|
|
||||||
)
|
|
||||||
other.familiarity[npc.id] = minf(
|
|
||||||
float(other.familiarity.get(npc.id, 0.0)) + 0.1, 1.0
|
|
||||||
)
|
|
||||||
|
|
||||||
if debug_logs:
|
if debug_logs:
|
||||||
print(
|
print(
|
||||||
@@ -491,27 +493,12 @@ func get_activity_target_claim_count(target_id: StringName, except_npc_id: int =
|
|||||||
|
|
||||||
|
|
||||||
func _notify_mourning(dead_npc: SimNPC) -> void:
|
func _notify_mourning(dead_npc: SimNPC) -> void:
|
||||||
var best_id := -1
|
var mourner := relationship_system.get_most_familiar_living_subject(dead_npc.id, npcs)
|
||||||
var best_score := -1.0
|
if mourner == null:
|
||||||
for key in dead_npc.familiarity:
|
|
||||||
var other_id: int = int(key)
|
|
||||||
var score: float = float(dead_npc.familiarity[key])
|
|
||||||
if score > best_score:
|
|
||||||
best_score = score
|
|
||||||
best_id = other_id
|
|
||||||
if best_id < 0:
|
|
||||||
return
|
return
|
||||||
for npc in npcs:
|
mourner.mourning_ticks = 100
|
||||||
if npc.id == best_id and not npc.is_dead:
|
|
||||||
npc.mourning_ticks = 100
|
|
||||||
if debug_logs:
|
if debug_logs:
|
||||||
print(
|
print("[SimulationManager] ", mourner.npc_name, " is mourning ", dead_npc.npc_name)
|
||||||
"[SimulationManager] ",
|
|
||||||
npc.npc_name,
|
|
||||||
" is mourning ",
|
|
||||||
dead_npc.npc_name
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
func _record_economic_event(
|
func _record_economic_event(
|
||||||
@@ -521,19 +508,66 @@ func _record_economic_event(
|
|||||||
destination_id: StringName,
|
destination_id: StringName,
|
||||||
item_id: StringName,
|
item_id: StringName,
|
||||||
amount: float
|
amount: float
|
||||||
|
) -> void:
|
||||||
|
_record_economic_event_at(
|
||||||
|
event_type,
|
||||||
|
actor_id,
|
||||||
|
source_id,
|
||||||
|
destination_id,
|
||||||
|
item_id,
|
||||||
|
amount,
|
||||||
|
_get_event_world_position(actor_id, source_id, destination_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _record_economic_event_at(
|
||||||
|
event_type: StringName,
|
||||||
|
actor_id: int,
|
||||||
|
source_id: StringName,
|
||||||
|
destination_id: StringName,
|
||||||
|
item_id: StringName,
|
||||||
|
amount: float,
|
||||||
|
world_position: Vector3
|
||||||
) -> void:
|
) -> void:
|
||||||
event_log.record_economic(
|
event_log.record_economic(
|
||||||
tick_count, event_type, actor_id, source_id, destination_id, item_id, amount
|
tick_count, event_type, actor_id, source_id, destination_id, item_id, amount, world_position
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
func record_narrative_event(
|
func record_narrative_event(
|
||||||
|
event_type: StringName, actor_id: int, source_id: StringName = &"", action_display: String = ""
|
||||||
|
) -> void:
|
||||||
|
_record_narrative_event_at(
|
||||||
|
event_type,
|
||||||
|
actor_id,
|
||||||
|
source_id,
|
||||||
|
action_display,
|
||||||
|
_get_event_world_position(actor_id, source_id, &"")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _record_narrative_event_at(
|
||||||
event_type: StringName,
|
event_type: StringName,
|
||||||
actor_id: int,
|
actor_id: int,
|
||||||
source_id: StringName = &"",
|
source_id: StringName,
|
||||||
action_display: String = ""
|
action_display: String,
|
||||||
|
world_position: Vector3
|
||||||
) -> void:
|
) -> void:
|
||||||
event_log.record_narrative(tick_count, event_type, actor_id, source_id, action_display)
|
event_log.record_narrative(
|
||||||
|
tick_count, event_type, actor_id, source_id, action_display, world_position
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func _get_event_world_position(
|
||||||
|
actor_id: int, source_id: StringName, destination_id: StringName
|
||||||
|
) -> Vector3:
|
||||||
|
for npc in npcs:
|
||||||
|
if npc.id != actor_id:
|
||||||
|
continue
|
||||||
|
if not npc.target_id.is_empty() and npc.target_id in [source_id, destination_id]:
|
||||||
|
return npc.travel_target_position
|
||||||
|
return npc.position
|
||||||
|
return Vector3.ZERO
|
||||||
|
|
||||||
|
|
||||||
func get_npc_events(npc_id: int, max_count: int = 8) -> Array[EconomicEventRecord]:
|
func get_npc_events(npc_id: int, max_count: int = 8) -> Array[EconomicEventRecord]:
|
||||||
@@ -545,9 +579,43 @@ func get_recent_events(max_count: int = 5) -> Array[EconomicEventRecord]:
|
|||||||
|
|
||||||
|
|
||||||
func _on_economic_event_recorded(event: EconomicEventRecord) -> void:
|
func _on_economic_event_recorded(event: EconomicEventRecord) -> void:
|
||||||
|
var learned_records: Array[KnownEventStateRecord] = event_knowledge_system.observe_event(
|
||||||
|
event, npcs
|
||||||
|
)
|
||||||
|
for learned_record in learned_records:
|
||||||
|
event_knowledge_changed.emit(learned_record.get_knower_id(), event)
|
||||||
|
var knower_ids: Array[int] = event_knowledge_system.get_knowers(int(event.data["event_id"]))
|
||||||
|
var changed_relationships: Array[RelationshipStateRecord] = relationship_system.apply_event(
|
||||||
|
event, npcs, knower_ids
|
||||||
|
)
|
||||||
|
for relationship in changed_relationships:
|
||||||
|
relationship_changed.emit(relationship, event)
|
||||||
economic_event_recorded.emit(event)
|
economic_event_recorded.emit(event)
|
||||||
|
|
||||||
|
|
||||||
|
func get_primary_relationship(npc_id: int) -> RelationshipStateRecord:
|
||||||
|
return relationship_system.get_primary_relationship(npc_id)
|
||||||
|
|
||||||
|
|
||||||
|
func get_relationship_cause(relationship: RelationshipStateRecord) -> EconomicEventRecord:
|
||||||
|
if relationship == null:
|
||||||
|
return null
|
||||||
|
return event_log.get_by_id(relationship.get_last_trust_cause_event_id())
|
||||||
|
|
||||||
|
|
||||||
|
func get_known_events(npc_id: int, max_count: int = 3) -> Array[EconomicEventRecord]:
|
||||||
|
var known: Array[EconomicEventRecord] = []
|
||||||
|
for event_id in event_knowledge_system.get_known_event_ids(npc_id, max_count):
|
||||||
|
var event := event_log.get_by_id(event_id)
|
||||||
|
if event != null:
|
||||||
|
known.append(event)
|
||||||
|
return known
|
||||||
|
|
||||||
|
|
||||||
|
func npc_knows_event(npc_id: int, event_id: int) -> bool:
|
||||||
|
return event_knowledge_system.knows_event(npc_id, event_id)
|
||||||
|
|
||||||
|
|
||||||
func get_current_speed() -> String:
|
func get_current_speed() -> String:
|
||||||
return "%.2fx" % SPEED_LEVELS[speed_index]
|
return "%.2fx" % SPEED_LEVELS[speed_index]
|
||||||
|
|
||||||
@@ -576,9 +644,11 @@ func register_storage_node(node: StorageNode) -> bool:
|
|||||||
var storage_state := storage_states.get(node.storage_id) as StorageStateRecord
|
var storage_state := storage_states.get(node.storage_id) as StorageStateRecord
|
||||||
if storage_state == null:
|
if storage_state == null:
|
||||||
push_error(
|
push_error(
|
||||||
|
(
|
||||||
"SimulationManager: StorageNode '%s' has no authoritative StorageStateRecord"
|
"SimulationManager: StorageNode '%s' has no authoritative StorageStateRecord"
|
||||||
% node.storage_id
|
% node.storage_id
|
||||||
)
|
)
|
||||||
|
)
|
||||||
return false
|
return false
|
||||||
return node.bind_state(storage_state)
|
return node.bind_state(storage_state)
|
||||||
|
|
||||||
@@ -671,7 +741,12 @@ func harvest_resource_node(node: ResourceNode) -> float:
|
|||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
var deposited := economy.deposit_resource(resource_state.get_resource_id(), extracted)
|
var deposited := economy.deposit_resource(resource_state.get_resource_id(), extracted)
|
||||||
_record_economic_event(
|
var event_position := (
|
||||||
|
node.interaction_point.global_position
|
||||||
|
if node.interaction_point != null
|
||||||
|
else node.global_position
|
||||||
|
)
|
||||||
|
_record_economic_event_at(
|
||||||
SimulationIds.EVENT_RESOURCE_EXTRACTED,
|
SimulationIds.EVENT_RESOURCE_EXTRACTED,
|
||||||
-1,
|
-1,
|
||||||
resource_state.get_node_id(),
|
resource_state.get_node_id(),
|
||||||
@@ -685,11 +760,16 @@ func harvest_resource_node(node: ResourceNode) -> float:
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
resource_state.get_resource_id(),
|
resource_state.get_resource_id(),
|
||||||
deposited
|
deposited,
|
||||||
|
event_position
|
||||||
)
|
)
|
||||||
if resource_state.get_amount_remaining() <= 0.0:
|
if resource_state.get_amount_remaining() <= 0.0:
|
||||||
record_narrative_event(
|
_record_narrative_event_at(
|
||||||
SimulationIds.EVENT_RESOURCE_DEPLETED, -1, resource_state.get_node_id()
|
SimulationIds.EVENT_RESOURCE_DEPLETED,
|
||||||
|
-1,
|
||||||
|
resource_state.get_node_id(),
|
||||||
|
"",
|
||||||
|
event_position
|
||||||
)
|
)
|
||||||
village_changed.emit(village)
|
village_changed.emit(village)
|
||||||
|
|
||||||
@@ -818,6 +898,10 @@ func create_state_record() -> SimulationStateRecord:
|
|||||||
record.storages.append(storage_state)
|
record.storages.append(storage_state)
|
||||||
for event in economic_events:
|
for event in economic_events:
|
||||||
record.economic_events.append(event)
|
record.economic_events.append(event)
|
||||||
|
for relationship in relationship_system.get_all_sorted():
|
||||||
|
record.relationships.append(relationship)
|
||||||
|
for known_event in event_knowledge_system.get_all_sorted():
|
||||||
|
record.event_knowledge.append(known_event)
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
|
||||||
@@ -853,6 +937,8 @@ func restore_state(record: SimulationStateRecord) -> bool:
|
|||||||
npcs.clear()
|
npcs.clear()
|
||||||
for npc_record in record.npcs:
|
for npc_record in record.npcs:
|
||||||
npcs.append(npc_record.restore(debug_logs))
|
npcs.append(npc_record.restore(debug_logs))
|
||||||
|
relationship_system.restore(record.relationships)
|
||||||
|
event_knowledge_system.restore(record.event_knowledge)
|
||||||
|
|
||||||
wander_random_sources.clear()
|
wander_random_sources.clear()
|
||||||
var wander_streams: Array = record.simulation["wander_random_streams"]
|
var wander_streams: Array = record.simulation["wander_random_streams"]
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
class_name ActionSelectionSystem
|
class_name ActionSelectionSystem
|
||||||
extends RefCounted
|
extends RefCounted
|
||||||
|
|
||||||
enum SchedulePeriod {
|
enum SchedulePeriod { WORK, SLEEP, MEAL, DISCRETIONARY }
|
||||||
WORK,
|
|
||||||
SLEEP,
|
|
||||||
MEAL,
|
|
||||||
DISCRETIONARY
|
|
||||||
}
|
|
||||||
|
|
||||||
const SLEEP_BEGIN := 0.88
|
const SLEEP_BEGIN := 0.88
|
||||||
const SLEEP_END := 0.15
|
const SLEEP_END := 0.15
|
||||||
@@ -17,9 +12,14 @@ const DINNER_END := 0.8
|
|||||||
const WORK_BEGIN := 0.28
|
const WORK_BEGIN := 0.28
|
||||||
const WORK_END := 0.85
|
const WORK_END := 0.85
|
||||||
const UNAVAILABLE_ACTION_SCORE := -1000000.0
|
const UNAVAILABLE_ACTION_SCORE := -1000000.0
|
||||||
|
const RELATIONSHIP_AID_PANTRY_THRESHOLD := 25.0
|
||||||
|
|
||||||
|
var relationship_system: RefCounted
|
||||||
|
|
||||||
|
|
||||||
func select_action(npc: SimNPC, village: SimVillage, time_of_day: float = 0.5, all_npcs: Array = []) -> ActionSelectionResult:
|
func select_action(
|
||||||
|
npc: SimNPC, village: SimVillage, time_of_day: float = 0.5, all_npcs: Array = []
|
||||||
|
) -> ActionSelectionResult:
|
||||||
if npc.is_dead:
|
if npc.is_dead:
|
||||||
return null
|
return null
|
||||||
|
|
||||||
@@ -70,9 +70,7 @@ func select_action(npc: SimNPC, village: SimVillage, time_of_day: float = 0.5, a
|
|||||||
return ActionSelectionResult.new(
|
return ActionSelectionResult.new(
|
||||||
SimulationIds.ACTION_EAT, -1.0, "Night-time hunger; eating from inventory"
|
SimulationIds.ACTION_EAT, -1.0, "Night-time hunger; eating from inventory"
|
||||||
)
|
)
|
||||||
return ActionSelectionResult.new(
|
return ActionSelectionResult.new(SimulationIds.ACTION_SLEEP, -1.0, "Night-time; going home")
|
||||||
SimulationIds.ACTION_SLEEP, -1.0, "Night-time; going home"
|
|
||||||
)
|
|
||||||
|
|
||||||
if period == SchedulePeriod.MEAL:
|
if period == SchedulePeriod.MEAL:
|
||||||
if npc.hunger > 50.0:
|
if npc.hunger > 50.0:
|
||||||
@@ -112,6 +110,16 @@ func select_action(npc: SimNPC, village: SimVillage, time_of_day: float = 0.5, a
|
|||||||
return ActionSelectionResult.new(
|
return ActionSelectionResult.new(
|
||||||
SimulationIds.ACTION_REST, -1.0, "Energy is below the rest threshold"
|
SimulationIds.ACTION_REST, -1.0, "Energy is below the rest threshold"
|
||||||
)
|
)
|
||||||
|
if village.food <= RELATIONSHIP_AID_PANTRY_THRESHOLD and relationship_system != null:
|
||||||
|
var trusted_starving_npc: SimNPC = relationship_system.get_trusted_starving_subject(
|
||||||
|
npc.id, all_npcs
|
||||||
|
)
|
||||||
|
if trusted_starving_npc != null:
|
||||||
|
return ActionSelectionResult.new(
|
||||||
|
SimulationIds.ACTION_GATHER_FOOD,
|
||||||
|
-1.0,
|
||||||
|
"Helping %s, a trusted acquaintance who is starving" % trusted_starving_npc.npc_name
|
||||||
|
)
|
||||||
|
|
||||||
if period == SchedulePeriod.DISCRETIONARY:
|
if period == SchedulePeriod.DISCRETIONARY:
|
||||||
var roll := npc.random_source.randf()
|
var roll := npc.random_source.randf()
|
||||||
@@ -120,10 +128,10 @@ func select_action(npc: SimNPC, village: SimVillage, time_of_day: float = 0.5, a
|
|||||||
SimulationIds.ACTION_WANDER, -1.0, "Discretionary wander"
|
SimulationIds.ACTION_WANDER, -1.0, "Discretionary wander"
|
||||||
)
|
)
|
||||||
|
|
||||||
return _choose_best_work_action(npc, village, all_npcs)
|
return _choose_best_work_action(npc, village)
|
||||||
|
|
||||||
|
|
||||||
func _choose_best_work_action(npc: SimNPC, village: SimVillage, all_npcs: Array) -> ActionSelectionResult:
|
func _choose_best_work_action(npc: SimNPC, village: SimVillage) -> ActionSelectionResult:
|
||||||
var scores := {
|
var scores := {
|
||||||
SimulationIds.ACTION_GATHER_FOOD:
|
SimulationIds.ACTION_GATHER_FOOD:
|
||||||
_calculate_score(
|
_calculate_score(
|
||||||
@@ -177,16 +185,6 @@ func _choose_best_work_action(npc: SimNPC, village: SimVillage, all_npcs: Array)
|
|||||||
if not rejection.is_empty():
|
if not rejection.is_empty():
|
||||||
scores[action_id] = UNAVAILABLE_ACTION_SCORE
|
scores[action_id] = UNAVAILABLE_ACTION_SCORE
|
||||||
rejections[action_id] = rejection
|
rejections[action_id] = rejection
|
||||||
for familiar_id in npc.familiarity:
|
|
||||||
if not familiar_id is int:
|
|
||||||
continue
|
|
||||||
var other_task: StringName
|
|
||||||
for other in all_npcs:
|
|
||||||
if other.id == familiar_id and not other.is_dead:
|
|
||||||
other_task = other.current_task
|
|
||||||
break
|
|
||||||
if other_task in scores:
|
|
||||||
scores[other_task] = float(scores[other_task]) + 0.3
|
|
||||||
var best_action := SimulationIds.ACTION_GATHER_FOOD
|
var best_action := SimulationIds.ACTION_GATHER_FOOD
|
||||||
var best_score: float = scores[best_action]
|
var best_score: float = scores[best_action]
|
||||||
for action_id in [
|
for action_id in [
|
||||||
@@ -207,11 +205,14 @@ func _get_cost_rejection(definition: ActionDefinition, village: SimVillage) -> S
|
|||||||
var available := _get_village_resource_amount(village, definition.completion_cost_resource_id)
|
var available := _get_village_resource_amount(village, definition.completion_cost_resource_id)
|
||||||
if available >= definition.completion_cost_amount:
|
if available >= definition.completion_cost_amount:
|
||||||
return ""
|
return ""
|
||||||
return "Needs %.0f %s (%.1f available)" % [
|
return (
|
||||||
|
"Needs %.0f %s (%.1f available)"
|
||||||
|
% [
|
||||||
definition.completion_cost_amount,
|
definition.completion_cost_amount,
|
||||||
String(definition.completion_cost_resource_id).capitalize(),
|
String(definition.completion_cost_resource_id).capitalize(),
|
||||||
available,
|
available,
|
||||||
]
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
func _get_village_resource_amount(village: SimVillage, resource_id: StringName) -> float:
|
func _get_village_resource_amount(village: SimVillage, resource_id: StringName) -> float:
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ func _resolve_activity(
|
|||||||
|
|
||||||
var best: Dictionary = {}
|
var best: Dictionary = {}
|
||||||
var best_distance := INF
|
var best_distance := INF
|
||||||
var candidates: Array[Dictionary] = active_world_adapter.get_activity_candidates(npc.current_task)
|
var candidates: Array[Dictionary] = active_world_adapter.get_activity_candidates(
|
||||||
|
npc.current_task
|
||||||
|
)
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
var target_id := StringName(candidate["target_id"])
|
var target_id := StringName(candidate["target_id"])
|
||||||
var capacity := maxi(int(candidate.get("capacity", 1)), 1)
|
var capacity := maxi(int(candidate.get("capacity", 1)), 1)
|
||||||
|
|||||||
@@ -131,8 +131,10 @@ static func validate() -> Array[String]:
|
|||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
not definition.completion_cost_resource_id.is_empty()
|
not definition.completion_cost_resource_id.is_empty()
|
||||||
and definition.completion_cost_resource_id
|
and (
|
||||||
|
definition.completion_cost_resource_id
|
||||||
not in [SimulationIds.RESOURCE_FOOD, SimulationIds.RESOURCE_WOOD]
|
not in [SimulationIds.RESOURCE_FOOD, SimulationIds.RESOURCE_WOOD]
|
||||||
|
)
|
||||||
):
|
):
|
||||||
errors.append(
|
errors.append(
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -144,9 +144,7 @@ func consume_npc_food(npc: SimNPC) -> bool:
|
|||||||
npc.starvation_ticks = 0
|
npc.starvation_ticks = 0
|
||||||
npc.is_starving = npc.hunger >= 90.0
|
npc.is_starving = npc.hunger >= 90.0
|
||||||
inventory_changed.emit(
|
inventory_changed.emit(
|
||||||
npc,
|
npc, SimulationIds.RESOURCE_FOOD, npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD)
|
||||||
SimulationIds.RESOURCE_FOOD,
|
|
||||||
npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD)
|
|
||||||
)
|
)
|
||||||
economic_event_requested.emit(
|
economic_event_requested.emit(
|
||||||
SimulationIds.EVENT_ITEM_CONSUMED,
|
SimulationIds.EVENT_ITEM_CONSUMED,
|
||||||
@@ -167,12 +165,15 @@ func consume_completion_cost(npc: SimNPC, definition: ActionDefinition) -> bool:
|
|||||||
var storage := get_storage_for_resource(resource_id)
|
var storage := get_storage_for_resource(resource_id)
|
||||||
var available := storage.get_amount(resource_id) if storage != null else 0.0
|
var available := storage.get_amount(resource_id) if storage != null else 0.0
|
||||||
if storage == null or available < required_amount:
|
if storage == null or available < required_amount:
|
||||||
var reason := "%s: needs %.0f %s (%.1f available)" % [
|
var reason := (
|
||||||
|
"%s: needs %.0f %s (%.1f available)"
|
||||||
|
% [
|
||||||
definition.display_name,
|
definition.display_name,
|
||||||
required_amount,
|
required_amount,
|
||||||
String(resource_id).capitalize(),
|
String(resource_id).capitalize(),
|
||||||
available,
|
available,
|
||||||
]
|
]
|
||||||
|
)
|
||||||
narrative_event_requested.emit(
|
narrative_event_requested.emit(
|
||||||
SimulationIds.EVENT_TASK_BLOCKED,
|
SimulationIds.EVENT_TASK_BLOCKED,
|
||||||
npc.id,
|
npc.id,
|
||||||
@@ -194,9 +195,11 @@ func consume_completion_cost(npc: SimNPC, definition: ActionDefinition) -> bool:
|
|||||||
)
|
)
|
||||||
if debug_logs:
|
if debug_logs:
|
||||||
print(
|
print(
|
||||||
|
(
|
||||||
"[VillageEconomy] %s consumed %.1f %s for %s"
|
"[VillageEconomy] %s consumed %.1f %s for %s"
|
||||||
% [npc.npc_name, consumed, resource_id, definition.action_id]
|
% [npc.npc_name, consumed, resource_id, definition.action_id]
|
||||||
)
|
)
|
||||||
|
)
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,21 @@ func record_economic(
|
|||||||
source_id: StringName,
|
source_id: StringName,
|
||||||
destination_id: StringName,
|
destination_id: StringName,
|
||||||
item_id: StringName,
|
item_id: StringName,
|
||||||
amount: float
|
amount: float,
|
||||||
|
world_position: Vector3 = Vector3.ZERO
|
||||||
) -> EconomicEventRecord:
|
) -> EconomicEventRecord:
|
||||||
if amount <= 0.0:
|
if amount <= 0.0:
|
||||||
return null
|
return null
|
||||||
var event := EconomicEventRecord.create(
|
var event := EconomicEventRecord.create(
|
||||||
next_event_id, event_type, tick, actor_id, source_id, destination_id, item_id, amount
|
next_event_id,
|
||||||
|
event_type,
|
||||||
|
tick,
|
||||||
|
actor_id,
|
||||||
|
source_id,
|
||||||
|
destination_id,
|
||||||
|
item_id,
|
||||||
|
amount,
|
||||||
|
world_position
|
||||||
)
|
)
|
||||||
_append(event)
|
_append(event)
|
||||||
return event
|
return event
|
||||||
@@ -32,10 +41,11 @@ func record_narrative(
|
|||||||
event_type: StringName,
|
event_type: StringName,
|
||||||
actor_id: int,
|
actor_id: int,
|
||||||
source_id: StringName = &"",
|
source_id: StringName = &"",
|
||||||
action_display: String = ""
|
action_display: String = "",
|
||||||
|
world_position: Vector3 = Vector3.ZERO
|
||||||
) -> EconomicEventRecord:
|
) -> EconomicEventRecord:
|
||||||
var event := EconomicEventRecord.create_narrative(
|
var event := EconomicEventRecord.create_narrative(
|
||||||
next_event_id, event_type, tick, actor_id, source_id, action_display
|
next_event_id, event_type, tick, actor_id, source_id, action_display, world_position
|
||||||
)
|
)
|
||||||
_append(event)
|
_append(event)
|
||||||
return event
|
return event
|
||||||
@@ -62,6 +72,13 @@ func get_recent(max_count: int = 5) -> Array[EconomicEventRecord]:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
func get_by_id(event_id: int) -> EconomicEventRecord:
|
||||||
|
for event in events:
|
||||||
|
if int(event.data["event_id"]) == event_id:
|
||||||
|
return event
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
func get_consumption_rates(
|
func get_consumption_rates(
|
||||||
current_tick: int,
|
current_tick: int,
|
||||||
window_ticks: int = DEFAULT_RATE_WINDOW_TICKS,
|
window_ticks: int = DEFAULT_RATE_WINDOW_TICKS,
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
class_name EventKnowledgeSystem
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const WITNESS_RADIUS := 10.0
|
||||||
|
|
||||||
|
var known_events: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
|
func observe_event(event: EconomicEventRecord, npcs: Array[SimNPC]) -> Array[KnownEventStateRecord]:
|
||||||
|
var learned: Array[KnownEventStateRecord] = []
|
||||||
|
if not _is_witnessable(event):
|
||||||
|
return learned
|
||||||
|
var actor := _find_npc(int(event.data["actor_id"]), npcs)
|
||||||
|
if actor == null:
|
||||||
|
return learned
|
||||||
|
var actor_record := _remember(actor.id, int(event.data["event_id"]))
|
||||||
|
if actor_record != null:
|
||||||
|
learned.append(actor_record)
|
||||||
|
var witness_radius_squared := WITNESS_RADIUS * WITNESS_RADIUS
|
||||||
|
var event_position := event.get_world_position()
|
||||||
|
for npc in npcs:
|
||||||
|
if npc.id == actor.id or npc.is_dead:
|
||||||
|
continue
|
||||||
|
if npc.position.distance_squared_to(event_position) > witness_radius_squared:
|
||||||
|
continue
|
||||||
|
var witness_record := _remember(npc.id, int(event.data["event_id"]))
|
||||||
|
if witness_record != null:
|
||||||
|
learned.append(witness_record)
|
||||||
|
learned.sort_custom(_sort_records)
|
||||||
|
return learned
|
||||||
|
|
||||||
|
|
||||||
|
func knows_event(knower_id: int, event_id: int) -> bool:
|
||||||
|
return known_events.has(_key(knower_id, event_id))
|
||||||
|
|
||||||
|
|
||||||
|
func get_knowers(event_id: int) -> Array[int]:
|
||||||
|
var knower_ids: Array[int] = []
|
||||||
|
for record in known_events.values():
|
||||||
|
if record.get_event_id() == event_id:
|
||||||
|
knower_ids.append(record.get_knower_id())
|
||||||
|
knower_ids.sort()
|
||||||
|
return knower_ids
|
||||||
|
|
||||||
|
|
||||||
|
func get_known_event_ids(knower_id: int, max_count: int = 3) -> Array[int]:
|
||||||
|
var event_ids: Array[int] = []
|
||||||
|
for record in known_events.values():
|
||||||
|
if record.get_knower_id() == knower_id:
|
||||||
|
event_ids.append(record.get_event_id())
|
||||||
|
event_ids.sort()
|
||||||
|
if max_count <= 0 or event_ids.size() <= max_count:
|
||||||
|
return event_ids
|
||||||
|
var recent_ids: Array[int] = []
|
||||||
|
for index in range(event_ids.size() - max_count, event_ids.size()):
|
||||||
|
recent_ids.append(event_ids[index])
|
||||||
|
return recent_ids
|
||||||
|
|
||||||
|
|
||||||
|
func get_all_sorted() -> Array[KnownEventStateRecord]:
|
||||||
|
var records: Array[KnownEventStateRecord] = []
|
||||||
|
for record in known_events.values():
|
||||||
|
records.append(record)
|
||||||
|
records.sort_custom(_sort_records)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
func restore(records: Array[KnownEventStateRecord]) -> void:
|
||||||
|
known_events.clear()
|
||||||
|
for record in records:
|
||||||
|
known_events[_key(record.get_knower_id(), record.get_event_id())] = record
|
||||||
|
|
||||||
|
|
||||||
|
func _remember(knower_id: int, event_id: int) -> KnownEventStateRecord:
|
||||||
|
var key := _key(knower_id, event_id)
|
||||||
|
if known_events.has(key):
|
||||||
|
return null
|
||||||
|
var record := KnownEventStateRecord.create(knower_id, event_id)
|
||||||
|
known_events[key] = record
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
func _is_witnessable(event: EconomicEventRecord) -> bool:
|
||||||
|
return (
|
||||||
|
StringName(event.data["event_type"]) == SimulationIds.EVENT_STORAGE_DEPOSITED
|
||||||
|
and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
|
||||||
|
and float(event.data["amount"]) > 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
static func _find_npc(npc_id: int, npcs: Array[SimNPC]) -> SimNPC:
|
||||||
|
for npc in npcs:
|
||||||
|
if npc.id == npc_id:
|
||||||
|
return npc
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
static func _key(knower_id: int, event_id: int) -> String:
|
||||||
|
return "%d:%d" % [knower_id, event_id]
|
||||||
|
|
||||||
|
|
||||||
|
static func _sort_records(first: KnownEventStateRecord, second: KnownEventStateRecord) -> bool:
|
||||||
|
if first.get_knower_id() != second.get_knower_id():
|
||||||
|
return first.get_knower_id() < second.get_knower_id()
|
||||||
|
return first.get_event_id() < second.get_event_id()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://8qgicjoe2gft
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const AID_HUNGER_THRESHOLD := 80.0
|
||||||
|
const TRUSTED_HELP_THRESHOLD := 0.6
|
||||||
|
const FOOD_AID_TRUST_GAIN := 0.15
|
||||||
|
const SHARED_WORK_FAMILIARITY_GAIN := 0.1
|
||||||
|
|
||||||
|
var relationships: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
|
func initialize_households(npcs: Array[SimNPC]) -> void:
|
||||||
|
relationships.clear()
|
||||||
|
for first_index in range(npcs.size()):
|
||||||
|
for second_index in range(first_index + 1, npcs.size()):
|
||||||
|
var first := npcs[first_index]
|
||||||
|
var second := npcs[second_index]
|
||||||
|
if first.home_position.distance_to(second.home_position) >= 4.0:
|
||||||
|
continue
|
||||||
|
_add(RelationshipStateRecord.create(first.id, second.id, 0.5))
|
||||||
|
_add(RelationshipStateRecord.create(second.id, first.id, 0.5))
|
||||||
|
|
||||||
|
|
||||||
|
func restore(records: Array[RelationshipStateRecord]) -> void:
|
||||||
|
relationships.clear()
|
||||||
|
for record in records:
|
||||||
|
_add(record)
|
||||||
|
|
||||||
|
|
||||||
|
func get_all_sorted() -> Array[RelationshipStateRecord]:
|
||||||
|
var records: Array[RelationshipStateRecord] = []
|
||||||
|
for record in relationships.values():
|
||||||
|
records.append(record)
|
||||||
|
records.sort_custom(_sort_relationships)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
func get_relationship(observer_id: int, subject_id: int) -> RelationshipStateRecord:
|
||||||
|
return relationships.get(_key(observer_id, subject_id)) as RelationshipStateRecord
|
||||||
|
|
||||||
|
|
||||||
|
func increase_shared_work_familiarity(first_id: int, second_id: int) -> void:
|
||||||
|
var first_to_second := _get_or_create(first_id, second_id)
|
||||||
|
var second_to_first := _get_or_create(second_id, first_id)
|
||||||
|
first_to_second.increase_familiarity(SHARED_WORK_FAMILIARITY_GAIN)
|
||||||
|
second_to_first.increase_familiarity(SHARED_WORK_FAMILIARITY_GAIN)
|
||||||
|
|
||||||
|
|
||||||
|
func apply_event(
|
||||||
|
event: EconomicEventRecord, npcs: Array[SimNPC], knower_ids: Array[int]
|
||||||
|
) -> Array[RelationshipStateRecord]:
|
||||||
|
var changed: Array[RelationshipStateRecord] = []
|
||||||
|
if StringName(event.data["event_type"]) != SimulationIds.EVENT_STORAGE_DEPOSITED:
|
||||||
|
return changed
|
||||||
|
if StringName(event.data["item_id"]) != SimulationIds.RESOURCE_FOOD:
|
||||||
|
return changed
|
||||||
|
var contributor_id := int(event.data["actor_id"])
|
||||||
|
var event_id := int(event.data["event_id"])
|
||||||
|
if contributor_id < 0 or event_id < 0 or float(event.data["amount"]) <= 0.0:
|
||||||
|
return changed
|
||||||
|
for observer in npcs:
|
||||||
|
if observer.id == contributor_id or observer.is_dead:
|
||||||
|
continue
|
||||||
|
if observer.id not in knower_ids:
|
||||||
|
continue
|
||||||
|
if observer.hunger < AID_HUNGER_THRESHOLD:
|
||||||
|
continue
|
||||||
|
var relationship := get_relationship(observer.id, contributor_id)
|
||||||
|
if relationship == null or relationship.get_familiarity() <= 0.0:
|
||||||
|
continue
|
||||||
|
if event_id <= relationship.get_last_trust_cause_event_id():
|
||||||
|
continue
|
||||||
|
var applied := relationship.increase_trust(FOOD_AID_TRUST_GAIN, event_id)
|
||||||
|
if applied <= 0.0:
|
||||||
|
continue
|
||||||
|
changed.append(relationship)
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
func get_primary_relationship(observer_id: int) -> RelationshipStateRecord:
|
||||||
|
var best: RelationshipStateRecord
|
||||||
|
for relationship in relationships.values():
|
||||||
|
if relationship.get_observer_id() != observer_id:
|
||||||
|
continue
|
||||||
|
if best == null or _is_stronger(relationship, best):
|
||||||
|
best = relationship
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
func get_most_familiar_living_subject(observer_id: int, npcs: Array[SimNPC]) -> SimNPC:
|
||||||
|
var living_by_id := {}
|
||||||
|
for npc in npcs:
|
||||||
|
if not npc.is_dead:
|
||||||
|
living_by_id[npc.id] = npc
|
||||||
|
var best_relationship: RelationshipStateRecord
|
||||||
|
for relationship in relationships.values():
|
||||||
|
if relationship.get_observer_id() != observer_id:
|
||||||
|
continue
|
||||||
|
if not living_by_id.has(relationship.get_subject_id()):
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
best_relationship == null
|
||||||
|
or relationship.get_familiarity() > best_relationship.get_familiarity()
|
||||||
|
or (
|
||||||
|
is_equal_approx(relationship.get_familiarity(), best_relationship.get_familiarity())
|
||||||
|
and relationship.get_subject_id() < best_relationship.get_subject_id()
|
||||||
|
)
|
||||||
|
):
|
||||||
|
best_relationship = relationship
|
||||||
|
if best_relationship == null:
|
||||||
|
return null
|
||||||
|
return living_by_id[best_relationship.get_subject_id()] as SimNPC
|
||||||
|
|
||||||
|
|
||||||
|
func get_trusted_starving_subject(observer_id: int, npcs: Array[SimNPC]) -> SimNPC:
|
||||||
|
var starving_by_id := {}
|
||||||
|
for npc in npcs:
|
||||||
|
if not npc.is_dead and npc.is_starving:
|
||||||
|
starving_by_id[npc.id] = npc
|
||||||
|
var best_relationship: RelationshipStateRecord
|
||||||
|
for relationship in relationships.values():
|
||||||
|
if relationship.get_observer_id() != observer_id:
|
||||||
|
continue
|
||||||
|
if relationship.get_trust() < TRUSTED_HELP_THRESHOLD:
|
||||||
|
continue
|
||||||
|
if not starving_by_id.has(relationship.get_subject_id()):
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
best_relationship == null
|
||||||
|
or relationship.get_trust() > best_relationship.get_trust()
|
||||||
|
or (
|
||||||
|
is_equal_approx(relationship.get_trust(), best_relationship.get_trust())
|
||||||
|
and relationship.get_subject_id() < best_relationship.get_subject_id()
|
||||||
|
)
|
||||||
|
):
|
||||||
|
best_relationship = relationship
|
||||||
|
if best_relationship == null:
|
||||||
|
return null
|
||||||
|
return starving_by_id[best_relationship.get_subject_id()] as SimNPC
|
||||||
|
|
||||||
|
|
||||||
|
func _get_or_create(observer_id: int, subject_id: int) -> RelationshipStateRecord:
|
||||||
|
var relationship := get_relationship(observer_id, subject_id)
|
||||||
|
if relationship != null:
|
||||||
|
return relationship
|
||||||
|
relationship = RelationshipStateRecord.create(observer_id, subject_id, 0.0)
|
||||||
|
_add(relationship)
|
||||||
|
return relationship
|
||||||
|
|
||||||
|
|
||||||
|
func _add(relationship: RelationshipStateRecord) -> void:
|
||||||
|
relationships[_key(relationship.get_observer_id(), relationship.get_subject_id())] = relationship
|
||||||
|
|
||||||
|
|
||||||
|
static func _key(observer_id: int, subject_id: int) -> String:
|
||||||
|
return "%d:%d" % [observer_id, subject_id]
|
||||||
|
|
||||||
|
|
||||||
|
static func _sort_relationships(
|
||||||
|
first: RelationshipStateRecord, second: RelationshipStateRecord
|
||||||
|
) -> bool:
|
||||||
|
if first.get_observer_id() != second.get_observer_id():
|
||||||
|
return first.get_observer_id() < second.get_observer_id()
|
||||||
|
return first.get_subject_id() < second.get_subject_id()
|
||||||
|
|
||||||
|
|
||||||
|
static func _is_stronger(
|
||||||
|
candidate: RelationshipStateRecord, current: RelationshipStateRecord
|
||||||
|
) -> bool:
|
||||||
|
if not is_equal_approx(candidate.get_trust(), current.get_trust()):
|
||||||
|
return candidate.get_trust() > current.get_trust()
|
||||||
|
if not is_equal_approx(candidate.get_familiarity(), current.get_familiarity()):
|
||||||
|
return candidate.get_familiarity() > current.get_familiarity()
|
||||||
|
return candidate.get_subject_id() < current.get_subject_id()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://d4hehra0mfvdf
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
class_name EconomicEventRecord
|
class_name EconomicEventRecord
|
||||||
extends RefCounted
|
extends RefCounted
|
||||||
|
|
||||||
const SCHEMA_VERSION := 1
|
const SCHEMA_VERSION := 2
|
||||||
|
const LEGACY_SCHEMA_VERSION := 1
|
||||||
|
|
||||||
var data: Dictionary
|
var data: Dictionary
|
||||||
|
|
||||||
@@ -18,7 +19,8 @@ static func create(
|
|||||||
source_id: StringName,
|
source_id: StringName,
|
||||||
destination_id: StringName,
|
destination_id: StringName,
|
||||||
item_id: StringName,
|
item_id: StringName,
|
||||||
amount: float
|
amount: float,
|
||||||
|
world_position: Vector3 = Vector3.ZERO
|
||||||
) -> EconomicEventRecord:
|
) -> EconomicEventRecord:
|
||||||
return EconomicEventRecord.new(
|
return EconomicEventRecord.new(
|
||||||
{
|
{
|
||||||
@@ -30,7 +32,8 @@ static func create(
|
|||||||
"source_id": String(source_id),
|
"source_id": String(source_id),
|
||||||
"destination_id": String(destination_id),
|
"destination_id": String(destination_id),
|
||||||
"item_id": String(item_id),
|
"item_id": String(item_id),
|
||||||
"amount": amount
|
"amount": amount,
|
||||||
|
"world_position": [world_position.x, world_position.y, world_position.z]
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,7 +44,8 @@ static func create_narrative(
|
|||||||
tick: int,
|
tick: int,
|
||||||
actor_id: int,
|
actor_id: int,
|
||||||
source_id: StringName,
|
source_id: StringName,
|
||||||
action_display: String = ""
|
action_display: String = "",
|
||||||
|
world_position: Vector3 = Vector3.ZERO
|
||||||
) -> EconomicEventRecord:
|
) -> EconomicEventRecord:
|
||||||
return EconomicEventRecord.new(
|
return EconomicEventRecord.new(
|
||||||
{
|
{
|
||||||
@@ -54,13 +58,15 @@ static func create_narrative(
|
|||||||
"destination_id": "",
|
"destination_id": "",
|
||||||
"item_id": "",
|
"item_id": "",
|
||||||
"amount": 0.0,
|
"amount": 0.0,
|
||||||
"action_name": action_display
|
"action_name": action_display,
|
||||||
|
"world_position": [world_position.x, world_position.y, world_position.z]
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
|
static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
|
||||||
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION:
|
var version := int(record_data.get("schema_version", -1))
|
||||||
|
if version not in [LEGACY_SCHEMA_VERSION, SCHEMA_VERSION]:
|
||||||
return null
|
return null
|
||||||
if not record_data.has_all(
|
if not record_data.has_all(
|
||||||
[
|
[
|
||||||
@@ -75,6 +81,8 @@ static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
|
|||||||
]
|
]
|
||||||
):
|
):
|
||||||
return null
|
return null
|
||||||
|
if version == SCHEMA_VERSION and not record_data.has("world_position"):
|
||||||
|
return null
|
||||||
var normalized := record_data.duplicate(true)
|
var normalized := record_data.duplicate(true)
|
||||||
normalized["schema_version"] = SCHEMA_VERSION
|
normalized["schema_version"] = SCHEMA_VERSION
|
||||||
normalized["event_id"] = int(record_data["event_id"])
|
normalized["event_id"] = int(record_data["event_id"])
|
||||||
@@ -85,11 +93,13 @@ static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
|
|||||||
normalized["destination_id"] = String(record_data["destination_id"])
|
normalized["destination_id"] = String(record_data["destination_id"])
|
||||||
normalized["item_id"] = String(record_data["item_id"])
|
normalized["item_id"] = String(record_data["item_id"])
|
||||||
normalized["amount"] = float(record_data["amount"])
|
normalized["amount"] = float(record_data["amount"])
|
||||||
if (
|
var saved_position = record_data.get("world_position", [0.0, 0.0, 0.0])
|
||||||
normalized["event_id"] < 0
|
if not saved_position is Array or saved_position.size() != 3:
|
||||||
or normalized["tick"] < 0
|
return null
|
||||||
or normalized["event_type"].is_empty()
|
normalized["world_position"] = [
|
||||||
):
|
float(saved_position[0]), float(saved_position[1]), float(saved_position[2])
|
||||||
|
]
|
||||||
|
if normalized["event_id"] < 0 or normalized["tick"] < 0 or normalized["event_type"].is_empty():
|
||||||
return null
|
return null
|
||||||
return EconomicEventRecord.new(normalized)
|
return EconomicEventRecord.new(normalized)
|
||||||
|
|
||||||
@@ -98,6 +108,11 @@ func to_dictionary() -> Dictionary:
|
|||||||
return data.duplicate(true)
|
return data.duplicate(true)
|
||||||
|
|
||||||
|
|
||||||
|
func get_world_position() -> Vector3:
|
||||||
|
var saved_position: Array = data["world_position"]
|
||||||
|
return Vector3(float(saved_position[0]), float(saved_position[1]), float(saved_position[2]))
|
||||||
|
|
||||||
|
|
||||||
func description(npc_names: Dictionary = {}) -> String:
|
func description(npc_names: Dictionary = {}) -> String:
|
||||||
var actor_name: String = npc_names.get(int(data["actor_id"]), "Someone")
|
var actor_name: String = npc_names.get(int(data["actor_id"]), "Someone")
|
||||||
var item: String = str(data["item_id"])
|
var item: String = str(data["item_id"])
|
||||||
|
|||||||
@@ -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
|
class_name NPCStateRecord
|
||||||
extends RefCounted
|
extends RefCounted
|
||||||
|
|
||||||
const SCHEMA_VERSION := 3
|
const SCHEMA_VERSION := 4
|
||||||
const LEGACY_SCHEMA_VERSION := 1
|
const LEGACY_SCHEMA_VERSION := 1
|
||||||
const PREVIOUS_SCHEMA_VERSION := 2
|
const PREVIOUS_SCHEMA_VERSION := 2
|
||||||
|
const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3
|
||||||
|
|
||||||
var data: Dictionary
|
var data: Dictionary
|
||||||
|
|
||||||
@@ -46,7 +47,6 @@ static func capture(npc: SimNPC) -> NPCStateRecord:
|
|||||||
"last_task": String(npc.last_task),
|
"last_task": String(npc.last_task),
|
||||||
"random_seed": str(npc.random_source.seed),
|
"random_seed": str(npc.random_source.seed),
|
||||||
"random_state": str(npc.random_source.state),
|
"random_state": str(npc.random_source.state),
|
||||||
"familiarity": _sorted_familiarity(npc.familiarity),
|
|
||||||
"mourning_ticks": npc.mourning_ticks
|
"mourning_ticks": npc.mourning_ticks
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -54,7 +54,10 @@ static func capture(npc: SimNPC) -> NPCStateRecord:
|
|||||||
|
|
||||||
static func from_dictionary(record_data: Dictionary) -> NPCStateRecord:
|
static func from_dictionary(record_data: Dictionary) -> NPCStateRecord:
|
||||||
var version := int(record_data.get("schema_version", -1))
|
var version := int(record_data.get("schema_version", -1))
|
||||||
if version in [LEGACY_SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION]:
|
if (
|
||||||
|
version
|
||||||
|
in [LEGACY_SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, RELATIONSHIP_LEGACY_SCHEMA_VERSION]
|
||||||
|
):
|
||||||
record_data = _migrate_legacy(record_data, version)
|
record_data = _migrate_legacy(record_data, version)
|
||||||
elif version != SCHEMA_VERSION:
|
elif version != SCHEMA_VERSION:
|
||||||
return null
|
return null
|
||||||
@@ -116,7 +119,9 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
|
|||||||
if version == LEGACY_SCHEMA_VERSION:
|
if version == LEGACY_SCHEMA_VERSION:
|
||||||
migrated["travel_target_position"] = legacy_data.get("position", [0.0, 0.0, 0.0])
|
migrated["travel_target_position"] = legacy_data.get("position", [0.0, 0.0, 0.0])
|
||||||
migrated["has_travel_target"] = false
|
migrated["has_travel_target"] = false
|
||||||
|
if version in [LEGACY_SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION]:
|
||||||
migrated["inventory"] = {}
|
migrated["inventory"] = {}
|
||||||
|
migrated.erase("familiarity")
|
||||||
return migrated
|
return migrated
|
||||||
|
|
||||||
|
|
||||||
@@ -141,9 +146,7 @@ func restore(debug_logs: bool) -> SimNPC:
|
|||||||
float(saved_position[0]), float(saved_position[1]), float(saved_position[2])
|
float(saved_position[0]), float(saved_position[1]), float(saved_position[2])
|
||||||
)
|
)
|
||||||
var saved_home: Array = data.get("home_position", saved_position)
|
var saved_home: Array = data.get("home_position", saved_position)
|
||||||
npc.home_position = Vector3(
|
npc.home_position = Vector3(float(saved_home[0]), float(saved_home[1]), float(saved_home[2]))
|
||||||
float(saved_home[0]), float(saved_home[1]), float(saved_home[2])
|
|
||||||
)
|
|
||||||
npc.is_starving = bool(data["is_starving"])
|
npc.is_starving = bool(data["is_starving"])
|
||||||
npc.starvation_ticks = int(data["starvation_ticks"])
|
npc.starvation_ticks = int(data["starvation_ticks"])
|
||||||
npc.starvation_death_threshold = int(data["starvation_death_threshold"])
|
npc.starvation_death_threshold = int(data["starvation_death_threshold"])
|
||||||
@@ -162,28 +165,9 @@ func restore(debug_logs: bool) -> SimNPC:
|
|||||||
npc.last_task = StringName(data["last_task"])
|
npc.last_task = StringName(data["last_task"])
|
||||||
npc.random_source.state = String(data["random_state"]).to_int()
|
npc.random_source.state = String(data["random_state"]).to_int()
|
||||||
npc.debug_logs = debug_logs
|
npc.debug_logs = debug_logs
|
||||||
var saved_familiarity = data.get("familiarity", {})
|
|
||||||
if saved_familiarity is Array:
|
|
||||||
for pair in saved_familiarity:
|
|
||||||
var pair_dict: Dictionary = pair
|
|
||||||
npc.familiarity[int(pair_dict["id"])] = float(pair_dict["score"])
|
|
||||||
elif saved_familiarity is Dictionary:
|
|
||||||
npc.familiarity = saved_familiarity.duplicate(true)
|
|
||||||
npc.mourning_ticks = int(data.get("mourning_ticks", 0))
|
npc.mourning_ticks = int(data.get("mourning_ticks", 0))
|
||||||
return npc
|
return npc
|
||||||
|
|
||||||
|
|
||||||
func to_dictionary() -> Dictionary:
|
func to_dictionary() -> Dictionary:
|
||||||
return data.duplicate(true)
|
return data.duplicate(true)
|
||||||
|
|
||||||
|
|
||||||
static func _sorted_familiarity(familiarity: Dictionary) -> Array:
|
|
||||||
var pairs: Array[Dictionary] = []
|
|
||||||
for key in familiarity:
|
|
||||||
pairs.append({"id": int(key), "score": float(familiarity[key])})
|
|
||||||
pairs.sort_custom(_familiarity_sort)
|
|
||||||
return pairs
|
|
||||||
|
|
||||||
|
|
||||||
static func _familiarity_sort(a: Dictionary, b: Dictionary) -> bool:
|
|
||||||
return int(a["id"]) < int(b["id"])
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
class_name RelationshipStateRecord
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const SCHEMA_VERSION := 1
|
||||||
|
const NO_CAUSE_EVENT := -1
|
||||||
|
const NEUTRAL_TRUST := 0.5
|
||||||
|
|
||||||
|
var data: Dictionary
|
||||||
|
|
||||||
|
|
||||||
|
func _init(record_data: Dictionary = {}) -> void:
|
||||||
|
data = record_data.duplicate(true)
|
||||||
|
|
||||||
|
|
||||||
|
static func create(
|
||||||
|
observer_id: int,
|
||||||
|
subject_id: int,
|
||||||
|
familiarity: float,
|
||||||
|
trust: float = NEUTRAL_TRUST,
|
||||||
|
last_trust_cause_event_id: int = NO_CAUSE_EVENT
|
||||||
|
) -> RelationshipStateRecord:
|
||||||
|
return (
|
||||||
|
RelationshipStateRecord
|
||||||
|
. new(
|
||||||
|
{
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"observer_id": observer_id,
|
||||||
|
"subject_id": subject_id,
|
||||||
|
"familiarity": clampf(familiarity, 0.0, 1.0),
|
||||||
|
"trust": clampf(trust, 0.0, 1.0),
|
||||||
|
"last_trust_cause_event_id": last_trust_cause_event_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
static func from_dictionary(record_data: Dictionary) -> RelationshipStateRecord:
|
||||||
|
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION:
|
||||||
|
return null
|
||||||
|
if not record_data.has_all(
|
||||||
|
["observer_id", "subject_id", "familiarity", "trust", "last_trust_cause_event_id"]
|
||||||
|
):
|
||||||
|
return null
|
||||||
|
var observer_id := int(record_data["observer_id"])
|
||||||
|
var subject_id := int(record_data["subject_id"])
|
||||||
|
var familiarity := float(record_data["familiarity"])
|
||||||
|
var trust := float(record_data["trust"])
|
||||||
|
var cause_event_id := int(record_data["last_trust_cause_event_id"])
|
||||||
|
if observer_id < 0 or subject_id < 0 or observer_id == subject_id:
|
||||||
|
return null
|
||||||
|
if familiarity < 0.0 or familiarity > 1.0 or trust < 0.0 or trust > 1.0:
|
||||||
|
return null
|
||||||
|
if cause_event_id < NO_CAUSE_EVENT:
|
||||||
|
return null
|
||||||
|
return create(observer_id, subject_id, familiarity, trust, cause_event_id)
|
||||||
|
|
||||||
|
|
||||||
|
func get_observer_id() -> int:
|
||||||
|
return int(data["observer_id"])
|
||||||
|
|
||||||
|
|
||||||
|
func get_subject_id() -> int:
|
||||||
|
return int(data["subject_id"])
|
||||||
|
|
||||||
|
|
||||||
|
func get_familiarity() -> float:
|
||||||
|
return float(data["familiarity"])
|
||||||
|
|
||||||
|
|
||||||
|
func get_trust() -> float:
|
||||||
|
return float(data["trust"])
|
||||||
|
|
||||||
|
|
||||||
|
func get_last_trust_cause_event_id() -> int:
|
||||||
|
return int(data["last_trust_cause_event_id"])
|
||||||
|
|
||||||
|
|
||||||
|
func increase_familiarity(amount: float) -> float:
|
||||||
|
var previous := get_familiarity()
|
||||||
|
data["familiarity"] = clampf(previous + maxf(amount, 0.0), 0.0, 1.0)
|
||||||
|
return get_familiarity() - previous
|
||||||
|
|
||||||
|
|
||||||
|
func increase_trust(amount: float, cause_event_id: int) -> float:
|
||||||
|
var previous := get_trust()
|
||||||
|
data["trust"] = clampf(previous + maxf(amount, 0.0), 0.0, 1.0)
|
||||||
|
var applied := get_trust() - previous
|
||||||
|
if applied > 0.0:
|
||||||
|
data["last_trust_cause_event_id"] = cause_event_id
|
||||||
|
return applied
|
||||||
|
|
||||||
|
|
||||||
|
func to_dictionary() -> Dictionary:
|
||||||
|
return data.duplicate(true)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bxvc6flu3fdka
|
||||||
@@ -2,9 +2,11 @@ class_name SimulationStateRecord
|
|||||||
extends RefCounted
|
extends RefCounted
|
||||||
|
|
||||||
const SCHEMA_NAME := "the_steward.simulation"
|
const SCHEMA_NAME := "the_steward.simulation"
|
||||||
const SCHEMA_VERSION := 3
|
const SCHEMA_VERSION := 5
|
||||||
const LEGACY_SCHEMA_VERSION := 1
|
const LEGACY_SCHEMA_VERSION := 1
|
||||||
const PREVIOUS_SCHEMA_VERSION := 2
|
const EVENT_LEGACY_SCHEMA_VERSION := 2
|
||||||
|
const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3
|
||||||
|
const PREVIOUS_SCHEMA_VERSION := 4
|
||||||
|
|
||||||
var simulation: Dictionary
|
var simulation: Dictionary
|
||||||
var village: VillageStateRecord
|
var village: VillageStateRecord
|
||||||
@@ -12,6 +14,8 @@ var npcs: Array[NPCStateRecord] = []
|
|||||||
var resources: Array[ResourceStateRecord] = []
|
var resources: Array[ResourceStateRecord] = []
|
||||||
var storages: Array[StorageStateRecord] = []
|
var storages: Array[StorageStateRecord] = []
|
||||||
var economic_events: Array[EconomicEventRecord] = []
|
var economic_events: Array[EconomicEventRecord] = []
|
||||||
|
var relationships: Array[RelationshipStateRecord] = []
|
||||||
|
var event_knowledge: Array[KnownEventStateRecord] = []
|
||||||
|
|
||||||
|
|
||||||
func to_dictionary() -> Dictionary:
|
func to_dictionary() -> Dictionary:
|
||||||
@@ -28,6 +32,12 @@ func to_dictionary() -> Dictionary:
|
|||||||
var event_data: Array[Dictionary] = []
|
var event_data: Array[Dictionary] = []
|
||||||
for event_record in economic_events:
|
for event_record in economic_events:
|
||||||
event_data.append(event_record.to_dictionary())
|
event_data.append(event_record.to_dictionary())
|
||||||
|
var relationship_data: Array[Dictionary] = []
|
||||||
|
for relationship_record in relationships:
|
||||||
|
relationship_data.append(relationship_record.to_dictionary())
|
||||||
|
var knowledge_data: Array[Dictionary] = []
|
||||||
|
for known_event_record in event_knowledge:
|
||||||
|
knowledge_data.append(known_event_record.to_dictionary())
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"schema": SCHEMA_NAME,
|
"schema": SCHEMA_NAME,
|
||||||
@@ -37,7 +47,9 @@ func to_dictionary() -> Dictionary:
|
|||||||
"npcs": npc_data,
|
"npcs": npc_data,
|
||||||
"resources": resource_data,
|
"resources": resource_data,
|
||||||
"storages": storage_data,
|
"storages": storage_data,
|
||||||
"economic_events": event_data
|
"economic_events": event_data,
|
||||||
|
"relationships": relationship_data,
|
||||||
|
"event_knowledge": knowledge_data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -56,12 +68,32 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
|||||||
if record_data.get("schema", "") != SCHEMA_NAME:
|
if record_data.get("schema", "") != SCHEMA_NAME:
|
||||||
return null
|
return null
|
||||||
var version := int(record_data.get("schema_version", -1))
|
var version := int(record_data.get("schema_version", -1))
|
||||||
if version in [LEGACY_SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION]:
|
if (
|
||||||
|
version
|
||||||
|
in [
|
||||||
|
LEGACY_SCHEMA_VERSION,
|
||||||
|
EVENT_LEGACY_SCHEMA_VERSION,
|
||||||
|
RELATIONSHIP_LEGACY_SCHEMA_VERSION,
|
||||||
|
PREVIOUS_SCHEMA_VERSION,
|
||||||
|
]
|
||||||
|
):
|
||||||
record_data = _migrate_legacy(record_data, version)
|
record_data = _migrate_legacy(record_data, version)
|
||||||
elif version != SCHEMA_VERSION:
|
elif version != SCHEMA_VERSION:
|
||||||
return null
|
return null
|
||||||
if not record_data.has_all(
|
if not (
|
||||||
["simulation", "village", "npcs", "resources", "storages", "economic_events"]
|
record_data
|
||||||
|
. has_all(
|
||||||
|
[
|
||||||
|
"simulation",
|
||||||
|
"village",
|
||||||
|
"npcs",
|
||||||
|
"resources",
|
||||||
|
"storages",
|
||||||
|
"economic_events",
|
||||||
|
"relationships",
|
||||||
|
"event_knowledge",
|
||||||
|
]
|
||||||
|
)
|
||||||
):
|
):
|
||||||
return null
|
return null
|
||||||
|
|
||||||
@@ -100,11 +132,15 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
|||||||
var resource_data = record_data["resources"]
|
var resource_data = record_data["resources"]
|
||||||
var storage_data = record_data["storages"]
|
var storage_data = record_data["storages"]
|
||||||
var event_data = record_data["economic_events"]
|
var event_data = record_data["economic_events"]
|
||||||
|
var relationship_data = record_data["relationships"]
|
||||||
|
var knowledge_data = record_data["event_knowledge"]
|
||||||
if (
|
if (
|
||||||
not npc_data is Array
|
not npc_data is Array
|
||||||
or not resource_data is Array
|
or not resource_data is Array
|
||||||
or not storage_data is Array
|
or not storage_data is Array
|
||||||
or not event_data is Array
|
or not event_data is Array
|
||||||
|
or not relationship_data is Array
|
||||||
|
or not knowledge_data is Array
|
||||||
):
|
):
|
||||||
return null
|
return null
|
||||||
|
|
||||||
@@ -152,6 +188,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
|||||||
record.storages.append(storage_record)
|
record.storages.append(storage_record)
|
||||||
|
|
||||||
var event_ids := {}
|
var event_ids := {}
|
||||||
|
var event_records_by_id := {}
|
||||||
var highest_event_id := -1
|
var highest_event_id := -1
|
||||||
for item in event_data:
|
for item in event_data:
|
||||||
if not item is Dictionary:
|
if not item is Dictionary:
|
||||||
@@ -163,11 +200,69 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
|||||||
if event_ids.has(event_id):
|
if event_ids.has(event_id):
|
||||||
return null
|
return null
|
||||||
event_ids[event_id] = true
|
event_ids[event_id] = true
|
||||||
|
event_records_by_id[event_id] = event_record
|
||||||
highest_event_id = maxi(highest_event_id, event_id)
|
highest_event_id = maxi(highest_event_id, event_id)
|
||||||
record.economic_events.append(event_record)
|
record.economic_events.append(event_record)
|
||||||
if int(record.simulation["next_event_id"]) <= highest_event_id:
|
if int(record.simulation["next_event_id"]) <= highest_event_id:
|
||||||
return null
|
return null
|
||||||
|
|
||||||
|
var knowledge_keys := {}
|
||||||
|
for item in knowledge_data:
|
||||||
|
if not item is Dictionary:
|
||||||
|
return null
|
||||||
|
var known_event_record := KnownEventStateRecord.from_dictionary(item)
|
||||||
|
if known_event_record == null:
|
||||||
|
return null
|
||||||
|
var knower_id := known_event_record.get_knower_id()
|
||||||
|
var known_event_id := known_event_record.get_event_id()
|
||||||
|
if not npc_ids.has(knower_id) or not event_ids.has(known_event_id):
|
||||||
|
return null
|
||||||
|
var knowledge_key := "%d:%d" % [knower_id, known_event_id]
|
||||||
|
if knowledge_keys.has(knowledge_key):
|
||||||
|
return null
|
||||||
|
knowledge_keys[knowledge_key] = true
|
||||||
|
record.event_knowledge.append(known_event_record)
|
||||||
|
|
||||||
|
var relationship_keys := {}
|
||||||
|
for item in relationship_data:
|
||||||
|
if not item is Dictionary:
|
||||||
|
return null
|
||||||
|
var relationship_record := RelationshipStateRecord.from_dictionary(item)
|
||||||
|
if relationship_record == null:
|
||||||
|
return null
|
||||||
|
var observer_id := relationship_record.get_observer_id()
|
||||||
|
var subject_id := relationship_record.get_subject_id()
|
||||||
|
if not npc_ids.has(observer_id) or not npc_ids.has(subject_id):
|
||||||
|
return null
|
||||||
|
var relationship_key := "%d:%d" % [observer_id, subject_id]
|
||||||
|
if relationship_keys.has(relationship_key):
|
||||||
|
return null
|
||||||
|
var cause_event_id := relationship_record.get_last_trust_cause_event_id()
|
||||||
|
if (
|
||||||
|
cause_event_id != RelationshipStateRecord.NO_CAUSE_EVENT
|
||||||
|
and not event_ids.has(cause_event_id)
|
||||||
|
):
|
||||||
|
return null
|
||||||
|
if (
|
||||||
|
cause_event_id != RelationshipStateRecord.NO_CAUSE_EVENT
|
||||||
|
and not knowledge_keys.has("%d:%d" % [observer_id, cause_event_id])
|
||||||
|
):
|
||||||
|
return null
|
||||||
|
if cause_event_id != RelationshipStateRecord.NO_CAUSE_EVENT:
|
||||||
|
var cause_event := event_records_by_id[cause_event_id] as EconomicEventRecord
|
||||||
|
if (
|
||||||
|
int(cause_event.data["actor_id"]) != subject_id
|
||||||
|
or (
|
||||||
|
StringName(cause_event.data["event_type"])
|
||||||
|
!= SimulationIds.EVENT_STORAGE_DEPOSITED
|
||||||
|
)
|
||||||
|
or StringName(cause_event.data["item_id"]) != SimulationIds.RESOURCE_FOOD
|
||||||
|
or float(cause_event.data["amount"]) <= 0.0
|
||||||
|
):
|
||||||
|
return null
|
||||||
|
relationship_keys[relationship_key] = true
|
||||||
|
record.relationships.append(relationship_record)
|
||||||
|
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
|
||||||
@@ -196,8 +291,89 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
|
|||||||
. to_dictionary()
|
. to_dictionary()
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
if version in [LEGACY_SCHEMA_VERSION, EVENT_LEGACY_SCHEMA_VERSION]:
|
||||||
migrated["economic_events"] = []
|
migrated["economic_events"] = []
|
||||||
var simulation_data: Dictionary = migrated.get("simulation", {})
|
var simulation_data: Dictionary = migrated.get("simulation", {})
|
||||||
simulation_data["next_event_id"] = 0
|
simulation_data["next_event_id"] = 0
|
||||||
migrated["simulation"] = simulation_data
|
migrated["simulation"] = simulation_data
|
||||||
|
if (
|
||||||
|
version
|
||||||
|
in [LEGACY_SCHEMA_VERSION, EVENT_LEGACY_SCHEMA_VERSION, RELATIONSHIP_LEGACY_SCHEMA_VERSION]
|
||||||
|
):
|
||||||
|
migrated["relationships"] = _migrate_npc_familiarity(legacy_data.get("npcs", []))
|
||||||
|
migrated["event_knowledge"] = _migrate_relationship_causes(migrated.get("relationships", []))
|
||||||
return migrated
|
return migrated
|
||||||
|
|
||||||
|
|
||||||
|
static func _migrate_npc_familiarity(npc_data: Variant) -> Array[Dictionary]:
|
||||||
|
var migrated_relationships: Array[Dictionary] = []
|
||||||
|
if not npc_data is Array:
|
||||||
|
return migrated_relationships
|
||||||
|
for npc_item in npc_data:
|
||||||
|
if not npc_item is Dictionary:
|
||||||
|
continue
|
||||||
|
var observer_id := int(npc_item.get("id", -1))
|
||||||
|
var familiarity = npc_item.get("familiarity", [])
|
||||||
|
if familiarity is Array:
|
||||||
|
for pair in familiarity:
|
||||||
|
if not pair is Dictionary:
|
||||||
|
continue
|
||||||
|
migrated_relationships.append(
|
||||||
|
(
|
||||||
|
RelationshipStateRecord
|
||||||
|
. create(
|
||||||
|
observer_id, int(pair.get("id", -1)), float(pair.get("score", 0.0))
|
||||||
|
)
|
||||||
|
. to_dictionary()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif familiarity is Dictionary:
|
||||||
|
for subject_id in familiarity:
|
||||||
|
migrated_relationships.append(
|
||||||
|
(
|
||||||
|
RelationshipStateRecord
|
||||||
|
. create(observer_id, int(subject_id), float(familiarity[subject_id]))
|
||||||
|
. to_dictionary()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
migrated_relationships.sort_custom(_relationship_dictionary_sort)
|
||||||
|
return migrated_relationships
|
||||||
|
|
||||||
|
|
||||||
|
static func _relationship_dictionary_sort(first: Dictionary, second: Dictionary) -> bool:
|
||||||
|
if int(first["observer_id"]) != int(second["observer_id"]):
|
||||||
|
return int(first["observer_id"]) < int(second["observer_id"])
|
||||||
|
return int(first["subject_id"]) < int(second["subject_id"])
|
||||||
|
|
||||||
|
|
||||||
|
static func _migrate_relationship_causes(relationship_data: Variant) -> Array[Dictionary]:
|
||||||
|
var migrated_knowledge: Array[Dictionary] = []
|
||||||
|
var known_keys := {}
|
||||||
|
if not relationship_data is Array:
|
||||||
|
return migrated_knowledge
|
||||||
|
for relationship_item in relationship_data:
|
||||||
|
if not relationship_item is Dictionary:
|
||||||
|
continue
|
||||||
|
var observer_id := int(relationship_item.get("observer_id", -1))
|
||||||
|
var cause_event_id := int(
|
||||||
|
relationship_item.get(
|
||||||
|
"last_trust_cause_event_id", RelationshipStateRecord.NO_CAUSE_EVENT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if observer_id < 0 or cause_event_id < 0:
|
||||||
|
continue
|
||||||
|
var knowledge_key := "%d:%d" % [observer_id, cause_event_id]
|
||||||
|
if known_keys.has(knowledge_key):
|
||||||
|
continue
|
||||||
|
known_keys[knowledge_key] = true
|
||||||
|
migrated_knowledge.append(
|
||||||
|
KnownEventStateRecord.create(observer_id, cause_event_id).to_dictionary()
|
||||||
|
)
|
||||||
|
migrated_knowledge.sort_custom(_knowledge_dictionary_sort)
|
||||||
|
return migrated_knowledge
|
||||||
|
|
||||||
|
|
||||||
|
static func _knowledge_dictionary_sort(first: Dictionary, second: Dictionary) -> bool:
|
||||||
|
if int(first["knower_id"]) != int(second["knower_id"]):
|
||||||
|
return int(first["knower_id"]) < int(second["knower_id"])
|
||||||
|
return int(first["event_id"]) < int(second["event_id"])
|
||||||
|
|||||||
@@ -85,9 +85,7 @@ func _test_selection_and_execution_are_separate() -> void:
|
|||||||
_check(
|
_check(
|
||||||
(
|
(
|
||||||
unfunded_selection.rejections.has(SimulationIds.ACTION_PATROL)
|
unfunded_selection.rejections.has(SimulationIds.ACTION_PATROL)
|
||||||
and "Needs 1 Wood" in String(
|
and "Needs 1 Wood" in String(unfunded_selection.rejections[SimulationIds.ACTION_PATROL])
|
||||||
unfunded_selection.rejections[SimulationIds.ACTION_PATROL]
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
"Selection should expose a readable definition-backed rejection reason"
|
"Selection should expose a readable definition-backed rejection reason"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -208,8 +208,7 @@ func _test_wood_work_requires_material() -> void:
|
|||||||
_check(
|
_check(
|
||||||
(
|
(
|
||||||
not blocked_events.is_empty()
|
not blocked_events.is_empty()
|
||||||
and StringName(blocked_events[0].data["event_type"])
|
and StringName(blocked_events[0].data["event_type"]) == SimulationIds.EVENT_TASK_BLOCKED
|
||||||
== SimulationIds.EVENT_TASK_BLOCKED
|
|
||||||
and "needs 1 Wood" in String(blocked_events[0].data.get("action_name", ""))
|
and "needs 1 Wood" in String(blocked_events[0].data.get("action_name", ""))
|
||||||
),
|
),
|
||||||
"An unpaid completion cost should create a readable blocked-task fact"
|
"An unpaid completion cost should create a readable blocked-task fact"
|
||||||
|
|||||||
@@ -16,8 +16,56 @@ func _run() -> void:
|
|||||||
|
|
||||||
var simulation_manager: Node = main_scene.get_node("SimulationManager")
|
var simulation_manager: Node = main_scene.get_node("SimulationManager")
|
||||||
simulation_manager.set_process(false)
|
simulation_manager.set_process(false)
|
||||||
|
var saved_clock_ticks: int = simulation_manager.clock.elapsed_ticks
|
||||||
|
simulation_manager.clock.elapsed_ticks = 0
|
||||||
|
await process_frame
|
||||||
|
await process_frame
|
||||||
|
var environment: Environment = (
|
||||||
|
(main_scene.get_node("JajceWorld/WorldEnvironment") as WorldEnvironment).environment
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
is_equal_approx(environment.ambient_light_energy, 0.25),
|
||||||
|
"Day/night presentation should follow the authoritative simulation clock"
|
||||||
|
)
|
||||||
|
simulation_manager.clock.elapsed_ticks = saved_clock_ticks
|
||||||
|
await process_frame
|
||||||
|
|
||||||
_check(simulation_manager.npcs.size() == 6, "Baseline should create six NPCs")
|
_check(simulation_manager.npcs.size() == 6, "Baseline should create six NPCs")
|
||||||
|
var inspector_label := (
|
||||||
|
main_scene.get_node("UI/NpcInspectorPanel/MarginContainer/NpcInspectorLabel") as Label
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
"Relationship:" in inspector_label.text
|
||||||
|
and "familiar 50%" in inspector_label.text
|
||||||
|
and "trust 50%" in inspector_label.text
|
||||||
|
),
|
||||||
|
"Runtime inspector should render authoritative directed relationship state"
|
||||||
|
)
|
||||||
|
var contributor: SimNPC = simulation_manager.npcs[0]
|
||||||
|
var witness: SimNPC = simulation_manager.npcs[1]
|
||||||
|
contributor.position = Vector3.ZERO
|
||||||
|
witness.position = Vector3(4.0, 0.0, 0.0)
|
||||||
|
witness.hunger = 85.0
|
||||||
|
contributor.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0)
|
||||||
|
_check(
|
||||||
|
is_equal_approx(
|
||||||
|
simulation_manager.economy.deposit_inventory(contributor, SimulationIds.RESOURCE_FOOD),
|
||||||
|
1.0
|
||||||
|
),
|
||||||
|
"Runtime knowledge UI check should use a completed simulation transaction"
|
||||||
|
)
|
||||||
|
var village_ui := main_scene.get_node("UI")
|
||||||
|
village_ui.selected_npc_index = witness.id
|
||||||
|
village_ui.call("_refresh_npc_inspector")
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
"Known fact: %s deposited" % contributor.npc_name in inspector_label.text
|
||||||
|
and "Relationship: %s" % contributor.npc_name in inspector_label.text
|
||||||
|
and "Because:" in inspector_label.text
|
||||||
|
),
|
||||||
|
"NPC inspector should show the witnessed fact and its relationship consequence"
|
||||||
|
)
|
||||||
_check(
|
_check(
|
||||||
main_scene.has_node("JajceWorld/TerrainRoot/Terrain3D"),
|
main_scene.has_node("JajceWorld/TerrainRoot/Terrain3D"),
|
||||||
"Playable runtime should instance the Jajce Terrain3D world"
|
"Playable runtime should instance the Jajce Terrain3D world"
|
||||||
@@ -39,6 +87,21 @@ func _run() -> void:
|
|||||||
"Presentation preset should pull the camera back from the village focus"
|
"Presentation preset should pull the camera back from the village focus"
|
||||||
)
|
)
|
||||||
_check(terrain.collision_mode != 0, "Runtime Terrain3D collision should be enabled")
|
_check(terrain.collision_mode != 0, "Runtime Terrain3D collision should be enabled")
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
main_scene.has_node("Player/Visual/Body")
|
||||||
|
and main_scene.has_node("Player/Visual/Head")
|
||||||
|
and main_scene.has_node("Player/Visual/Scarf")
|
||||||
|
),
|
||||||
|
"Player should use the same readable multi-part visual language as villagers"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
not main_scene.has_node("Player/MeshInstance3D")
|
||||||
|
and not main_scene.has_node("Player/FaceMarker")
|
||||||
|
),
|
||||||
|
"Runtime should not retain the placeholder player capsule presentation"
|
||||||
|
)
|
||||||
_check(
|
_check(
|
||||||
not main_scene.has_node("JajceWorld/NavigationRegion3D/GreyboxGround"),
|
not main_scene.has_node("JajceWorld/NavigationRegion3D/GreyboxGround"),
|
||||||
"Runtime should not keep the temporary greybox navigation ground"
|
"Runtime should not keep the temporary greybox navigation ground"
|
||||||
@@ -53,7 +116,9 @@ func _run() -> void:
|
|||||||
"Runtime should not retain duplicate flat-world objects"
|
"Runtime should not retain duplicate flat-world objects"
|
||||||
)
|
)
|
||||||
var resource_root := main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes")
|
var resource_root := main_scene.get_node("JajceWorld/WorldObjects/ResourceNodes")
|
||||||
_check(resource_root.get_child_count() == 18, "Runtime should contain eighteen Jajce ResourceNodes")
|
_check(
|
||||||
|
resource_root.get_child_count() == 18, "Runtime should contain eighteen Jajce ResourceNodes"
|
||||||
|
)
|
||||||
_check(
|
_check(
|
||||||
simulation_manager.resource_states.size() == 18,
|
simulation_manager.resource_states.size() == 18,
|
||||||
"Simulation authority should bind all eighteen Jajce resources"
|
"Simulation authority should bind all eighteen Jajce resources"
|
||||||
@@ -63,7 +128,9 @@ func _run() -> void:
|
|||||||
for child in village_root.get_children():
|
for child in village_root.get_children():
|
||||||
if child.name.begins_with("Path_"):
|
if child.name.begins_with("Path_"):
|
||||||
path_strips += 1
|
path_strips += 1
|
||||||
_check(path_strips >= 4, "Runtime should include authored path strips for first-read composition")
|
_check(
|
||||||
|
path_strips >= 4, "Runtime should include authored path strips for first-read composition"
|
||||||
|
)
|
||||||
_check(
|
_check(
|
||||||
main_scene.has_node("JajceWorld/FortressBlockout/Keep/RidgeBanner"),
|
main_scene.has_node("JajceWorld/FortressBlockout/Keep/RidgeBanner"),
|
||||||
"Runtime should include a readable ridge landmark banner"
|
"Runtime should include a readable ridge landmark banner"
|
||||||
@@ -86,9 +153,14 @@ func _run() -> void:
|
|||||||
)
|
)
|
||||||
|
|
||||||
var navigation_map: RID = main_scene.get_world_3d().navigation_map
|
var navigation_map: RID = main_scene.get_world_3d().navigation_map
|
||||||
var navigation_region := main_scene.get_node("JajceWorld/NavigationRegion3D") as NavigationRegion3D
|
var navigation_region := (
|
||||||
|
main_scene.get_node("JajceWorld/NavigationRegion3D") as NavigationRegion3D
|
||||||
|
)
|
||||||
_check(
|
_check(
|
||||||
navigation_region.navigation_mesh.resource_path == "res://world/jajce/JajceNavigationMesh.tres",
|
(
|
||||||
|
navigation_region.navigation_mesh.resource_path
|
||||||
|
== "res://world/jajce/JajceNavigationMesh.tres"
|
||||||
|
),
|
||||||
"Runtime navigation should use the Terrain3D-derived baked mesh resource"
|
"Runtime navigation should use the Terrain3D-derived baked mesh resource"
|
||||||
)
|
)
|
||||||
await _wait_for_navigation_map(navigation_map)
|
await _wait_for_navigation_map(navigation_map)
|
||||||
@@ -104,13 +176,17 @@ func _run() -> void:
|
|||||||
not main_scene.has_node("JajceWorld/LegacyActivityMarkers/PantryMarker"),
|
not main_scene.has_node("JajceWorld/LegacyActivityMarkers/PantryMarker"),
|
||||||
"Runtime should not keep the pantry as a legacy activity marker"
|
"Runtime should not keep the pantry as a legacy activity marker"
|
||||||
)
|
)
|
||||||
var pantry := main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") as StorageNode
|
var pantry := (
|
||||||
|
main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") as StorageNode
|
||||||
|
)
|
||||||
destinations.append(pantry.get_interaction_position())
|
destinations.append(pantry.get_interaction_position())
|
||||||
_check(
|
_check(
|
||||||
main_scene.has_node("JajceWorld/WorldObjects/StorageSites/VillageWoodpile"),
|
main_scene.has_node("JajceWorld/WorldObjects/StorageSites/VillageWoodpile"),
|
||||||
"Runtime should expose a typed VillageWoodpile StorageNode"
|
"Runtime should expose a typed VillageWoodpile StorageNode"
|
||||||
)
|
)
|
||||||
var woodpile := main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillageWoodpile") as StorageNode
|
var woodpile := (
|
||||||
|
main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillageWoodpile") as StorageNode
|
||||||
|
)
|
||||||
destinations.append(woodpile.get_interaction_position())
|
destinations.append(woodpile.get_interaction_position())
|
||||||
var activity_root := main_scene.get_node("JajceWorld/WorldObjects/ActivitySites")
|
var activity_root := main_scene.get_node("JajceWorld/WorldObjects/ActivitySites")
|
||||||
_check(activity_root.get_child_count() == 3, "Runtime should expose three typed activity sites")
|
_check(activity_root.get_child_count() == 3, "Runtime should expose three typed activity sites")
|
||||||
@@ -193,7 +269,9 @@ func _check(condition: bool, message: String) -> void:
|
|||||||
failures.append(message)
|
failures.append(message)
|
||||||
|
|
||||||
|
|
||||||
func _check_path_tracks_terrain(terrain: Terrain3D, path: PackedVector3Array, message: String) -> void:
|
func _check_path_tracks_terrain(
|
||||||
|
terrain: Terrain3D, path: PackedVector3Array, message: String
|
||||||
|
) -> void:
|
||||||
if path.is_empty():
|
if path.is_empty():
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -204,13 +282,17 @@ func _check_path_tracks_terrain(terrain: Terrain3D, path: PackedVector3Array, me
|
|||||||
return
|
return
|
||||||
if absf(point.y - terrain_height) > 0.85:
|
if absf(point.y - terrain_height) > 0.85:
|
||||||
failures.append(
|
failures.append(
|
||||||
|
(
|
||||||
"%s: path point %s is too far from terrain height %.2f"
|
"%s: path point %s is too far from terrain height %.2f"
|
||||||
% [message, point, terrain_height]
|
% [message, point, terrain_height]
|
||||||
)
|
)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
func _check_path_reaches_destination(path: PackedVector3Array, destination: Vector3, message: String) -> void:
|
func _check_path_reaches_destination(
|
||||||
|
path: PackedVector3Array, destination: Vector3, message: String
|
||||||
|
) -> void:
|
||||||
if path.is_empty():
|
if path.is_empty():
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -219,6 +301,5 @@ func _check_path_reaches_destination(path: PackedVector3Array, destination: Vect
|
|||||||
var destination_xz := Vector2(destination.x, destination.z)
|
var destination_xz := Vector2(destination.x, destination.z)
|
||||||
if final_xz.distance_to(destination_xz) > 1.5:
|
if final_xz.distance_to(destination_xz) > 1.5:
|
||||||
failures.append(
|
failures.append(
|
||||||
"%s: final path point %s is too far from %s"
|
"%s: final path point %s is too far from %s" % [message, final_point, destination]
|
||||||
% [message, final_point, destination]
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -67,6 +67,94 @@ func _run() -> void:
|
|||||||
world.get_node("FoliageRoot").get_child_count() >= 10,
|
world.get_node("FoliageRoot").get_child_count() >= 10,
|
||||||
"JajceWorld should include restrained authored foliage clusters"
|
"JajceWorld should include restrained authored foliage clusters"
|
||||||
)
|
)
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
not world.has_node("WindStrokes_Valley")
|
||||||
|
and not world.has_node("WindStrokes_Ridge")
|
||||||
|
and not world.has_node("WindSpecks_Valley")
|
||||||
|
and not world.has_node("WindSpecks_Ridge")
|
||||||
|
),
|
||||||
|
"Legacy overlapping valley and ridge wind fields should remain removed"
|
||||||
|
)
|
||||||
|
var wind_gust_field := world.get_node("AtmosphereRoot/WindGustField") as WindGustField
|
||||||
|
_check(wind_gust_field != null, "Jajce should expose one bounded wind-gust controller")
|
||||||
|
if wind_gust_field != null:
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
wind_gust_field.interval_min >= 5.0
|
||||||
|
and wind_gust_field.interval_max <= 12.0
|
||||||
|
and wind_gust_field.duration_max <= 2.5
|
||||||
|
and wind_gust_field.stroke_count_max <= 3
|
||||||
|
),
|
||||||
|
"Calligraphic wind should remain sporadic, brief, and sparse"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
wind_gust_field.trigger_gust_at(Vector3.ZERO, true, 0.5),
|
||||||
|
"Wind controller should support a deterministic staged gust"
|
||||||
|
)
|
||||||
|
var active_stroke_count := wind_gust_field.get_active_stroke_count()
|
||||||
|
_check(
|
||||||
|
active_stroke_count in [2, 3],
|
||||||
|
"One gust should contain only two or three related brush strokes"
|
||||||
|
)
|
||||||
|
var gust_burst := wind_gust_field.get_node_or_null("WindGustBurst") as Node3D
|
||||||
|
var gust_geometry_valid := gust_burst != null
|
||||||
|
if gust_burst != null:
|
||||||
|
for stroke in gust_burst.get_children():
|
||||||
|
var mesh_instance := stroke as MeshInstance3D
|
||||||
|
if mesh_instance == null:
|
||||||
|
gust_geometry_valid = false
|
||||||
|
continue
|
||||||
|
var material := mesh_instance.material_override as ShaderMaterial
|
||||||
|
var tint: Color = (
|
||||||
|
material.get_shader_parameter("tint") if material != null else Color.WHITE
|
||||||
|
)
|
||||||
|
var progress := (
|
||||||
|
float(material.get_shader_parameter("progress")) if material != null else 0.0
|
||||||
|
)
|
||||||
|
gust_geometry_valid = (
|
||||||
|
gust_geometry_valid
|
||||||
|
and mesh_instance.mesh is ArrayMesh
|
||||||
|
and material != null
|
||||||
|
and material.shader == load("res://world/jajce/materials/wind_gust.gdshader")
|
||||||
|
and tint.a >= 0.18
|
||||||
|
and tint.a <= 0.3
|
||||||
|
and progress >= 0.4
|
||||||
|
and progress <= 0.6
|
||||||
|
and (mesh_instance.cast_shadow == GeometryInstance3D.SHADOW_CASTING_SETTING_OFF)
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
gust_geometry_valid,
|
||||||
|
"Gust strokes should use soft tapered meshes without bright alpha or shadows"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
not wind_gust_field.trigger_gust_at(Vector3.ZERO)
|
||||||
|
and wind_gust_field.get_active_stroke_count() == active_stroke_count
|
||||||
|
),
|
||||||
|
"Repeated gust triggers should not stack visual fields"
|
||||||
|
)
|
||||||
|
var tree_phases: Array[float] = []
|
||||||
|
var tree_wind_is_bounded := true
|
||||||
|
for tree in world.get_node("FoliageRoot").get_children():
|
||||||
|
if not tree.has_node("Canopy"):
|
||||||
|
continue
|
||||||
|
var canopy := tree.get_node("Canopy") as MeshInstance3D
|
||||||
|
var material := canopy.mesh.material as ShaderMaterial
|
||||||
|
tree_wind_is_bounded = (
|
||||||
|
tree_wind_is_bounded
|
||||||
|
and material != null
|
||||||
|
and material.shader == load("res://world/jajce/materials/wind.gdshader")
|
||||||
|
and float(material.get_shader_parameter("wind_strength")) <= 0.08
|
||||||
|
and float(material.get_shader_parameter("wind_speed")) <= 0.65
|
||||||
|
)
|
||||||
|
tree_phases.append(float(material.get_shader_parameter("wind_phase")))
|
||||||
|
_check(tree_phases.size() >= 2, "Decorative trees should expose named wind-reactive canopies")
|
||||||
|
_check(
|
||||||
|
tree_phases.size() >= 2 and not is_equal_approx(tree_phases[0], tree_phases[1]),
|
||||||
|
"Tree wind should use stable per-tree phase instead of moving every canopy in lockstep"
|
||||||
|
)
|
||||||
|
_check(tree_wind_is_bounded, "Tree wind should stay within the restrained cozy-motion budget")
|
||||||
_check(world.has_node("FortressBlockout"), "JajceWorld should include the fortress landmark")
|
_check(world.has_node("FortressBlockout"), "JajceWorld should include the fortress landmark")
|
||||||
_check(world.has_node("WaterRoot/WaterfallBody"), "WaterRoot should include a waterfall drop")
|
_check(world.has_node("WaterRoot/WaterfallBody"), "WaterRoot should include a waterfall drop")
|
||||||
_check(
|
_check(
|
||||||
@@ -91,9 +179,11 @@ func _run() -> void:
|
|||||||
"Jajce environment should provide sky and restrained valley fog"
|
"Jajce environment should provide sky and restrained valley fog"
|
||||||
)
|
)
|
||||||
_check(
|
_check(
|
||||||
|
(
|
||||||
environment.glow_enabled
|
environment.glow_enabled
|
||||||
and environment.volumetric_fog_enabled
|
and environment.volumetric_fog_enabled
|
||||||
and environment.adjustment_enabled,
|
and environment.adjustment_enabled
|
||||||
|
),
|
||||||
"WorldEnvironment should provide bounded glow, depth haze, and color grading"
|
"WorldEnvironment should provide bounded glow, depth haze, and color grading"
|
||||||
)
|
)
|
||||||
_check(
|
_check(
|
||||||
@@ -102,24 +192,47 @@ func _run() -> void:
|
|||||||
)
|
)
|
||||||
var smoke_count := 0
|
var smoke_count := 0
|
||||||
var smoke_is_translucent := true
|
var smoke_is_translucent := true
|
||||||
|
var smoke_follows_wind := true
|
||||||
|
var smoke_direction := Vector2.ZERO
|
||||||
for suffix in ["House_01", "House_02", "House_03"]:
|
for suffix in ["House_01", "House_02", "House_03"]:
|
||||||
if world.has_node("VillageRoot/%s/Smoke" % suffix):
|
if world.has_node("VillageRoot/%s/Smoke" % suffix):
|
||||||
var smoke: GPUParticles3D = world.get_node("VillageRoot/%s/Smoke" % suffix)
|
var smoke: GPUParticles3D = world.get_node("VillageRoot/%s/Smoke" % suffix)
|
||||||
if smoke.draw_pass_1 == null:
|
if smoke.draw_pass_1 == null:
|
||||||
continue
|
continue
|
||||||
var smoke_material := (smoke.draw_pass_1 as QuadMesh).material as StandardMaterial3D
|
var smoke_material := (smoke.draw_pass_1 as QuadMesh).material as StandardMaterial3D
|
||||||
|
var smoke_process := smoke.process_material as ParticleProcessMaterial
|
||||||
|
if smoke_process != null and smoke_direction.is_zero_approx():
|
||||||
|
smoke_direction = (
|
||||||
|
Vector2(smoke_process.gravity.x, smoke_process.gravity.z).normalized()
|
||||||
|
)
|
||||||
smoke_is_translucent = (
|
smoke_is_translucent = (
|
||||||
smoke_is_translucent
|
smoke_is_translucent
|
||||||
and smoke_material != null
|
and smoke_material != null
|
||||||
and smoke_material.transparency != BaseMaterial3D.TRANSPARENCY_DISABLED
|
and smoke_material.transparency != BaseMaterial3D.TRANSPARENCY_DISABLED
|
||||||
and smoke_material.albedo_color.a < 0.5
|
and smoke_material.albedo_color.a < 0.5
|
||||||
)
|
)
|
||||||
|
smoke_follows_wind = (
|
||||||
|
smoke_follows_wind
|
||||||
|
and smoke_process != null
|
||||||
|
and smoke_process.gravity.x > 0.0
|
||||||
|
and smoke_process.gravity.z > 0.0
|
||||||
|
)
|
||||||
smoke_count += 1
|
smoke_count += 1
|
||||||
_check(
|
_check(
|
||||||
smoke_count >= 2,
|
smoke_count >= 2,
|
||||||
"At least two houses should have chimney smoke particles (found %s)" % smoke_count
|
"At least two houses should have chimney smoke particles (found %s)" % smoke_count
|
||||||
)
|
)
|
||||||
_check(smoke_is_translucent, "Chimney smoke should not render as opaque white quads")
|
_check(smoke_is_translucent, "Chimney smoke should not render as opaque white quads")
|
||||||
|
_check(smoke_follows_wind, "Chimney smoke should lean with the prevailing canopy wind")
|
||||||
|
var gust_direction := Vector2.ZERO
|
||||||
|
if wind_gust_field != null:
|
||||||
|
gust_direction = (
|
||||||
|
Vector2(wind_gust_field.wind_direction.x, wind_gust_field.wind_direction.z).normalized()
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
gust_direction.dot(smoke_direction) >= 0.98,
|
||||||
|
"Calligraphic gusts, canopy bend, and chimney smoke should share one wind direction"
|
||||||
|
)
|
||||||
var lookdev: Node3D = load("res://world/jajce/JajceLookdev.tscn").instantiate()
|
var lookdev: Node3D = load("res://world/jajce/JajceLookdev.tscn").instantiate()
|
||||||
_check(lookdev.has_node("JajceWorld"), "Lookdev should instance JajceWorld")
|
_check(lookdev.has_node("JajceWorld"), "Lookdev should instance JajceWorld")
|
||||||
_check(lookdev.has_node("BeautyCamera"), "Lookdev should provide a beauty camera")
|
_check(lookdev.has_node("BeautyCamera"), "Lookdev should provide a beauty camera")
|
||||||
@@ -131,6 +244,7 @@ func _run() -> void:
|
|||||||
ids.append(String((resource as ResourceNode).node_id))
|
ids.append(String((resource as ResourceNode).node_id))
|
||||||
ids.sort()
|
ids.sort()
|
||||||
_check(
|
_check(
|
||||||
|
(
|
||||||
ids
|
ids
|
||||||
== [
|
== [
|
||||||
"animal_camp_01",
|
"animal_camp_01",
|
||||||
@@ -151,7 +265,8 @@ func _run() -> void:
|
|||||||
"tree_south_edge_01",
|
"tree_south_edge_01",
|
||||||
"wood_pile_mill_01",
|
"wood_pile_mill_01",
|
||||||
"wood_pile_village_01"
|
"wood_pile_village_01"
|
||||||
],
|
]
|
||||||
|
),
|
||||||
"Jajce resources should preserve all stable and discoverable IDs"
|
"Jajce resources should preserve all stable and discoverable IDs"
|
||||||
)
|
)
|
||||||
var metadata_valid := true
|
var metadata_valid := true
|
||||||
@@ -190,7 +305,9 @@ func _run() -> void:
|
|||||||
_check(wood_count >= 9, "Jajce should expose at least nine finite wood resources")
|
_check(wood_count >= 9, "Jajce should expose at least nine finite wood resources")
|
||||||
_check(animal_context_count >= 2, "Jajce food discovery should include animal-camp contexts")
|
_check(animal_context_count >= 2, "Jajce food discovery should include animal-camp contexts")
|
||||||
_check(berry_context_count >= 6, "Jajce food discovery should include berry contexts")
|
_check(berry_context_count >= 6, "Jajce food discovery should include berry contexts")
|
||||||
_check(village_context_count >= 1, "Jajce discovery should include at least one village stockpile")
|
_check(
|
||||||
|
village_context_count >= 1, "Jajce discovery should include at least one village stockpile"
|
||||||
|
)
|
||||||
_check(outskirt_context_count >= 4, "Jajce discovery should include outskirts/river contexts")
|
_check(outskirt_context_count >= 4, "Jajce discovery should include outskirts/river contexts")
|
||||||
_check(farming_context_count >= 1, "Jajce food discovery should include farming contexts")
|
_check(farming_context_count >= 1, "Jajce food discovery should include farming contexts")
|
||||||
_check(world.has_node("WorldObjects/StorageSites"), "JajceWorld should expose StorageSites")
|
_check(world.has_node("WorldObjects/StorageSites"), "JajceWorld should expose StorageSites")
|
||||||
@@ -232,7 +349,10 @@ func _run() -> void:
|
|||||||
"Jajce navigation mesh should contain baked polygons"
|
"Jajce navigation mesh should contain baked polygons"
|
||||||
)
|
)
|
||||||
_check(
|
_check(
|
||||||
navigation_region.navigation_mesh.resource_path == "res://world/jajce/JajceNavigationMesh.tres",
|
(
|
||||||
|
navigation_region.navigation_mesh.resource_path
|
||||||
|
== "res://world/jajce/JajceNavigationMesh.tres"
|
||||||
|
),
|
||||||
"Jajce navigation should use the Terrain3D-derived baked mesh resource"
|
"Jajce navigation should use the Terrain3D-derived baked mesh resource"
|
||||||
)
|
)
|
||||||
await _wait_for_navigation_map(navigation_map)
|
await _wait_for_navigation_map(navigation_map)
|
||||||
@@ -329,9 +449,7 @@ func _run() -> void:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if failures.is_empty():
|
if failures.is_empty():
|
||||||
print(
|
print("[TEST] Jajce scaffold passed: Terrain3D, scattered resources, typed sites")
|
||||||
"[TEST] Jajce scaffold passed: Terrain3D, scattered resources, typed sites"
|
|
||||||
)
|
|
||||||
quit(0)
|
quit(0)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -357,7 +475,9 @@ func _check(condition: bool, message: String) -> void:
|
|||||||
failures.append(message)
|
failures.append(message)
|
||||||
|
|
||||||
|
|
||||||
func _check_path_tracks_terrain(terrain: Terrain3D, path: PackedVector3Array, message: String) -> void:
|
func _check_path_tracks_terrain(
|
||||||
|
terrain: Terrain3D, path: PackedVector3Array, message: String
|
||||||
|
) -> void:
|
||||||
if path.is_empty():
|
if path.is_empty():
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -368,13 +488,17 @@ func _check_path_tracks_terrain(terrain: Terrain3D, path: PackedVector3Array, me
|
|||||||
return
|
return
|
||||||
if absf(point.y - terrain_height) > 0.85:
|
if absf(point.y - terrain_height) > 0.85:
|
||||||
failures.append(
|
failures.append(
|
||||||
|
(
|
||||||
"%s: path point %s is too far from terrain height %.2f"
|
"%s: path point %s is too far from terrain height %.2f"
|
||||||
% [message, point, terrain_height]
|
% [message, point, terrain_height]
|
||||||
)
|
)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
func _check_path_reaches_destination(path: PackedVector3Array, destination: Vector3, message: String) -> void:
|
func _check_path_reaches_destination(
|
||||||
|
path: PackedVector3Array, destination: Vector3, message: String
|
||||||
|
) -> void:
|
||||||
if path.is_empty():
|
if path.is_empty():
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -383,6 +507,5 @@ func _check_path_reaches_destination(path: PackedVector3Array, destination: Vect
|
|||||||
var destination_xz := Vector2(destination.x, destination.z)
|
var destination_xz := Vector2(destination.x, destination.z)
|
||||||
if final_xz.distance_to(destination_xz) > 1.5:
|
if final_xz.distance_to(destination_xz) > 1.5:
|
||||||
failures.append(
|
failures.append(
|
||||||
"%s: final path point %s is too far from %s"
|
"%s: final path point %s is too far from %s" % [message, final_point, destination]
|
||||||
% [message, final_point, destination]
|
|
||||||
)
|
)
|
||||||
|
|||||||
+25
-24
@@ -64,19 +64,14 @@ func _test_work_period_during_day() -> void:
|
|||||||
manager.clock.cycle_duration_seconds = 240.0
|
manager.clock.cycle_duration_seconds = 240.0
|
||||||
manager.clock.elapsed_ticks = 100
|
manager.clock.elapsed_ticks = 100
|
||||||
var time_of_day: float = manager.clock.time_of_day()
|
var time_of_day: float = manager.clock.time_of_day()
|
||||||
_check(
|
_check(time_of_day > 0.28 and time_of_day < 0.85, "100 ticks should land in the work period")
|
||||||
time_of_day > 0.28 and time_of_day < 0.85,
|
|
||||||
"100 ticks should land in the work period"
|
|
||||||
)
|
|
||||||
var npc: SimNPC = manager.npcs[0]
|
var npc: SimNPC = manager.npcs[0]
|
||||||
npc.profession = SimulationIds.PROFESSION_WANDERER
|
npc.profession = SimulationIds.PROFESSION_WANDERER
|
||||||
npc.hunger = 20.0
|
npc.hunger = 20.0
|
||||||
npc.energy = 80.0
|
npc.energy = 80.0
|
||||||
npc.home_position = npc.position
|
npc.home_position = npc.position
|
||||||
|
|
||||||
var selection := ActionSelectionSystem.new().select_action(
|
var selection := ActionSelectionSystem.new().select_action(npc, manager.village, time_of_day)
|
||||||
npc, manager.village, time_of_day
|
|
||||||
)
|
|
||||||
_check(selection != null, "Daytime should produce an action selection")
|
_check(selection != null, "Daytime should produce an action selection")
|
||||||
_check(
|
_check(
|
||||||
selection.action_id != SimulationIds.ACTION_SLEEP,
|
selection.action_id != SimulationIds.ACTION_SLEEP,
|
||||||
@@ -101,9 +96,7 @@ func _test_meal_period_hunger() -> void:
|
|||||||
manager.village.food = 5.0
|
manager.village.food = 5.0
|
||||||
npc.home_position = npc.position
|
npc.home_position = npc.position
|
||||||
|
|
||||||
var selection := ActionSelectionSystem.new().select_action(
|
var selection := ActionSelectionSystem.new().select_action(npc, manager.village, time_of_day)
|
||||||
npc, manager.village, time_of_day
|
|
||||||
)
|
|
||||||
_check(selection != null, "Meal period should produce an action selection")
|
_check(selection != null, "Meal period should produce an action selection")
|
||||||
_check(
|
_check(
|
||||||
selection.action_id == SimulationIds.ACTION_WITHDRAW_FOOD,
|
selection.action_id == SimulationIds.ACTION_WITHDRAW_FOOD,
|
||||||
@@ -129,10 +122,7 @@ func _test_sleep_restores_energy() -> void:
|
|||||||
manager.simulate_tick()
|
manager.simulate_tick()
|
||||||
|
|
||||||
_check(npc.task_complete, "Sleep should complete after its duration")
|
_check(npc.task_complete, "Sleep should complete after its duration")
|
||||||
_check(
|
_check(npc.energy > energy_before, "Completing sleep should restore energy")
|
||||||
npc.energy > energy_before,
|
|
||||||
"Completing sleep should restore energy"
|
|
||||||
)
|
|
||||||
manager.free()
|
manager.free()
|
||||||
|
|
||||||
|
|
||||||
@@ -206,20 +196,26 @@ func _test_household_familiarity() -> void:
|
|||||||
var d: SimNPC = manager.npcs[3]
|
var d: SimNPC = manager.npcs[3]
|
||||||
var e: SimNPC = manager.npcs[4]
|
var e: SimNPC = manager.npcs[4]
|
||||||
|
|
||||||
|
var a_to_b: RelationshipStateRecord = manager.relationship_system.get_relationship(a.id, b.id)
|
||||||
|
var b_to_a: RelationshipStateRecord = manager.relationship_system.get_relationship(b.id, a.id)
|
||||||
|
_check(a_to_b != null and b_to_a != null, "Nearby homes should create directed household ties")
|
||||||
|
if a_to_b != null:
|
||||||
_check(
|
_check(
|
||||||
a.familiarity.has(b.id) and b.familiarity.has(a.id),
|
(
|
||||||
"NPCs with nearby homes should be mutual household members"
|
is_equal_approx(a_to_b.get_familiarity(), 0.5)
|
||||||
)
|
and is_equal_approx(a_to_b.get_trust(), RelationshipStateRecord.NEUTRAL_TRUST)
|
||||||
_check(
|
),
|
||||||
is_equal_approx(float(a.familiarity[b.id]), 0.5),
|
|
||||||
"Household familiarity should start at baseline 0.5"
|
"Household familiarity should start at baseline 0.5"
|
||||||
)
|
)
|
||||||
_check(
|
_check(
|
||||||
c.familiarity.has(d.id) and d.familiarity.has(c.id),
|
(
|
||||||
|
manager.relationship_system.get_relationship(c.id, d.id) != null
|
||||||
|
and manager.relationship_system.get_relationship(d.id, c.id) != null
|
||||||
|
),
|
||||||
"Second household pair should also be familiar"
|
"Second household pair should also be familiar"
|
||||||
)
|
)
|
||||||
_check(
|
_check(
|
||||||
not a.familiarity.has(e.id),
|
manager.relationship_system.get_relationship(a.id, e.id) == null,
|
||||||
"Distant NPCs should not have household familiarity"
|
"Distant NPCs should not have household familiarity"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -232,10 +228,15 @@ func _test_household_familiarity() -> void:
|
|||||||
restored.restore_state_from_json(saved_json),
|
restored.restore_state_from_json(saved_json),
|
||||||
"Familiarity should survive save/load round-trip"
|
"Familiarity should survive save/load round-trip"
|
||||||
)
|
)
|
||||||
var ra: SimNPC = restored.npcs[0]
|
var restored_relationship: RelationshipStateRecord = (
|
||||||
|
restored.relationship_system.get_relationship(a.id, b.id)
|
||||||
|
)
|
||||||
_check(
|
_check(
|
||||||
ra.familiarity.size() > 0,
|
(
|
||||||
"Restored NPC should retain household familiarity"
|
restored_relationship != null
|
||||||
|
and is_equal_approx(restored_relationship.get_familiarity(), 0.5)
|
||||||
|
),
|
||||||
|
"Restored simulation should retain household familiarity"
|
||||||
)
|
)
|
||||||
|
|
||||||
manager.free()
|
manager.free()
|
||||||
|
|||||||
@@ -37,20 +37,23 @@ func _run() -> void:
|
|||||||
"NPC presentation should use a readable multi-part villager silhouette"
|
"NPC presentation should use a readable multi-part villager silhouette"
|
||||||
)
|
)
|
||||||
_check(
|
_check(
|
||||||
|
(
|
||||||
visual.get_node("ProfessionProp").mesh != null
|
visual.get_node("ProfessionProp").mesh != null
|
||||||
and profession_definition.display_name in visual.get_node("ProfessionLabel").text,
|
and profession_definition.display_name in visual.get_node("ProfessionLabel").text
|
||||||
|
),
|
||||||
"NPC visual should expose a profession prop and readable label"
|
"NPC visual should expose a profession prop and readable label"
|
||||||
)
|
)
|
||||||
visual.set_task_presentation(SimulationIds.ACTION_GATHER_FOOD, SimNPC.TASK_STATE_TRAVELING)
|
visual.set_task_presentation(SimulationIds.ACTION_GATHER_FOOD, SimNPC.TASK_STATE_TRAVELING)
|
||||||
_check(
|
_check(
|
||||||
|
(
|
||||||
visual.get_node("TaskGlyphRoot").visible
|
visual.get_node("TaskGlyphRoot").visible
|
||||||
and visual.get_node("TaskGlyphRoot/TaskGlyph").mesh is SphereMesh,
|
and visual.get_node("TaskGlyphRoot/TaskGlyph").mesh is SphereMesh
|
||||||
|
),
|
||||||
"NPC visual should expose a compact task glyph for cinematic readability"
|
"NPC visual should expose a compact task glyph for cinematic readability"
|
||||||
)
|
)
|
||||||
visual.set_debug_overlay_visible(false)
|
visual.set_debug_overlay_visible(false)
|
||||||
_check(
|
_check(
|
||||||
not visual.get_node("ProfessionLabel").visible
|
not visual.get_node("ProfessionLabel").visible and visual.get_node("TaskGlyphRoot").visible,
|
||||||
and visual.get_node("TaskGlyphRoot").visible,
|
|
||||||
"Cinematic mode should hide debug labels while preserving task glyphs"
|
"Cinematic mode should hide debug labels while preserving task glyphs"
|
||||||
)
|
)
|
||||||
visual.set_debug_overlay_visible(true)
|
visual.set_debug_overlay_visible(true)
|
||||||
@@ -68,12 +71,9 @@ func _run() -> void:
|
|||||||
)
|
)
|
||||||
manager.latest_decisions[npc.id] = test_decision
|
manager.latest_decisions[npc.id] = test_decision
|
||||||
manager.emit_signal("npc_decision_recorded", npc, test_decision)
|
manager.emit_signal("npc_decision_recorded", npc, test_decision)
|
||||||
var inspector_label: Label = ui.get_node(
|
var inspector_label: Label = ui.get_node("NpcInspectorPanel/MarginContainer/NpcInspectorLabel")
|
||||||
"NpcInspectorPanel/MarginContainer/NpcInspectorLabel"
|
|
||||||
)
|
|
||||||
_check(
|
_check(
|
||||||
"Test shortage reason" in inspector_label.text
|
"Test shortage reason" in inspector_label.text and npc.npc_name in inspector_label.text,
|
||||||
and npc.npc_name in inspector_label.text,
|
|
||||||
"NPC inspector should show the selected villager's real decision reason"
|
"NPC inspector should show the selected villager's real decision reason"
|
||||||
)
|
)
|
||||||
_check(
|
_check(
|
||||||
|
|||||||
@@ -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"
|
"Village should receive exactly the bush's remaining food"
|
||||||
)
|
)
|
||||||
_check(bush_state.get_reserved_by() == -1, "Depletion should release the NPC reservation")
|
_check(bush_state.get_reserved_by() == -1, "Depletion should release the NPC reservation")
|
||||||
|
var player_extraction: EconomicEventRecord
|
||||||
|
var player_depletion: EconomicEventRecord
|
||||||
|
for event in simulation_manager.economic_events:
|
||||||
|
if int(event.data["actor_id"]) != -1 or StringName(event.data["source_id"]) != bush.node_id:
|
||||||
|
continue
|
||||||
|
if StringName(event.data["event_type"]) == SimulationIds.EVENT_RESOURCE_EXTRACTED:
|
||||||
|
player_extraction = event
|
||||||
|
elif StringName(event.data["event_type"]) == SimulationIds.EVENT_RESOURCE_DEPLETED:
|
||||||
|
player_depletion = event
|
||||||
|
var bush_event_position := bush.interaction_point.global_position
|
||||||
|
_check(player_extraction != null, "Player extraction should create a structured event")
|
||||||
|
_check(player_depletion != null, "Player depletion should create a structured event")
|
||||||
|
if player_extraction != null:
|
||||||
|
_check(
|
||||||
|
player_extraction.get_world_position().is_equal_approx(bush_event_position),
|
||||||
|
"Player extraction should preserve the resource interaction position"
|
||||||
|
)
|
||||||
|
if player_depletion != null:
|
||||||
|
_check(
|
||||||
|
player_depletion.get_world_position().is_equal_approx(bush_event_position),
|
||||||
|
"Player depletion should preserve the resource interaction position"
|
||||||
|
)
|
||||||
|
|
||||||
player.global_position = tree.interaction_point.global_position
|
player.global_position = tree.interaction_point.global_position
|
||||||
var wood_before: float = simulation_manager.village.wood
|
var wood_before: float = simulation_manager.village.wood
|
||||||
@@ -57,18 +79,20 @@ func _run() -> void:
|
|||||||
"Village should receive exactly the extracted wood"
|
"Village should receive exactly the extracted wood"
|
||||||
)
|
)
|
||||||
|
|
||||||
var pantry := main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") as StorageNode
|
var pantry := (
|
||||||
var pantry_state: StorageStateRecord = simulation_manager.get_pantry()
|
main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") as StorageNode
|
||||||
pantry_state.deposit(
|
|
||||||
SimulationIds.RESOURCE_FOOD, pantry_state.get_available_capacity() - 0.5
|
|
||||||
)
|
)
|
||||||
|
var pantry_state: StorageStateRecord = simulation_manager.get_pantry()
|
||||||
|
pantry_state.deposit(SimulationIds.RESOURCE_FOOD, pantry_state.get_available_capacity() - 0.5)
|
||||||
simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
|
simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
|
||||||
bush_state.set_amount_remaining(1.0)
|
bush_state.set_amount_remaining(1.0)
|
||||||
player.global_position = bush.interaction_point.global_position
|
player.global_position = bush.interaction_point.global_position
|
||||||
player.try_interact()
|
player.try_interact()
|
||||||
_check(
|
_check(
|
||||||
|
(
|
||||||
is_equal_approx(bush_state.get_amount_remaining(), 0.5)
|
is_equal_approx(bush_state.get_amount_remaining(), 0.5)
|
||||||
and is_equal_approx(pantry_state.get_available_capacity(), 0.0),
|
and is_equal_approx(pantry_state.get_available_capacity(), 0.0)
|
||||||
|
),
|
||||||
"Player harvesting should leave overflow at its source when storage fills"
|
"Player harvesting should leave overflow at its source when storage fills"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -81,7 +105,9 @@ func _run() -> void:
|
|||||||
pantry_state.get_amount(SimulationIds.RESOURCE_FOOD) < pantry_food_before,
|
pantry_state.get_amount(SimulationIds.RESOURCE_FOOD) < pantry_food_before,
|
||||||
"Player should eat from the typed pantry StorageNode"
|
"Player should eat from the typed pantry StorageNode"
|
||||||
)
|
)
|
||||||
var guard_site := main_scene.get_node("JajceWorld/WorldObjects/ActivitySites/GuardPost") as ActivitySite
|
var guard_site := (
|
||||||
|
main_scene.get_node("JajceWorld/WorldObjects/ActivitySites/GuardPost") as ActivitySite
|
||||||
|
)
|
||||||
player.global_position = guard_site.get_interaction_position()
|
player.global_position = guard_site.get_interaction_position()
|
||||||
var safety_before: float = simulation_manager.village.safety
|
var safety_before: float = simulation_manager.village.safety
|
||||||
player.try_interact()
|
player.try_interact()
|
||||||
|
|||||||
@@ -34,17 +34,16 @@ func _test_registry_integrity() -> void:
|
|||||||
SimulationDefinitions.get_action(definition.action_id) == definition,
|
SimulationDefinitions.get_action(definition.action_id) == definition,
|
||||||
"Action lookup should return '%s'" % definition.action_id
|
"Action lookup should return '%s'" % definition.action_id
|
||||||
)
|
)
|
||||||
_check(action_ids.size() == 11, "Registry should contain all eleven executable prototype actions")
|
_check(
|
||||||
|
action_ids.size() == 11, "Registry should contain all eleven executable prototype actions"
|
||||||
|
)
|
||||||
|
|
||||||
var profession_ids := SimulationDefinitions.get_profession_ids()
|
var profession_ids := SimulationDefinitions.get_profession_ids()
|
||||||
_check(profession_ids.size() == 5, "Registry should contain all five prototype professions")
|
_check(profession_ids.size() == 5, "Registry should contain all five prototype professions")
|
||||||
var profession_colors := {}
|
var profession_colors := {}
|
||||||
for profession_id in profession_ids:
|
for profession_id in profession_ids:
|
||||||
var profession := SimulationDefinitions.get_profession(profession_id)
|
var profession := SimulationDefinitions.get_profession(profession_id)
|
||||||
_check(
|
_check(profession != null, "Profession lookup should return '%s'" % profession_id)
|
||||||
profession != null,
|
|
||||||
"Profession lookup should return '%s'" % profession_id
|
|
||||||
)
|
|
||||||
if profession != null:
|
if profession != null:
|
||||||
profession_colors[profession.visual_color.to_html()] = true
|
profession_colors[profession.visual_color.to_html()] = true
|
||||||
_check(
|
_check(
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ func _run() -> void:
|
|||||||
_test_legacy_resource_migration()
|
_test_legacy_resource_migration()
|
||||||
_test_legacy_world_storage_migration()
|
_test_legacy_world_storage_migration()
|
||||||
_test_previous_world_event_migration()
|
_test_previous_world_event_migration()
|
||||||
|
_test_previous_world_relationship_migration()
|
||||||
|
_test_previous_world_knowledge_migration()
|
||||||
|
_test_relationship_schema_rejection()
|
||||||
_test_schema_rejection()
|
_test_schema_rejection()
|
||||||
|
|
||||||
if failures.is_empty():
|
if failures.is_empty():
|
||||||
@@ -157,8 +160,10 @@ func _test_legacy_resource_migration() -> void:
|
|||||||
"Resource migration should preserve mutable authority"
|
"Resource migration should preserve mutable authority"
|
||||||
)
|
)
|
||||||
_check(
|
_check(
|
||||||
|
(
|
||||||
is_equal_approx(migrated.get_safety_risk(), 0.0)
|
is_equal_approx(migrated.get_safety_risk(), 0.0)
|
||||||
and is_equal_approx(migrated.get_comfort_distance(), 18.0),
|
and is_equal_approx(migrated.get_comfort_distance(), 18.0)
|
||||||
|
),
|
||||||
"Resource migration should default discovery metadata"
|
"Resource migration should default discovery metadata"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -190,9 +195,7 @@ func _test_legacy_world_storage_migration() -> void:
|
|||||||
var migrated_storages := {}
|
var migrated_storages := {}
|
||||||
for storage in migrated.storages:
|
for storage in migrated.storages:
|
||||||
migrated_storages[storage.get_storage_id()] = storage
|
migrated_storages[storage.get_storage_id()] = storage
|
||||||
var pantry: StorageStateRecord = migrated_storages.get(
|
var pantry: StorageStateRecord = migrated_storages.get(SimulationIds.STORAGE_VILLAGE_PANTRY)
|
||||||
SimulationIds.STORAGE_VILLAGE_PANTRY
|
|
||||||
)
|
|
||||||
var woodpile: StorageStateRecord = migrated_storages.get(
|
var woodpile: StorageStateRecord = migrated_storages.get(
|
||||||
SimulationIds.STORAGE_VILLAGE_WOODPILE
|
SimulationIds.STORAGE_VILLAGE_WOODPILE
|
||||||
)
|
)
|
||||||
@@ -200,8 +203,12 @@ func _test_legacy_world_storage_migration() -> void:
|
|||||||
(
|
(
|
||||||
pantry != null
|
pantry != null
|
||||||
and woodpile != null
|
and woodpile != null
|
||||||
and is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), manager.village.food)
|
and is_equal_approx(
|
||||||
and is_equal_approx(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), manager.village.wood)
|
pantry.get_amount(SimulationIds.RESOURCE_FOOD), manager.village.food
|
||||||
|
)
|
||||||
|
and is_equal_approx(
|
||||||
|
woodpile.get_amount(SimulationIds.RESOURCE_WOOD), manager.village.wood
|
||||||
|
)
|
||||||
),
|
),
|
||||||
"World migration should preserve legacy village resources in their storage"
|
"World migration should preserve legacy village resources in their storage"
|
||||||
)
|
)
|
||||||
@@ -211,7 +218,7 @@ func _test_legacy_world_storage_migration() -> void:
|
|||||||
func _test_previous_world_event_migration() -> void:
|
func _test_previous_world_event_migration() -> void:
|
||||||
var manager := _create_manager(56)
|
var manager := _create_manager(56)
|
||||||
var previous_data: Dictionary = manager.create_state_record().to_dictionary()
|
var previous_data: Dictionary = manager.create_state_record().to_dictionary()
|
||||||
previous_data["schema_version"] = (SimulationStateRecord.PREVIOUS_SCHEMA_VERSION)
|
previous_data["schema_version"] = SimulationStateRecord.EVENT_LEGACY_SCHEMA_VERSION
|
||||||
previous_data.erase("economic_events")
|
previous_data.erase("economic_events")
|
||||||
previous_data["simulation"].erase("next_event_id")
|
previous_data["simulation"].erase("next_event_id")
|
||||||
var migrated := SimulationStateRecord.from_dictionary(previous_data)
|
var migrated := SimulationStateRecord.from_dictionary(previous_data)
|
||||||
@@ -224,6 +231,169 @@ func _test_previous_world_event_migration() -> void:
|
|||||||
manager.free()
|
manager.free()
|
||||||
|
|
||||||
|
|
||||||
|
func _test_previous_world_relationship_migration() -> void:
|
||||||
|
var manager := _create_manager(57)
|
||||||
|
var previous_data: Dictionary = manager.create_state_record().to_dictionary()
|
||||||
|
previous_data["schema_version"] = SimulationStateRecord.RELATIONSHIP_LEGACY_SCHEMA_VERSION
|
||||||
|
previous_data.erase("relationships")
|
||||||
|
previous_data.erase("event_knowledge")
|
||||||
|
for npc_data in previous_data["npcs"]:
|
||||||
|
npc_data["schema_version"] = NPCStateRecord.RELATIONSHIP_LEGACY_SCHEMA_VERSION
|
||||||
|
npc_data["familiarity"] = []
|
||||||
|
previous_data["npcs"][0]["familiarity"] = [{"id": 1, "score": 0.75}]
|
||||||
|
var migrated := SimulationStateRecord.from_dictionary(previous_data)
|
||||||
|
_check(migrated != null, "World schema v3 should migrate NPC familiarity into relationships")
|
||||||
|
if migrated != null:
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
migrated.relationships.size() == 1
|
||||||
|
and migrated.relationships[0].get_observer_id() == 0
|
||||||
|
and migrated.relationships[0].get_subject_id() == 1
|
||||||
|
and is_equal_approx(migrated.relationships[0].get_familiarity(), 0.75)
|
||||||
|
and is_equal_approx(
|
||||||
|
migrated.relationships[0].get_trust(), RelationshipStateRecord.NEUTRAL_TRUST
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"World v3 migration should preserve familiarity and initialize neutral trust"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
not migrated.npcs[0].data.has("familiarity"),
|
||||||
|
"World v3 migration should remove obsolete NPC-local familiarity data"
|
||||||
|
)
|
||||||
|
manager.free()
|
||||||
|
|
||||||
|
|
||||||
|
func _test_previous_world_knowledge_migration() -> void:
|
||||||
|
var manager := _create_manager(59)
|
||||||
|
var previous_data: Dictionary = manager.create_state_record().to_dictionary()
|
||||||
|
previous_data["schema_version"] = SimulationStateRecord.PREVIOUS_SCHEMA_VERSION
|
||||||
|
previous_data.erase("event_knowledge")
|
||||||
|
var deposit_event := EconomicEventRecord.create(
|
||||||
|
0,
|
||||||
|
SimulationIds.EVENT_STORAGE_DEPOSITED,
|
||||||
|
12,
|
||||||
|
1,
|
||||||
|
SimulationIds.npc_inventory_id(1),
|
||||||
|
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
||||||
|
SimulationIds.RESOURCE_FOOD,
|
||||||
|
2.0
|
||||||
|
)
|
||||||
|
previous_data["economic_events"] = [deposit_event.to_dictionary()]
|
||||||
|
previous_data["simulation"]["next_event_id"] = 1
|
||||||
|
previous_data["relationships"] = [
|
||||||
|
RelationshipStateRecord.create(0, 1, 0.5, 0.65, 0).to_dictionary()
|
||||||
|
]
|
||||||
|
var migrated := SimulationStateRecord.from_dictionary(previous_data)
|
||||||
|
_check(migrated != null, "World schema v4 should migrate causal facts into NPC knowledge")
|
||||||
|
if migrated != null:
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
migrated.event_knowledge.size() == 1
|
||||||
|
and migrated.event_knowledge[0].get_knower_id() == 0
|
||||||
|
and migrated.event_knowledge[0].get_event_id() == 0
|
||||||
|
),
|
||||||
|
"World v4 migration should preserve the fact implied by a relationship cause"
|
||||||
|
)
|
||||||
|
manager.free()
|
||||||
|
|
||||||
|
|
||||||
|
func _test_relationship_schema_rejection() -> void:
|
||||||
|
var manager := _create_manager(58)
|
||||||
|
var missing_cause_data: Dictionary = manager.create_state_record().to_dictionary()
|
||||||
|
missing_cause_data["relationships"] = [
|
||||||
|
RelationshipStateRecord.create(0, 1, 0.5, 0.65, 999).to_dictionary()
|
||||||
|
]
|
||||||
|
_check(
|
||||||
|
SimulationStateRecord.from_dictionary(missing_cause_data) == null,
|
||||||
|
"Relationship causes must reference an event in the same world record"
|
||||||
|
)
|
||||||
|
|
||||||
|
var duplicate_pair_data: Dictionary = manager.create_state_record().to_dictionary()
|
||||||
|
var relationship_data := RelationshipStateRecord.create(0, 1, 0.5).to_dictionary()
|
||||||
|
duplicate_pair_data["relationships"] = [relationship_data, relationship_data.duplicate(true)]
|
||||||
|
_check(
|
||||||
|
SimulationStateRecord.from_dictionary(duplicate_pair_data) == null,
|
||||||
|
"World state should reject duplicate directed relationship pairs"
|
||||||
|
)
|
||||||
|
|
||||||
|
var unknown_knowledge_data: Dictionary = manager.create_state_record().to_dictionary()
|
||||||
|
unknown_knowledge_data["event_knowledge"] = [
|
||||||
|
KnownEventStateRecord.create(0, 999).to_dictionary()
|
||||||
|
]
|
||||||
|
_check(
|
||||||
|
SimulationStateRecord.from_dictionary(unknown_knowledge_data) == null,
|
||||||
|
"Known-event records must reference an event in the same world record"
|
||||||
|
)
|
||||||
|
var duplicate_knowledge_data: Dictionary = manager.create_state_record().to_dictionary()
|
||||||
|
var event := EconomicEventRecord.create(
|
||||||
|
0,
|
||||||
|
SimulationIds.EVENT_STORAGE_DEPOSITED,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
SimulationIds.npc_inventory_id(0),
|
||||||
|
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
||||||
|
SimulationIds.RESOURCE_FOOD,
|
||||||
|
1.0
|
||||||
|
)
|
||||||
|
duplicate_knowledge_data["economic_events"] = [event.to_dictionary()]
|
||||||
|
duplicate_knowledge_data["simulation"]["next_event_id"] = 1
|
||||||
|
var known_event_data := KnownEventStateRecord.create(0, 0).to_dictionary()
|
||||||
|
duplicate_knowledge_data["event_knowledge"] = [
|
||||||
|
known_event_data, known_event_data.duplicate(true)
|
||||||
|
]
|
||||||
|
_check(
|
||||||
|
SimulationStateRecord.from_dictionary(duplicate_knowledge_data) == null,
|
||||||
|
"World state should reject duplicate NPC knowledge pairs"
|
||||||
|
)
|
||||||
|
|
||||||
|
var wrong_subject_cause_data: Dictionary = manager.create_state_record().to_dictionary()
|
||||||
|
var unrelated_deposit := EconomicEventRecord.create(
|
||||||
|
0,
|
||||||
|
SimulationIds.EVENT_STORAGE_DEPOSITED,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
SimulationIds.npc_inventory_id(2),
|
||||||
|
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
||||||
|
SimulationIds.RESOURCE_FOOD,
|
||||||
|
1.0
|
||||||
|
)
|
||||||
|
wrong_subject_cause_data["economic_events"] = [unrelated_deposit.to_dictionary()]
|
||||||
|
wrong_subject_cause_data["simulation"]["next_event_id"] = 1
|
||||||
|
wrong_subject_cause_data["event_knowledge"] = [
|
||||||
|
KnownEventStateRecord.create(0, 0).to_dictionary()
|
||||||
|
]
|
||||||
|
wrong_subject_cause_data["relationships"] = [
|
||||||
|
RelationshipStateRecord.create(0, 1, 0.5, 0.65, 0).to_dictionary()
|
||||||
|
]
|
||||||
|
_check(
|
||||||
|
SimulationStateRecord.from_dictionary(wrong_subject_cause_data) == null,
|
||||||
|
"A trust cause must be a known food deposit performed by the relationship subject"
|
||||||
|
)
|
||||||
|
|
||||||
|
var nonpositive_cause_data: Dictionary = manager.create_state_record().to_dictionary()
|
||||||
|
var nonpositive_deposit := EconomicEventRecord.create(
|
||||||
|
0,
|
||||||
|
SimulationIds.EVENT_STORAGE_DEPOSITED,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
SimulationIds.npc_inventory_id(1),
|
||||||
|
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
||||||
|
SimulationIds.RESOURCE_FOOD,
|
||||||
|
0.0
|
||||||
|
)
|
||||||
|
nonpositive_cause_data["economic_events"] = [nonpositive_deposit.to_dictionary()]
|
||||||
|
nonpositive_cause_data["simulation"]["next_event_id"] = 1
|
||||||
|
nonpositive_cause_data["event_knowledge"] = [KnownEventStateRecord.create(0, 0).to_dictionary()]
|
||||||
|
nonpositive_cause_data["relationships"] = [
|
||||||
|
RelationshipStateRecord.create(0, 1, 0.5, 0.65, 0).to_dictionary()
|
||||||
|
]
|
||||||
|
_check(
|
||||||
|
SimulationStateRecord.from_dictionary(nonpositive_cause_data) == null,
|
||||||
|
"A trust cause must describe a successful positive food deposit"
|
||||||
|
)
|
||||||
|
manager.free()
|
||||||
|
|
||||||
|
|
||||||
func _create_manager(seed_value: int) -> Node:
|
func _create_manager(seed_value: int) -> Node:
|
||||||
var manager: Node = load("res://simulation/SimulationManager.gd").new()
|
var manager: Node = load("res://simulation/SimulationManager.gd").new()
|
||||||
manager.simulation_seed = seed_value
|
manager.simulation_seed = seed_value
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ extends GutTest
|
|||||||
|
|
||||||
const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.gd")
|
const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.gd")
|
||||||
const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd")
|
const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd")
|
||||||
|
const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd")
|
||||||
|
const EventKnowledgeSystemScript := preload("res://simulation/knowledge/EventKnowledgeSystem.gd")
|
||||||
|
|
||||||
|
|
||||||
func test_storage_never_accepts_more_than_its_capacity() -> void:
|
func test_storage_never_accepts_more_than_its_capacity() -> void:
|
||||||
@@ -45,3 +47,84 @@ func test_event_log_preserves_order_and_derives_consumption_rate() -> void:
|
|||||||
assert_eq(int(event_log.events[1].data["event_id"]), 1)
|
assert_eq(int(event_log.events[1].data["event_id"]), 1)
|
||||||
assert_eq(event_log.get_for_actor(4).size(), 2)
|
assert_eq(event_log.get_for_actor(4).size(), 2)
|
||||||
assert_eq(float(event_log.get_consumption_rates(200)["food_per_day"]), 1.0)
|
assert_eq(float(event_log.get_consumption_rates(200)["food_per_day"]), 1.0)
|
||||||
|
var legacy_event_data := event_log.events[1].to_dictionary()
|
||||||
|
legacy_event_data["schema_version"] = EconomicEventRecord.LEGACY_SCHEMA_VERSION
|
||||||
|
legacy_event_data.erase("world_position")
|
||||||
|
var migrated_event := EconomicEventRecord.from_dictionary(legacy_event_data)
|
||||||
|
assert_not_null(migrated_event)
|
||||||
|
assert_eq(migrated_event.get_world_position(), Vector3.ZERO)
|
||||||
|
|
||||||
|
|
||||||
|
func test_food_aid_builds_directed_trust_once_per_event() -> void:
|
||||||
|
var contributor := SimNPC.new(0, "Amina", SimulationIds.PROFESSION_FARMER, 5.0, 5.0)
|
||||||
|
var observer := SimNPC.new(1, "Tarik", SimulationIds.PROFESSION_GUARD, 5.0, 5.0)
|
||||||
|
contributor.home_position = Vector3.ZERO
|
||||||
|
observer.home_position = Vector3(1.0, 0.0, 0.0)
|
||||||
|
observer.hunger = 85.0
|
||||||
|
var villagers: Array[SimNPC] = [contributor, observer]
|
||||||
|
var relationship_system := RelationshipSystemScript.new()
|
||||||
|
relationship_system.initialize_households(villagers)
|
||||||
|
var deposit_event := EconomicEventRecord.create(
|
||||||
|
7,
|
||||||
|
SimulationIds.EVENT_STORAGE_DEPOSITED,
|
||||||
|
12,
|
||||||
|
contributor.id,
|
||||||
|
SimulationIds.npc_inventory_id(contributor.id),
|
||||||
|
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
||||||
|
SimulationIds.RESOURCE_FOOD,
|
||||||
|
2.0
|
||||||
|
)
|
||||||
|
var later_deposit_event := EconomicEventRecord.create(
|
||||||
|
8,
|
||||||
|
SimulationIds.EVENT_STORAGE_DEPOSITED,
|
||||||
|
13,
|
||||||
|
contributor.id,
|
||||||
|
SimulationIds.npc_inventory_id(contributor.id),
|
||||||
|
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
||||||
|
SimulationIds.RESOURCE_FOOD,
|
||||||
|
1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
var knower_ids: Array[int] = [contributor.id, observer.id]
|
||||||
|
relationship_system.apply_event(deposit_event, villagers, knower_ids)
|
||||||
|
relationship_system.apply_event(later_deposit_event, villagers, knower_ids)
|
||||||
|
relationship_system.apply_event(deposit_event, villagers, knower_ids)
|
||||||
|
var relationship: RelationshipStateRecord = relationship_system.get_relationship(
|
||||||
|
observer.id, contributor.id
|
||||||
|
)
|
||||||
|
assert_eq(relationship.get_trust(), 0.8)
|
||||||
|
assert_eq(relationship.get_last_trust_cause_event_id(), 8)
|
||||||
|
assert_eq(
|
||||||
|
relationship_system.get_relationship(contributor.id, observer.id).get_trust(),
|
||||||
|
RelationshipStateRecord.NEUTRAL_TRUST
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func test_event_knowledge_is_spatial_directed_and_idempotent() -> void:
|
||||||
|
var actor := SimNPC.new(0, "Amina", SimulationIds.PROFESSION_FARMER, 5.0, 5.0)
|
||||||
|
var nearby := SimNPC.new(1, "Tarik", SimulationIds.PROFESSION_GUARD, 5.0, 5.0)
|
||||||
|
var distant := SimNPC.new(2, "Jasmin", SimulationIds.PROFESSION_GUARD, 5.0, 5.0)
|
||||||
|
actor.position = Vector3.ZERO
|
||||||
|
nearby.position = Vector3(4.0, 0.0, 0.0)
|
||||||
|
distant.position = Vector3(20.0, 0.0, 0.0)
|
||||||
|
var villagers: Array[SimNPC] = [actor, nearby, distant]
|
||||||
|
var event := EconomicEventRecord.create(
|
||||||
|
12,
|
||||||
|
SimulationIds.EVENT_STORAGE_DEPOSITED,
|
||||||
|
30,
|
||||||
|
actor.id,
|
||||||
|
SimulationIds.npc_inventory_id(actor.id),
|
||||||
|
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
||||||
|
SimulationIds.RESOURCE_FOOD,
|
||||||
|
2.0,
|
||||||
|
Vector3.ZERO
|
||||||
|
)
|
||||||
|
actor.position = Vector3(100.0, 0.0, 0.0)
|
||||||
|
var knowledge_system := EventKnowledgeSystemScript.new()
|
||||||
|
|
||||||
|
assert_eq(knowledge_system.observe_event(event, villagers).size(), 2)
|
||||||
|
assert_eq(knowledge_system.observe_event(event, villagers).size(), 0)
|
||||||
|
assert_true(knowledge_system.knows_event(actor.id, 12))
|
||||||
|
assert_true(knowledge_system.knows_event(nearby.id, 12))
|
||||||
|
assert_false(knowledge_system.knows_event(distant.id, 12))
|
||||||
|
assert_eq(knowledge_system.get_knowers(12), [actor.id, nearby.id])
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
var failures: Array[String] = []
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
call_deferred("_run")
|
||||||
|
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
var manager := _create_manager()
|
||||||
|
var contributor: SimNPC = manager.npcs[0]
|
||||||
|
var nearby_observer: SimNPC = manager.npcs[1]
|
||||||
|
var distant_observer: SimNPC = manager.npcs[2]
|
||||||
|
contributor.position = Vector3.ZERO
|
||||||
|
nearby_observer.position = Vector3(4.0, 0.0, 0.0)
|
||||||
|
distant_observer.position = Vector3(20.0, 0.0, 0.0)
|
||||||
|
nearby_observer.hunger = 85.0
|
||||||
|
distant_observer.hunger = 85.0
|
||||||
|
contributor.add_inventory(SimulationIds.RESOURCE_FOOD, 2.0)
|
||||||
|
|
||||||
|
_check(
|
||||||
|
is_equal_approx(
|
||||||
|
manager.economy.deposit_inventory(contributor, SimulationIds.RESOURCE_FOOD), 2.0
|
||||||
|
),
|
||||||
|
"The witnessed fact should originate from a real successful food deposit"
|
||||||
|
)
|
||||||
|
var deposit_events: Array = manager.get_npc_events(contributor.id, 3)
|
||||||
|
var deposit_event: EconomicEventRecord
|
||||||
|
for event in deposit_events:
|
||||||
|
if StringName(event.data["event_type"]) == SimulationIds.EVENT_STORAGE_DEPOSITED:
|
||||||
|
deposit_event = event
|
||||||
|
break
|
||||||
|
_check(deposit_event != null, "The objective event stream should contain the deposit fact")
|
||||||
|
if deposit_event == null:
|
||||||
|
manager.free()
|
||||||
|
_finish()
|
||||||
|
return
|
||||||
|
var event_id := int(deposit_event.data["event_id"])
|
||||||
|
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
manager.npc_knows_event(contributor.id, event_id)
|
||||||
|
and manager.npc_knows_event(nearby_observer.id, event_id)
|
||||||
|
and not manager.npc_knows_event(distant_observer.id, event_id)
|
||||||
|
),
|
||||||
|
"Nearby and distant familiar NPCs should retain different evidence about one event"
|
||||||
|
)
|
||||||
|
var nearby_relationship: RelationshipStateRecord = manager.relationship_system.get_relationship(
|
||||||
|
nearby_observer.id, contributor.id
|
||||||
|
)
|
||||||
|
var distant_relationship: RelationshipStateRecord = (
|
||||||
|
manager.relationship_system.get_relationship(distant_observer.id, contributor.id)
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
nearby_relationship != null
|
||||||
|
and is_equal_approx(nearby_relationship.get_trust(), 0.65)
|
||||||
|
and nearby_relationship.get_last_trust_cause_event_id() == event_id
|
||||||
|
),
|
||||||
|
"Witnessed food aid should raise directed trust with the known fact as its cause"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
distant_relationship != null
|
||||||
|
and is_equal_approx(
|
||||||
|
distant_relationship.get_trust(), RelationshipStateRecord.NEUTRAL_TRUST
|
||||||
|
)
|
||||||
|
and (
|
||||||
|
distant_relationship.get_last_trust_cause_event_id()
|
||||||
|
== RelationshipStateRecord.NO_CAUSE_EVENT
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"Familiarity alone should not grant trust without evidence"
|
||||||
|
)
|
||||||
|
|
||||||
|
_prepare_choice_divergence(manager, contributor, nearby_observer, distant_observer)
|
||||||
|
var nearby_choice: ActionSelectionResult = manager.action_selector.select_action(
|
||||||
|
nearby_observer, manager.village, 0.5, manager.npcs
|
||||||
|
)
|
||||||
|
var distant_choice: ActionSelectionResult = manager.action_selector.select_action(
|
||||||
|
distant_observer, manager.village, 0.5, manager.npcs
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
nearby_choice.action_id == SimulationIds.ACTION_GATHER_FOOD
|
||||||
|
and contributor.npc_name in nearby_choice.reason
|
||||||
|
),
|
||||||
|
"The informed trusted observer should leave ordinary work to help"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
distant_choice.action_id == SimulationIds.ACTION_PATROL,
|
||||||
|
"The uninformed neutral observer should continue ordinary guard work"
|
||||||
|
)
|
||||||
|
|
||||||
|
var saved_json: String = manager.serialize_state()
|
||||||
|
var restored := _create_manager(999)
|
||||||
|
_check(
|
||||||
|
restored.restore_state_from_json(saved_json),
|
||||||
|
"Witnessed knowledge should restore through the versioned world schema"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
restored.npc_knows_event(nearby_observer.id, event_id)
|
||||||
|
and not restored.npc_knows_event(distant_observer.id, event_id)
|
||||||
|
and restored.get_known_events(nearby_observer.id, 1).size() == 1
|
||||||
|
),
|
||||||
|
"Save/load should preserve different per-NPC knowledge of the same objective event"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
restored.get_state_checksum() == manager.get_state_checksum(),
|
||||||
|
"Restored knowledge should retain the complete deterministic checksum"
|
||||||
|
)
|
||||||
|
|
||||||
|
var current_nearby_choice: ActionSelectionResult = manager.action_selector.select_action(
|
||||||
|
nearby_observer, manager.village, 0.5, manager.npcs
|
||||||
|
)
|
||||||
|
var restored_nearby: SimNPC = restored.npcs[nearby_observer.id]
|
||||||
|
var restored_nearby_choice: ActionSelectionResult = restored.action_selector.select_action(
|
||||||
|
restored_nearby, restored.village, 0.5, restored.npcs
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
(
|
||||||
|
current_nearby_choice.action_id == SimulationIds.ACTION_GATHER_FOOD
|
||||||
|
and restored_nearby_choice.action_id == SimulationIds.ACTION_GATHER_FOOD
|
||||||
|
and contributor.npc_name in current_nearby_choice.reason
|
||||||
|
and contributor.npc_name in restored_nearby_choice.reason
|
||||||
|
),
|
||||||
|
"The restored informed NPC should retain the named helping branch"
|
||||||
|
)
|
||||||
|
|
||||||
|
var current_future_choice: ActionSelectionResult = manager.action_selector.select_action(
|
||||||
|
distant_observer, manager.village, 0.5, manager.npcs
|
||||||
|
)
|
||||||
|
var restored_distant: SimNPC = restored.npcs[distant_observer.id]
|
||||||
|
var restored_future_choice: ActionSelectionResult = restored.action_selector.select_action(
|
||||||
|
restored_distant, restored.village, 0.5, restored.npcs
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
current_future_choice.action_id == restored_future_choice.action_id,
|
||||||
|
"Knowledge restoration should preserve the next deterministic decision"
|
||||||
|
)
|
||||||
|
_check(
|
||||||
|
restored.get_state_checksum() == manager.get_state_checksum(),
|
||||||
|
"Equivalent future decisions should advance restored RNG state identically"
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.free()
|
||||||
|
restored.free()
|
||||||
|
_finish()
|
||||||
|
|
||||||
|
|
||||||
|
func _prepare_choice_divergence(
|
||||||
|
manager: Node, contributor: SimNPC, nearby: SimNPC, distant: SimNPC
|
||||||
|
) -> void:
|
||||||
|
var pantry: StorageStateRecord = manager.get_pantry()
|
||||||
|
pantry.withdraw(
|
||||||
|
SimulationIds.RESOURCE_FOOD,
|
||||||
|
maxf(
|
||||||
|
(
|
||||||
|
pantry.get_amount(SimulationIds.RESOURCE_FOOD)
|
||||||
|
- (ActionSelectionSystem.RELATIONSHIP_AID_PANTRY_THRESHOLD - 5.0)
|
||||||
|
),
|
||||||
|
0.0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
|
||||||
|
manager.economy.deposit_resource(SimulationIds.RESOURCE_WOOD, 10.0)
|
||||||
|
manager.village.safety = 50.0
|
||||||
|
manager.village.knowledge = 20.0
|
||||||
|
manager.village.update_priorities()
|
||||||
|
contributor.hunger = 95.0
|
||||||
|
contributor.is_starving = true
|
||||||
|
for observer in [nearby, distant]:
|
||||||
|
observer.profession = SimulationIds.PROFESSION_GUARD
|
||||||
|
observer.hunger = 20.0
|
||||||
|
observer.energy = 80.0
|
||||||
|
observer.inventory.clear()
|
||||||
|
observer.last_task = &""
|
||||||
|
|
||||||
|
|
||||||
|
func _create_manager(seed_value: int = 811) -> Node:
|
||||||
|
var manager: Node = load("res://simulation/SimulationManager.gd").new()
|
||||||
|
manager.simulation_seed = seed_value
|
||||||
|
manager.debug_logs = false
|
||||||
|
var home_positions: Array[Vector3] = [
|
||||||
|
Vector3(0.0, 0.0, 0.0),
|
||||||
|
Vector3(1.0, 0.0, 0.0),
|
||||||
|
Vector3(2.5, 0.0, 0.0),
|
||||||
|
Vector3(20.0, 0.0, 20.0),
|
||||||
|
Vector3(30.0, 0.0, 30.0),
|
||||||
|
Vector3(40.0, 0.0, 40.0),
|
||||||
|
]
|
||||||
|
manager.home_positions = home_positions
|
||||||
|
root.add_child(manager)
|
||||||
|
manager.set_process(false)
|
||||||
|
return manager
|
||||||
|
|
||||||
|
|
||||||
|
func _finish() -> void:
|
||||||
|
if failures.is_empty():
|
||||||
|
print("[TEST] Witnessed knowledge passed: evidence -> trust -> divergent choices")
|
||||||
|
quit(0)
|
||||||
|
return
|
||||||
|
for failure in failures:
|
||||||
|
push_error("[TEST] " + failure)
|
||||||
|
quit(1)
|
||||||
|
|
||||||
|
|
||||||
|
func _check(condition: bool, message: String) -> void:
|
||||||
|
if not condition:
|
||||||
|
failures.append(message)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://m0x2wooal8pa
|
||||||
@@ -32,7 +32,9 @@ func _run() -> void:
|
|||||||
var bake_aabb: AABB = nav_mesh.filter_baking_aabb
|
var bake_aabb: AABB = nav_mesh.filter_baking_aabb
|
||||||
bake_aabb.position += nav_mesh.filter_baking_aabb_offset
|
bake_aabb.position += nav_mesh.filter_baking_aabb_offset
|
||||||
|
|
||||||
var terrain_faces: PackedVector3Array = terrain.generate_nav_mesh_source_geometry(bake_aabb, false)
|
var terrain_faces: PackedVector3Array = terrain.generate_nav_mesh_source_geometry(
|
||||||
|
bake_aabb, false
|
||||||
|
)
|
||||||
if terrain_faces.is_empty():
|
if terrain_faces.is_empty():
|
||||||
push_error("Terrain3D produced no navigation source faces")
|
push_error("Terrain3D produced no navigation source faces")
|
||||||
quit(1)
|
quit(1)
|
||||||
@@ -54,9 +56,11 @@ func _run() -> void:
|
|||||||
return
|
return
|
||||||
|
|
||||||
print(
|
print(
|
||||||
|
(
|
||||||
"[TOOL] Saved %s with %d vertices and %d polygons"
|
"[TOOL] Saved %s with %d vertices and %d polygons"
|
||||||
% [OUTPUT_PATH, nav_mesh.get_vertices().size(), nav_mesh.get_polygon_count()]
|
% [OUTPUT_PATH, nav_mesh.get_vertices().size(), nav_mesh.get_polygon_count()]
|
||||||
)
|
)
|
||||||
|
)
|
||||||
quit(0)
|
quit(0)
|
||||||
|
|
||||||
|
|
||||||
@@ -68,7 +72,5 @@ func _create_navigation_mesh_template() -> NavigationMesh:
|
|||||||
nav_mesh.agent_max_slope = 35.0
|
nav_mesh.agent_max_slope = 35.0
|
||||||
nav_mesh.cell_size = 0.25
|
nav_mesh.cell_size = 0.25
|
||||||
nav_mesh.cell_height = 0.1
|
nav_mesh.cell_height = 0.1
|
||||||
nav_mesh.filter_baking_aabb = AABB(
|
nav_mesh.filter_baking_aabb = AABB(Vector3(-36.0, -8.0, -36.0), Vector3(72.0, 32.0, 72.0))
|
||||||
Vector3(-36.0, -8.0, -36.0), Vector3(72.0, 32.0, 72.0)
|
|
||||||
)
|
|
||||||
return nav_mesh
|
return nav_mesh
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func _run() -> void:
|
|||||||
if not demo_controller.debug_overlay_visible:
|
if not demo_controller.debug_overlay_visible:
|
||||||
demo_controller.toggle_debug_overlay()
|
demo_controller.toggle_debug_overlay()
|
||||||
|
|
||||||
|
await _stage_wind_gust(main_scene)
|
||||||
var debug_analysis := _capture(DEBUG_OUTPUT_PATH)
|
var debug_analysis := _capture(DEBUG_OUTPUT_PATH)
|
||||||
if debug_analysis.is_empty():
|
if debug_analysis.is_empty():
|
||||||
quit(1)
|
quit(1)
|
||||||
@@ -48,9 +49,7 @@ func _run() -> void:
|
|||||||
|
|
||||||
if demo_controller.has_method("toggle_debug_overlay") and demo_controller.debug_overlay_visible:
|
if demo_controller.has_method("toggle_debug_overlay") and demo_controller.debug_overlay_visible:
|
||||||
demo_controller.toggle_debug_overlay()
|
demo_controller.toggle_debug_overlay()
|
||||||
await process_frame
|
await _stage_wind_gust(main_scene)
|
||||||
for _frame in 30:
|
|
||||||
await process_frame
|
|
||||||
|
|
||||||
var cinematic_analysis := _capture(CINEMATIC_OUTPUT_PATH)
|
var cinematic_analysis := _capture(CINEMATIC_OUTPUT_PATH)
|
||||||
if cinematic_analysis.is_empty():
|
if cinematic_analysis.is_empty():
|
||||||
@@ -58,12 +57,25 @@ func _run() -> void:
|
|||||||
return
|
return
|
||||||
|
|
||||||
print(
|
print(
|
||||||
|
(
|
||||||
"[TOOL] Saved Simulation Garden captures | debug %s | cinematic %s"
|
"[TOOL] Saved Simulation Garden captures | debug %s | cinematic %s"
|
||||||
% [debug_analysis, cinematic_analysis]
|
% [debug_analysis, cinematic_analysis]
|
||||||
)
|
)
|
||||||
|
)
|
||||||
quit(0)
|
quit(0)
|
||||||
|
|
||||||
|
|
||||||
|
func _stage_wind_gust(main_scene: Node) -> void:
|
||||||
|
var gust_field := (
|
||||||
|
main_scene.get_node_or_null("JajceWorld/AtmosphereRoot/WindGustField") as WindGustField
|
||||||
|
)
|
||||||
|
if gust_field == null:
|
||||||
|
return
|
||||||
|
gust_field.trigger_gust_at(Vector3(1.5, 0.0, -1.0), true, 0.5)
|
||||||
|
for _frame in 2:
|
||||||
|
await process_frame
|
||||||
|
|
||||||
|
|
||||||
func _capture(output_path: String) -> Dictionary:
|
func _capture(output_path: String) -> Dictionary:
|
||||||
var image := root.get_texture().get_image()
|
var image := root.get_texture().get_image()
|
||||||
if image == null or image.is_empty():
|
if image == null or image.is_empty():
|
||||||
|
|||||||
Regular → Executable
+6
-6
@@ -7,20 +7,20 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
# detect gdformat
|
# detect gdformat
|
||||||
FMT=""
|
FMT=()
|
||||||
if [[ -x "$ROOT/.venv/bin/gdformat" ]]; then
|
if [[ -x "$ROOT/.venv/bin/gdformat" ]]; then
|
||||||
FMT="$ROOT/.venv/bin/gdformat"
|
FMT=("$ROOT/.venv/bin/gdformat")
|
||||||
elif command -v gdformat &>/dev/null; then
|
elif command -v gdformat &>/dev/null; then
|
||||||
FMT="gdformat"
|
FMT=(gdformat)
|
||||||
elif python -m gdtoolkit.formatter --help &>/dev/null 2>&1; then
|
elif python -m gdtoolkit.formatter --help &>/dev/null 2>&1; then
|
||||||
FMT="python -m gdtoolkit.formatter"
|
FMT=(python -m gdtoolkit.formatter)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "$FMT" ]]; then
|
if [[ ${#FMT[@]} -eq 0 ]]; then
|
||||||
echo "ERROR: gdformat not found. Install requirements-dev.txt; see docs/local_quality_gate.md."
|
echo "ERROR: gdformat not found. Install requirements-dev.txt; see docs/local_quality_gate.md."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Formatting all .gd files in $ROOT ..."
|
echo "Formatting all .gd files in $ROOT ..."
|
||||||
$FMT .
|
"${FMT[@]}" player simulation tests tools world
|
||||||
echo "Done."
|
echo "Done."
|
||||||
|
|||||||
+41
-7
@@ -86,6 +86,8 @@ function Test-GdLint {
|
|||||||
return $false
|
return $false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$script:GdToolExitCode = 0
|
||||||
|
|
||||||
function Invoke-GdFormat {
|
function Invoke-GdFormat {
|
||||||
$args = $args
|
$args = $args
|
||||||
$localTool = "$ROOT/.venv/Scripts/gdformat.exe"
|
$localTool = "$ROOT/.venv/Scripts/gdformat.exe"
|
||||||
@@ -96,6 +98,7 @@ function Invoke-GdFormat {
|
|||||||
} else {
|
} else {
|
||||||
& python -m gdtoolkit.formatter @args 2>&1
|
& python -m gdtoolkit.formatter @args 2>&1
|
||||||
}
|
}
|
||||||
|
$script:GdToolExitCode = $LASTEXITCODE
|
||||||
}
|
}
|
||||||
|
|
||||||
function Invoke-GdLint {
|
function Invoke-GdLint {
|
||||||
@@ -108,11 +111,13 @@ function Invoke-GdLint {
|
|||||||
} else {
|
} else {
|
||||||
& python -m gdtoolkit.linter @args 2>&1
|
& python -m gdtoolkit.linter @args 2>&1
|
||||||
}
|
}
|
||||||
|
$script:GdToolExitCode = $LASTEXITCODE
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-ChangedGd {
|
function Get-ChangedGd {
|
||||||
$f = git diff --name-only HEAD -- '*.gd' 2>$null
|
$changed = @(git diff --name-only HEAD -- '*.gd' 2>$null)
|
||||||
return @($f | Where-Object { $_ -ne '' })
|
$untracked = @(git ls-files --others --exclude-standard -- '*.gd' 2>$null)
|
||||||
|
return @((@($changed) + @($untracked)) | Where-Object { $_ -ne '' } | Sort-Object -Unique)
|
||||||
}
|
}
|
||||||
|
|
||||||
function To-ResPath($p) {
|
function To-ResPath($p) {
|
||||||
@@ -171,8 +176,10 @@ $hasProjectClasses = (Test-Path $classCache) -and
|
|||||||
(Select-String -Path $classCache -SimpleMatch '"class": &"SimNPC"' -Quiet)
|
(Select-String -Path $classCache -SimpleMatch '"class": &"SimNPC"' -Quiet)
|
||||||
$hasGutClasses = (Test-Path $classCache) -and
|
$hasGutClasses = (Test-Path $classCache) -and
|
||||||
(Select-String -Path $classCache -SimpleMatch '"class": &"GutTest"' -Quiet)
|
(Select-String -Path $classCache -SimpleMatch '"class": &"GutTest"' -Quiet)
|
||||||
|
$changedGd = @(Get-ChangedGd)
|
||||||
$needsImport = (-not $hasProjectClasses) -or
|
$needsImport = (-not $hasProjectClasses) -or
|
||||||
((Test-Path "addons/gut/gut_cmdln.gd") -and (-not $hasGutClasses))
|
((Test-Path "addons/gut/gut_cmdln.gd") -and (-not $hasGutClasses)) -or
|
||||||
|
($changedGd.Count -gt 0)
|
||||||
if ($needsImport) {
|
if ($needsImport) {
|
||||||
$importArgs = "--headless --path `"$ROOT`" --import"
|
$importArgs = "--headless --path `"$ROOT`" --import"
|
||||||
$importOutput, $importExit = Invoke-GodotWithTimeout -ArgumentString $importArgs
|
$importOutput, $importExit = Invoke-GodotWithTimeout -ArgumentString $importArgs
|
||||||
@@ -200,6 +207,7 @@ if ($importResult -eq "FAIL") {
|
|||||||
# -- 1. gdformat --------------------------------------------------------------
|
# -- 1. gdformat --------------------------------------------------------------
|
||||||
$fmtAvail = Test-GdFormat
|
$fmtAvail = Test-GdFormat
|
||||||
$fmtResult = "PASS"
|
$fmtResult = "PASS"
|
||||||
|
$fmtFailed = $false
|
||||||
Set-Content -Path "$LOG/gdformat.log" -Value "" -Encoding utf8
|
Set-Content -Path "$LOG/gdformat.log" -Value "" -Encoding utf8
|
||||||
if (-not $fmtAvail) {
|
if (-not $fmtAvail) {
|
||||||
$fmtResult = "SKIPPED"
|
$fmtResult = "SKIPPED"
|
||||||
@@ -215,8 +223,9 @@ if (-not $fmtAvail) {
|
|||||||
if (-not (Test-Path $f)) { continue }
|
if (-not (Test-Path $f)) { continue }
|
||||||
$total++
|
$total++
|
||||||
$out = Invoke-GdFormat "--check" $f
|
$out = Invoke-GdFormat "--check" $f
|
||||||
|
$formatExit = $script:GdToolExitCode
|
||||||
$out | Add-Content -Path "$LOG/gdformat.log" -Encoding utf8
|
$out | Add-Content -Path "$LOG/gdformat.log" -Encoding utf8
|
||||||
if ($LASTEXITCODE -ne 0) { $bad++ }
|
if ($formatExit -ne 0) { $bad++; $fmtFailed = $true }
|
||||||
}
|
}
|
||||||
$msg = "($bad/$total files need formatting)"
|
$msg = "($bad/$total files need formatting)"
|
||||||
Add-Content -Path "$LOG/gdformat.log" -Value $msg -Encoding utf8
|
Add-Content -Path "$LOG/gdformat.log" -Value $msg -Encoding utf8
|
||||||
@@ -224,12 +233,15 @@ if (-not $fmtAvail) {
|
|||||||
} else {
|
} else {
|
||||||
$formatArgs = @("--check") + $OWNED_GDSCRIPT_ROOTS
|
$formatArgs = @("--check") + $OWNED_GDSCRIPT_ROOTS
|
||||||
$out = Invoke-GdFormat @formatArgs
|
$out = Invoke-GdFormat @formatArgs
|
||||||
|
$formatExit = $script:GdToolExitCode
|
||||||
$out | Add-Content -Path "$LOG/gdformat.log" -Encoding utf8
|
$out | Add-Content -Path "$LOG/gdformat.log" -Encoding utf8
|
||||||
|
if ($formatExit -ne 0) { $fmtFailed = $true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Select-String -SimpleMatch "would reformat" -Path "$LOG/gdformat.log" -Quiet) {
|
if ($fmtFailed) { $fmtResult = "FAIL" }
|
||||||
$fmtResult = "FAIL"
|
$formatDiagnostics = Select-String -SimpleMatch "would reformat" -Path "$LOG/gdformat.log" -Quiet
|
||||||
|
if ($formatDiagnostics) {
|
||||||
$lines = Select-String -SimpleMatch "would reformat" -Path "$LOG/gdformat.log" | Select-Object -ExpandProperty Line
|
$lines = Select-String -SimpleMatch "would reformat" -Path "$LOG/gdformat.log" | Select-Object -ExpandProperty Line
|
||||||
foreach ($line in $lines) {
|
foreach ($line in $lines) {
|
||||||
$p = ($line -replace '^would reformat ', '').Trim()
|
$p = ($line -replace '^would reformat ', '').Trim()
|
||||||
@@ -237,11 +249,15 @@ if (Select-String -SimpleMatch "would reformat" -Path "$LOG/gdformat.log" -Quiet
|
|||||||
$ERRORS += "$rp needs formatting"
|
$ERRORS += "$rp needs formatting"
|
||||||
}
|
}
|
||||||
$FIXES += "Run gdformat to auto-format files"
|
$FIXES += "Run gdformat to auto-format files"
|
||||||
|
} elseif ($fmtFailed) {
|
||||||
|
$ERRORS += "gdformat: formatter exited non-zero; see $LOG/gdformat.log"
|
||||||
|
$FIXES += "Fix the gdformat error before continuing"
|
||||||
}
|
}
|
||||||
|
|
||||||
# -- 2. gdlint ----------------------------------------------------------------
|
# -- 2. gdlint ----------------------------------------------------------------
|
||||||
$lintAvail = Test-GdLint
|
$lintAvail = Test-GdLint
|
||||||
$lintResult = "PASS"
|
$lintResult = "PASS"
|
||||||
|
$lintFailed = $false
|
||||||
Set-Content -Path "$LOG/gdlint.log" -Value "" -Encoding utf8
|
Set-Content -Path "$LOG/gdlint.log" -Value "" -Encoding utf8
|
||||||
if (-not $lintAvail) {
|
if (-not $lintAvail) {
|
||||||
$lintResult = "SKIPPED"
|
$lintResult = "SKIPPED"
|
||||||
@@ -255,18 +271,22 @@ if (-not $lintAvail) {
|
|||||||
foreach ($f in $files) {
|
foreach ($f in $files) {
|
||||||
if (-not (Test-Path $f)) { continue }
|
if (-not (Test-Path $f)) { continue }
|
||||||
$out = Invoke-GdLint $f
|
$out = Invoke-GdLint $f
|
||||||
|
$lintExit = $script:GdToolExitCode
|
||||||
$out | Add-Content -Path "$LOG/gdlint.log" -Encoding utf8
|
$out | Add-Content -Path "$LOG/gdlint.log" -Encoding utf8
|
||||||
|
if ($lintExit -ne 0) { $lintFailed = $true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$out = Invoke-GdLint @OWNED_GDSCRIPT_ROOTS
|
$out = Invoke-GdLint @OWNED_GDSCRIPT_ROOTS
|
||||||
|
$lintExit = $script:GdToolExitCode
|
||||||
$out | Add-Content -Path "$LOG/gdlint.log" -Encoding utf8
|
$out | Add-Content -Path "$LOG/gdlint.log" -Encoding utf8
|
||||||
|
if ($lintExit -ne 0) { $lintFailed = $true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($lintFailed) { $lintResult = "FAIL" }
|
||||||
$lintLines = Select-String -Pattern '^[^ ]+:\d+:\d+:' -Path "$LOG/gdlint.log" | Select-Object -ExpandProperty Line
|
$lintLines = Select-String -Pattern '^[^ ]+:\d+:\d+:' -Path "$LOG/gdlint.log" | Select-Object -ExpandProperty Line
|
||||||
if ($lintLines) {
|
if ($lintLines) {
|
||||||
$lintResult = "FAIL"
|
|
||||||
foreach ($line in $lintLines) {
|
foreach ($line in $lintLines) {
|
||||||
$parts = $line -split ':', 4
|
$parts = $line -split ':', 4
|
||||||
if ($parts.Count -lt 4) { continue }
|
if ($parts.Count -lt 4) { continue }
|
||||||
@@ -280,6 +300,9 @@ if ($lintLines) {
|
|||||||
$rule = $parts[3]; $fname = Split-Path -Leaf $parts[0]
|
$rule = $parts[3]; $fname = Split-Path -Leaf $parts[0]
|
||||||
$FIXES += "Fix $rule in $fname"
|
$FIXES += "Fix $rule in $fname"
|
||||||
}
|
}
|
||||||
|
} elseif ($lintFailed) {
|
||||||
|
$ERRORS += "gdlint: linter exited non-zero; see $LOG/gdlint.log"
|
||||||
|
$FIXES += "Fix the gdlint error before continuing"
|
||||||
}
|
}
|
||||||
|
|
||||||
# -- 3. godot headless check --------------------------------------------------
|
# -- 3. godot headless check --------------------------------------------------
|
||||||
@@ -300,6 +323,12 @@ if ($godotExit -eq $null) {
|
|||||||
}
|
}
|
||||||
$FIXES += "Fix Godot parser errors"
|
$FIXES += "Fix Godot parser errors"
|
||||||
}
|
}
|
||||||
|
$godotLoadError = Select-String -Path "$LOG/godot-check.log" -Pattern 'SCRIPT ERROR:|Parse Error:|Failed to load script' -Quiet
|
||||||
|
if ($godotLoadError) {
|
||||||
|
$godotResult = "FAIL"
|
||||||
|
$ERRORS += "godot: project check reported a script load or parse error"
|
||||||
|
$FIXES += "Fix Godot parser errors"
|
||||||
|
}
|
||||||
|
|
||||||
# -- 4. project scenario tests ------------------------------------------------
|
# -- 4. project scenario tests ------------------------------------------------
|
||||||
$scenarioResult = "PASS"
|
$scenarioResult = "PASS"
|
||||||
@@ -315,6 +344,11 @@ foreach ($test in $scenarioTests) {
|
|||||||
$ERRORS += "scenario: $($test.Name) failed or timed out"
|
$ERRORS += "scenario: $($test.Name) failed or timed out"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$scenarioLoadError = Select-String -Path "$LOG/scenarios.log" -Pattern 'SCRIPT ERROR:|Parse Error:|Failed to load script' -Quiet
|
||||||
|
if ($scenarioLoadError) {
|
||||||
|
$scenarioResult = "FAIL"
|
||||||
|
$ERRORS += "scenario: Godot reported a script load or parse error"
|
||||||
|
}
|
||||||
if ($scenarioResult -eq "FAIL") {
|
if ($scenarioResult -eq "FAIL") {
|
||||||
$FIXES += "Fix failing project scenario tests"
|
$FIXES += "Fix failing project scenario tests"
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-55
@@ -53,22 +53,43 @@ find_godot() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
find_gdformat() {
|
has_gdformat() {
|
||||||
if [[ -x "$ROOT/.venv/bin/gdformat" ]]; then echo "$ROOT/.venv/bin/gdformat"; return 0; fi
|
[[ -x "$ROOT/.venv/bin/gdformat" ]] && return 0
|
||||||
if command -v gdformat &>/dev/null; then echo "gdformat"; return 0; fi
|
command -v gdformat &>/dev/null && return 0
|
||||||
if python -m gdtoolkit.formatter --help &>/dev/null 2>&1; then echo "python -m gdtoolkit.formatter"; return 0; fi
|
python -m gdtoolkit.formatter --help &>/dev/null 2>&1
|
||||||
return 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
find_gdlint() {
|
run_gdformat() {
|
||||||
if [[ -x "$ROOT/.venv/bin/gdlint" ]]; then echo "$ROOT/.venv/bin/gdlint"; return 0; fi
|
if [[ -x "$ROOT/.venv/bin/gdformat" ]]; then
|
||||||
if command -v gdlint &>/dev/null; then echo "gdlint"; return 0; fi
|
"$ROOT/.venv/bin/gdformat" "$@"
|
||||||
if python -m gdtoolkit.linter --help &>/dev/null 2>&1; then echo "python -m gdtoolkit.linter"; return 0; fi
|
elif command -v gdformat &>/dev/null; then
|
||||||
return 1
|
gdformat "$@"
|
||||||
|
else
|
||||||
|
python -m gdtoolkit.formatter "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
has_gdlint() {
|
||||||
|
[[ -x "$ROOT/.venv/bin/gdlint" ]] && return 0
|
||||||
|
command -v gdlint &>/dev/null && return 0
|
||||||
|
python -m gdtoolkit.linter --help &>/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
run_gdlint() {
|
||||||
|
if [[ -x "$ROOT/.venv/bin/gdlint" ]]; then
|
||||||
|
"$ROOT/.venv/bin/gdlint" "$@"
|
||||||
|
elif command -v gdlint &>/dev/null; then
|
||||||
|
gdlint "$@"
|
||||||
|
else
|
||||||
|
python -m gdtoolkit.linter "$@"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
changed_gd() {
|
changed_gd() {
|
||||||
git diff --name-only HEAD -- '*.gd' 2>/dev/null || true
|
{
|
||||||
|
git diff --name-only HEAD -- '*.gd' 2>/dev/null
|
||||||
|
git ls-files --others --exclude-standard -- '*.gd' 2>/dev/null
|
||||||
|
} | sort -u || true
|
||||||
}
|
}
|
||||||
|
|
||||||
res_path() {
|
res_path() {
|
||||||
@@ -83,8 +104,38 @@ contains_element() {
|
|||||||
local e; for e in "${@:2}"; do [[ "$e" == "$1" ]] && return 0; done; return 1
|
local e; for e in "${@:2}"; do [[ "$e" == "$1" ]] && return 0; done; return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
need_timeout() {
|
run_with_timeout() {
|
||||||
command -v timeout &>/dev/null
|
local seconds="$1"
|
||||||
|
shift
|
||||||
|
if command -v timeout &>/dev/null; then
|
||||||
|
timeout "$seconds" "$@"
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
|
||||||
|
local marker="$LOG/.timeout-$$-${RANDOM}"
|
||||||
|
: > "$marker"
|
||||||
|
"$@" &
|
||||||
|
local command_pid=$!
|
||||||
|
(
|
||||||
|
sleep "$seconds"
|
||||||
|
if kill -0 "$command_pid" 2>/dev/null; then
|
||||||
|
echo "timeout" > "$marker"
|
||||||
|
kill "$command_pid" 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
kill -KILL "$command_pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
) &
|
||||||
|
local watchdog_pid=$!
|
||||||
|
|
||||||
|
wait "$command_pid"
|
||||||
|
local status=$?
|
||||||
|
kill "$watchdog_pid" 2>/dev/null || true
|
||||||
|
wait "$watchdog_pid" 2>/dev/null || true
|
||||||
|
if [[ -s "$marker" ]]; then
|
||||||
|
status=124
|
||||||
|
fi
|
||||||
|
rm -f "$marker"
|
||||||
|
return "$status"
|
||||||
}
|
}
|
||||||
|
|
||||||
# -- godot --------------------------------------------------------------------
|
# -- godot --------------------------------------------------------------------
|
||||||
@@ -104,16 +155,15 @@ needs_import=false
|
|||||||
if [[ -f "addons/gut/gut_cmdln.gd" ]] && ! grep -q '"class": &"GutTest"' "$class_cache" 2>/dev/null; then
|
if [[ -f "addons/gut/gut_cmdln.gd" ]] && ! grep -q '"class": &"GutTest"' "$class_cache" 2>/dev/null; then
|
||||||
needs_import=true
|
needs_import=true
|
||||||
fi
|
fi
|
||||||
|
[[ -n "$(changed_gd)" ]] && needs_import=true
|
||||||
if $needs_import; then
|
if $needs_import; then
|
||||||
if need_timeout; then
|
run_with_timeout 60 "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --import >> "$LOG/godot-import.log" 2>&1
|
||||||
timeout 60 "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --import >> "$LOG/godot-import.log" 2>&1
|
|
||||||
import_exit=$?
|
import_exit=$?
|
||||||
else
|
|
||||||
"${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --import >> "$LOG/godot-import.log" 2>&1
|
|
||||||
import_exit=$?
|
|
||||||
fi
|
|
||||||
if [[ $import_exit -ne 0 ]]; then
|
if [[ $import_exit -ne 0 ]]; then
|
||||||
import_result="FAIL"
|
import_result="FAIL"
|
||||||
|
if [[ $import_exit -eq 124 ]]; then
|
||||||
|
echo "[timed out after 60s]" >> "$LOG/godot-import.log"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
if [[ ! -f "$class_cache" ]] || ! grep -q '"class": &"SimNPC"' "$class_cache"; then
|
if [[ ! -f "$class_cache" ]] || ! grep -q '"class": &"SimNPC"' "$class_cache"; then
|
||||||
@@ -131,10 +181,10 @@ if [[ "$import_result" == "FAIL" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# -- 1. gdformat --------------------------------------------------------------
|
# -- 1. gdformat --------------------------------------------------------------
|
||||||
fmt_tool=$(find_gdformat) || true
|
|
||||||
fmt_result="PASS"
|
fmt_result="PASS"
|
||||||
|
fmt_failed=false
|
||||||
: > "$LOG/gdformat.log"
|
: > "$LOG/gdformat.log"
|
||||||
if [[ -z "$fmt_tool" ]]; then
|
if ! has_gdformat; then
|
||||||
fmt_result="SKIPPED"
|
fmt_result="SKIPPED"
|
||||||
echo "gdformat not found — install requirements-dev.txt (see docs/local_quality_gate.md)" > "$LOG/gdformat.log"
|
echo "gdformat not found — install requirements-dev.txt (see docs/local_quality_gate.md)" > "$LOG/gdformat.log"
|
||||||
else
|
else
|
||||||
@@ -147,32 +197,40 @@ else
|
|||||||
while IFS= read -r f; do
|
while IFS= read -r f; do
|
||||||
[[ -f "$f" ]] || continue
|
[[ -f "$f" ]] || continue
|
||||||
total=$((total+1))
|
total=$((total+1))
|
||||||
if ! $fmt_tool --check "$f" >> "$LOG/gdformat.log" 2>&1; then
|
if ! run_gdformat --check "$f" >> "$LOG/gdformat.log" 2>&1; then
|
||||||
bad=$((bad+1))
|
bad=$((bad+1))
|
||||||
|
fmt_failed=true
|
||||||
fi
|
fi
|
||||||
done <<< "$files"
|
done <<< "$files"
|
||||||
echo "($bad/$total files need formatting)" >> "$LOG/gdformat.log"
|
echo "($bad/$total files need formatting)" >> "$LOG/gdformat.log"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
$fmt_tool --check "${OWNED_GDSCRIPT_ROOTS[@]}" >> "$LOG/gdformat.log" 2>&1 || true
|
if ! run_gdformat --check "${OWNED_GDSCRIPT_ROOTS[@]}" >> "$LOG/gdformat.log" 2>&1; then
|
||||||
|
fmt_failed=true
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if grep -q "would reformat" "$LOG/gdformat.log" 2>/dev/null; then
|
if $fmt_failed; then
|
||||||
fmt_result="FAIL"
|
fmt_result="FAIL"
|
||||||
|
fi
|
||||||
|
if grep -q "would reformat" "$LOG/gdformat.log" 2>/dev/null; then
|
||||||
while IFS= read -r line; do
|
while IFS= read -r line; do
|
||||||
line="${line#would reformat }"
|
line="${line#would reformat }"
|
||||||
rp=$(res_path "$line")
|
rp=$(res_path "$line")
|
||||||
ERRORS+=("$rp needs formatting")
|
ERRORS+=("$rp needs formatting")
|
||||||
done < <(grep "would reformat" "$LOG/gdformat.log")
|
done < <(grep "would reformat" "$LOG/gdformat.log")
|
||||||
FIXES+=("Run gdformat to auto-format files")
|
FIXES+=("Run gdformat to auto-format files")
|
||||||
|
elif $fmt_failed; then
|
||||||
|
ERRORS+=("gdformat: formatter exited non-zero; see $LOG/gdformat.log")
|
||||||
|
FIXES+=("Fix the gdformat error before continuing")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# -- 2. gdlint ----------------------------------------------------------------
|
# -- 2. gdlint ----------------------------------------------------------------
|
||||||
lint_tool=$(find_gdlint) || true
|
|
||||||
lint_result="PASS"
|
lint_result="PASS"
|
||||||
|
lint_failed=false
|
||||||
: > "$LOG/gdlint.log"
|
: > "$LOG/gdlint.log"
|
||||||
if [[ -z "$lint_tool" ]]; then
|
if ! has_gdlint; then
|
||||||
lint_result="SKIPPED"
|
lint_result="SKIPPED"
|
||||||
echo "gdlint not found — install requirements-dev.txt (see docs/local_quality_gate.md)" > "$LOG/gdlint.log"
|
echo "gdlint not found — install requirements-dev.txt (see docs/local_quality_gate.md)" > "$LOG/gdlint.log"
|
||||||
else
|
else
|
||||||
@@ -183,16 +241,22 @@ else
|
|||||||
else
|
else
|
||||||
while IFS= read -r f; do
|
while IFS= read -r f; do
|
||||||
[[ -f "$f" ]] || continue
|
[[ -f "$f" ]] || continue
|
||||||
$lint_tool "$f" >> "$LOG/gdlint.log" 2>&1 || true
|
if ! run_gdlint "$f" >> "$LOG/gdlint.log" 2>&1; then
|
||||||
|
lint_failed=true
|
||||||
|
fi
|
||||||
done <<< "$files"
|
done <<< "$files"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
$lint_tool "${OWNED_GDSCRIPT_ROOTS[@]}" >> "$LOG/gdlint.log" 2>&1 || true
|
if ! run_gdlint "${OWNED_GDSCRIPT_ROOTS[@]}" >> "$LOG/gdlint.log" 2>&1; then
|
||||||
|
lint_failed=true
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if grep -qE "^[^ ]+:[0-9]+:" "$LOG/gdlint.log" 2>/dev/null; then
|
if $lint_failed; then
|
||||||
lint_result="FAIL"
|
lint_result="FAIL"
|
||||||
|
fi
|
||||||
|
if grep -qE "^[^ ]+:[0-9]+:" "$LOG/gdlint.log" 2>/dev/null; then
|
||||||
while IFS= read -r line; do
|
while IFS= read -r line; do
|
||||||
[[ "$line" =~ ^([^:]+):([0-9]+):([0-9]+):[^:]+:(.*) ]] || continue
|
[[ "$line" =~ ^([^:]+):([0-9]+):([0-9]+):[^:]+:(.*) ]] || continue
|
||||||
file="${BASH_REMATCH[1]}"
|
file="${BASH_REMATCH[1]}"
|
||||||
@@ -209,28 +273,21 @@ if grep -qE "^[^ ]+:[0-9]+:" "$LOG/gdlint.log" 2>/dev/null; then
|
|||||||
fname=$(basename "${BASH_REMATCH[1]}")
|
fname=$(basename "${BASH_REMATCH[1]}")
|
||||||
FIXES+=("Fix $rule in $fname")
|
FIXES+=("Fix $rule in $fname")
|
||||||
done < <(grep -E "^[^ ]+:[0-9]+:" "$LOG/gdlint.log")
|
done < <(grep -E "^[^ ]+:[0-9]+:" "$LOG/gdlint.log")
|
||||||
|
elif $lint_failed; then
|
||||||
|
ERRORS+=("gdlint: linter exited non-zero; see $LOG/gdlint.log")
|
||||||
|
FIXES+=("Fix the gdlint error before continuing")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# -- 3. godot headless check --------------------------------------------------
|
# -- 3. godot headless check --------------------------------------------------
|
||||||
godot_result="PASS"
|
godot_result="PASS"
|
||||||
: > "$LOG/godot-check.log"
|
: > "$LOG/godot-check.log"
|
||||||
if need_timeout; then
|
if run_with_timeout 30 "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --script res://tests/simulation_definitions_test.gd >> "$LOG/godot-check.log" 2>&1; then
|
||||||
if timeout 30 "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --script res://tests/simulation_definitions_test.gd >> "$LOG/godot-check.log" 2>&1; then
|
|
||||||
godot_result="PASS"
|
godot_result="PASS"
|
||||||
else
|
|
||||||
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
|
else
|
||||||
if "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --script res://tests/simulation_definitions_test.gd >> "$LOG/godot-check.log" 2>&1; then
|
ec=$?
|
||||||
godot_result="PASS"
|
|
||||||
else
|
|
||||||
godot_result="FAIL"
|
godot_result="FAIL"
|
||||||
|
if [[ $ec -eq 124 ]]; then
|
||||||
|
echo "[timed out after 30s]" >> "$LOG/godot-check.log"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
if grep -qE "SCRIPT ERROR:|Parse Error:|Failed to load script" "$LOG/godot-check.log"; then
|
if grep -qE "SCRIPT ERROR:|Parse Error:|Failed to load script" "$LOG/godot-check.log"; then
|
||||||
@@ -248,15 +305,10 @@ scenario_result="PASS"
|
|||||||
: > "$LOG/scenarios.log"
|
: > "$LOG/scenarios.log"
|
||||||
for test in tests/*_test.gd; do
|
for test in tests/*_test.gd; do
|
||||||
echo "[RUN] $(basename "$test")" >> "$LOG/scenarios.log"
|
echo "[RUN] $(basename "$test")" >> "$LOG/scenarios.log"
|
||||||
if need_timeout; then
|
if ! run_with_timeout 30 "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --script "res://$test" >> "$LOG/scenarios.log" 2>&1; then
|
||||||
if ! timeout 30 "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --script "res://$test" >> "$LOG/scenarios.log" 2>&1; then
|
|
||||||
scenario_result="FAIL"
|
scenario_result="FAIL"
|
||||||
ERRORS+=("scenario: $(basename "$test") failed or timed out")
|
ERRORS+=("scenario: $(basename "$test") failed or timed out")
|
||||||
fi
|
fi
|
||||||
elif ! "${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT" --script "res://$test" >> "$LOG/scenarios.log" 2>&1; then
|
|
||||||
scenario_result="FAIL"
|
|
||||||
ERRORS+=("scenario: $(basename "$test") failed")
|
|
||||||
fi
|
|
||||||
done
|
done
|
||||||
if grep -qE "SCRIPT ERROR:|Parse Error:|Failed to load script" "$LOG/scenarios.log"; then
|
if grep -qE "SCRIPT ERROR:|Parse Error:|Failed to load script" "$LOG/scenarios.log"; then
|
||||||
scenario_result="FAIL"
|
scenario_result="FAIL"
|
||||||
@@ -278,13 +330,8 @@ else
|
|||||||
"${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT"
|
"${GODOT_ENV[@]}" "$GODOT" --headless --path "$ROOT"
|
||||||
-s addons/gut/gut_cmdln.gd -gexit -gdisable_colors
|
-s addons/gut/gut_cmdln.gd -gexit -gdisable_colors
|
||||||
)
|
)
|
||||||
if need_timeout; then
|
run_with_timeout 30 "${gut_command[@]}" >> "$LOG/gut.log" 2>&1
|
||||||
timeout 30 "${gut_command[@]}" >> "$LOG/gut.log" 2>&1
|
|
||||||
gut_exit=$?
|
gut_exit=$?
|
||||||
else
|
|
||||||
"${gut_command[@]}" >> "$LOG/gut.log" 2>&1
|
|
||||||
gut_exit=$?
|
|
||||||
fi
|
|
||||||
if [[ $gut_exit -eq 0 ]]; then
|
if [[ $gut_exit -eq 0 ]]; then
|
||||||
gut_result="PASS"
|
gut_result="PASS"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -34,9 +34,11 @@ func toggle_debug_overlay() -> void:
|
|||||||
debug_overlay_visible = not debug_overlay_visible
|
debug_overlay_visible = not debug_overlay_visible
|
||||||
_apply_debug_overlay_visibility()
|
_apply_debug_overlay_visibility()
|
||||||
print(
|
print(
|
||||||
|
(
|
||||||
"[DemoController] %s"
|
"[DemoController] %s"
|
||||||
% ("Debug overlay visible" if debug_overlay_visible else "Cinematic overlay hidden")
|
% ("Debug overlay visible" if debug_overlay_visible else "Cinematic overlay hidden")
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
func reset_demo_scene() -> void:
|
func reset_demo_scene() -> void:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
[sub_resource type="ParticleProcessMaterial" id="3"]
|
[sub_resource type="ParticleProcessMaterial" id="3"]
|
||||||
emission_shape = 1
|
emission_shape = 1
|
||||||
emission_box_extents = Vector3(0.1, 0.025, 0.1)
|
emission_box_extents = Vector3(0.1, 0.025, 0.1)
|
||||||
gravity = Vector3(0, 0.3, 0)
|
gravity = Vector3(0.065, 0.3, 0.025)
|
||||||
velocity_min = 0.0
|
velocity_min = 0.0
|
||||||
velocity_max = 0.5
|
velocity_max = 0.5
|
||||||
scale_min = 0.05
|
scale_min = 0.05
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
[gd_scene load_steps=37 format=3]
|
[gd_scene load_steps=36 format=3]
|
||||||
|
|
||||||
[ext_resource type="Terrain3DAssets" path="res://terrain/jajce/assets.tres" id="1_assets"]
|
[ext_resource type="Terrain3DAssets" path="res://terrain/jajce/assets.tres" id="1_assets"]
|
||||||
[ext_resource type="PackedScene" path="res://world/resource_nodes/ResourceNode.tscn" id="2_resource"]
|
[ext_resource type="PackedScene" path="res://world/resource_nodes/ResourceNode.tscn" id="2_resource"]
|
||||||
@@ -16,8 +16,7 @@
|
|||||||
[ext_resource type="PackedScene" path="res://world/storage/StorageNode.tscn" id="14_storage"]
|
[ext_resource type="PackedScene" path="res://world/storage/StorageNode.tscn" id="14_storage"]
|
||||||
[ext_resource type="Script" path="res://world/activity/ActivitySite.gd" id="15_activity"]
|
[ext_resource type="Script" path="res://world/activity/ActivitySite.gd" id="15_activity"]
|
||||||
[ext_resource type="NavigationMesh" path="res://world/jajce/JajceNavigationMesh.tres" id="16_nav"]
|
[ext_resource type="NavigationMesh" path="res://world/jajce/JajceNavigationMesh.tres" id="16_nav"]
|
||||||
[ext_resource type="PackedScene" path="res://world/jajce/WindStrokes.tscn" id="19_windstrokes"]
|
[ext_resource type="PackedScene" path="res://world/jajce/vfx/WindGustField.tscn" id="17_wind_gust"]
|
||||||
[ext_resource type="PackedScene" path="res://world/jajce/WindSpecks.tscn" id="20_windspecks"]
|
|
||||||
|
|
||||||
[sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"]
|
[sub_resource type="Terrain3DMaterial" id="Terrain3DMaterial_jajce"]
|
||||||
_shader_parameters = {
|
_shader_parameters = {
|
||||||
@@ -593,15 +592,10 @@ light_energy = 1.45
|
|||||||
shadow_enabled = true
|
shadow_enabled = true
|
||||||
directional_shadow_max_distance = 180.0
|
directional_shadow_max_distance = 180.0
|
||||||
|
|
||||||
[node name="WindStrokes_Valley" parent="." instance=ExtResource("19_windstrokes")]
|
[node name="AtmosphereRoot" type="Node3D" parent="."]
|
||||||
|
|
||||||
[node name="WindStrokes_Ridge" parent="." instance=ExtResource("19_windstrokes")]
|
[node name="WindGustField" parent="AtmosphereRoot" instance=ExtResource("17_wind_gust")]
|
||||||
position = Vector3(-25, 7, 9)
|
position = Vector3(-2, 0, 0)
|
||||||
|
|
||||||
[node name="WindSpecks_Valley" parent="." instance=ExtResource("20_windspecks")]
|
|
||||||
|
|
||||||
[node name="WindSpecks_Ridge" parent="." instance=ExtResource("20_windspecks")]
|
|
||||||
position = Vector3(-25, 7, 9)
|
|
||||||
|
|
||||||
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
|
||||||
environment = SubResource("Environment_greybox")
|
environment = SubResource("Environment_greybox")
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ enum CanopyVariant {
|
|||||||
YELLOW_GREEN,
|
YELLOW_GREEN,
|
||||||
}
|
}
|
||||||
|
|
||||||
@export var canopy_variant: CanopyVariant = CanopyVariant.GREEN
|
|
||||||
@export var canopy_radius: float = 1.35
|
|
||||||
|
|
||||||
const COLORS := {
|
const COLORS := {
|
||||||
CanopyVariant.GREEN: Color(0.19, 0.38, 0.17),
|
CanopyVariant.GREEN: Color(0.19, 0.38, 0.17),
|
||||||
CanopyVariant.DARK_GREEN: Color(0.12, 0.30, 0.12),
|
CanopyVariant.DARK_GREEN: Color(0.12, 0.30, 0.12),
|
||||||
@@ -16,6 +13,8 @@ const COLORS := {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const WIND_SHADER := preload("res://world/jajce/materials/wind.gdshader")
|
const WIND_SHADER := preload("res://world/jajce/materials/wind.gdshader")
|
||||||
|
const WIND_STRENGTH := 0.07
|
||||||
|
const WIND_SPEED := 0.55
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -25,8 +24,9 @@ func _ready() -> void:
|
|||||||
var hash_seed := hash(global_position)
|
var hash_seed := hash(global_position)
|
||||||
var rng := RandomNumberGenerator.new()
|
var rng := RandomNumberGenerator.new()
|
||||||
rng.seed = hash_seed
|
rng.seed = hash_seed
|
||||||
canopy_variant = rng.randi() % COLORS.size() as CanopyVariant
|
var canopy_variant := rng.randi() % COLORS.size() as CanopyVariant
|
||||||
canopy_radius = 1.0 + rng.randf() * 0.8
|
var canopy_radius := 1.0 + rng.randf() * 0.8
|
||||||
|
var wind_phase := rng.randf_range(0.0, TAU)
|
||||||
|
|
||||||
var trunk_mesh := CylinderMesh.new()
|
var trunk_mesh := CylinderMesh.new()
|
||||||
trunk_mesh.top_radius = 0.22
|
trunk_mesh.top_radius = 0.22
|
||||||
@@ -39,6 +39,7 @@ func _ready() -> void:
|
|||||||
trunk_mesh.material = trunk_mat
|
trunk_mesh.material = trunk_mat
|
||||||
|
|
||||||
var trunk := MeshInstance3D.new()
|
var trunk := MeshInstance3D.new()
|
||||||
|
trunk.name = "Trunk"
|
||||||
trunk.mesh = trunk_mesh
|
trunk.mesh = trunk_mesh
|
||||||
trunk.position = Vector3(0, 1.4, 0)
|
trunk.position = Vector3(0, 1.4, 0)
|
||||||
add_child(trunk)
|
add_child(trunk)
|
||||||
@@ -51,9 +52,13 @@ func _ready() -> void:
|
|||||||
canopy_mat.shader = WIND_SHADER
|
canopy_mat.shader = WIND_SHADER
|
||||||
canopy_mat.set_shader_parameter("albedo", COLORS[canopy_variant])
|
canopy_mat.set_shader_parameter("albedo", COLORS[canopy_variant])
|
||||||
canopy_mat.set_shader_parameter("roughness", 0.9)
|
canopy_mat.set_shader_parameter("roughness", 0.9)
|
||||||
|
canopy_mat.set_shader_parameter("wind_strength", WIND_STRENGTH)
|
||||||
|
canopy_mat.set_shader_parameter("wind_speed", WIND_SPEED)
|
||||||
|
canopy_mat.set_shader_parameter("wind_phase", wind_phase)
|
||||||
canopy_mesh.material = canopy_mat
|
canopy_mesh.material = canopy_mat
|
||||||
|
|
||||||
var canopy := MeshInstance3D.new()
|
var canopy := MeshInstance3D.new()
|
||||||
|
canopy.name = "Canopy"
|
||||||
canopy.mesh = canopy_mesh
|
canopy.mesh = canopy_mesh
|
||||||
canopy.position = Vector3(0, 3.2 + (canopy_radius - 1.35) * 0.5, 0)
|
canopy.position = Vector3(0, 3.2 + (canopy_radius - 1.35) * 0.5, 0)
|
||||||
add_child(canopy)
|
add_child(canopy)
|
||||||
|
|||||||
@@ -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:
|
func _ready() -> void:
|
||||||
_simulation_node = get_tree().get_first_node_in_group("simulation_manager")
|
_find_simulation_node()
|
||||||
if _simulation_node == null:
|
if _simulation_node == null:
|
||||||
elapsed = cycle_duration_seconds * initial_cycle
|
elapsed = cycle_duration_seconds * initial_cycle
|
||||||
_apply_light_rotation(initial_cycle)
|
_apply_light_rotation(initial_cycle)
|
||||||
@@ -48,8 +48,10 @@ func _ready() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _process(_delta: float) -> void:
|
func _process(_delta: float) -> void:
|
||||||
|
if _simulation_node == null or not is_instance_valid(_simulation_node):
|
||||||
|
_find_simulation_node()
|
||||||
var cycle: float
|
var cycle: float
|
||||||
if _simulation_node != null and "clock" in _simulation_node:
|
if _simulation_node != null and "clock" in _simulation_node and _simulation_node.clock != null:
|
||||||
cycle = _simulation_node.clock.time_of_day()
|
cycle = _simulation_node.clock.time_of_day()
|
||||||
else:
|
else:
|
||||||
elapsed += _delta
|
elapsed += _delta
|
||||||
@@ -77,18 +79,18 @@ func _apply_environment(cycle: float) -> void:
|
|||||||
directional_light.light_color = _lerp_color(NIGHT_LIGHT_COLOR, DAY_LIGHT_COLOR, day_factor)
|
directional_light.light_color = _lerp_color(NIGHT_LIGHT_COLOR, DAY_LIGHT_COLOR, day_factor)
|
||||||
directional_light.light_energy = lerpf(NIGHT_LIGHT_ENERGY, DAY_LIGHT_ENERGY, day_factor)
|
directional_light.light_energy = lerpf(NIGHT_LIGHT_ENERGY, DAY_LIGHT_ENERGY, day_factor)
|
||||||
|
|
||||||
var sunset_peak := _sunrise_sunset_weight(cycle)
|
var transition_peak := _sunrise_sunset_weight(cycle)
|
||||||
if sunset_peak > 0.0:
|
if transition_peak > 0.0:
|
||||||
var sunset_color: Color
|
var transition_color: Color
|
||||||
if cycle < 0.5:
|
if cycle < 0.5:
|
||||||
sunset_color = SUNSET_LIGHT_COLOR
|
transition_color = SUNRISE_LIGHT_COLOR
|
||||||
else:
|
else:
|
||||||
sunset_color = SUNRISE_LIGHT_COLOR
|
transition_color = SUNSET_LIGHT_COLOR
|
||||||
directional_light.light_color = directional_light.light_color.lerp(
|
directional_light.light_color = directional_light.light_color.lerp(
|
||||||
sunset_color, sunset_peak * 0.85
|
transition_color, transition_peak * 0.85
|
||||||
)
|
)
|
||||||
directional_light.light_energy = maxf(
|
directional_light.light_energy = maxf(
|
||||||
directional_light.light_energy, SUNRISE_SUNSET_ENERGY * sunset_peak
|
directional_light.light_energy, SUNRISE_SUNSET_ENERGY * transition_peak
|
||||||
)
|
)
|
||||||
|
|
||||||
if world_environment == null or world_environment.environment == null:
|
if world_environment == null or world_environment.environment == null:
|
||||||
@@ -112,6 +114,10 @@ func _apply_environment(cycle: float) -> void:
|
|||||||
mat.ground_horizon_color = _lerp_color(NIGHT_GROUND_HORIZON, DAY_GROUND_HORIZON, day_factor)
|
mat.ground_horizon_color = _lerp_color(NIGHT_GROUND_HORIZON, DAY_GROUND_HORIZON, day_factor)
|
||||||
|
|
||||||
|
|
||||||
|
func _find_simulation_node() -> void:
|
||||||
|
_simulation_node = get_tree().get_first_node_in_group("simulation_manager")
|
||||||
|
|
||||||
|
|
||||||
func _day_factor(cycle: float) -> float:
|
func _day_factor(cycle: float) -> float:
|
||||||
if cycle < 0.15:
|
if cycle < 0.15:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
shader_type spatial;
|
shader_type spatial;
|
||||||
render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_burley, specular_schlick_ggx;
|
render_mode blend_mix, depth_draw_opaque, cull_back, diffuse_burley, specular_schlick_ggx;
|
||||||
|
|
||||||
uniform float wind_strength : hint_range(0.0, 0.5) = 0.08;
|
uniform vec2 wind_direction = vec2(0.94, 0.34);
|
||||||
uniform float wind_speed : hint_range(0.0, 5.0) = 0.6;
|
uniform float wind_strength : hint_range(0.0, 0.2) = 0.07;
|
||||||
|
uniform float wind_speed : hint_range(0.0, 2.0) = 0.55;
|
||||||
|
uniform float gust_speed : hint_range(0.0, 1.0) = 0.12;
|
||||||
|
uniform float gust_strength : hint_range(0.0, 0.8) = 0.35;
|
||||||
|
uniform float flutter_strength : hint_range(0.0, 0.05) = 0.012;
|
||||||
|
uniform float wind_phase : hint_range(0.0, 6.2832) = 0.0;
|
||||||
uniform vec3 albedo : source_color = vec3(0.19, 0.38, 0.17);
|
uniform vec3 albedo : source_color = vec3(0.19, 0.38, 0.17);
|
||||||
uniform float roughness : hint_range(0.0, 1.0) = 0.9;
|
uniform float roughness : hint_range(0.0, 1.0) = 0.9;
|
||||||
|
|
||||||
@@ -11,11 +16,16 @@ void vertex() {
|
|||||||
float height_factor = clamp((VERTEX.y + half_height) / (half_height * 2.0), 0.0, 1.0);
|
float height_factor = clamp((VERTEX.y + half_height) / (half_height * 2.0), 0.0, 1.0);
|
||||||
height_factor = height_factor * height_factor;
|
height_factor = height_factor * height_factor;
|
||||||
|
|
||||||
float sway_x = sin(VERTEX.x * 0.3 + TIME * wind_speed) * wind_strength * height_factor;
|
vec2 direction = normalize(wind_direction);
|
||||||
float sway_z = cos(VERTEX.z * 0.3 + TIME * wind_speed * 0.7) * wind_strength * height_factor;
|
vec2 crosswind = vec2(-direction.y, direction.x);
|
||||||
|
float world_phase = wind_phase + dot(MODEL_MATRIX[3].xz, vec2(0.07, 0.05));
|
||||||
|
float gust_wave = 0.5 + 0.5 * sin(TIME * gust_speed + world_phase * 0.7);
|
||||||
|
float gust = mix(1.0 - gust_strength, 1.0, gust_wave);
|
||||||
|
float bend = sin(TIME * wind_speed + world_phase) * wind_strength * gust;
|
||||||
|
float flutter = sin(TIME * 1.7 + world_phase * 1.9 + VERTEX.y * 2.0) * flutter_strength;
|
||||||
|
|
||||||
VERTEX.x += sway_x;
|
VERTEX.xz += direction * bend * height_factor;
|
||||||
VERTEX.z += sway_z;
|
VERTEX.xz += crosswind * flutter * height_factor;
|
||||||
}
|
}
|
||||||
|
|
||||||
void fragment() {
|
void fragment() {
|
||||||
|
|||||||
@@ -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_size := font.get_string_size(time_label, HORIZONTAL_ALIGNMENT_CENTER, -1, font_size)
|
||||||
var text_x := (SIZE_DIAL - text_size.x) / 2.0
|
var text_x := (SIZE_DIAL - text_size.x) / 2.0
|
||||||
var text_y := SIZE_DIAL - text_size.y + 4.0
|
var text_y := SIZE_DIAL - text_size.y + 4.0
|
||||||
draw_string(font, Vector2(text_x, text_y), time_label, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, Color.WHITE)
|
draw_string(
|
||||||
|
font,
|
||||||
|
Vector2(text_x, text_y),
|
||||||
|
time_label,
|
||||||
|
HORIZONTAL_ALIGNMENT_LEFT,
|
||||||
|
-1,
|
||||||
|
font_size,
|
||||||
|
Color.WHITE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
func _cycle_fraction() -> float:
|
func _cycle_fraction() -> float:
|
||||||
|
|||||||
+68
-18
@@ -27,6 +27,10 @@ func _ready() -> void:
|
|||||||
simulation_manager.state_restored.connect(_on_state_restored)
|
simulation_manager.state_restored.connect(_on_state_restored)
|
||||||
if simulation_manager.has_signal("speed_changed"):
|
if simulation_manager.has_signal("speed_changed"):
|
||||||
simulation_manager.speed_changed.connect(_on_speed_changed)
|
simulation_manager.speed_changed.connect(_on_speed_changed)
|
||||||
|
if simulation_manager.has_signal("relationship_changed"):
|
||||||
|
simulation_manager.relationship_changed.connect(_on_relationship_changed)
|
||||||
|
if simulation_manager.has_signal("event_knowledge_changed"):
|
||||||
|
simulation_manager.event_knowledge_changed.connect(_on_event_knowledge_changed)
|
||||||
|
|
||||||
if "village" in simulation_manager:
|
if "village" in simulation_manager:
|
||||||
_on_village_changed(simulation_manager.village)
|
_on_village_changed(simulation_manager.village)
|
||||||
@@ -119,6 +123,16 @@ func _on_speed_changed(_multiplier: float) -> void:
|
|||||||
_on_village_changed(simulation_manager.village)
|
_on_village_changed(simulation_manager.village)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_relationship_changed(
|
||||||
|
_relationship: RelationshipStateRecord, _cause_event: EconomicEventRecord
|
||||||
|
) -> void:
|
||||||
|
_refresh_npc_inspector()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_event_knowledge_changed(_knower_id: int, _event: EconomicEventRecord) -> void:
|
||||||
|
_refresh_npc_inspector()
|
||||||
|
|
||||||
|
|
||||||
func _refresh_npc_inspector() -> void:
|
func _refresh_npc_inspector() -> void:
|
||||||
if not debug_overlay_visible:
|
if not debug_overlay_visible:
|
||||||
return
|
return
|
||||||
@@ -147,30 +161,42 @@ func _refresh_npc_inspector() -> void:
|
|||||||
for action_id in score_keys:
|
for action_id in score_keys:
|
||||||
if decision.rejections.has(action_id):
|
if decision.rejections.has(action_id):
|
||||||
rejection_rows.append(
|
rejection_rows.append(
|
||||||
|
(
|
||||||
"%s — %s"
|
"%s — %s"
|
||||||
% [_get_action_name(StringName(action_id)), String(decision.rejections[action_id])]
|
% [
|
||||||
|
_get_action_name(StringName(action_id)),
|
||||||
|
String(decision.rejections[action_id])
|
||||||
|
]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
score_rows.append(
|
score_rows.append(
|
||||||
"%s %.2f" % [_get_action_name(StringName(action_id)), float(decision.scores[action_id])]
|
(
|
||||||
|
"%s %.2f"
|
||||||
|
% [_get_action_name(StringName(action_id)), float(decision.scores[action_id])]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if not score_rows.is_empty():
|
if not score_rows.is_empty():
|
||||||
score_text = "\n\nUtility\n" + "\n".join(score_rows)
|
score_text = "\n\nUtility\n" + "\n".join(score_rows)
|
||||||
if not rejection_rows.is_empty():
|
if not rejection_rows.is_empty():
|
||||||
score_text += "\n\nUnavailable\n" + "\n".join(rejection_rows)
|
score_text += "\n\nUnavailable\n" + "\n".join(rejection_rows)
|
||||||
var event_text := _build_event_history(npc)
|
var event_text := _build_event_history(npc)
|
||||||
var housemate_text := _build_housemate_display(npc)
|
var relationship_text := _build_relationship_display(npc)
|
||||||
|
var known_fact_text := _build_known_fact_display(npc)
|
||||||
npc_inspector_label.text = (
|
npc_inspector_label.text = (
|
||||||
|
(
|
||||||
"%s · %s\n"
|
"%s · %s\n"
|
||||||
+ "Task: %s (%s)\n"
|
+ "Task: %s (%s)\n"
|
||||||
+ "Destination: %s\n"
|
+ "Destination: %s\n"
|
||||||
+ "Hunger: %.0f Energy: %.0f\n"
|
+ "Hunger: %.0f Energy: %.0f\n"
|
||||||
+ "Carrying food: %.0f wood: %.0f\n"
|
+ "Carrying food: %.0f wood: %.0f\n"
|
||||||
+ "%s\n"
|
+ "%s\n"
|
||||||
|
+ "%s\n"
|
||||||
+ "Why\n%s%s\n\n"
|
+ "Why\n%s%s\n\n"
|
||||||
+ "Recent\n%s\n\n"
|
+ "Recent\n%s\n\n"
|
||||||
+ "[Tab] Next villager [F10] Cinematic/debug [F12] Demo reset"
|
+ "[Tab] Next villager [F10] Cinematic/debug [F12] Demo reset"
|
||||||
) % [
|
)
|
||||||
|
% [
|
||||||
npc.npc_name,
|
npc.npc_name,
|
||||||
profession_name,
|
profession_name,
|
||||||
action_name,
|
action_name,
|
||||||
@@ -180,11 +206,13 @@ func _refresh_npc_inspector() -> void:
|
|||||||
npc.energy,
|
npc.energy,
|
||||||
npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD),
|
npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD),
|
||||||
npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD),
|
npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD),
|
||||||
housemate_text,
|
relationship_text,
|
||||||
|
known_fact_text,
|
||||||
reason_text,
|
reason_text,
|
||||||
score_text,
|
score_text,
|
||||||
event_text
|
event_text
|
||||||
]
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
func _get_action_name(action_id: StringName) -> String:
|
func _get_action_name(action_id: StringName) -> String:
|
||||||
@@ -220,22 +248,44 @@ func _build_event_history(npc: SimNPC) -> String:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
func _build_housemate_display(npc: SimNPC) -> String:
|
func _build_relationship_display(npc: SimNPC) -> String:
|
||||||
if npc.familiarity.is_empty():
|
if not simulation_manager.has_method("get_primary_relationship"):
|
||||||
return ""
|
return ""
|
||||||
var highest_id: int = -1
|
var relationship: RelationshipStateRecord = simulation_manager.get_primary_relationship(npc.id)
|
||||||
var highest_score := -1.0
|
if relationship == null:
|
||||||
for key in npc.familiarity:
|
|
||||||
var other_id: int = int(key)
|
|
||||||
var score: float = float(npc.familiarity[key])
|
|
||||||
if score > highest_score:
|
|
||||||
highest_score = score
|
|
||||||
highest_id = other_id
|
|
||||||
if highest_id < 0:
|
|
||||||
return ""
|
return ""
|
||||||
var other_name := "Someone"
|
var other_name := "Someone"
|
||||||
for other_npc in simulation_manager.npcs:
|
for other_npc in simulation_manager.npcs:
|
||||||
if other_npc.id == highest_id:
|
if other_npc.id == relationship.get_subject_id():
|
||||||
other_name = other_npc.npc_name
|
other_name = other_npc.npc_name
|
||||||
break
|
break
|
||||||
return "Familiar: %s (%.0f%%)" % [other_name, highest_score * 100.0]
|
var display := (
|
||||||
|
"Relationship: %s — familiar %.0f%%, trust %.0f%%"
|
||||||
|
% [
|
||||||
|
other_name,
|
||||||
|
relationship.get_familiarity() * 100.0,
|
||||||
|
relationship.get_trust() * 100.0,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if not simulation_manager.has_method("get_relationship_cause"):
|
||||||
|
return display
|
||||||
|
var cause: EconomicEventRecord = simulation_manager.get_relationship_cause(relationship)
|
||||||
|
if cause == null:
|
||||||
|
return display
|
||||||
|
var name_map := {}
|
||||||
|
for other_npc in simulation_manager.npcs:
|
||||||
|
name_map[other_npc.id] = other_npc.npc_name
|
||||||
|
return "%s\nBecause: %s" % [display, cause.description(name_map)]
|
||||||
|
|
||||||
|
|
||||||
|
func _build_known_fact_display(npc: SimNPC) -> String:
|
||||||
|
if not simulation_manager.has_method("get_known_events"):
|
||||||
|
return ""
|
||||||
|
var known_events: Array = simulation_manager.get_known_events(npc.id, 1)
|
||||||
|
if known_events.is_empty():
|
||||||
|
return "Known fact: none yet"
|
||||||
|
var name_map := {}
|
||||||
|
for other_npc in simulation_manager.npcs:
|
||||||
|
name_map[other_npc.id] = other_npc.npc_name
|
||||||
|
var latest_event := known_events[known_events.size() - 1] as EconomicEventRecord
|
||||||
|
return "Known fact: %s" % latest_event.description(name_map)
|
||||||
|
|||||||
Reference in New Issue
Block a user