diff --git a/docs/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index 9ecfe87..cb625e0 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -652,14 +652,14 @@ Completed after the architecture gate: The practical next sequence is: -1. Continue growing resource discovery with finite `ResourceNode` instances - placed in meaningful foliage, animal-camp, berry, and village-stockpile - contexts. The first distance/safety/comfort scoring pass, twelve authored - resources, and placement validation exist; next additions should increase - authored variety only when they preserve reachability, stable IDs, scoring - metadata, and clear player interaction ranges. -2. Expand persistence only when schedules, player state, or a real save menu - creates a concrete requirement. +1. Expand the structured event feed with actor/target/cause metadata so the + inspector can show a readable timeline per NPC. Narrative events for task + starts, sleep, and death are recorded; next should add depletion notices, + relationship seeds, and village-level event summaries filtered by relevance. +2. Seed relationship dimensions (familiarity, trust, obligation) from shared + work, meal, and sleep proximity, then expose them through utility + considerations so NPCs begin to prefer known colleagues and familiar routes. + Start with one lightweight dimension before building the full graph. Recently completed: @@ -693,6 +693,24 @@ Recently completed: multi-layer UV flow, fresnel edges, foam highlights, and sparkle; wider river and waterfall; fortress with crenellations, three turrets, and prominent ridge banner. +- Resource discovery expanded to 18 finite ResourceNodes across farming, + deep-forest, river-bank, and mill-adjacent contexts with scored metadata. +- NPC schedules implemented: SLEEP/MEAL/WORK/DISCRETIONARY periods driven by + simulation clock time-of-day. DayNightCycle synced to simulation clock. NPCs + walk home to designated house positions at night, sleep to restore energy, + eat at meal times, and work during the day. Starving NPCs always override + sleep to seek food. +- Starvation balance reworked: hunger ~25/day, death threshold ~3 days of + starvation (realistic ~6-7 day survival without food). Energy drain uses + exact binary fractions for lossless JSON serialization. +- Wood inventory and woodpile storage: wood now follows the same + gather→carry→deposit pattern as food. VillageWoodpile StorageNode receives + wood deposits. Village wood syncs from storage. Economic events track all + transfers. +- Structured event feed: EconomicEventRecord now supports narrative events + (task started, NPC slept, NPC died) alongside economic transfers. + Per-NPC event history with human-readable descriptions exposed in the + inspector under a "Recent" timeline. This order strengthens the simulation while regularly producing visible progress suitable for public development updates. diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 5a22cfc..c254779 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -126,6 +126,13 @@ func simulate_tick() -> void: 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) @@ -135,6 +142,7 @@ func simulate_tick() -> void: 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) if debug_logs: print("[SimulationManager] NPC died: ", npc.npc_name) @@ -218,6 +226,7 @@ func simulate_tick() -> void: 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 ( @@ -460,6 +469,32 @@ func _record_economic_event( economic_event_recorded.emit(event) +func record_narrative_event( + event_type: StringName, + actor_id: int, + 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) + + +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 + + func _initialize_storage() -> void: if not storage_states.has(SimulationIds.STORAGE_VILLAGE_PANTRY): storage_states[SimulationIds.STORAGE_VILLAGE_PANTRY] = (StorageStateRecord.create( diff --git a/simulation/definitions/SimulationIds.gd b/simulation/definitions/SimulationIds.gd index e3d3b4f..da3c8ac 100644 --- a/simulation/definitions/SimulationIds.gd +++ b/simulation/definitions/SimulationIds.gd @@ -35,3 +35,7 @@ const EVENT_RESOURCE_EXTRACTED := &"resource_extracted" const EVENT_STORAGE_DEPOSITED := &"storage_deposited" const EVENT_STORAGE_WITHDRAWN := &"storage_withdrawn" const EVENT_ITEM_CONSUMED := &"item_consumed" +const EVENT_NPC_SLEPT := &"npc_slept" +const EVENT_NPC_DIED := &"npc_died" +const EVENT_TASK_STARTED := &"task_started" +const EVENT_RESOURCE_DEPLETED := &"resource_depleted" diff --git a/simulation/state/EconomicEventRecord.gd b/simulation/state/EconomicEventRecord.gd index 4159b20..005101f 100644 --- a/simulation/state/EconomicEventRecord.gd +++ b/simulation/state/EconomicEventRecord.gd @@ -35,6 +35,30 @@ static func create( ) +static func create_narrative( + event_id: int, + event_type: StringName, + tick: int, + actor_id: int, + source_id: StringName, + action_display: String = "" +) -> EconomicEventRecord: + return EconomicEventRecord.new( + { + "schema_version": SCHEMA_VERSION, + "event_id": event_id, + "event_type": String(event_type), + "tick": tick, + "actor_id": actor_id, + "source_id": String(source_id), + "destination_id": "", + "item_id": "", + "amount": 0.0, + "action_name": action_display + } + ) + + static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord: if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: return null @@ -65,10 +89,6 @@ static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord: normalized["event_id"] < 0 or normalized["tick"] < 0 or normalized["event_type"].is_empty() - or normalized["source_id"].is_empty() - or normalized["destination_id"].is_empty() - or normalized["item_id"].is_empty() - or normalized["amount"] <= 0.0 ): return null return EconomicEventRecord.new(normalized) @@ -76,3 +96,32 @@ static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord: func to_dictionary() -> Dictionary: return data.duplicate(true) + + +func description(npc_names: Dictionary = {}) -> String: + var actor_name: String = npc_names.get(int(data["actor_id"]), "Someone") + var item: String = str(data["item_id"]) + var amount: float = float(data["amount"]) + var event_type: String = str(data["event_type"]) + var source: String = str(data["source_id"]) + var destination: String = str(data["destination_id"]) + var action_name: String = str(data.get("action_name", "")) + match event_type: + "resource_extracted": + return "%s gathered %.0f %s from %s" % [actor_name, amount, item, source] + "storage_deposited": + return "%s deposited %.0f %s into %s" % [actor_name, amount, item, destination] + "storage_withdrawn": + return "%s withdrew %.0f %s from %s" % [actor_name, amount, item, source] + "item_consumed": + return "%s consumed %.0f %s" % [actor_name, amount, item] + "npc_slept": + return "%s slept and recovered energy" % actor_name + "npc_died": + return "%s died from starvation" % actor_name + "task_started": + return "%s began %s" % [actor_name, action_name if not action_name.is_empty() else item] + "resource_depleted": + return "%s was depleted" % source + _: + return "tick %d: %s" % [int(data["tick"]), event_type] diff --git a/tests/food_storage_loop_test.gd b/tests/food_storage_loop_test.gd index a336c7f..1d0ce29 100644 --- a/tests/food_storage_loop_test.gd +++ b/tests/food_storage_loop_test.gd @@ -150,17 +150,21 @@ func _check_economic_event_chain(manager: Node) -> void: SimulationIds.EVENT_STORAGE_WITHDRAWN, SimulationIds.EVENT_ITEM_CONSUMED ] + var transfer_events: Array[EconomicEventRecord] = [] + for event in manager.economic_events: + if float(event.data["amount"]) > 0.0: + transfer_events.append(event) _check( - manager.economic_events.size() == expected_types.size(), + transfer_events.size() == expected_types.size(), "Food transfer loop should emit one structured event per transfer" ) - if manager.economic_events.size() != expected_types.size(): + if transfer_events.size() != expected_types.size(): return for index in expected_types.size(): - var event: EconomicEventRecord = manager.economic_events[index] + var event: EconomicEventRecord = transfer_events[index] _check( ( - int(event.data["event_id"]) == index + int(event.data["event_id"]) >= 0 and StringName(event.data["event_type"]) == expected_types[index] and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD ), @@ -168,10 +172,10 @@ func _check_economic_event_chain(manager: Node) -> void: ) _check( ( - is_equal_approx(float(manager.economic_events[0].data["amount"]), 2.0) - and is_equal_approx(float(manager.economic_events[1].data["amount"]), 2.0) - and is_equal_approx(float(manager.economic_events[2].data["amount"]), 1.0) - and is_equal_approx(float(manager.economic_events[3].data["amount"]), 1.0) + is_equal_approx(float(transfer_events[0].data["amount"]), 2.0) + and is_equal_approx(float(transfer_events[1].data["amount"]), 2.0) + and is_equal_approx(float(transfer_events[2].data["amount"]), 1.0) + and is_equal_approx(float(transfer_events[3].data["amount"]), 1.0) ), "Economic events should record exact transferred quantities" ) diff --git a/world/ui/ui.gd b/world/ui/ui.gd index a902cac..0109713 100644 --- a/world/ui/ui.gd +++ b/world/ui/ui.gd @@ -118,13 +118,15 @@ func _refresh_npc_inspector() -> void: ) if not score_rows.is_empty(): score_text = "\n\nUtility\n" + "\n".join(score_rows) + var event_text := _build_event_history(npc) npc_inspector_label.text = ( "%s · %s\n" + "Task: %s (%s)\n" + "Destination: %s\n" + "Hunger: %.0f Energy: %.0f\n" - + "Carrying food: %.0f\n\n" + + "Carrying food: %.0f wood: %.0f\n\n" + "Why\n%s%s\n\n" + + "Recent\n%s\n\n" + "[Tab] Next villager [F10] Cinematic/debug [F12] Demo reset" ) % [ npc.npc_name, @@ -135,8 +137,10 @@ func _refresh_npc_inspector() -> void: npc.hunger, npc.energy, npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD), + npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD), reason_text, - score_text + score_text, + event_text ] @@ -156,3 +160,18 @@ func set_debug_overlay_visible(is_visible: bool) -> void: visible = is_visible if is_visible: _refresh_npc_inspector() + + +func _build_event_history(npc: SimNPC) -> String: + if not simulation_manager.has_method("get_npc_events"): + return "" + var events: Array = simulation_manager.get_npc_events(npc.id, 5) + if events.is_empty(): + return "No recorded events yet" + var name_map := {} + for other_npc in simulation_manager.npcs: + name_map[other_npc.id] = other_npc.npc_name + var lines: Array[String] = [] + for event in events: + lines.append(event.description(name_map)) + return "\n".join(lines)