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