refactor: clarify simulation ownership
This commit is contained in:
@@ -68,8 +68,23 @@ It also publishes the latest `ActionSelectionResult` for presentation; the UI
|
|||||||
does not recompute decisions. At completion it atomically pays any
|
does not recompute decisions. At completion it atomically pays any
|
||||||
definition-backed stored-resource cost before applying the action effect. A
|
definition-backed stored-resource cost before applying the action effect. A
|
||||||
late shortfall suppresses the effect and records a `task_blocked` fact.
|
late shortfall suppresses the effect and records a `task_blocked` fact.
|
||||||
Further decomposition should follow measured pressure rather than splitting it
|
|
||||||
into managers for their own sake.
|
### VillageEconomy
|
||||||
|
|
||||||
|
- owns storage and NPC-inventory transfer operations;
|
||||||
|
- keeps `village.food` and `village.wood` synchronized as aggregate views;
|
||||||
|
- validates and pays definition-backed completion costs;
|
||||||
|
- emits completed transaction facts without owning their history.
|
||||||
|
|
||||||
|
### SimulationEventLog
|
||||||
|
|
||||||
|
- owns ordered economic and narrative event identity;
|
||||||
|
- answers recent-history, actor-history, and consumption-rate queries;
|
||||||
|
- restores persisted history without performing or replaying transactions.
|
||||||
|
|
||||||
|
These collaborators are `RefCounted` rule services, not additional scene-tree
|
||||||
|
managers. Further decomposition should follow measured pressure and a proven
|
||||||
|
gameplay consumer.
|
||||||
|
|
||||||
## Active-position contract
|
## Active-position contract
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# The Steward — Architecture Overview
|
||||||
|
|
||||||
|
This project is organized around gameplay ownership, not scene-tree location.
|
||||||
|
Serializable simulation records are authoritative; loaded Godot nodes present
|
||||||
|
that state and contribute active-world facts such as positions and navigation
|
||||||
|
results.
|
||||||
|
|
||||||
|
## Runtime flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
SimulationClock
|
||||||
|
-> SimulationManager orchestrates one deterministic tick
|
||||||
|
-> ActionExecutionSystem advances needs and work
|
||||||
|
-> ActionSelectionSystem chooses an action
|
||||||
|
-> ActionTargetResolver resolves a stable target ID
|
||||||
|
-> VillageEconomy performs inventory/storage transactions
|
||||||
|
-> SimulationEventLog records completed facts
|
||||||
|
-> WorldViewManager presents travel and NPC state
|
||||||
|
-> ActiveWorldAdapter supplies loaded-world positions/capacity
|
||||||
|
-> NpcVisual performs local navigation and animation
|
||||||
|
```
|
||||||
|
|
||||||
|
`SimulationManager` is the scene-tree façade for the simulation. It owns the
|
||||||
|
tick lifecycle, authoritative NPC/village/resource records, reservations, and
|
||||||
|
the signals consumed by presentation. Focused collaborators own rules that
|
||||||
|
would otherwise obscure that lifecycle:
|
||||||
|
|
||||||
|
- `simulation/actions/` owns selection, progress, and target resolution;
|
||||||
|
- `simulation/economy/VillageEconomy.gd` owns storage/inventory transactions
|
||||||
|
and keeps village resource summaries synchronized;
|
||||||
|
- `simulation/events/SimulationEventLog.gd` owns ordered event identity,
|
||||||
|
history queries, and rate calculations;
|
||||||
|
- `simulation/persistence/` owns save-slot file safety;
|
||||||
|
- `simulation/state/` owns versioned serialized record contracts;
|
||||||
|
- `simulation/definitions/` owns stable IDs and immutable action/profession
|
||||||
|
definitions.
|
||||||
|
|
||||||
|
The manager deliberately remains a façade instead of being split into a
|
||||||
|
collection of scene-tree manager nodes. A new collaborator is justified when
|
||||||
|
one cohesive rule set has several real consumers or makes the tick lifecycle
|
||||||
|
hard to read.
|
||||||
|
|
||||||
|
## Folder ownership
|
||||||
|
|
||||||
|
| Path | Responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| `simulation/` | Headless-capable orchestration and core models |
|
||||||
|
| `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/state/` | Versioned, serializable mutable records |
|
||||||
|
| `simulation/definitions/` | Stable IDs and immutable gameplay definitions |
|
||||||
|
| `simulation/persistence/` | Validated local save-file storage |
|
||||||
|
| `world/` | Loaded-world interaction geometry and presentation adapters |
|
||||||
|
| `world/resource_nodes/` | Finite resource presentation bound by stable ID |
|
||||||
|
| `world/storage/` | Storage interaction geometry, never stored quantities |
|
||||||
|
| `world/activity/` | Rest/study/patrol interaction sites and capacity facts |
|
||||||
|
| `player/` | Player input, camera, and active NPC presentation |
|
||||||
|
| `tests/` | Deterministic headless gameplay scenarios |
|
||||||
|
|
||||||
|
Top-level core model scripts keep their stable paths because Godot's global
|
||||||
|
class cache records `class_name` locations. Moving them solely for cosmetic
|
||||||
|
nesting can break editor and headless startup for existing workspaces without
|
||||||
|
improving ownership.
|
||||||
|
|
||||||
|
## Dependency rules
|
||||||
|
|
||||||
|
- Simulation code must run without `main.tscn` or loaded world nodes.
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
- Prefer one tested vertical behavior over a generic framework with no proven
|
||||||
|
consumers.
|
||||||
|
|
||||||
|
## Where new code goes
|
||||||
|
|
||||||
|
Put a rule beside the state it governs. A relationship consequence belongs in
|
||||||
|
a focused simulation system plus serialized relationship records; its icon or
|
||||||
|
animation belongs in presentation. Add a world node only when the behavior
|
||||||
|
needs loaded-world geometry. Add a stable ID or definition when content must be
|
||||||
|
referenced across saves, scenes, or unloaded simulation.
|
||||||
|
|
||||||
|
The architectural decision and detailed contracts live in
|
||||||
|
[ADR 0001](decisions/0001-simulation-authority-boundary.md),
|
||||||
|
[the action system architecture](ACTION_SYSTEM_ARCHITECTURE.md), and
|
||||||
|
[the simulation state schema](SIMULATION_STATE_SCHEMA.md).
|
||||||
@@ -20,6 +20,11 @@ Events are immutable facts about completed transfers. They do not perform the
|
|||||||
transaction and are not replayed to reconstruct current state. Resource,
|
transaction and are not replayed to reconstruct current state. Resource,
|
||||||
inventory, and storage records remain authoritative.
|
inventory, and storage records remain authoritative.
|
||||||
|
|
||||||
|
`SimulationEventLog` owns ordered event identity, append/restore behavior, and
|
||||||
|
history/rate queries. `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
|
An action whose definition-backed completion cost becomes unavailable records
|
||||||
a zero-amount `task_blocked` narrative fact with the action and shortfall
|
a zero-amount `task_blocked` narrative fact with the action and shortfall
|
||||||
reason. This makes late contention inspectable without pretending that a
|
reason. This makes late contention inspectable without pretending that a
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ world only when consumed.
|
|||||||
|
|
||||||
- `StorageStateRecord` owns pantry and woodpile contents and capacity.
|
- `StorageStateRecord` owns pantry and woodpile contents and capacity.
|
||||||
- `SimNPC.inventory` owns carried item amounts.
|
- `SimNPC.inventory` owns carried item amounts.
|
||||||
- `SimulationManager` performs deposit, withdrawal, and consumption
|
- `VillageEconomy` performs deposit, withdrawal, consumption, and
|
||||||
transactions.
|
definition-backed completion-cost transactions.
|
||||||
|
- `SimulationManager` coordinates action lifecycle and exposes the transaction
|
||||||
|
results to presentation.
|
||||||
- `village.food` and `village.wood` are synchronized aggregate views used by
|
- `village.food` and `village.wood` are synchronized aggregate views used by
|
||||||
the existing UI, priorities, and utility scoring. They are not second
|
the existing UI, priorities, and utility scoring. They are not second
|
||||||
mutation paths.
|
mutation paths.
|
||||||
@@ -45,6 +47,8 @@ Transactions apply the amount actually available:
|
|||||||
|
|
||||||
- extraction cannot exceed the source;
|
- extraction cannot exceed the source;
|
||||||
- deposit cannot exceed storage capacity;
|
- deposit cannot exceed storage capacity;
|
||||||
|
- player extraction is limited to storage capacity until player inventory
|
||||||
|
exists, so overflow remains at the source;
|
||||||
- withdrawal cannot exceed pantry contents;
|
- withdrawal cannot exceed pantry contents;
|
||||||
- eating succeeds only when the NPC carries one food.
|
- eating succeeds only when the NPC carries one food.
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,8 @@ Each milestone should produce five outcomes:
|
|||||||
Do not advance because files exist. Advance when the exit test passes.
|
Do not advance because files exist. Advance when the exit test passes.
|
||||||
|
|
||||||
The current implementation has completed ResourceNode migration through player
|
The current implementation has completed ResourceNode migration through player
|
||||||
parity plus the minimal Jajce scaffold and navigation proof. The architecture
|
parity, the Jajce scaffold/navigation proof, and the mandatory architecture
|
||||||
gate below is now active and must pass before beauty production or broader
|
gate below. See
|
||||||
simulation features. See
|
|
||||||
[ADR 0001](decisions/0001-simulation-authority-boundary.md).
|
[ADR 0001](decisions/0001-simulation-authority-boundary.md).
|
||||||
|
|
||||||
## Mandatory architecture gate
|
## Mandatory architecture gate
|
||||||
@@ -660,6 +659,12 @@ The practical next sequence is:
|
|||||||
|
|
||||||
Recently completed:
|
Recently completed:
|
||||||
|
|
||||||
|
- Simulation responsibility cleanup: storage/inventory transactions now live
|
||||||
|
in `VillageEconomy`, ordered history and rate queries live in
|
||||||
|
`SimulationEventLog`, and `SimulationManager` exposes a shorter tick
|
||||||
|
lifecycle while remaining the scene-tree façade. Dead prototype APIs and
|
||||||
|
unused scene artifacts were removed, with the ownership map documented in
|
||||||
|
`ARCHITECTURE_OVERVIEW.md`.
|
||||||
- `F10` cinematic/debug presentation toggle that hides development UI, world
|
- `F10` cinematic/debug presentation toggle that hides development UI, world
|
||||||
labels, and NPC name/profession labels without changing simulation state;
|
labels, and NPC name/profession labels without changing simulation state;
|
||||||
- `F12` repeatable simulation-garden demo reset by reloading the current scene
|
- `F12` repeatable simulation-garden demo reset by reloading the current scene
|
||||||
|
|||||||
+30
-19
@@ -375,40 +375,46 @@ glyphs.
|
|||||||
### `simulation/SimNPC.gd`
|
### `simulation/SimNPC.gd`
|
||||||
|
|
||||||
`SimNPC` is a `RefCounted` simulation model. It owns needs, task selection,
|
`SimNPC` is a `RefCounted` simulation model. It owns needs, task selection,
|
||||||
task progression, profession affinity, starvation, and death.
|
task state, profession affinity, carried inventory, starvation, and death.
|
||||||
|
|
||||||
This separation from the visual node is an important architectural seed and
|
This separation from the visual node is an important architectural seed and
|
||||||
should be preserved.
|
should be preserved.
|
||||||
|
|
||||||
### `simulation/SimVillage.gd`
|
### `simulation/SimVillage.gd`
|
||||||
|
|
||||||
`SimVillage` is a `RefCounted` aggregate for shared resources, modifiers,
|
`SimVillage` is a `RefCounted` aggregate for synchronized village resource
|
||||||
priorities, and applying completed NPC work.
|
views, modifiers, and priorities.
|
||||||
|
|
||||||
### `simulation/SimulationManager.gd`
|
### `simulation/SimulationManager.gd`
|
||||||
|
|
||||||
`SimulationManager` is currently a scene-tree `Node` that:
|
`SimulationManager` is currently a scene-tree `Node` that:
|
||||||
|
|
||||||
- owns the village and NPC array;
|
- owns the village and NPC array;
|
||||||
- advances a tick approximately every 1.2 seconds;
|
- advances deterministic ticks through `SimulationClock`;
|
||||||
- creates NPCs;
|
- creates NPCs;
|
||||||
- coordinates task completion;
|
- coordinates selection, travel, reservations, and task completion;
|
||||||
- emits village, task, and death signals;
|
- emits village, task, and death signals;
|
||||||
- exposes direct resource-changing methods to the player.
|
- exposes the bounded player/simulation command API.
|
||||||
|
|
||||||
It currently combines clock, orchestration, event publication, population
|
Focused `RefCounted` collaborators keep rule ownership visible:
|
||||||
creation, and some gameplay API responsibilities.
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
`SimulationManager` remains the scene-tree façade and signal boundary rather
|
||||||
|
than duplicating these responsibilities across additional manager nodes.
|
||||||
|
|
||||||
### `world/world_view_manager.gd`
|
### `world/world_view_manager.gd`
|
||||||
|
|
||||||
`WorldViewManager` bridges simulation data to visible NPC nodes. It:
|
`WorldViewManager` bridges simulation data to visible NPC nodes. It:
|
||||||
|
|
||||||
- instantiates `NpcVisual` scenes;
|
- instantiates `NpcVisual` scenes;
|
||||||
- resolves resource nodes, random wander targets, and remaining activity
|
- supplies active visual positions for simulation-owned target resolution;
|
||||||
markers;
|
- sends resolved travel destinations to visuals;
|
||||||
- writes the selected ResourceNode ID onto the NPC as a transitional behavior;
|
|
||||||
- sends targets to visuals;
|
|
||||||
- reports arrival and navigation failure back to `SimulationManager`;
|
- reports arrival and navigation failure back to `SimulationManager`;
|
||||||
|
- synchronizes successful visual movement into authoritative NPC position;
|
||||||
- applies visual death state.
|
- applies visual death state.
|
||||||
|
|
||||||
### `player/npc/NpcVisual.gd`
|
### `player/npc/NpcVisual.gd`
|
||||||
@@ -456,7 +462,9 @@ NpcVisual navigates through the active world
|
|||||||
| Later ticks complete work
|
| Later ticks complete work
|
||||||
| |
|
| |
|
||||||
| v
|
| v
|
||||||
| ResourceStateRecord.extract() -> village.apply_resource_delta()
|
| ResourceStateRecord.extract() -> NPC inventory
|
||||||
|
| -> VillageEconomy transfers inventory/storage as actions complete
|
||||||
|
| -> SimulationEventLog appends completed facts
|
||||||
| |
|
| |
|
||||||
| v
|
| v
|
||||||
| village_changed signal updates the UI
|
| village_changed signal updates the UI
|
||||||
@@ -488,6 +496,9 @@ NpcVisual navigates through the active world
|
|||||||
│ ├── SimulationManager.gd
|
│ ├── SimulationManager.gd
|
||||||
│ ├── actions/ Selection, execution, and target resolution
|
│ ├── actions/ Selection, execution, and target resolution
|
||||||
│ ├── definitions/ Stable IDs and custom definition resources
|
│ ├── definitions/ Stable IDs and custom definition resources
|
||||||
|
│ ├── economy/ Inventory and storage transactions
|
||||||
|
│ ├── events/ Ordered event history and queries
|
||||||
|
│ ├── persistence/ Validated local save-slot storage
|
||||||
│ └── state/ Versioned simulation-state records
|
│ └── state/ Versioned simulation-state records
|
||||||
├── tests/
|
├── tests/
|
||||||
│ ├── action_system_boundaries_test.gd
|
│ ├── action_system_boundaries_test.gd
|
||||||
@@ -538,9 +549,9 @@ These are expected prototype constraints, not necessarily isolated bugs:
|
|||||||
- Automated coverage includes deterministic same-seed and save/restore
|
- Automated coverage includes deterministic same-seed and save/restore
|
||||||
continuation checks, player-parity/resource-contention, flat-map, and Jajce
|
continuation checks, player-parity/resource-contention, flat-map, and Jajce
|
||||||
scaffold scenarios; broader gameplay coverage is still missing.
|
scaffold scenarios; broader gameplay coverage is still missing.
|
||||||
- Resources are global floating-point counters rather than items in locations
|
- Food and wood now move through finite sources, NPC inventory, and typed
|
||||||
and inventories, except food, which now moves through sources, NPC inventory,
|
village storage. Other village metrics remain aggregate values rather than
|
||||||
and the village pantry.
|
located items.
|
||||||
- NPCs do not have homes, schedules, possessions, memories, relationships,
|
- NPCs do not have homes, schedules, possessions, memories, relationships,
|
||||||
goals, or social knowledge.
|
goals, or social knowledge.
|
||||||
- The reason inspector exposes current decisions, but deeper historical traces
|
- The reason inspector exposes current decisions, but deeper historical traces
|
||||||
@@ -549,9 +560,9 @@ These are expected prototype constraints, not necessarily isolated bugs:
|
|||||||
- Unloaded traveling NPCs preserve their state but do not yet advance through
|
- Unloaded traveling NPCs preserve their state but do not yet advance through
|
||||||
abstract travel time.
|
abstract travel time.
|
||||||
- There is no spatial query/index layer for large populations.
|
- There is no spatial query/index layer for large populations.
|
||||||
- SimulationManager still orchestrates multiple systems and player-facing
|
- SimulationManager still coordinates the tick lifecycle and bounded
|
||||||
mutation APIs, but selection, execution, target resolution, and active-world
|
player-facing commands, while action rules, active-world queries, economic
|
||||||
queries now have focused collaborators.
|
transactions, and event history have focused collaborators.
|
||||||
- Path failure and interruption emit a `navigation_failed` signal and send the NPC to wander; this is functional but not yet polished.
|
- Path failure and interruption emit a `navigation_failed` signal and send the NPC to wander; this is functional but not yet polished.
|
||||||
- The old greybox navigation source has been replaced by a project-owned
|
- The old greybox navigation source has been replaced by a project-owned
|
||||||
Terrain3D-derived navigation resource. The current bake is still a first
|
Terrain3D-derived navigation resource. The current bake is still a first
|
||||||
|
|||||||
+7
-5
@@ -6,22 +6,24 @@ sources of truth.
|
|||||||
1. [`PROJECT_CONTEXT.md`](PROJECT_CONTEXT.md) is the canonical description of
|
1. [`PROJECT_CONTEXT.md`](PROJECT_CONTEXT.md) is the canonical description of
|
||||||
the vision, current implementation, target architecture, and active
|
the vision, current implementation, target architecture, and active
|
||||||
constraints.
|
constraints.
|
||||||
2. [`LEARNING_ROADMAP.md`](LEARNING_ROADMAP.md) owns milestone order,
|
2. [`ARCHITECTURE_OVERVIEW.md`](ARCHITECTURE_OVERVIEW.md) is the concise map of
|
||||||
|
runtime ownership, folder responsibilities, and dependency rules.
|
||||||
|
3. [`LEARNING_ROADMAP.md`](LEARNING_ROADMAP.md) owns milestone order,
|
||||||
architecture gates, reusable-system exit tests, and intentionally deferred
|
architecture gates, reusable-system exit tests, and intentionally deferred
|
||||||
work.
|
work.
|
||||||
3. [`BUILD_IN_PUBLIC_PLAN.md`](BUILD_IN_PUBLIC_PLAN.md) owns the scoped Jajce
|
4. [`BUILD_IN_PUBLIC_PLAN.md`](BUILD_IN_PUBLIC_PLAN.md) owns the scoped Jajce
|
||||||
visual slice. It must respect the architecture gates in the learning
|
visual slice. It must respect the architecture gates in the learning
|
||||||
roadmap.
|
roadmap.
|
||||||
4. [`RESOURCE_NODE_MIGRATION.md`](RESOURCE_NODE_MIGRATION.md) is a focused
|
5. [`RESOURCE_NODE_MIGRATION.md`](RESOURCE_NODE_MIGRATION.md) is a focused
|
||||||
migration plan. Phases 1–6 are complete; follow-up work should expand
|
migration plan. Phases 1–6 are complete; follow-up work should expand
|
||||||
resource discovery without reintroducing abstract resource zones.
|
resource discovery without reintroducing abstract resource zones.
|
||||||
5. [`ACTION_SYSTEM_ARCHITECTURE.md`](ACTION_SYSTEM_ARCHITECTURE.md),
|
6. [`ACTION_SYSTEM_ARCHITECTURE.md`](ACTION_SYSTEM_ARCHITECTURE.md),
|
||||||
[`ECONOMIC_EVENTS.md`](ECONOMIC_EVENTS.md),
|
[`ECONOMIC_EVENTS.md`](ECONOMIC_EVENTS.md),
|
||||||
[`FOOD_STORAGE_ARCHITECTURE.md`](FOOD_STORAGE_ARCHITECTURE.md),
|
[`FOOD_STORAGE_ARCHITECTURE.md`](FOOD_STORAGE_ARCHITECTURE.md),
|
||||||
[`SIMULATION_DEFINITIONS.md`](SIMULATION_DEFINITIONS.md), and
|
[`SIMULATION_DEFINITIONS.md`](SIMULATION_DEFINITIONS.md), and
|
||||||
[`SIMULATION_STATE_SCHEMA.md`](SIMULATION_STATE_SCHEMA.md) document the
|
[`SIMULATION_STATE_SCHEMA.md`](SIMULATION_STATE_SCHEMA.md) document the
|
||||||
current data contracts.
|
current data contracts.
|
||||||
6. [`decisions/`](decisions/) contains durable architectural decisions,
|
7. [`decisions/`](decisions/) contains durable architectural decisions,
|
||||||
including consequences and revisit conditions.
|
including consequences and revisit conditions.
|
||||||
|
|
||||||
When documents disagree:
|
When documents disagree:
|
||||||
|
|||||||
@@ -60,9 +60,10 @@ destination. This allows a reloaded visual to resume travel without changing
|
|||||||
target selection or deterministic RNG state.
|
target selection or deterministic RNG state.
|
||||||
|
|
||||||
NPCStateRecord v3 adds carried inventory. SimulationStateRecord v2 adds
|
NPCStateRecord v3 adds carried inventory. SimulationStateRecord v2 adds
|
||||||
StorageStateRecord entries; world-schema v1 migrates legacy village food into
|
StorageStateRecord entries; world-schema v1 migrates legacy village food and
|
||||||
the stable `village_pantry` record. Parsed storage values are canonicalized so
|
wood into the stable `village_pantry` and `village_woodpile` records. Parsed
|
||||||
save/restore continuation retains byte-stable checksums.
|
storage values are canonicalized so save/restore continuation retains
|
||||||
|
byte-stable checksums.
|
||||||
|
|
||||||
SimulationStateRecord v3 adds ordered `EconomicEventRecord` entries and
|
SimulationStateRecord v3 adds ordered `EconomicEventRecord` entries and
|
||||||
`next_event_id`. World schemas v1 and v2 migrate explicitly to an empty event
|
`next_event_id`. World schemas v1 and v2 migrate explicitly to an empty event
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ random streams, and no dependency on a loaded gameplay scene.
|
|||||||
5. ✅ Split action selection, execution, and presentation travel.
|
5. ✅ Split action selection, execution, and presentation travel.
|
||||||
6. ✅ Synchronize active position and prove visual unload/reload
|
6. ✅ Synchronize active position and prove visual unload/reload
|
||||||
invariance.
|
invariance.
|
||||||
7. Add save-slot persistence before schedules or relationships
|
7. ✅ Add save-slot persistence before schedules or relationships
|
||||||
substantially expand mutable state.
|
substantially expand mutable state.
|
||||||
|
|
||||||
Schema v1 and its deterministic continuation contract are documented in
|
Schema v1 and its deterministic continuation contract are documented in
|
||||||
|
|||||||
@@ -79,8 +79,6 @@ npc_visual_scene = ExtResource("5_lquwl")
|
|||||||
simulation_manager = NodePath("../SimulationManager")
|
simulation_manager = NodePath("../SimulationManager")
|
||||||
active_npcs_parent = NodePath("../ActiveNPCs")
|
active_npcs_parent = NodePath("../ActiveNPCs")
|
||||||
|
|
||||||
[node name="EventBus" type="Node" parent="." unique_id=1149294963]
|
|
||||||
|
|
||||||
[node name="ActiveNPCs" type="Node3D" parent="." unique_id=88374680]
|
[node name="ActiveNPCs" type="Node3D" parent="." unique_id=88374680]
|
||||||
|
|
||||||
[node name="UI" type="CanvasLayer" parent="." unique_id=875790201 node_paths=PackedStringArray("simulation_manager")]
|
[node name="UI" type="CanvasLayer" parent="." unique_id=875790201 node_paths=PackedStringArray("simulation_manager")]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ signal arrived_at_target(sim_id: int)
|
|||||||
signal navigation_failed(sim_id: int)
|
signal navigation_failed(sim_id: int)
|
||||||
signal position_changed(sim_id: int, active_position: Vector3)
|
signal position_changed(sim_id: int, active_position: Vector3)
|
||||||
|
|
||||||
const DEBUG_LOGS := true
|
@export var debug_logs := false
|
||||||
|
|
||||||
@export var move_speed := 3.0
|
@export var move_speed := 3.0
|
||||||
@export var rotation_speed := 8.0
|
@export var rotation_speed := 8.0
|
||||||
@@ -47,7 +47,7 @@ var glyph_phase := 0.0
|
|||||||
|
|
||||||
|
|
||||||
func debug_log(message: String) -> void:
|
func debug_log(message: String) -> void:
|
||||||
if DEBUG_LOGS:
|
if debug_logs:
|
||||||
print("[NPCVisual] ", name, " | ", message)
|
print("[NPCVisual] ", name, " | ", message)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ func _physics_process(delta: float) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
# keep your camera mouse code here too
|
|
||||||
if event.is_action_pressed("interact"):
|
if event.is_action_pressed("interact"):
|
||||||
try_interact()
|
try_interact()
|
||||||
|
|
||||||
@@ -100,13 +99,6 @@ func try_harvest_resource_node() -> bool:
|
|||||||
return true
|
return true
|
||||||
|
|
||||||
|
|
||||||
func is_near(zone: Marker3D) -> bool:
|
|
||||||
if zone == null:
|
|
||||||
return false
|
|
||||||
|
|
||||||
return global_position.distance_to(zone.global_position) <= interaction_range
|
|
||||||
|
|
||||||
|
|
||||||
func is_near_storage(storage_node: StorageNode) -> bool:
|
func is_near_storage(storage_node: StorageNode) -> bool:
|
||||||
if storage_node == null:
|
if storage_node == null:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ var last_task: StringName
|
|||||||
var random_source: RandomNumberGenerator
|
var random_source: RandomNumberGenerator
|
||||||
var familiarity: Dictionary = {}
|
var familiarity: Dictionary = {}
|
||||||
var mourning_ticks := 0
|
var mourning_ticks := 0
|
||||||
var debug_logs := true
|
var debug_logs := false
|
||||||
|
|
||||||
|
|
||||||
func _init(
|
func _init(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ var food := 20.0
|
|||||||
var wood := 10.0
|
var wood := 10.0
|
||||||
var safety := 50.0
|
var safety := 50.0
|
||||||
var knowledge := 0.0
|
var knowledge := 0.0
|
||||||
var debug_logs := true
|
var debug_logs := false
|
||||||
|
|
||||||
var food_modifier := 1.0
|
var food_modifier := 1.0
|
||||||
var wood_modifier := 1.0
|
var wood_modifier := 1.0
|
||||||
@@ -70,19 +70,13 @@ func update_priorities() -> void:
|
|||||||
print(get_priority_summary())
|
print(get_priority_summary())
|
||||||
|
|
||||||
|
|
||||||
func apply_resource_delta(resource_id: StringName, amount: float) -> void:
|
func apply_metric_delta(metric_id: StringName, amount: float) -> void:
|
||||||
match resource_id:
|
match metric_id:
|
||||||
&"food":
|
|
||||||
food += amount
|
|
||||||
&"wood":
|
|
||||||
wood += amount
|
|
||||||
&"safety":
|
&"safety":
|
||||||
safety = clamp(safety + amount, 0.0, 100.0)
|
safety = clamp(safety + amount, 0.0, 100.0)
|
||||||
&"knowledge":
|
&"knowledge":
|
||||||
knowledge += amount
|
knowledge += amount
|
||||||
|
|
||||||
food = max(food, 0.0)
|
|
||||||
wood = max(wood, 0.0)
|
|
||||||
safety = clamp(safety, 0.0, 100.0)
|
safety = clamp(safety, 0.0, 100.0)
|
||||||
knowledge = max(knowledge, 0.0)
|
knowledge = max(knowledge, 0.0)
|
||||||
|
|
||||||
|
|||||||
+189
-433
@@ -1,5 +1,8 @@
|
|||||||
extends Node
|
extends Node
|
||||||
|
|
||||||
|
const SimulationEventLogScript := preload("res://simulation/events/SimulationEventLog.gd")
|
||||||
|
const VillageEconomyScript := preload("res://simulation/economy/VillageEconomy.gd")
|
||||||
|
|
||||||
signal npc_task_changed(npc: SimNPC, old_task: StringName, new_task: StringName)
|
signal npc_task_changed(npc: SimNPC, old_task: StringName, new_task: StringName)
|
||||||
signal village_changed(village: SimVillage)
|
signal village_changed(village: SimVillage)
|
||||||
signal npc_died(npc: SimNPC)
|
signal npc_died(npc: SimNPC)
|
||||||
@@ -16,7 +19,7 @@ var npcs: Array[SimNPC] = []
|
|||||||
@export var tick_interval := 1.2
|
@export var tick_interval := 1.2
|
||||||
@export var simulation_seed: int = 1337
|
@export var simulation_seed: int = 1337
|
||||||
@export var cycle_duration_seconds := 240.0
|
@export var cycle_duration_seconds := 240.0
|
||||||
@export var debug_logs := true
|
@export var debug_logs := false
|
||||||
@export var active_world_adapter: Node
|
@export var active_world_adapter: Node
|
||||||
@export var home_positions: Array[Vector3] = []
|
@export var home_positions: Array[Vector3] = []
|
||||||
|
|
||||||
@@ -24,9 +27,19 @@ var clock: SimulationClock
|
|||||||
var tick_count := 0
|
var tick_count := 0
|
||||||
var wander_random_sources := {}
|
var wander_random_sources := {}
|
||||||
var resource_states: Dictionary = {}
|
var resource_states: Dictionary = {}
|
||||||
var storage_states: Dictionary = {}
|
var event_log := SimulationEventLogScript.new()
|
||||||
var economic_events: Array[EconomicEventRecord] = []
|
var economy := VillageEconomyScript.new()
|
||||||
var next_event_id := 0
|
var storage_states: Dictionary:
|
||||||
|
get:
|
||||||
|
return economy.storage_states
|
||||||
|
var economic_events: Array[EconomicEventRecord]:
|
||||||
|
get:
|
||||||
|
return event_log.events
|
||||||
|
var next_event_id: int:
|
||||||
|
get:
|
||||||
|
return event_log.next_event_id
|
||||||
|
set(value):
|
||||||
|
event_log.next_event_id = value
|
||||||
var latest_decisions: Dictionary = {}
|
var latest_decisions: Dictionary = {}
|
||||||
var action_selector := ActionSelectionSystem.new()
|
var action_selector := ActionSelectionSystem.new()
|
||||||
var action_executor := ActionExecutionSystem.new()
|
var action_executor := ActionExecutionSystem.new()
|
||||||
@@ -35,11 +48,15 @@ var speed_index := 2
|
|||||||
const SPEED_LEVELS := [0.25, 0.5, 1.0, 2.0, 4.0, 10.0]
|
const SPEED_LEVELS := [0.25, 0.5, 1.0, 2.0, 4.0, 10.0]
|
||||||
signal speed_changed(multiplier: float)
|
signal speed_changed(multiplier: float)
|
||||||
|
|
||||||
var names := ["Amina", "Tarik", "Jasmin", "Elma", "Mirza", "Lejla"]
|
const NPC_NAMES := ["Amina", "Tarik", "Jasmin", "Elma", "Mirza", "Lejla"]
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
add_to_group("simulation_manager")
|
add_to_group("simulation_manager")
|
||||||
|
event_log.event_recorded.connect(_on_economic_event_recorded)
|
||||||
|
economy.inventory_changed.connect(_on_economy_inventory_changed)
|
||||||
|
economy.economic_event_requested.connect(_record_economic_event)
|
||||||
|
economy.narrative_event_requested.connect(record_narrative_event)
|
||||||
var definition_errors := SimulationDefinitions.validate()
|
var definition_errors := SimulationDefinitions.validate()
|
||||||
if not definition_errors.is_empty():
|
if not definition_errors.is_empty():
|
||||||
for error in definition_errors:
|
for error in definition_errors:
|
||||||
@@ -50,7 +67,8 @@ func _ready() -> void:
|
|||||||
clock.cycle_duration_seconds = cycle_duration_seconds
|
clock.cycle_duration_seconds = cycle_duration_seconds
|
||||||
clock.elapsed_ticks = int(0.25 * cycle_duration_seconds / tick_interval)
|
clock.elapsed_ticks = int(0.25 * cycle_duration_seconds / tick_interval)
|
||||||
village.debug_logs = debug_logs
|
village.debug_logs = debug_logs
|
||||||
_initialize_storage()
|
economy.configure(village, debug_logs)
|
||||||
|
economy.initialize_storage()
|
||||||
village.update_modifiers()
|
village.update_modifiers()
|
||||||
village.update_priorities()
|
village.update_priorities()
|
||||||
generate_npcs()
|
generate_npcs()
|
||||||
@@ -83,12 +101,12 @@ func _input(event: InputEvent) -> void:
|
|||||||
|
|
||||||
func generate_npcs() -> void:
|
func generate_npcs() -> void:
|
||||||
var profession_ids := SimulationDefinitions.get_profession_ids()
|
var profession_ids := SimulationDefinitions.get_profession_ids()
|
||||||
for i in range(names.size()):
|
for i in range(NPC_NAMES.size()):
|
||||||
var npc_random := _create_random_source(i, 0)
|
var npc_random := _create_random_source(i, 0)
|
||||||
var profession_index := npc_random.randi_range(0, profession_ids.size() - 1)
|
var profession_index := npc_random.randi_range(0, profession_ids.size() - 1)
|
||||||
var npc := SimNPC.new(
|
var npc := SimNPC.new(
|
||||||
i,
|
i,
|
||||||
names[i],
|
NPC_NAMES[i],
|
||||||
profession_ids[profession_index],
|
profession_ids[profession_index],
|
||||||
npc_random.randf_range(1.0, 10.0),
|
npc_random.randf_range(1.0, 10.0),
|
||||||
npc_random.randf_range(1.0, 10.0),
|
npc_random.randf_range(1.0, 10.0),
|
||||||
@@ -131,165 +149,81 @@ func simulate_tick() -> void:
|
|||||||
print("--- Tick ", tick_count, " ---")
|
print("--- Tick ", tick_count, " ---")
|
||||||
|
|
||||||
var village_was_changed := false
|
var village_was_changed := false
|
||||||
|
|
||||||
for npc in npcs:
|
for npc in npcs:
|
||||||
var old_task := npc.current_task
|
village_was_changed = _simulate_npc_tick(npc) or village_was_changed
|
||||||
var old_target := npc.target_id
|
|
||||||
var old_state := npc.task_state
|
if village_was_changed:
|
||||||
|
village_changed.emit(village)
|
||||||
|
|
||||||
|
if debug_logs:
|
||||||
|
print(village.get_summary())
|
||||||
|
|
||||||
|
|
||||||
|
func _simulate_npc_tick(npc: SimNPC) -> bool:
|
||||||
|
var previous_task := npc.current_task
|
||||||
|
var previous_target := npc.target_id
|
||||||
|
var previous_state := npc.task_state
|
||||||
var was_complete := npc.task_complete
|
var was_complete := npc.task_complete
|
||||||
var was_dead := npc.is_dead
|
var was_dead := npc.is_dead
|
||||||
|
|
||||||
action_executor.advance_npc(npc, village)
|
action_executor.advance_npc(npc, village)
|
||||||
if (
|
_select_action_if_idle(npc, previous_state)
|
||||||
not npc.is_dead
|
|
||||||
and old_state in [SimNPC.TASK_STATE_IDLE, SimNPC.TASK_STATE_COMPLETE]
|
|
||||||
and npc.task_state in [SimNPC.TASK_STATE_IDLE, SimNPC.TASK_STATE_COMPLETE]
|
|
||||||
):
|
|
||||||
var selection := action_selector.select_action(npc, village, clock.time_of_day(), npcs)
|
|
||||||
if selection != null:
|
|
||||||
latest_decisions[npc.id] = selection
|
|
||||||
npc_decision_recorded.emit(npc, selection)
|
|
||||||
npc.set_task(selection.action_id, selection.duration_override)
|
|
||||||
var action_def := SimulationDefinitions.get_action(selection.action_id)
|
|
||||||
var action_display := String(selection.action_id)
|
|
||||||
if action_def != null:
|
|
||||||
action_display = action_def.display_name
|
|
||||||
record_narrative_event(
|
|
||||||
SimulationIds.EVENT_TASK_STARTED, npc.id, &"", action_display
|
|
||||||
)
|
|
||||||
|
|
||||||
if old_task != npc.current_task and old_target != &"":
|
if previous_task != npc.current_task and not previous_target.is_empty():
|
||||||
release_npc_reservation(npc.id)
|
release_npc_reservation(npc.id)
|
||||||
|
|
||||||
if not was_dead and npc.is_dead:
|
if not was_dead and npc.is_dead:
|
||||||
if old_target != &"":
|
_handle_npc_death(npc, previous_task, previous_target)
|
||||||
|
elif previous_task != npc.current_task:
|
||||||
|
npc_task_changed.emit(npc, previous_task, npc.current_task)
|
||||||
|
npc_target_requested.emit(npc)
|
||||||
|
|
||||||
|
var completed := not was_complete and npc.task_complete and not npc.is_dead
|
||||||
|
if completed:
|
||||||
|
_complete_current_action(npc)
|
||||||
|
|
||||||
|
_debug_npc_tick(npc, previous_state)
|
||||||
|
return completed
|
||||||
|
|
||||||
|
|
||||||
|
func _select_action_if_idle(npc: SimNPC, previous_state: StringName) -> void:
|
||||||
|
if npc.is_dead:
|
||||||
|
return
|
||||||
|
if previous_state not in [SimNPC.TASK_STATE_IDLE, SimNPC.TASK_STATE_COMPLETE]:
|
||||||
|
return
|
||||||
|
if npc.task_state not in [SimNPC.TASK_STATE_IDLE, SimNPC.TASK_STATE_COMPLETE]:
|
||||||
|
return
|
||||||
|
var selection := action_selector.select_action(npc, village, clock.time_of_day(), npcs)
|
||||||
|
if selection == null:
|
||||||
|
return
|
||||||
|
latest_decisions[npc.id] = selection
|
||||||
|
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)
|
||||||
|
record_narrative_event(SimulationIds.EVENT_TASK_STARTED, npc.id, &"", display_name)
|
||||||
|
|
||||||
|
|
||||||
|
func _handle_npc_death(
|
||||||
|
npc: SimNPC, previous_task: StringName, previous_target: StringName
|
||||||
|
) -> void:
|
||||||
|
if not previous_target.is_empty():
|
||||||
release_npc_reservation(npc.id)
|
release_npc_reservation(npc.id)
|
||||||
npc_died.emit(npc)
|
npc_died.emit(npc)
|
||||||
npc_task_changed.emit(npc, old_task, npc.current_task)
|
npc_task_changed.emit(npc, previous_task, npc.current_task)
|
||||||
record_narrative_event(SimulationIds.EVENT_NPC_DIED, npc.id)
|
record_narrative_event(SimulationIds.EVENT_NPC_DIED, npc.id)
|
||||||
_notify_mourning(npc)
|
_notify_mourning(npc)
|
||||||
|
|
||||||
if debug_logs:
|
if debug_logs:
|
||||||
print("[SimulationManager] NPC died: ", npc.npc_name)
|
print("[SimulationManager] NPC died: ", npc.npc_name)
|
||||||
|
|
||||||
if old_task != npc.current_task and not npc.is_dead:
|
|
||||||
npc_task_changed.emit(npc, old_task, npc.current_task)
|
|
||||||
npc_target_requested.emit(npc)
|
|
||||||
|
|
||||||
if not was_complete and npc.task_complete and not npc.is_dead:
|
func _complete_current_action(npc: SimNPC) -> void:
|
||||||
var completed_task := npc.current_task
|
var completed_task := npc.current_task
|
||||||
var completed_definition := SimulationDefinitions.get_action(completed_task)
|
var definition := SimulationDefinitions.get_action(completed_task)
|
||||||
var completion_cost_paid := _consume_action_completion_cost(
|
if economy.consume_completion_cost(npc, definition):
|
||||||
npc, completed_definition
|
_apply_action_completion(npc, completed_task, definition)
|
||||||
)
|
elif not npc.target_id.is_empty():
|
||||||
if not completion_cost_paid:
|
|
||||||
if not npc.target_id.is_empty():
|
|
||||||
release_npc_reservation(npc.id)
|
release_npc_reservation(npc.id)
|
||||||
elif (
|
|
||||||
npc.target_id != &""
|
|
||||||
and completed_definition != null
|
|
||||||
and completed_definition.target_type == SimulationIds.TARGET_RESOURCE
|
|
||||||
):
|
|
||||||
var resource_state := get_resource_state(npc.target_id)
|
|
||||||
if resource_state != null and resource_state.get_reserved_by() == npc.id:
|
|
||||||
var extracted := resource_state.extract()
|
|
||||||
if extracted > 0:
|
|
||||||
if resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD:
|
|
||||||
npc.add_inventory(SimulationIds.RESOURCE_FOOD, extracted)
|
|
||||||
npc_inventory_changed.emit(
|
|
||||||
npc,
|
|
||||||
SimulationIds.RESOURCE_FOOD,
|
|
||||||
npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD)
|
|
||||||
)
|
|
||||||
elif resource_state.get_resource_id() == SimulationIds.RESOURCE_WOOD:
|
|
||||||
npc.add_inventory(SimulationIds.RESOURCE_WOOD, extracted)
|
|
||||||
npc_inventory_changed.emit(
|
|
||||||
npc,
|
|
||||||
SimulationIds.RESOURCE_WOOD,
|
|
||||||
npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
village.apply_resource_delta(
|
|
||||||
resource_state.get_resource_id(), extracted
|
|
||||||
)
|
|
||||||
_record_economic_event(
|
|
||||||
SimulationIds.EVENT_RESOURCE_EXTRACTED,
|
|
||||||
npc.id,
|
|
||||||
resource_state.get_node_id(),
|
|
||||||
(
|
|
||||||
_npc_inventory_id(npc.id)
|
|
||||||
if resource_state.get_resource_id() in [
|
|
||||||
SimulationIds.RESOURCE_FOOD, SimulationIds.RESOURCE_WOOD
|
|
||||||
]
|
|
||||||
else &"village"
|
|
||||||
),
|
|
||||||
resource_state.get_resource_id(),
|
|
||||||
extracted
|
|
||||||
)
|
|
||||||
if resource_state.get_amount_remaining() <= 0.0:
|
|
||||||
record_narrative_event(
|
|
||||||
SimulationIds.EVENT_RESOURCE_DEPLETED,
|
|
||||||
npc.id,
|
|
||||||
resource_state.get_node_id()
|
|
||||||
)
|
|
||||||
if debug_logs:
|
|
||||||
print(
|
|
||||||
"[SimulationManager] ",
|
|
||||||
npc.npc_name,
|
|
||||||
" extracted ",
|
|
||||||
extracted,
|
|
||||||
" ",
|
|
||||||
resource_state.get_resource_id(),
|
|
||||||
" from ",
|
|
||||||
resource_state.get_node_id()
|
|
||||||
)
|
|
||||||
elif debug_logs:
|
|
||||||
print(
|
|
||||||
"[SimulationManager] ",
|
|
||||||
npc.npc_name,
|
|
||||||
" could not complete ",
|
|
||||||
completed_task,
|
|
||||||
": target reservation was invalid"
|
|
||||||
)
|
|
||||||
release_npc_reservation(npc.id)
|
|
||||||
elif completed_task == SimulationIds.ACTION_DEPOSIT_FOOD:
|
|
||||||
deposit_npc_inventory(npc, SimulationIds.RESOURCE_FOOD)
|
|
||||||
elif completed_task == SimulationIds.ACTION_DEPOSIT_WOOD:
|
|
||||||
deposit_npc_wood(npc)
|
|
||||||
elif completed_task == SimulationIds.ACTION_WITHDRAW_FOOD:
|
|
||||||
withdraw_to_npc(npc, SimulationIds.RESOURCE_FOOD, 1.0)
|
|
||||||
elif completed_task == SimulationIds.ACTION_EAT:
|
|
||||||
consume_npc_food(npc)
|
|
||||||
elif completed_task == SimulationIds.ACTION_SLEEP:
|
|
||||||
npc.energy = minf(npc.energy + 40.0, 100.0)
|
|
||||||
npc.position = npc.home_position
|
|
||||||
record_narrative_event(SimulationIds.EVENT_NPC_SLEPT, npc.id)
|
|
||||||
if debug_logs:
|
|
||||||
print("[SimulationManager] ", npc.npc_name, " completed sleep at home")
|
|
||||||
elif (
|
|
||||||
completed_task
|
|
||||||
not in [
|
|
||||||
SimulationIds.ACTION_GATHER_FOOD,
|
|
||||||
SimulationIds.ACTION_GATHER_WOOD,
|
|
||||||
SimulationIds.ACTION_PATROL,
|
|
||||||
SimulationIds.ACTION_STUDY
|
|
||||||
]
|
|
||||||
):
|
|
||||||
village.apply_npc_task(npc)
|
|
||||||
elif completed_task == SimulationIds.ACTION_PATROL:
|
|
||||||
village.apply_npc_task(npc)
|
|
||||||
elif completed_task == SimulationIds.ACTION_STUDY:
|
|
||||||
village.apply_npc_task(npc)
|
|
||||||
elif debug_logs:
|
|
||||||
print(
|
|
||||||
"[SimulationManager] ",
|
|
||||||
npc.npc_name,
|
|
||||||
" could not complete ",
|
|
||||||
completed_task,
|
|
||||||
": no resource target"
|
|
||||||
)
|
|
||||||
|
|
||||||
village_was_changed = true
|
|
||||||
|
|
||||||
npc.task_complete = true
|
npc.task_complete = true
|
||||||
npc.task_state = SimNPC.TASK_STATE_IDLE
|
npc.task_state = SimNPC.TASK_STATE_IDLE
|
||||||
@@ -299,7 +233,71 @@ func simulate_tick() -> void:
|
|||||||
npc.target_id = &""
|
npc.target_id = &""
|
||||||
npc_task_changed.emit(npc, completed_task, npc.current_task)
|
npc_task_changed.emit(npc, completed_task, npc.current_task)
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_action_completion(
|
||||||
|
npc: SimNPC, completed_task: StringName, definition: ActionDefinition
|
||||||
|
) -> void:
|
||||||
|
if definition != null and definition.target_type == SimulationIds.TARGET_RESOURCE:
|
||||||
|
_complete_resource_gather(npc, completed_task)
|
||||||
|
return
|
||||||
|
match completed_task:
|
||||||
|
SimulationIds.ACTION_DEPOSIT_FOOD:
|
||||||
|
economy.deposit_inventory(npc, SimulationIds.RESOURCE_FOOD)
|
||||||
|
SimulationIds.ACTION_DEPOSIT_WOOD:
|
||||||
|
economy.deposit_inventory(npc, SimulationIds.RESOURCE_WOOD)
|
||||||
|
SimulationIds.ACTION_WITHDRAW_FOOD:
|
||||||
|
economy.withdraw_to_inventory(npc, SimulationIds.RESOURCE_FOOD, 1.0)
|
||||||
|
SimulationIds.ACTION_EAT:
|
||||||
|
economy.consume_npc_food(npc)
|
||||||
|
SimulationIds.ACTION_SLEEP:
|
||||||
|
npc.energy = minf(npc.energy + 40.0, 100.0)
|
||||||
|
npc.position = npc.home_position
|
||||||
|
record_narrative_event(SimulationIds.EVENT_NPC_SLEPT, npc.id)
|
||||||
|
SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY, SimulationIds.ACTION_REST, SimulationIds.ACTION_WANDER:
|
||||||
|
village.apply_npc_task(npc)
|
||||||
|
if debug_logs and completed_task == SimulationIds.ACTION_SLEEP:
|
||||||
|
print("[SimulationManager] ", npc.npc_name, " completed sleep at home")
|
||||||
|
|
||||||
|
|
||||||
|
func _complete_resource_gather(npc: SimNPC, completed_task: StringName) -> void:
|
||||||
|
var resource_state := get_resource_state(npc.target_id)
|
||||||
|
if resource_state == null or resource_state.get_reserved_by() != npc.id:
|
||||||
if debug_logs:
|
if debug_logs:
|
||||||
|
print(
|
||||||
|
"[SimulationManager] %s could not complete %s: invalid reservation"
|
||||||
|
% [npc.npc_name, completed_task]
|
||||||
|
)
|
||||||
|
release_npc_reservation(npc.id)
|
||||||
|
return
|
||||||
|
|
||||||
|
var extracted := resource_state.extract()
|
||||||
|
if extracted > 0.0:
|
||||||
|
var resource_id := resource_state.get_resource_id()
|
||||||
|
npc.add_inventory(resource_id, extracted)
|
||||||
|
npc_inventory_changed.emit(npc, resource_id, npc.get_inventory_amount(resource_id))
|
||||||
|
_record_economic_event(
|
||||||
|
SimulationIds.EVENT_RESOURCE_EXTRACTED,
|
||||||
|
npc.id,
|
||||||
|
resource_state.get_node_id(),
|
||||||
|
SimulationIds.npc_inventory_id(npc.id),
|
||||||
|
resource_id,
|
||||||
|
extracted
|
||||||
|
)
|
||||||
|
if resource_state.get_amount_remaining() <= 0.0:
|
||||||
|
record_narrative_event(
|
||||||
|
SimulationIds.EVENT_RESOURCE_DEPLETED, npc.id, resource_state.get_node_id()
|
||||||
|
)
|
||||||
|
if debug_logs:
|
||||||
|
print(
|
||||||
|
"[SimulationManager] %s extracted %.1f %s from %s"
|
||||||
|
% [npc.npc_name, extracted, resource_id, resource_state.get_node_id()]
|
||||||
|
)
|
||||||
|
release_npc_reservation(npc.id)
|
||||||
|
|
||||||
|
|
||||||
|
func _debug_npc_tick(npc: SimNPC, previous_state: StringName) -> void:
|
||||||
|
if not debug_logs:
|
||||||
|
return
|
||||||
print(
|
print(
|
||||||
npc.npc_name,
|
npc.npc_name,
|
||||||
" | ",
|
" | ",
|
||||||
@@ -325,30 +323,16 @@ func simulate_tick() -> void:
|
|||||||
" | dead: ",
|
" | dead: ",
|
||||||
npc.is_dead
|
npc.is_dead
|
||||||
)
|
)
|
||||||
|
if previous_state != npc.task_state:
|
||||||
if old_state != npc.task_state:
|
|
||||||
print(
|
print(
|
||||||
"[SimulationManager] State changed: ",
|
"[SimulationManager] State changed: ",
|
||||||
npc.npc_name,
|
npc.npc_name,
|
||||||
" ",
|
" ",
|
||||||
old_state,
|
previous_state,
|
||||||
" -> ",
|
" -> ",
|
||||||
npc.task_state
|
npc.task_state
|
||||||
)
|
)
|
||||||
|
|
||||||
if village_was_changed:
|
|
||||||
village_changed.emit(village)
|
|
||||||
|
|
||||||
if debug_logs:
|
|
||||||
print(village.get_summary())
|
|
||||||
|
|
||||||
|
|
||||||
func set_npc_target_id(npc_id: int, target_id: StringName) -> void:
|
|
||||||
for npc in npcs:
|
|
||||||
if npc.id == npc_id:
|
|
||||||
npc.target_id = target_id
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
func release_npc_reservation(npc_id: int) -> void:
|
func release_npc_reservation(npc_id: int) -> void:
|
||||||
for npc in npcs:
|
for npc in npcs:
|
||||||
@@ -446,10 +430,6 @@ func notify_npc_navigation_failed(npc_id: int) -> void:
|
|||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
func notify_npc_target_unavailable(npc_id: int) -> void:
|
|
||||||
notify_npc_navigation_failed(npc_id)
|
|
||||||
|
|
||||||
|
|
||||||
func resolve_npc_target(npc_id: int, origin: Vector3) -> bool:
|
func resolve_npc_target(npc_id: int, origin: Vector3) -> bool:
|
||||||
for npc in npcs:
|
for npc in npcs:
|
||||||
if npc.id != npc_id:
|
if npc.id != npc_id:
|
||||||
@@ -466,7 +446,7 @@ func resolve_npc_target(npc_id: int, origin: Vector3) -> bool:
|
|||||||
return false
|
return false
|
||||||
var result := target_resolver.resolve(npc, origin, self, active_world_adapter)
|
var result := target_resolver.resolve(npc, origin, self, active_world_adapter)
|
||||||
if result.is_empty():
|
if result.is_empty():
|
||||||
notify_npc_target_unavailable(npc.id)
|
notify_npc_navigation_failed(npc.id)
|
||||||
return false
|
return false
|
||||||
npc.target_id = StringName(result.get("target_id", ""))
|
npc.target_id = StringName(result.get("target_id", ""))
|
||||||
npc.travel_target_position = result["position"]
|
npc.travel_target_position = result["position"]
|
||||||
@@ -510,10 +490,6 @@ func get_activity_target_claim_count(target_id: StringName, except_npc_id: int =
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
func _npc_inventory_id(npc_id: int) -> StringName:
|
|
||||||
return StringName("npc_%d_inventory" % npc_id)
|
|
||||||
|
|
||||||
|
|
||||||
func _notify_mourning(dead_npc: SimNPC) -> void:
|
func _notify_mourning(dead_npc: SimNPC) -> void:
|
||||||
var best_id := -1
|
var best_id := -1
|
||||||
var best_score := -1.0
|
var best_score := -1.0
|
||||||
@@ -546,14 +522,9 @@ func _record_economic_event(
|
|||||||
item_id: StringName,
|
item_id: StringName,
|
||||||
amount: float
|
amount: float
|
||||||
) -> void:
|
) -> void:
|
||||||
if amount <= 0.0:
|
event_log.record_economic(
|
||||||
return
|
tick_count, event_type, actor_id, source_id, destination_id, item_id, amount
|
||||||
var event := EconomicEventRecord.create(
|
|
||||||
next_event_id, event_type, tick_count, actor_id, source_id, destination_id, item_id, amount
|
|
||||||
)
|
)
|
||||||
next_event_id += 1
|
|
||||||
economic_events.append(event)
|
|
||||||
economic_event_recorded.emit(event)
|
|
||||||
|
|
||||||
|
|
||||||
func record_narrative_event(
|
func record_narrative_event(
|
||||||
@@ -562,32 +533,19 @@ func record_narrative_event(
|
|||||||
source_id: StringName = &"",
|
source_id: StringName = &"",
|
||||||
action_display: String = ""
|
action_display: String = ""
|
||||||
) -> void:
|
) -> void:
|
||||||
var event := EconomicEventRecord.create_narrative(
|
event_log.record_narrative(tick_count, event_type, actor_id, source_id, action_display)
|
||||||
next_event_id, event_type, tick_count, actor_id, source_id, action_display
|
|
||||||
)
|
|
||||||
next_event_id += 1
|
|
||||||
economic_events.append(event)
|
|
||||||
economic_event_recorded.emit(event)
|
|
||||||
|
|
||||||
|
|
||||||
func get_npc_events(npc_id: int, max_count: int = 8) -> Array[EconomicEventRecord]:
|
func get_npc_events(npc_id: int, max_count: int = 8) -> Array[EconomicEventRecord]:
|
||||||
var results: Array[EconomicEventRecord] = []
|
return event_log.get_for_actor(npc_id, max_count)
|
||||||
for i in range(economic_events.size() - 1, -1, -1):
|
|
||||||
var event: EconomicEventRecord = economic_events[i]
|
|
||||||
if event.data["actor_id"] == npc_id:
|
|
||||||
results.append(event)
|
|
||||||
if results.size() >= max_count:
|
|
||||||
break
|
|
||||||
results.reverse()
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
func get_recent_events(max_count: int = 5) -> Array[EconomicEventRecord]:
|
func get_recent_events(max_count: int = 5) -> Array[EconomicEventRecord]:
|
||||||
var results: Array[EconomicEventRecord] = []
|
return event_log.get_recent(max_count)
|
||||||
var start := maxi(economic_events.size() - max_count, 0)
|
|
||||||
for i in range(start, economic_events.size()):
|
|
||||||
results.append(economic_events[i])
|
func _on_economic_event_recorded(event: EconomicEventRecord) -> void:
|
||||||
return results
|
economic_event_recorded.emit(event)
|
||||||
|
|
||||||
|
|
||||||
func get_current_speed() -> String:
|
func get_current_speed() -> String:
|
||||||
@@ -595,57 +553,15 @@ func get_current_speed() -> String:
|
|||||||
|
|
||||||
|
|
||||||
func get_resource_rates() -> Dictionary:
|
func get_resource_rates() -> Dictionary:
|
||||||
var food_consumed := 0.0
|
return event_log.get_consumption_rates(tick_count)
|
||||||
var wood_consumed := 0.0
|
|
||||||
var recent_ticks := maxi(tick_count - 200, 0)
|
|
||||||
for event in economic_events:
|
|
||||||
if int(event.data["tick"]) < recent_ticks:
|
|
||||||
continue
|
|
||||||
if String(event.data["event_type"]) != "item_consumed":
|
|
||||||
continue
|
|
||||||
if float(event.data["amount"]) <= 0.0:
|
|
||||||
continue
|
|
||||||
if String(event.data["item_id"]) == "food":
|
|
||||||
food_consumed += float(event.data["amount"])
|
|
||||||
elif String(event.data["item_id"]) == "wood":
|
|
||||||
wood_consumed += float(event.data["amount"])
|
|
||||||
var elapsed := maxi(tick_count - recent_ticks, 1)
|
|
||||||
return {
|
|
||||||
"food_per_day": food_consumed / elapsed * 200.0,
|
|
||||||
"wood_per_day": wood_consumed / elapsed * 200.0
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func _initialize_storage() -> void:
|
|
||||||
if not storage_states.has(SimulationIds.STORAGE_VILLAGE_PANTRY):
|
|
||||||
storage_states[SimulationIds.STORAGE_VILLAGE_PANTRY] = (StorageStateRecord.create(
|
|
||||||
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
|
||||||
{String(SimulationIds.RESOURCE_FOOD): village.food}
|
|
||||||
))
|
|
||||||
if not storage_states.has(SimulationIds.STORAGE_VILLAGE_WOODPILE):
|
|
||||||
storage_states[SimulationIds.STORAGE_VILLAGE_WOODPILE] = (StorageStateRecord.create(
|
|
||||||
SimulationIds.STORAGE_VILLAGE_WOODPILE,
|
|
||||||
{String(SimulationIds.RESOURCE_WOOD): village.wood}
|
|
||||||
))
|
|
||||||
_sync_village_food()
|
|
||||||
_sync_village_wood()
|
|
||||||
|
|
||||||
|
|
||||||
func get_pantry() -> StorageStateRecord:
|
func get_pantry() -> StorageStateRecord:
|
||||||
return storage_states.get(SimulationIds.STORAGE_VILLAGE_PANTRY) as StorageStateRecord
|
return economy.get_pantry()
|
||||||
|
|
||||||
|
|
||||||
func get_woodpile() -> StorageStateRecord:
|
func get_woodpile() -> StorageStateRecord:
|
||||||
return storage_states.get(SimulationIds.STORAGE_VILLAGE_WOODPILE) as StorageStateRecord
|
return economy.get_woodpile()
|
||||||
|
|
||||||
|
|
||||||
func get_storage_for_resource(resource_id: StringName) -> StorageStateRecord:
|
|
||||||
match resource_id:
|
|
||||||
SimulationIds.RESOURCE_FOOD:
|
|
||||||
return get_pantry()
|
|
||||||
SimulationIds.RESOURCE_WOOD:
|
|
||||||
return get_woodpile()
|
|
||||||
return null
|
|
||||||
|
|
||||||
|
|
||||||
func register_loaded_storage_nodes() -> void:
|
func register_loaded_storage_nodes() -> void:
|
||||||
@@ -667,149 +583,8 @@ func register_storage_node(node: StorageNode) -> bool:
|
|||||||
return node.bind_state(storage_state)
|
return node.bind_state(storage_state)
|
||||||
|
|
||||||
|
|
||||||
func _sync_village_food() -> void:
|
func _on_economy_inventory_changed(npc: SimNPC, item_id: StringName, amount: float) -> void:
|
||||||
var pantry := get_pantry()
|
npc_inventory_changed.emit(npc, item_id, amount)
|
||||||
if pantry != null:
|
|
||||||
village.food = pantry.get_amount(SimulationIds.RESOURCE_FOOD)
|
|
||||||
village.update_modifiers()
|
|
||||||
village.update_priorities()
|
|
||||||
|
|
||||||
|
|
||||||
func _sync_village_wood() -> void:
|
|
||||||
var woodpile := get_woodpile()
|
|
||||||
if woodpile != null:
|
|
||||||
village.wood = woodpile.get_amount(SimulationIds.RESOURCE_WOOD)
|
|
||||||
village.update_modifiers()
|
|
||||||
village.update_priorities()
|
|
||||||
|
|
||||||
|
|
||||||
func _sync_village_resource(resource_id: StringName) -> void:
|
|
||||||
match resource_id:
|
|
||||||
SimulationIds.RESOURCE_FOOD:
|
|
||||||
_sync_village_food()
|
|
||||||
SimulationIds.RESOURCE_WOOD:
|
|
||||||
_sync_village_wood()
|
|
||||||
|
|
||||||
|
|
||||||
func deposit_npc_inventory(npc: SimNPC, item_id: StringName) -> float:
|
|
||||||
var available := npc.get_inventory_amount(item_id)
|
|
||||||
var deposited := get_pantry().deposit(item_id, available)
|
|
||||||
npc.remove_inventory(item_id, deposited)
|
|
||||||
_sync_village_food()
|
|
||||||
if deposited > 0.0:
|
|
||||||
npc_inventory_changed.emit(npc, item_id, npc.get_inventory_amount(item_id))
|
|
||||||
_record_economic_event(
|
|
||||||
SimulationIds.EVENT_STORAGE_DEPOSITED,
|
|
||||||
npc.id,
|
|
||||||
_npc_inventory_id(npc.id),
|
|
||||||
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
|
||||||
item_id,
|
|
||||||
deposited
|
|
||||||
)
|
|
||||||
return deposited
|
|
||||||
|
|
||||||
|
|
||||||
func deposit_npc_wood(npc: SimNPC) -> float:
|
|
||||||
var available := npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD)
|
|
||||||
var woodpile := get_woodpile()
|
|
||||||
if woodpile == null:
|
|
||||||
return 0.0
|
|
||||||
var deposited := woodpile.deposit(SimulationIds.RESOURCE_WOOD, available)
|
|
||||||
npc.remove_inventory(SimulationIds.RESOURCE_WOOD, deposited)
|
|
||||||
_sync_village_wood()
|
|
||||||
if deposited > 0.0:
|
|
||||||
npc_inventory_changed.emit(
|
|
||||||
npc, SimulationIds.RESOURCE_WOOD, npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD)
|
|
||||||
)
|
|
||||||
_record_economic_event(
|
|
||||||
SimulationIds.EVENT_STORAGE_DEPOSITED,
|
|
||||||
npc.id,
|
|
||||||
_npc_inventory_id(npc.id),
|
|
||||||
SimulationIds.STORAGE_VILLAGE_WOODPILE,
|
|
||||||
SimulationIds.RESOURCE_WOOD,
|
|
||||||
deposited
|
|
||||||
)
|
|
||||||
return deposited
|
|
||||||
|
|
||||||
|
|
||||||
func _consume_action_completion_cost(npc: SimNPC, definition: ActionDefinition) -> bool:
|
|
||||||
if definition == null or not definition.has_completion_cost():
|
|
||||||
return true
|
|
||||||
var resource_id := definition.completion_cost_resource_id
|
|
||||||
var required_amount := definition.completion_cost_amount
|
|
||||||
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,
|
|
||||||
]
|
|
||||||
record_narrative_event(
|
|
||||||
SimulationIds.EVENT_TASK_BLOCKED,
|
|
||||||
npc.id,
|
|
||||||
storage.get_storage_id() if storage != null else &"",
|
|
||||||
reason
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
var consumed := storage.withdraw(resource_id, required_amount)
|
|
||||||
_sync_village_resource(resource_id)
|
|
||||||
if consumed < required_amount:
|
|
||||||
return false
|
|
||||||
_record_economic_event(
|
|
||||||
SimulationIds.EVENT_ITEM_CONSUMED,
|
|
||||||
npc.id,
|
|
||||||
storage.get_storage_id(),
|
|
||||||
&"consumed",
|
|
||||||
resource_id,
|
|
||||||
consumed
|
|
||||||
)
|
|
||||||
if debug_logs:
|
|
||||||
print(
|
|
||||||
"[SimulationManager] %s consumed %.1f %s for %s"
|
|
||||||
% [npc.npc_name, consumed, resource_id, definition.action_id]
|
|
||||||
)
|
|
||||||
return true
|
|
||||||
|
|
||||||
|
|
||||||
func withdraw_to_npc(npc: SimNPC, item_id: StringName, amount: float) -> float:
|
|
||||||
var withdrawn := get_pantry().withdraw(item_id, amount)
|
|
||||||
npc.add_inventory(item_id, withdrawn)
|
|
||||||
_sync_village_food()
|
|
||||||
if withdrawn > 0.0:
|
|
||||||
npc_inventory_changed.emit(npc, item_id, npc.get_inventory_amount(item_id))
|
|
||||||
_record_economic_event(
|
|
||||||
SimulationIds.EVENT_STORAGE_WITHDRAWN,
|
|
||||||
npc.id,
|
|
||||||
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
|
||||||
_npc_inventory_id(npc.id),
|
|
||||||
item_id,
|
|
||||||
withdrawn
|
|
||||||
)
|
|
||||||
return withdrawn
|
|
||||||
|
|
||||||
|
|
||||||
func consume_npc_food(npc: SimNPC) -> bool:
|
|
||||||
if npc.remove_inventory(SimulationIds.RESOURCE_FOOD, 1.0) < 1.0:
|
|
||||||
npc.hunger = minf(npc.hunger + 5.0, 100.0)
|
|
||||||
npc.is_starving = npc.hunger >= 90.0
|
|
||||||
return false
|
|
||||||
npc.hunger = maxf(npc.hunger - 55.0, 0.0)
|
|
||||||
npc.starvation_ticks = 0
|
|
||||||
npc.is_starving = npc.hunger >= 90.0
|
|
||||||
npc_inventory_changed.emit(
|
|
||||||
npc, SimulationIds.RESOURCE_FOOD, npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD)
|
|
||||||
)
|
|
||||||
_record_economic_event(
|
|
||||||
SimulationIds.EVENT_ITEM_CONSUMED,
|
|
||||||
npc.id,
|
|
||||||
_npc_inventory_id(npc.id),
|
|
||||||
&"consumed",
|
|
||||||
SimulationIds.RESOURCE_FOOD,
|
|
||||||
1.0
|
|
||||||
)
|
|
||||||
return true
|
|
||||||
|
|
||||||
|
|
||||||
func register_loaded_resource_nodes() -> void:
|
func register_loaded_resource_nodes() -> void:
|
||||||
@@ -825,11 +600,12 @@ func register_resource_node(node: ResourceNode) -> bool:
|
|||||||
action_definition == null
|
action_definition == null
|
||||||
or action_definition.target_type != SimulationIds.TARGET_RESOURCE
|
or action_definition.target_type != SimulationIds.TARGET_RESOURCE
|
||||||
or action_definition.resource_action_id != node.action_id
|
or action_definition.resource_action_id != node.action_id
|
||||||
|
or node.resource_id not in [SimulationIds.RESOURCE_FOOD, SimulationIds.RESOURCE_WOOD]
|
||||||
):
|
):
|
||||||
push_error(
|
push_error(
|
||||||
(
|
(
|
||||||
"SimulationManager: ResourceNode '%s' has invalid action_id '%s'"
|
"SimulationManager: ResourceNode '%s' has invalid action/resource IDs '%s'/'%s'"
|
||||||
% [node.node_id, node.action_id]
|
% [node.node_id, node.action_id, node.resource_id]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return false
|
return false
|
||||||
@@ -863,7 +639,14 @@ func find_resource_node_for_player(from_position: Vector3, max_distance: float)
|
|||||||
var best_distance := max_distance * max_distance
|
var best_distance := max_distance * max_distance
|
||||||
for node in ResourceNode.get_all():
|
for node in ResourceNode.get_all():
|
||||||
var resource_state := get_resource_state(node.node_id)
|
var resource_state := get_resource_state(node.node_id)
|
||||||
if resource_state == null or not resource_state.can_player_use_resource():
|
if (
|
||||||
|
resource_state == null
|
||||||
|
or not resource_state.can_player_use_resource()
|
||||||
|
or not resource_state.can_extract()
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
var storage := economy.get_storage_for_resource(resource_state.get_resource_id())
|
||||||
|
if storage == null or storage.get_available_capacity() <= 0.0:
|
||||||
continue
|
continue
|
||||||
var distance := from_position.distance_squared_to(node.interaction_point.global_position)
|
var distance := from_position.distance_squared_to(node.interaction_point.global_position)
|
||||||
if distance <= best_distance:
|
if distance <= best_distance:
|
||||||
@@ -878,18 +661,16 @@ func harvest_resource_node(node: ResourceNode) -> float:
|
|||||||
var resource_state := get_resource_state(node.node_id)
|
var resource_state := get_resource_state(node.node_id)
|
||||||
if resource_state == null or not resource_state.can_player_use_resource():
|
if resource_state == null or not resource_state.can_player_use_resource():
|
||||||
return 0.0
|
return 0.0
|
||||||
var extracted := resource_state.extract()
|
var storage := economy.get_storage_for_resource(resource_state.get_resource_id())
|
||||||
|
if storage == null:
|
||||||
|
return 0.0
|
||||||
|
var extracted := resource_state.extract(
|
||||||
|
minf(resource_state.get_yield_per_action(), storage.get_available_capacity())
|
||||||
|
)
|
||||||
if extracted <= 0.0:
|
if extracted <= 0.0:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
if resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD:
|
var deposited := economy.deposit_resource(resource_state.get_resource_id(), extracted)
|
||||||
get_pantry().deposit(SimulationIds.RESOURCE_FOOD, extracted)
|
|
||||||
_sync_village_food()
|
|
||||||
elif resource_state.get_resource_id() == SimulationIds.RESOURCE_WOOD:
|
|
||||||
get_woodpile().deposit(SimulationIds.RESOURCE_WOOD, extracted)
|
|
||||||
_sync_village_wood()
|
|
||||||
else:
|
|
||||||
village.apply_resource_delta(resource_state.get_resource_id(), extracted)
|
|
||||||
_record_economic_event(
|
_record_economic_event(
|
||||||
SimulationIds.EVENT_RESOURCE_EXTRACTED,
|
SimulationIds.EVENT_RESOURCE_EXTRACTED,
|
||||||
-1,
|
-1,
|
||||||
@@ -904,7 +685,7 @@ func harvest_resource_node(node: ResourceNode) -> float:
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
resource_state.get_resource_id(),
|
resource_state.get_resource_id(),
|
||||||
extracted
|
deposited
|
||||||
)
|
)
|
||||||
if resource_state.get_amount_remaining() <= 0.0:
|
if resource_state.get_amount_remaining() <= 0.0:
|
||||||
record_narrative_event(
|
record_narrative_event(
|
||||||
@@ -922,43 +703,21 @@ func harvest_resource_node(node: ResourceNode) -> float:
|
|||||||
resource_state.get_node_id()
|
resource_state.get_node_id()
|
||||||
)
|
)
|
||||||
|
|
||||||
return extracted
|
return deposited
|
||||||
|
|
||||||
|
|
||||||
func eat_food(amount: float) -> void:
|
func eat_food(amount: float) -> void:
|
||||||
get_pantry().withdraw(SimulationIds.RESOURCE_FOOD, amount)
|
economy.withdraw_resource(SimulationIds.RESOURCE_FOOD, amount)
|
||||||
_sync_village_food()
|
|
||||||
village_changed.emit(village)
|
|
||||||
|
|
||||||
|
|
||||||
func add_food(amount: float) -> void:
|
|
||||||
if amount >= 0.0:
|
|
||||||
get_pantry().deposit(SimulationIds.RESOURCE_FOOD, amount)
|
|
||||||
else:
|
|
||||||
get_pantry().withdraw(SimulationIds.RESOURCE_FOOD, -amount)
|
|
||||||
_sync_village_food()
|
|
||||||
village_changed.emit(village)
|
|
||||||
|
|
||||||
|
|
||||||
func add_wood(amount: float) -> void:
|
|
||||||
var woodpile := get_woodpile()
|
|
||||||
if woodpile == null:
|
|
||||||
return
|
|
||||||
if amount >= 0.0:
|
|
||||||
woodpile.deposit(SimulationIds.RESOURCE_WOOD, amount)
|
|
||||||
else:
|
|
||||||
woodpile.withdraw(SimulationIds.RESOURCE_WOOD, -amount)
|
|
||||||
_sync_village_wood()
|
|
||||||
village_changed.emit(village)
|
village_changed.emit(village)
|
||||||
|
|
||||||
|
|
||||||
func add_safety(amount: float) -> void:
|
func add_safety(amount: float) -> void:
|
||||||
village.apply_resource_delta(&"safety", amount)
|
village.apply_metric_delta(&"safety", amount)
|
||||||
village_changed.emit(village)
|
village_changed.emit(village)
|
||||||
|
|
||||||
|
|
||||||
func add_knowledge(amount: float) -> void:
|
func add_knowledge(amount: float) -> void:
|
||||||
village.apply_resource_delta(&"knowledge", amount)
|
village.apply_metric_delta(&"knowledge", amount)
|
||||||
village_changed.emit(village)
|
village_changed.emit(village)
|
||||||
|
|
||||||
|
|
||||||
@@ -1081,17 +840,14 @@ func restore_state(record: SimulationStateRecord) -> bool:
|
|||||||
simulation_seed = int(record.simulation["seed"])
|
simulation_seed = int(record.simulation["seed"])
|
||||||
tick_interval = float(record.simulation["tick_interval"])
|
tick_interval = float(record.simulation["tick_interval"])
|
||||||
tick_count = int(record.simulation["tick_count"])
|
tick_count = int(record.simulation["tick_count"])
|
||||||
next_event_id = int(record.simulation["next_event_id"])
|
|
||||||
clock = SimulationClock.new(tick_interval)
|
clock = SimulationClock.new(tick_interval)
|
||||||
clock.accumulator = float(record.simulation["clock_accumulator"])
|
clock.accumulator = float(record.simulation["clock_accumulator"])
|
||||||
clock.elapsed_ticks = int(record.simulation["clock_elapsed_ticks"])
|
clock.elapsed_ticks = int(record.simulation["clock_elapsed_ticks"])
|
||||||
clock.cycle_duration_seconds = float(record.simulation.get("cycle_duration_seconds", 240.0))
|
clock.cycle_duration_seconds = float(record.simulation.get("cycle_duration_seconds", 240.0))
|
||||||
village = record.village.restore(debug_logs)
|
village = record.village.restore(debug_logs)
|
||||||
storage_states.clear()
|
economy.configure(village, debug_logs)
|
||||||
for storage_record in record.storages:
|
economy.restore_storage(record.storages)
|
||||||
storage_states[storage_record.get_storage_id()] = storage_record
|
event_log.restore(record.economic_events, int(record.simulation["next_event_id"]))
|
||||||
_sync_village_food()
|
|
||||||
economic_events = record.economic_events.duplicate()
|
|
||||||
latest_decisions.clear()
|
latest_decisions.clear()
|
||||||
|
|
||||||
npcs.clear()
|
npcs.clear()
|
||||||
|
|||||||
@@ -88,18 +88,6 @@ func select_action(npc: SimNPC, village: SimVillage, time_of_day: float = 0.5, a
|
|||||||
SimulationIds.ACTION_GATHER_FOOD, -1.0, "Meal-time hunger; pantry is empty"
|
SimulationIds.ACTION_GATHER_FOOD, -1.0, "Meal-time hunger; pantry is empty"
|
||||||
)
|
)
|
||||||
|
|
||||||
if npc.is_starving:
|
|
||||||
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
|
|
||||||
return ActionSelectionResult.new(
|
|
||||||
SimulationIds.ACTION_EAT, 1.0, "Starving and carrying food"
|
|
||||||
)
|
|
||||||
if village.food > 0:
|
|
||||||
return ActionSelectionResult.new(
|
|
||||||
SimulationIds.ACTION_WITHDRAW_FOOD, 1.0, "Starving; pantry has food"
|
|
||||||
)
|
|
||||||
return ActionSelectionResult.new(
|
|
||||||
SimulationIds.ACTION_GATHER_FOOD, 4.0, "Starving; pantry is empty"
|
|
||||||
)
|
|
||||||
if npc.hunger > 75.0:
|
if npc.hunger > 75.0:
|
||||||
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
|
if npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0:
|
||||||
return ActionSelectionResult.new(
|
return ActionSelectionResult.new(
|
||||||
|
|||||||
@@ -40,3 +40,7 @@ const EVENT_NPC_DIED := &"npc_died"
|
|||||||
const EVENT_TASK_STARTED := &"task_started"
|
const EVENT_TASK_STARTED := &"task_started"
|
||||||
const EVENT_TASK_BLOCKED := &"task_blocked"
|
const EVENT_TASK_BLOCKED := &"task_blocked"
|
||||||
const EVENT_RESOURCE_DEPLETED := &"resource_depleted"
|
const EVENT_RESOURCE_DEPLETED := &"resource_depleted"
|
||||||
|
|
||||||
|
|
||||||
|
static func npc_inventory_id(npc_id: int) -> StringName:
|
||||||
|
return StringName("npc_%d_inventory" % npc_id)
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
signal inventory_changed(npc: SimNPC, item_id: StringName, amount: float)
|
||||||
|
signal economic_event_requested(
|
||||||
|
event_type: StringName,
|
||||||
|
actor_id: int,
|
||||||
|
source_id: StringName,
|
||||||
|
destination_id: StringName,
|
||||||
|
item_id: StringName,
|
||||||
|
amount: float
|
||||||
|
)
|
||||||
|
signal narrative_event_requested(
|
||||||
|
event_type: StringName, actor_id: int, source_id: StringName, action_display: String
|
||||||
|
)
|
||||||
|
|
||||||
|
var village: SimVillage
|
||||||
|
var storage_states: Dictionary = {}
|
||||||
|
var debug_logs := false
|
||||||
|
|
||||||
|
|
||||||
|
func configure(village_state: SimVillage, should_debug: bool) -> void:
|
||||||
|
village = village_state
|
||||||
|
debug_logs = should_debug
|
||||||
|
|
||||||
|
|
||||||
|
func initialize_storage() -> void:
|
||||||
|
if village == null:
|
||||||
|
return
|
||||||
|
if not storage_states.has(SimulationIds.STORAGE_VILLAGE_PANTRY):
|
||||||
|
storage_states[SimulationIds.STORAGE_VILLAGE_PANTRY] = StorageStateRecord.create(
|
||||||
|
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
||||||
|
{String(SimulationIds.RESOURCE_FOOD): village.food}
|
||||||
|
)
|
||||||
|
if not storage_states.has(SimulationIds.STORAGE_VILLAGE_WOODPILE):
|
||||||
|
storage_states[SimulationIds.STORAGE_VILLAGE_WOODPILE] = StorageStateRecord.create(
|
||||||
|
SimulationIds.STORAGE_VILLAGE_WOODPILE,
|
||||||
|
{String(SimulationIds.RESOURCE_WOOD): village.wood}
|
||||||
|
)
|
||||||
|
sync_all()
|
||||||
|
|
||||||
|
|
||||||
|
func restore_storage(records: Array[StorageStateRecord]) -> void:
|
||||||
|
storage_states.clear()
|
||||||
|
for storage_record in records:
|
||||||
|
storage_states[storage_record.get_storage_id()] = storage_record
|
||||||
|
# Older saves may not contain every authored storage. Preserve their village
|
||||||
|
# aggregates by creating only the missing records before synchronizing.
|
||||||
|
initialize_storage()
|
||||||
|
|
||||||
|
|
||||||
|
func get_storage(storage_id: StringName) -> StorageStateRecord:
|
||||||
|
return storage_states.get(storage_id) as StorageStateRecord
|
||||||
|
|
||||||
|
|
||||||
|
func get_pantry() -> StorageStateRecord:
|
||||||
|
return get_storage(SimulationIds.STORAGE_VILLAGE_PANTRY)
|
||||||
|
|
||||||
|
|
||||||
|
func get_woodpile() -> StorageStateRecord:
|
||||||
|
return get_storage(SimulationIds.STORAGE_VILLAGE_WOODPILE)
|
||||||
|
|
||||||
|
|
||||||
|
func get_storage_for_resource(resource_id: StringName) -> StorageStateRecord:
|
||||||
|
match resource_id:
|
||||||
|
SimulationIds.RESOURCE_FOOD:
|
||||||
|
return get_pantry()
|
||||||
|
SimulationIds.RESOURCE_WOOD:
|
||||||
|
return get_woodpile()
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func sync_all() -> void:
|
||||||
|
if village == null:
|
||||||
|
return
|
||||||
|
var pantry := get_pantry()
|
||||||
|
var woodpile := get_woodpile()
|
||||||
|
if pantry != null:
|
||||||
|
village.food = pantry.get_amount(SimulationIds.RESOURCE_FOOD)
|
||||||
|
if woodpile != null:
|
||||||
|
village.wood = woodpile.get_amount(SimulationIds.RESOURCE_WOOD)
|
||||||
|
_refresh_village_scores()
|
||||||
|
|
||||||
|
|
||||||
|
func sync_resource(resource_id: StringName) -> void:
|
||||||
|
if village == null:
|
||||||
|
return
|
||||||
|
var storage := get_storage_for_resource(resource_id)
|
||||||
|
if storage == null:
|
||||||
|
return
|
||||||
|
match resource_id:
|
||||||
|
SimulationIds.RESOURCE_FOOD:
|
||||||
|
village.food = storage.get_amount(resource_id)
|
||||||
|
SimulationIds.RESOURCE_WOOD:
|
||||||
|
village.wood = storage.get_amount(resource_id)
|
||||||
|
_refresh_village_scores()
|
||||||
|
|
||||||
|
|
||||||
|
func deposit_inventory(npc: SimNPC, item_id: StringName) -> float:
|
||||||
|
var storage := get_storage_for_resource(item_id)
|
||||||
|
if storage == null:
|
||||||
|
return 0.0
|
||||||
|
var deposited := storage.deposit(item_id, npc.get_inventory_amount(item_id))
|
||||||
|
npc.remove_inventory(item_id, deposited)
|
||||||
|
sync_resource(item_id)
|
||||||
|
if deposited > 0.0:
|
||||||
|
inventory_changed.emit(npc, item_id, npc.get_inventory_amount(item_id))
|
||||||
|
economic_event_requested.emit(
|
||||||
|
SimulationIds.EVENT_STORAGE_DEPOSITED,
|
||||||
|
npc.id,
|
||||||
|
SimulationIds.npc_inventory_id(npc.id),
|
||||||
|
storage.get_storage_id(),
|
||||||
|
item_id,
|
||||||
|
deposited
|
||||||
|
)
|
||||||
|
return deposited
|
||||||
|
|
||||||
|
|
||||||
|
func withdraw_to_inventory(npc: SimNPC, item_id: StringName, amount: float) -> float:
|
||||||
|
var storage := get_storage_for_resource(item_id)
|
||||||
|
if storage == null:
|
||||||
|
return 0.0
|
||||||
|
var withdrawn := storage.withdraw(item_id, amount)
|
||||||
|
npc.add_inventory(item_id, withdrawn)
|
||||||
|
sync_resource(item_id)
|
||||||
|
if withdrawn > 0.0:
|
||||||
|
inventory_changed.emit(npc, item_id, npc.get_inventory_amount(item_id))
|
||||||
|
economic_event_requested.emit(
|
||||||
|
SimulationIds.EVENT_STORAGE_WITHDRAWN,
|
||||||
|
npc.id,
|
||||||
|
storage.get_storage_id(),
|
||||||
|
SimulationIds.npc_inventory_id(npc.id),
|
||||||
|
item_id,
|
||||||
|
withdrawn
|
||||||
|
)
|
||||||
|
return withdrawn
|
||||||
|
|
||||||
|
|
||||||
|
func consume_npc_food(npc: SimNPC) -> bool:
|
||||||
|
if npc.remove_inventory(SimulationIds.RESOURCE_FOOD, 1.0) < 1.0:
|
||||||
|
npc.hunger = minf(npc.hunger + 5.0, 100.0)
|
||||||
|
npc.is_starving = npc.hunger >= 90.0
|
||||||
|
return false
|
||||||
|
npc.hunger = maxf(npc.hunger - 55.0, 0.0)
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
economic_event_requested.emit(
|
||||||
|
SimulationIds.EVENT_ITEM_CONSUMED,
|
||||||
|
npc.id,
|
||||||
|
SimulationIds.npc_inventory_id(npc.id),
|
||||||
|
&"consumed",
|
||||||
|
SimulationIds.RESOURCE_FOOD,
|
||||||
|
1.0
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func consume_completion_cost(npc: SimNPC, definition: ActionDefinition) -> bool:
|
||||||
|
if definition == null or not definition.has_completion_cost():
|
||||||
|
return true
|
||||||
|
var resource_id := definition.completion_cost_resource_id
|
||||||
|
var required_amount := definition.completion_cost_amount
|
||||||
|
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,
|
||||||
|
]
|
||||||
|
narrative_event_requested.emit(
|
||||||
|
SimulationIds.EVENT_TASK_BLOCKED,
|
||||||
|
npc.id,
|
||||||
|
storage.get_storage_id() if storage != null else &"",
|
||||||
|
reason
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
var consumed := storage.withdraw(resource_id, required_amount)
|
||||||
|
sync_resource(resource_id)
|
||||||
|
if consumed < required_amount:
|
||||||
|
return false
|
||||||
|
economic_event_requested.emit(
|
||||||
|
SimulationIds.EVENT_ITEM_CONSUMED,
|
||||||
|
npc.id,
|
||||||
|
storage.get_storage_id(),
|
||||||
|
&"consumed",
|
||||||
|
resource_id,
|
||||||
|
consumed
|
||||||
|
)
|
||||||
|
if debug_logs:
|
||||||
|
print(
|
||||||
|
"[VillageEconomy] %s consumed %.1f %s for %s"
|
||||||
|
% [npc.npc_name, consumed, resource_id, definition.action_id]
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func deposit_resource(resource_id: StringName, amount: float) -> float:
|
||||||
|
var storage := get_storage_for_resource(resource_id)
|
||||||
|
if storage == null:
|
||||||
|
return 0.0
|
||||||
|
var deposited := storage.deposit(resource_id, amount)
|
||||||
|
sync_resource(resource_id)
|
||||||
|
return deposited
|
||||||
|
|
||||||
|
|
||||||
|
func withdraw_resource(resource_id: StringName, amount: float) -> float:
|
||||||
|
var storage := get_storage_for_resource(resource_id)
|
||||||
|
if storage == null:
|
||||||
|
return 0.0
|
||||||
|
var withdrawn := storage.withdraw(resource_id, amount)
|
||||||
|
sync_resource(resource_id)
|
||||||
|
return withdrawn
|
||||||
|
|
||||||
|
|
||||||
|
func _refresh_village_scores() -> void:
|
||||||
|
village.update_modifiers()
|
||||||
|
village.update_priorities()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://boecv7w7txoj0
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
signal event_recorded(event: EconomicEventRecord)
|
||||||
|
|
||||||
|
const DEFAULT_RATE_WINDOW_TICKS := 200
|
||||||
|
const DEFAULT_TICKS_PER_DAY := 200.0
|
||||||
|
|
||||||
|
var events: Array[EconomicEventRecord] = []
|
||||||
|
var next_event_id := 0
|
||||||
|
|
||||||
|
|
||||||
|
func record_economic(
|
||||||
|
tick: int,
|
||||||
|
event_type: StringName,
|
||||||
|
actor_id: int,
|
||||||
|
source_id: StringName,
|
||||||
|
destination_id: StringName,
|
||||||
|
item_id: StringName,
|
||||||
|
amount: float
|
||||||
|
) -> 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
|
||||||
|
)
|
||||||
|
_append(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
func record_narrative(
|
||||||
|
tick: int,
|
||||||
|
event_type: StringName,
|
||||||
|
actor_id: int,
|
||||||
|
source_id: StringName = &"",
|
||||||
|
action_display: String = ""
|
||||||
|
) -> EconomicEventRecord:
|
||||||
|
var event := EconomicEventRecord.create_narrative(
|
||||||
|
next_event_id, event_type, tick, actor_id, source_id, action_display
|
||||||
|
)
|
||||||
|
_append(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
func get_for_actor(actor_id: int, max_count: int = 8) -> Array[EconomicEventRecord]:
|
||||||
|
var results: Array[EconomicEventRecord] = []
|
||||||
|
for index in range(events.size() - 1, -1, -1):
|
||||||
|
var event := events[index]
|
||||||
|
if int(event.data["actor_id"]) != actor_id:
|
||||||
|
continue
|
||||||
|
results.append(event)
|
||||||
|
if results.size() >= max_count:
|
||||||
|
break
|
||||||
|
results.reverse()
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
func get_recent(max_count: int = 5) -> Array[EconomicEventRecord]:
|
||||||
|
var results: Array[EconomicEventRecord] = []
|
||||||
|
var start := maxi(events.size() - max_count, 0)
|
||||||
|
for index in range(start, events.size()):
|
||||||
|
results.append(events[index])
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
func get_consumption_rates(
|
||||||
|
current_tick: int,
|
||||||
|
window_ticks: int = DEFAULT_RATE_WINDOW_TICKS,
|
||||||
|
ticks_per_day: float = DEFAULT_TICKS_PER_DAY
|
||||||
|
) -> Dictionary:
|
||||||
|
var consumed := {
|
||||||
|
SimulationIds.RESOURCE_FOOD: 0.0,
|
||||||
|
SimulationIds.RESOURCE_WOOD: 0.0,
|
||||||
|
}
|
||||||
|
var first_tick := maxi(current_tick - window_ticks, 0)
|
||||||
|
for event in events:
|
||||||
|
if int(event.data["tick"]) < first_tick:
|
||||||
|
continue
|
||||||
|
if StringName(event.data["event_type"]) != SimulationIds.EVENT_ITEM_CONSUMED:
|
||||||
|
continue
|
||||||
|
var item_id := StringName(event.data["item_id"])
|
||||||
|
if consumed.has(item_id):
|
||||||
|
consumed[item_id] = float(consumed[item_id]) + float(event.data["amount"])
|
||||||
|
var elapsed := maxi(current_tick - first_tick, 1)
|
||||||
|
return {
|
||||||
|
"food_per_day": float(consumed[SimulationIds.RESOURCE_FOOD]) / elapsed * ticks_per_day,
|
||||||
|
"wood_per_day": float(consumed[SimulationIds.RESOURCE_WOOD]) / elapsed * ticks_per_day,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func restore(restored_events: Array[EconomicEventRecord], restored_next_id: int) -> void:
|
||||||
|
events = restored_events.duplicate()
|
||||||
|
next_event_id = restored_next_id
|
||||||
|
|
||||||
|
|
||||||
|
func _append(event: EconomicEventRecord) -> void:
|
||||||
|
next_event_id += 1
|
||||||
|
events.append(event)
|
||||||
|
event_recorded.emit(event)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cjgpqcofspncx
|
||||||
@@ -177,6 +177,7 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
|
|||||||
if version == LEGACY_SCHEMA_VERSION:
|
if version == LEGACY_SCHEMA_VERSION:
|
||||||
var village_data: Dictionary = legacy_data.get("village", {})
|
var village_data: Dictionary = legacy_data.get("village", {})
|
||||||
var initial_food := float(village_data.get("food", 0.0))
|
var initial_food := float(village_data.get("food", 0.0))
|
||||||
|
var initial_wood := float(village_data.get("wood", 0.0))
|
||||||
migrated["storages"] = [
|
migrated["storages"] = [
|
||||||
(
|
(
|
||||||
StorageStateRecord
|
StorageStateRecord
|
||||||
@@ -185,6 +186,14 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
|
|||||||
{String(SimulationIds.RESOURCE_FOOD): initial_food}
|
{String(SimulationIds.RESOURCE_FOOD): initial_food}
|
||||||
)
|
)
|
||||||
. to_dictionary()
|
. to_dictionary()
|
||||||
|
),
|
||||||
|
(
|
||||||
|
StorageStateRecord
|
||||||
|
. create(
|
||||||
|
SimulationIds.STORAGE_VILLAGE_WOODPILE,
|
||||||
|
{String(SimulationIds.RESOURCE_WOOD): initial_wood}
|
||||||
|
)
|
||||||
|
. to_dictionary()
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
migrated["economic_events"] = []
|
migrated["economic_events"] = []
|
||||||
|
|||||||
@@ -57,10 +57,12 @@ func get_total_amount() -> float:
|
|||||||
return total
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
func get_available_capacity() -> float:
|
||||||
|
return maxf(float(data["capacity"]) - get_total_amount(), 0.0)
|
||||||
|
|
||||||
|
|
||||||
func deposit(item_id: StringName, requested_amount: float) -> float:
|
func deposit(item_id: StringName, requested_amount: float) -> float:
|
||||||
var accepted := minf(
|
var accepted := minf(maxf(requested_amount, 0.0), get_available_capacity())
|
||||||
maxf(requested_amount, 0.0), maxf(float(data["capacity"]) - get_total_amount(), 0.0)
|
|
||||||
)
|
|
||||||
if accepted <= 0.0:
|
if accepted <= 0.0:
|
||||||
return 0.0
|
return 0.0
|
||||||
data["amounts"][String(item_id)] = get_amount(item_id) + accepted
|
data["amounts"][String(item_id)] = get_amount(item_id) + accepted
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func _run() -> void:
|
|||||||
|
|
||||||
var pantry: StorageStateRecord = manager.get_pantry()
|
var pantry: StorageStateRecord = manager.get_pantry()
|
||||||
pantry.withdraw(SimulationIds.RESOURCE_FOOD, 1000.0)
|
pantry.withdraw(SimulationIds.RESOURCE_FOOD, 1000.0)
|
||||||
manager._sync_village_food()
|
manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
|
||||||
var resource := ResourceStateRecord.from_dictionary(
|
var resource := ResourceStateRecord.from_dictionary(
|
||||||
{
|
{
|
||||||
"schema_version": ResourceStateRecord.SCHEMA_VERSION,
|
"schema_version": ResourceStateRecord.SCHEMA_VERSION,
|
||||||
@@ -190,7 +190,7 @@ func _test_wood_work_requires_material() -> void:
|
|||||||
var woodpile: StorageStateRecord = manager.get_woodpile()
|
var woodpile: StorageStateRecord = manager.get_woodpile()
|
||||||
woodpile.withdraw(SimulationIds.RESOURCE_WOOD, 1000.0)
|
woodpile.withdraw(SimulationIds.RESOURCE_WOOD, 1000.0)
|
||||||
woodpile.deposit(SimulationIds.RESOURCE_WOOD, 0.5)
|
woodpile.deposit(SimulationIds.RESOURCE_WOOD, 0.5)
|
||||||
manager._sync_village_wood()
|
manager.economy.sync_resource(SimulationIds.RESOURCE_WOOD)
|
||||||
var npc: SimNPC = manager.npcs[0]
|
var npc: SimNPC = manager.npcs[0]
|
||||||
var safety_before: float = manager.village.safety
|
var safety_before: float = manager.village.safety
|
||||||
|
|
||||||
@@ -215,7 +215,8 @@ func _test_wood_work_requires_material() -> void:
|
|||||||
"An unpaid completion cost should create a readable blocked-task fact"
|
"An unpaid completion cost should create a readable blocked-task fact"
|
||||||
)
|
)
|
||||||
|
|
||||||
manager.add_wood(1.5)
|
woodpile.deposit(SimulationIds.RESOURCE_WOOD, 1.5)
|
||||||
|
manager.economy.sync_resource(SimulationIds.RESOURCE_WOOD)
|
||||||
_check(
|
_check(
|
||||||
(
|
(
|
||||||
is_equal_approx(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), 2.0)
|
is_equal_approx(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), 2.0)
|
||||||
|
|||||||
@@ -59,8 +59,21 @@ func _run() -> void:
|
|||||||
|
|
||||||
var pantry := main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") as StorageNode
|
var pantry := main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") as StorageNode
|
||||||
var pantry_state: StorageStateRecord = simulation_manager.get_pantry()
|
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),
|
||||||
|
"Player harvesting should leave overflow at its source when storage fills"
|
||||||
|
)
|
||||||
|
|
||||||
pantry_state.deposit(SimulationIds.RESOURCE_FOOD, 3.0)
|
pantry_state.deposit(SimulationIds.RESOURCE_FOOD, 3.0)
|
||||||
simulation_manager._sync_village_food()
|
simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
|
||||||
var pantry_food_before := pantry_state.get_amount(SimulationIds.RESOURCE_FOOD)
|
var pantry_food_before := pantry_state.get_amount(SimulationIds.RESOURCE_FOOD)
|
||||||
player.global_position = pantry.get_interaction_position()
|
player.global_position = pantry.get_interaction_position()
|
||||||
player.try_interact()
|
player.try_interact()
|
||||||
|
|||||||
@@ -185,17 +185,25 @@ func _test_legacy_world_storage_migration() -> void:
|
|||||||
legacy_data["schema_version"] = SimulationStateRecord.LEGACY_SCHEMA_VERSION
|
legacy_data["schema_version"] = SimulationStateRecord.LEGACY_SCHEMA_VERSION
|
||||||
legacy_data.erase("storages")
|
legacy_data.erase("storages")
|
||||||
var migrated := SimulationStateRecord.from_dictionary(legacy_data)
|
var migrated := SimulationStateRecord.from_dictionary(legacy_data)
|
||||||
_check(migrated != null, "World schema v1 should migrate pantry storage")
|
_check(migrated != null, "World schema v1 should migrate authored storage")
|
||||||
if migrated != null:
|
if migrated != null:
|
||||||
|
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 woodpile: StorageStateRecord = migrated_storages.get(
|
||||||
|
SimulationIds.STORAGE_VILLAGE_WOODPILE
|
||||||
|
)
|
||||||
_check(
|
_check(
|
||||||
(
|
(
|
||||||
migrated.storages.size() == 1
|
pantry != null
|
||||||
and is_equal_approx(
|
and woodpile != null
|
||||||
migrated.storages[0].get_amount(SimulationIds.RESOURCE_FOOD),
|
and is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), manager.village.food)
|
||||||
manager.village.food
|
and is_equal_approx(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), manager.village.wood)
|
||||||
)
|
|
||||||
),
|
),
|
||||||
"World migration should preserve legacy village food in pantry"
|
"World migration should preserve legacy village resources in their storage"
|
||||||
)
|
)
|
||||||
manager.free()
|
manager.free()
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
[gd_scene load_steps=41 format=3]
|
[gd_scene load_steps=37 format=3]
|
||||||
|
|
||||||
[ext_resource type="Terrain3DAssets" path="res://terrain/jajce/assets.tres" id="1_assets"]
|
[ext_resource type="Terrain3DAssets" path="res://terrain/jajce/assets.tres" id="1_assets"]
|
||||||
[ext_resource type="PackedScene" path="res://world/resource_nodes/ResourceNode.tscn" id="2_resource"]
|
[ext_resource type="PackedScene" path="res://world/resource_nodes/ResourceNode.tscn" id="2_resource"]
|
||||||
@@ -33,20 +33,6 @@ _shader_parameters = {
|
|||||||
world_background = 0
|
world_background = 0
|
||||||
auto_shader = true
|
auto_shader = true
|
||||||
|
|
||||||
[sub_resource type="NavigationMesh" id="NavigationMesh_greybox"]
|
|
||||||
vertices = PackedVector3Array(-14.5, 0.5, 0, -1, 0.5, 0, -1, 0.5, -0.75, 0, 0.5, -1, 0, 0.5, -14.5, -14.5, 0.5, -14.5, 0.75, 0.5, -1, 0.75, 0.75, -0.5, 14.5, 0.5, -0.5, 14.5, 0.5, -14.5, 0.75, 0.75, 0, 0, 1, 0, 0, 0.75, 0.75, -0.5, 0.75, 0.75, -0.5, 0.5, 14.5, 14.5, 0.5, 14.5, -1, 0.5, 0.75, -14.5, 0.5, 14.5)
|
|
||||||
polygons = [PackedInt32Array(2, 1, 0), PackedInt32Array(3, 2, 4), PackedInt32Array(4, 2, 5), PackedInt32Array(5, 2, 0), PackedInt32Array(6, 3, 4), PackedInt32Array(8, 7, 6), PackedInt32Array(8, 6, 9), PackedInt32Array(9, 6, 4), PackedInt32Array(12, 11, 10), PackedInt32Array(10, 7, 8), PackedInt32Array(14, 13, 12), PackedInt32Array(10, 8, 12), PackedInt32Array(12, 8, 14), PackedInt32Array(14, 8, 15), PackedInt32Array(0, 1, 16), PackedInt32Array(16, 13, 14), PackedInt32Array(14, 17, 16), PackedInt32Array(16, 17, 0)]
|
|
||||||
|
|
||||||
[sub_resource type="StandardMaterial3D" id="Material_ground"]
|
|
||||||
albedo_color = Color(0.24, 0.48, 0.25, 1)
|
|
||||||
|
|
||||||
[sub_resource type="BoxMesh" id="Mesh_ground"]
|
|
||||||
material = SubResource("Material_ground")
|
|
||||||
size = Vector3(64, 0.2, 64)
|
|
||||||
|
|
||||||
[sub_resource type="BoxShape3D" id="Shape_ground"]
|
|
||||||
size = Vector3(64, 0.2, 64)
|
|
||||||
|
|
||||||
[sub_resource type="StandardMaterial3D" id="Material_path_readability"]
|
[sub_resource type="StandardMaterial3D" id="Material_path_readability"]
|
||||||
albedo_color = Color(0.52, 0.39, 0.24, 1)
|
albedo_color = Color(0.52, 0.39, 0.24, 1)
|
||||||
roughness = 0.95
|
roughness = 0.95
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
extends CanvasLayer
|
extends CanvasLayer
|
||||||
|
|
||||||
const DEBUG_LOGS := false
|
|
||||||
|
|
||||||
@export var simulation_manager: Node
|
@export var simulation_manager: Node
|
||||||
|
|
||||||
@onready var village_stats_label: Label = $VillagePanel/MarginContainer/VillageStatsLabel
|
@onready var village_stats_label: Label = $VillagePanel/MarginContainer/VillageStatsLabel
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
extends Node
|
extends Node
|
||||||
|
|
||||||
const DEBUG_LOGS := true
|
@export var debug_logs := false
|
||||||
|
|
||||||
@export var npc_visual_scene: PackedScene
|
@export var npc_visual_scene: PackedScene
|
||||||
@export var simulation_manager: Node
|
@export var simulation_manager: Node
|
||||||
@@ -10,7 +10,7 @@ var active_npc_visuals := {}
|
|||||||
|
|
||||||
|
|
||||||
func debug_log(message: String) -> void:
|
func debug_log(message: String) -> void:
|
||||||
if DEBUG_LOGS:
|
if debug_logs:
|
||||||
print("[WorldViewManager] ", message)
|
print("[WorldViewManager] ", message)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user