diff --git a/docs/ACTION_SYSTEM_ARCHITECTURE.md b/docs/ACTION_SYSTEM_ARCHITECTURE.md index d2d8fa9..aae9454 100644 --- a/docs/ACTION_SYSTEM_ARCHITECTURE.md +++ b/docs/ACTION_SYSTEM_ARCHITECTURE.md @@ -68,8 +68,23 @@ It also publishes the latest `ActionSelectionResult` for presentation; the UI does not recompute decisions. At completion it atomically pays any definition-backed stored-resource cost before applying the action effect. A late shortfall suppresses the effect and records a `task_blocked` fact. -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 diff --git a/docs/ARCHITECTURE_OVERVIEW.md b/docs/ARCHITECTURE_OVERVIEW.md new file mode 100644 index 0000000..d7bb825 --- /dev/null +++ b/docs/ARCHITECTURE_OVERVIEW.md @@ -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). diff --git a/docs/ECONOMIC_EVENTS.md b/docs/ECONOMIC_EVENTS.md index 084c00f..ead09ab 100644 --- a/docs/ECONOMIC_EVENTS.md +++ b/docs/ECONOMIC_EVENTS.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, 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 a zero-amount `task_blocked` narrative fact with the action and shortfall reason. This makes late contention inspectable without pretending that a diff --git a/docs/FOOD_STORAGE_ARCHITECTURE.md b/docs/FOOD_STORAGE_ARCHITECTURE.md index e03e106..789377e 100644 --- a/docs/FOOD_STORAGE_ARCHITECTURE.md +++ b/docs/FOOD_STORAGE_ARCHITECTURE.md @@ -20,8 +20,10 @@ world only when consumed. - `StorageStateRecord` owns pantry and woodpile contents and capacity. - `SimNPC.inventory` owns carried item amounts. -- `SimulationManager` performs deposit, withdrawal, and consumption - transactions. +- `VillageEconomy` performs deposit, withdrawal, consumption, and + 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 the existing UI, priorities, and utility scoring. They are not second mutation paths. @@ -45,6 +47,8 @@ Transactions apply the amount actually available: - extraction cannot exceed the source; - 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; - eating succeeds only when the NPC carries one food. diff --git a/docs/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index fbc462f..af91081 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -29,9 +29,8 @@ Each milestone should produce five outcomes: Do not advance because files exist. Advance when the exit test passes. The current implementation has completed ResourceNode migration through player -parity plus the minimal Jajce scaffold and navigation proof. The architecture -gate below is now active and must pass before beauty production or broader -simulation features. See +parity, the Jajce scaffold/navigation proof, and the mandatory architecture +gate below. See [ADR 0001](decisions/0001-simulation-authority-boundary.md). ## Mandatory architecture gate @@ -660,6 +659,12 @@ The practical next sequence is: 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 labels, and NPC name/profession labels without changing simulation state; - `F12` repeatable simulation-garden demo reset by reloading the current scene diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 9908a1d..d49c682 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -375,40 +375,46 @@ glyphs. ### `simulation/SimNPC.gd` `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 should be preserved. ### `simulation/SimVillage.gd` -`SimVillage` is a `RefCounted` aggregate for shared resources, modifiers, -priorities, and applying completed NPC work. +`SimVillage` is a `RefCounted` aggregate for synchronized village resource +views, modifiers, and priorities. ### `simulation/SimulationManager.gd` `SimulationManager` is currently a scene-tree `Node` that: - owns the village and NPC array; -- advances a tick approximately every 1.2 seconds; +- advances deterministic ticks through `SimulationClock`; - creates NPCs; -- coordinates task completion; +- coordinates selection, travel, reservations, and task completion; - 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 -creation, and some gameplay API responsibilities. +Focused `RefCounted` collaborators keep rule ownership visible: + +- action systems own selection, execution progress, and target resolution; +- `VillageEconomy` owns storage/inventory transactions and synchronized + village resource views; +- `SimulationEventLog` owns deterministic event history and queries. + +`SimulationManager` remains the scene-tree façade and signal boundary rather +than duplicating these responsibilities across additional manager nodes. ### `world/world_view_manager.gd` `WorldViewManager` bridges simulation data to visible NPC nodes. It: - instantiates `NpcVisual` scenes; -- resolves resource nodes, random wander targets, and remaining activity - markers; -- writes the selected ResourceNode ID onto the NPC as a transitional behavior; -- sends targets to visuals; +- supplies active visual positions for simulation-owned target resolution; +- sends resolved travel destinations to visuals; - reports arrival and navigation failure back to `SimulationManager`; +- synchronizes successful visual movement into authoritative NPC position; - applies visual death state. ### `player/npc/NpcVisual.gd` @@ -456,7 +462,9 @@ NpcVisual navigates through the active world | Later ticks complete work | | | v - | ResourceStateRecord.extract() -> village.apply_resource_delta() + | ResourceStateRecord.extract() -> NPC inventory + | -> VillageEconomy transfers inventory/storage as actions complete + | -> SimulationEventLog appends completed facts | | | v | village_changed signal updates the UI @@ -488,6 +496,9 @@ NpcVisual navigates through the active world │ ├── SimulationManager.gd │ ├── actions/ Selection, execution, and target resolution │ ├── 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 ├── tests/ │ ├── 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 continuation checks, player-parity/resource-contention, flat-map, and Jajce scaffold scenarios; broader gameplay coverage is still missing. -- Resources are global floating-point counters rather than items in locations - and inventories, except food, which now moves through sources, NPC inventory, - and the village pantry. +- Food and wood now move through finite sources, NPC inventory, and typed + village storage. Other village metrics remain aggregate values rather than + located items. - NPCs do not have homes, schedules, possessions, memories, relationships, goals, or social knowledge. - The reason inspector exposes current decisions, but deeper historical traces @@ -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 abstract travel time. - There is no spatial query/index layer for large populations. -- SimulationManager still orchestrates multiple systems and player-facing - mutation APIs, but selection, execution, target resolution, and active-world - queries now have focused collaborators. +- SimulationManager still coordinates the tick lifecycle and bounded + player-facing commands, while action rules, active-world queries, economic + 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. - The old greybox navigation source has been replaced by a project-owned Terrain3D-derived navigation resource. The current bake is still a first diff --git a/docs/README.md b/docs/README.md index 485a86b..3d949fe 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,22 +6,24 @@ sources of truth. 1. [`PROJECT_CONTEXT.md`](PROJECT_CONTEXT.md) is the canonical description of the vision, current implementation, target architecture, and active 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 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 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 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), [`FOOD_STORAGE_ARCHITECTURE.md`](FOOD_STORAGE_ARCHITECTURE.md), [`SIMULATION_DEFINITIONS.md`](SIMULATION_DEFINITIONS.md), and [`SIMULATION_STATE_SCHEMA.md`](SIMULATION_STATE_SCHEMA.md) document the current data contracts. -6. [`decisions/`](decisions/) contains durable architectural decisions, +7. [`decisions/`](decisions/) contains durable architectural decisions, including consequences and revisit conditions. When documents disagree: diff --git a/docs/SIMULATION_STATE_SCHEMA.md b/docs/SIMULATION_STATE_SCHEMA.md index a6ee588..79609de 100644 --- a/docs/SIMULATION_STATE_SCHEMA.md +++ b/docs/SIMULATION_STATE_SCHEMA.md @@ -60,9 +60,10 @@ destination. This allows a reloaded visual to resume travel without changing target selection or deterministic RNG state. NPCStateRecord v3 adds carried inventory. SimulationStateRecord v2 adds -StorageStateRecord entries; world-schema v1 migrates legacy village food into -the stable `village_pantry` record. Parsed storage values are canonicalized so -save/restore continuation retains byte-stable checksums. +StorageStateRecord entries; world-schema v1 migrates legacy village food and +wood into the stable `village_pantry` and `village_woodpile` records. Parsed +storage values are canonicalized so save/restore continuation retains +byte-stable checksums. SimulationStateRecord v3 adds ordered `EconomicEventRecord` entries and `next_event_id`. World schemas v1 and v2 migrate explicitly to an empty event diff --git a/docs/decisions/0001-simulation-authority-boundary.md b/docs/decisions/0001-simulation-authority-boundary.md index fb59c4e..647dc20 100644 --- a/docs/decisions/0001-simulation-authority-boundary.md +++ b/docs/decisions/0001-simulation-authority-boundary.md @@ -54,7 +54,7 @@ random streams, and no dependency on a loaded gameplay scene. 5. ✅ Split action selection, execution, and presentation travel. 6. ✅ Synchronize active position and prove visual unload/reload invariance. -7. Add save-slot persistence before schedules or relationships +7. ✅ Add save-slot persistence before schedules or relationships substantially expand mutable state. Schema v1 and its deterministic continuation contract are documented in diff --git a/main.tscn b/main.tscn index bf83800..9ad77b5 100644 --- a/main.tscn +++ b/main.tscn @@ -79,8 +79,6 @@ npc_visual_scene = ExtResource("5_lquwl") simulation_manager = NodePath("../SimulationManager") 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="UI" type="CanvasLayer" parent="." unique_id=875790201 node_paths=PackedStringArray("simulation_manager")] diff --git a/player/npc/NpcVisual.gd b/player/npc/NpcVisual.gd index 0011bdd..612ae56 100644 --- a/player/npc/NpcVisual.gd +++ b/player/npc/NpcVisual.gd @@ -4,7 +4,7 @@ signal arrived_at_target(sim_id: int) signal navigation_failed(sim_id: int) 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 rotation_speed := 8.0 @@ -47,7 +47,7 @@ var glyph_phase := 0.0 func debug_log(message: String) -> void: - if DEBUG_LOGS: + if debug_logs: print("[NPCVisual] ", name, " | ", message) diff --git a/player/player.gd b/player/player.gd index 8410947..c64eec0 100644 --- a/player/player.gd +++ b/player/player.gd @@ -47,7 +47,6 @@ func _physics_process(delta: float) -> void: func _unhandled_input(event: InputEvent) -> void: - # keep your camera mouse code here too if event.is_action_pressed("interact"): try_interact() @@ -100,13 +99,6 @@ func try_harvest_resource_node() -> bool: 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: if storage_node == null: return false diff --git a/simulation/SimNPC.gd b/simulation/SimNPC.gd index 771634f..264557b 100644 --- a/simulation/SimNPC.gd +++ b/simulation/SimNPC.gd @@ -34,7 +34,7 @@ var last_task: StringName var random_source: RandomNumberGenerator var familiarity: Dictionary = {} var mourning_ticks := 0 -var debug_logs := true +var debug_logs := false func _init( diff --git a/simulation/SimVillage.gd b/simulation/SimVillage.gd index 6bb0d23..949a588 100644 --- a/simulation/SimVillage.gd +++ b/simulation/SimVillage.gd @@ -5,7 +5,7 @@ var food := 20.0 var wood := 10.0 var safety := 50.0 var knowledge := 0.0 -var debug_logs := true +var debug_logs := false var food_modifier := 1.0 var wood_modifier := 1.0 @@ -70,19 +70,13 @@ func update_priorities() -> void: print(get_priority_summary()) -func apply_resource_delta(resource_id: StringName, amount: float) -> void: - match resource_id: - &"food": - food += amount - &"wood": - wood += amount +func apply_metric_delta(metric_id: StringName, amount: float) -> void: + match metric_id: &"safety": safety = clamp(safety + amount, 0.0, 100.0) &"knowledge": knowledge += amount - food = max(food, 0.0) - wood = max(wood, 0.0) safety = clamp(safety, 0.0, 100.0) knowledge = max(knowledge, 0.0) diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index adb8f95..b00d5c2 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -1,5 +1,8 @@ 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 village_changed(village: SimVillage) signal npc_died(npc: SimNPC) @@ -16,7 +19,7 @@ var npcs: Array[SimNPC] = [] @export var tick_interval := 1.2 @export var simulation_seed: int = 1337 @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 home_positions: Array[Vector3] = [] @@ -24,9 +27,19 @@ var clock: SimulationClock var tick_count := 0 var wander_random_sources := {} var resource_states: Dictionary = {} -var storage_states: Dictionary = {} -var economic_events: Array[EconomicEventRecord] = [] -var next_event_id := 0 +var event_log := SimulationEventLogScript.new() +var economy := VillageEconomyScript.new() +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 action_selector := ActionSelectionSystem.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] 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: 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() if not definition_errors.is_empty(): for error in definition_errors: @@ -50,7 +67,8 @@ func _ready() -> void: clock.cycle_duration_seconds = cycle_duration_seconds clock.elapsed_ticks = int(0.25 * cycle_duration_seconds / tick_interval) village.debug_logs = debug_logs - _initialize_storage() + economy.configure(village, debug_logs) + economy.initialize_storage() village.update_modifiers() village.update_priorities() generate_npcs() @@ -83,12 +101,12 @@ func _input(event: InputEvent) -> void: func generate_npcs() -> void: 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 profession_index := npc_random.randi_range(0, profession_ids.size() - 1) var npc := SimNPC.new( i, - names[i], + NPC_NAMES[i], profession_ids[profession_index], 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, " ---") var village_was_changed := false - for npc in npcs: - var old_task := npc.current_task - 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 - ) + village_was_changed = _simulate_npc_tick(npc) or village_was_changed if village_was_changed: village_changed.emit(village) @@ -343,11 +159,179 @@ func simulate_tick() -> void: 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 _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_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: @@ -446,10 +430,6 @@ func notify_npc_navigation_failed(npc_id: int) -> void: 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: for npc in npcs: if npc.id != npc_id: @@ -466,7 +446,7 @@ func resolve_npc_target(npc_id: int, origin: Vector3) -> bool: return false var result := target_resolver.resolve(npc, origin, self, active_world_adapter) if result.is_empty(): - notify_npc_target_unavailable(npc.id) + notify_npc_navigation_failed(npc.id) return false npc.target_id = StringName(result.get("target_id", "")) 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 -func _npc_inventory_id(npc_id: int) -> StringName: - return StringName("npc_%d_inventory" % npc_id) - - func _notify_mourning(dead_npc: SimNPC) -> void: var best_id := -1 var best_score := -1.0 @@ -546,14 +522,9 @@ func _record_economic_event( item_id: StringName, amount: float ) -> void: - if amount <= 0.0: - return - var event := EconomicEventRecord.create( - next_event_id, event_type, tick_count, actor_id, source_id, destination_id, item_id, amount + event_log.record_economic( + tick_count, event_type, 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( @@ -562,32 +533,19 @@ func record_narrative_event( source_id: StringName = &"", action_display: String = "" ) -> void: - var event := EconomicEventRecord.create_narrative( - 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) + event_log.record_narrative(tick_count, event_type, actor_id, source_id, action_display) func get_npc_events(npc_id: int, max_count: int = 8) -> Array[EconomicEventRecord]: - var results: Array[EconomicEventRecord] = [] - 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 + return event_log.get_for_actor(npc_id, max_count) func get_recent_events(max_count: int = 5) -> Array[EconomicEventRecord]: - var results: Array[EconomicEventRecord] = [] - var start := maxi(economic_events.size() - max_count, 0) - for i in range(start, economic_events.size()): - results.append(economic_events[i]) - return results + return event_log.get_recent(max_count) + + +func _on_economic_event_recorded(event: EconomicEventRecord) -> void: + economic_event_recorded.emit(event) func get_current_speed() -> String: @@ -595,57 +553,15 @@ func get_current_speed() -> String: func get_resource_rates() -> Dictionary: - var food_consumed := 0.0 - 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() + return event_log.get_consumption_rates(tick_count) func get_pantry() -> StorageStateRecord: - return storage_states.get(SimulationIds.STORAGE_VILLAGE_PANTRY) as StorageStateRecord + return economy.get_pantry() func get_woodpile() -> StorageStateRecord: - return storage_states.get(SimulationIds.STORAGE_VILLAGE_WOODPILE) as StorageStateRecord - - -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 + return economy.get_woodpile() func register_loaded_storage_nodes() -> void: @@ -667,149 +583,8 @@ func register_storage_node(node: StorageNode) -> bool: return node.bind_state(storage_state) -func _sync_village_food() -> void: - var pantry := get_pantry() - 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 _on_economy_inventory_changed(npc: SimNPC, item_id: StringName, amount: float) -> void: + npc_inventory_changed.emit(npc, item_id, amount) func register_loaded_resource_nodes() -> void: @@ -825,11 +600,12 @@ func register_resource_node(node: ResourceNode) -> bool: action_definition == null or action_definition.target_type != SimulationIds.TARGET_RESOURCE or action_definition.resource_action_id != node.action_id + or node.resource_id not in [SimulationIds.RESOURCE_FOOD, SimulationIds.RESOURCE_WOOD] ): push_error( ( - "SimulationManager: ResourceNode '%s' has invalid action_id '%s'" - % [node.node_id, node.action_id] + "SimulationManager: ResourceNode '%s' has invalid action/resource IDs '%s'/'%s'" + % [node.node_id, node.action_id, node.resource_id] ) ) 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 for node in ResourceNode.get_all(): 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 var distance := from_position.distance_squared_to(node.interaction_point.global_position) if distance <= best_distance: @@ -878,18 +661,16 @@ func harvest_resource_node(node: ResourceNode) -> float: var resource_state := get_resource_state(node.node_id) if resource_state == null or not resource_state.can_player_use_resource(): 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: return 0.0 - if resource_state.get_resource_id() == SimulationIds.RESOURCE_FOOD: - 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) + var deposited := economy.deposit_resource(resource_state.get_resource_id(), extracted) _record_economic_event( SimulationIds.EVENT_RESOURCE_EXTRACTED, -1, @@ -904,7 +685,7 @@ func harvest_resource_node(node: ResourceNode) -> float: ) ), resource_state.get_resource_id(), - extracted + deposited ) if resource_state.get_amount_remaining() <= 0.0: record_narrative_event( @@ -922,43 +703,21 @@ func harvest_resource_node(node: ResourceNode) -> float: resource_state.get_node_id() ) - return extracted + return deposited func eat_food(amount: float) -> void: - get_pantry().withdraw(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() + economy.withdraw_resource(SimulationIds.RESOURCE_FOOD, amount) village_changed.emit(village) func add_safety(amount: float) -> void: - village.apply_resource_delta(&"safety", amount) + village.apply_metric_delta(&"safety", amount) village_changed.emit(village) func add_knowledge(amount: float) -> void: - village.apply_resource_delta(&"knowledge", amount) + village.apply_metric_delta(&"knowledge", amount) village_changed.emit(village) @@ -1081,17 +840,14 @@ func restore_state(record: SimulationStateRecord) -> bool: simulation_seed = int(record.simulation["seed"]) tick_interval = float(record.simulation["tick_interval"]) tick_count = int(record.simulation["tick_count"]) - next_event_id = int(record.simulation["next_event_id"]) clock = SimulationClock.new(tick_interval) clock.accumulator = float(record.simulation["clock_accumulator"]) clock.elapsed_ticks = int(record.simulation["clock_elapsed_ticks"]) clock.cycle_duration_seconds = float(record.simulation.get("cycle_duration_seconds", 240.0)) village = record.village.restore(debug_logs) - storage_states.clear() - for storage_record in record.storages: - storage_states[storage_record.get_storage_id()] = storage_record - _sync_village_food() - economic_events = record.economic_events.duplicate() + economy.configure(village, debug_logs) + economy.restore_storage(record.storages) + event_log.restore(record.economic_events, int(record.simulation["next_event_id"])) latest_decisions.clear() npcs.clear() diff --git a/simulation/actions/ActionSelectionSystem.gd b/simulation/actions/ActionSelectionSystem.gd index 02397e8..6a0f3b3 100644 --- a/simulation/actions/ActionSelectionSystem.gd +++ b/simulation/actions/ActionSelectionSystem.gd @@ -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" ) - 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.get_inventory_amount(SimulationIds.RESOURCE_FOOD) >= 1.0: return ActionSelectionResult.new( diff --git a/simulation/definitions/SimulationIds.gd b/simulation/definitions/SimulationIds.gd index 6c0a5e3..11622b6 100644 --- a/simulation/definitions/SimulationIds.gd +++ b/simulation/definitions/SimulationIds.gd @@ -40,3 +40,7 @@ const EVENT_NPC_DIED := &"npc_died" const EVENT_TASK_STARTED := &"task_started" const EVENT_TASK_BLOCKED := &"task_blocked" const EVENT_RESOURCE_DEPLETED := &"resource_depleted" + + +static func npc_inventory_id(npc_id: int) -> StringName: + return StringName("npc_%d_inventory" % npc_id) diff --git a/simulation/economy/VillageEconomy.gd b/simulation/economy/VillageEconomy.gd new file mode 100644 index 0000000..9b763c1 --- /dev/null +++ b/simulation/economy/VillageEconomy.gd @@ -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() diff --git a/simulation/economy/VillageEconomy.gd.uid b/simulation/economy/VillageEconomy.gd.uid new file mode 100644 index 0000000..27f2bb3 --- /dev/null +++ b/simulation/economy/VillageEconomy.gd.uid @@ -0,0 +1 @@ +uid://boecv7w7txoj0 diff --git a/simulation/events/SimulationEventLog.gd b/simulation/events/SimulationEventLog.gd new file mode 100644 index 0000000..03d6b8b --- /dev/null +++ b/simulation/events/SimulationEventLog.gd @@ -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) diff --git a/simulation/events/SimulationEventLog.gd.uid b/simulation/events/SimulationEventLog.gd.uid new file mode 100644 index 0000000..89b554a --- /dev/null +++ b/simulation/events/SimulationEventLog.gd.uid @@ -0,0 +1 @@ +uid://cjgpqcofspncx diff --git a/simulation/state/SimulationStateRecord.gd b/simulation/state/SimulationStateRecord.gd index cc318f5..510f4de 100644 --- a/simulation/state/SimulationStateRecord.gd +++ b/simulation/state/SimulationStateRecord.gd @@ -177,6 +177,7 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary if version == LEGACY_SCHEMA_VERSION: var village_data: Dictionary = legacy_data.get("village", {}) var initial_food := float(village_data.get("food", 0.0)) + var initial_wood := float(village_data.get("wood", 0.0)) migrated["storages"] = [ ( StorageStateRecord @@ -185,6 +186,14 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary {String(SimulationIds.RESOURCE_FOOD): initial_food} ) . to_dictionary() + ), + ( + StorageStateRecord + . create( + SimulationIds.STORAGE_VILLAGE_WOODPILE, + {String(SimulationIds.RESOURCE_WOOD): initial_wood} + ) + . to_dictionary() ) ] migrated["economic_events"] = [] diff --git a/simulation/state/StorageStateRecord.gd b/simulation/state/StorageStateRecord.gd index 8ab3278..404b442 100644 --- a/simulation/state/StorageStateRecord.gd +++ b/simulation/state/StorageStateRecord.gd @@ -57,10 +57,12 @@ func get_total_amount() -> float: 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: - var accepted := minf( - maxf(requested_amount, 0.0), maxf(float(data["capacity"]) - get_total_amount(), 0.0) - ) + var accepted := minf(maxf(requested_amount, 0.0), get_available_capacity()) if accepted <= 0.0: return 0.0 data["amounts"][String(item_id)] = get_amount(item_id) + accepted diff --git a/tests/food_storage_loop_test.gd b/tests/food_storage_loop_test.gd index 8d104ae..4db8a9f 100644 --- a/tests/food_storage_loop_test.gd +++ b/tests/food_storage_loop_test.gd @@ -16,7 +16,7 @@ func _run() -> void: var pantry: StorageStateRecord = manager.get_pantry() pantry.withdraw(SimulationIds.RESOURCE_FOOD, 1000.0) - manager._sync_village_food() + manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD) var resource := ResourceStateRecord.from_dictionary( { "schema_version": ResourceStateRecord.SCHEMA_VERSION, @@ -190,7 +190,7 @@ func _test_wood_work_requires_material() -> void: var woodpile: StorageStateRecord = manager.get_woodpile() woodpile.withdraw(SimulationIds.RESOURCE_WOOD, 1000.0) 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 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" ) - manager.add_wood(1.5) + woodpile.deposit(SimulationIds.RESOURCE_WOOD, 1.5) + manager.economy.sync_resource(SimulationIds.RESOURCE_WOOD) _check( ( is_equal_approx(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), 2.0) diff --git a/tests/resource_node_player_parity_test.gd b/tests/resource_node_player_parity_test.gd index df72360..db7bc4a 100644 --- a/tests/resource_node_player_parity_test.gd +++ b/tests/resource_node_player_parity_test.gd @@ -59,8 +59,21 @@ func _run() -> void: var pantry := main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillagePantry") as StorageNode var pantry_state: StorageStateRecord = simulation_manager.get_pantry() + pantry_state.deposit( + SimulationIds.RESOURCE_FOOD, pantry_state.get_available_capacity() - 0.5 + ) + simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD) + bush_state.set_amount_remaining(1.0) + player.global_position = bush.interaction_point.global_position + player.try_interact() + _check( + is_equal_approx(bush_state.get_amount_remaining(), 0.5) + and is_equal_approx(pantry_state.get_available_capacity(), 0.0), + "Player harvesting should leave overflow at its source when storage fills" + ) + 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) player.global_position = pantry.get_interaction_position() player.try_interact() diff --git a/tests/simulation_state_serialization_test.gd b/tests/simulation_state_serialization_test.gd index a5ca5f5..06248c7 100644 --- a/tests/simulation_state_serialization_test.gd +++ b/tests/simulation_state_serialization_test.gd @@ -185,17 +185,25 @@ func _test_legacy_world_storage_migration() -> void: legacy_data["schema_version"] = SimulationStateRecord.LEGACY_SCHEMA_VERSION legacy_data.erase("storages") 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: + 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( ( - migrated.storages.size() == 1 - and is_equal_approx( - migrated.storages[0].get_amount(SimulationIds.RESOURCE_FOOD), - manager.village.food - ) + pantry != null + and woodpile != null + and is_equal_approx(pantry.get_amount(SimulationIds.RESOURCE_FOOD), manager.village.food) + and is_equal_approx(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), manager.village.wood) ), - "World migration should preserve legacy village food in pantry" + "World migration should preserve legacy village resources in their storage" ) manager.free() diff --git a/world/jajce/JajceWorld.tscn b/world/jajce/JajceWorld.tscn index f8fbfb3..3b10f77 100644 --- a/world/jajce/JajceWorld.tscn +++ b/world/jajce/JajceWorld.tscn @@ -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="PackedScene" path="res://world/resource_nodes/ResourceNode.tscn" id="2_resource"] @@ -33,20 +33,6 @@ _shader_parameters = { world_background = 0 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"] albedo_color = Color(0.52, 0.39, 0.24, 1) roughness = 0.95 diff --git a/world/ui/ui.gd b/world/ui/ui.gd index 54e24d8..7ce4fe3 100644 --- a/world/ui/ui.gd +++ b/world/ui/ui.gd @@ -1,7 +1,5 @@ extends CanvasLayer -const DEBUG_LOGS := false - @export var simulation_manager: Node @onready var village_stats_label: Label = $VillagePanel/MarginContainer/VillageStatsLabel diff --git a/world/world_view_manager.gd b/world/world_view_manager.gd index 1389743..2619dcd 100644 --- a/world/world_view_manager.gd +++ b/world/world_view_manager.gd @@ -1,6 +1,6 @@ extends Node -const DEBUG_LOGS := true +@export var debug_logs := false @export var npc_visual_scene: PackedScene @export var simulation_manager: Node @@ -10,7 +10,7 @@ var active_npc_visuals := {} func debug_log(message: String) -> void: - if DEBUG_LOGS: + if debug_logs: print("[WorldViewManager] ", message)