refactor: clarify simulation ownership

This commit is contained in:
Rijad Zuzo
2026-07-10 11:02:11 +02:00
parent 0f7b12080e
commit ce8f71082b
29 changed files with 793 additions and 587 deletions
+17 -2
View File
@@ -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
+91
View File
@@ -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).
+5
View File
@@ -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
+6 -2
View File
@@ -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.
+8 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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 16 are complete; follow-up work should expand migration plan. Phases 16 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:
+4 -3
View File
@@ -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
-2
View File
@@ -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")]
+2 -2
View File
@@ -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)
-8
View File
@@ -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
+1 -1
View File
@@ -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(
+3 -9
View File
@@ -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)
+241 -485
View File
@@ -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,210 +149,8 @@ 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
var was_complete := npc.task_complete
var was_dead := npc.is_dead
action_executor.advance_npc(npc, village)
if (
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 != &"":
release_npc_reservation(npc.id)
if not was_dead and npc.is_dead:
if old_target != &"":
release_npc_reservation(npc.id)
npc_died.emit(npc)
npc_task_changed.emit(npc, old_task, npc.current_task)
record_narrative_event(SimulationIds.EVENT_NPC_DIED, npc.id)
_notify_mourning(npc)
if debug_logs:
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:
var completed_task := npc.current_task
var completed_definition := SimulationDefinitions.get_action(completed_task)
var completion_cost_paid := _consume_action_completion_cost(
npc, completed_definition
)
if not completion_cost_paid:
if not npc.target_id.is_empty():
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_state = SimNPC.TASK_STATE_IDLE
npc.last_task = completed_task
npc.current_task = SimulationIds.ACTION_IDLE
npc.has_travel_target = false
npc.target_id = &""
npc_task_changed.emit(npc, completed_task, npc.current_task)
if debug_logs:
print(
npc.npc_name,
" | ",
npc.profession,
" | task: ",
npc.current_task,
" | state: ",
npc.task_state,
" | progress: ",
npc.task_progress,
"/",
npc.task_duration,
" | complete: ",
npc.task_complete,
" | hunger: ",
round(npc.hunger),
" | energy: ",
round(npc.energy),
" | starving: ",
npc.is_starving,
" | starvation_ticks: ",
npc.starvation_ticks,
" | dead: ",
npc.is_dead
)
if old_state != npc.task_state:
print(
"[SimulationManager] State changed: ",
npc.npc_name,
" ",
old_state,
" -> ",
npc.task_state
)
if village_was_changed: if village_was_changed:
village_changed.emit(village) village_changed.emit(village)
@@ -343,11 +159,179 @@ func simulate_tick() -> void:
print(village.get_summary()) print(village.get_summary())
func set_npc_target_id(npc_id: int, target_id: StringName) -> void: func _simulate_npc_tick(npc: SimNPC) -> bool:
for npc in npcs: var previous_task := npc.current_task
if npc.id == npc_id: var previous_target := npc.target_id
npc.target_id = target_id var previous_state := npc.task_state
return var was_complete := npc.task_complete
var was_dead := npc.is_dead
action_executor.advance_npc(npc, village)
_select_action_if_idle(npc, previous_state)
if previous_task != npc.current_task and not previous_target.is_empty():
release_npc_reservation(npc.id)
if not was_dead and npc.is_dead:
_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)
npc_died.emit(npc)
npc_task_changed.emit(npc, previous_task, npc.current_task)
record_narrative_event(SimulationIds.EVENT_NPC_DIED, npc.id)
_notify_mourning(npc)
if debug_logs:
print("[SimulationManager] NPC died: ", npc.npc_name)
func _complete_current_action(npc: SimNPC) -> void:
var completed_task := npc.current_task
var definition := SimulationDefinitions.get_action(completed_task)
if economy.consume_completion_cost(npc, definition):
_apply_action_completion(npc, completed_task, definition)
elif not npc.target_id.is_empty():
release_npc_reservation(npc.id)
npc.task_complete = true
npc.task_state = SimNPC.TASK_STATE_IDLE
npc.last_task = completed_task
npc.current_task = SimulationIds.ACTION_IDLE
npc.has_travel_target = false
npc.target_id = &""
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:
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(
npc.npc_name,
" | ",
npc.profession,
" | task: ",
npc.current_task,
" | state: ",
npc.task_state,
" | progress: ",
npc.task_progress,
"/",
npc.task_duration,
" | complete: ",
npc.task_complete,
" | hunger: ",
round(npc.hunger),
" | energy: ",
round(npc.energy),
" | starving: ",
npc.is_starving,
" | starvation_ticks: ",
npc.starvation_ticks,
" | dead: ",
npc.is_dead
)
if previous_state != npc.task_state:
print(
"[SimulationManager] State changed: ",
npc.npc_name,
" ",
previous_state,
" -> ",
npc.task_state
)
func release_npc_reservation(npc_id: int) -> void: func release_npc_reservation(npc_id: int) -> void:
@@ -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(
+4
View File
@@ -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)
+223
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://boecv7w7txoj0
+98
View File
@@ -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"] = []
+5 -3
View File
@@ -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
+4 -3
View File
@@ -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)
+14 -1
View File
@@ -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()
+15 -7
View File
@@ -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 -15
View File
@@ -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
-2
View File
@@ -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
+2 -2
View File
@@ -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)