feat: structured narrative event feed with per-NPC inspector timeline

Add narrative event types (task_started, npc_slept, npc_died, resource_depleted) alongside economic transfers. EconomicEventRecord gains create_narrative factory and human-readable description method. SimulationManager records task-start and sleep/death events, exposes get_npc_events() for per-NPC history queries. Inspector now shows a Recent timeline of the last 5 events for the selected NPC, plus displays carried wood count. Relax EconomicEventRecord validation to accept non-economic events. Update roadmap with completed milestones.
This commit is contained in:
2026-07-08 19:02:11 +02:00
parent c61d286005
commit 8a1913c5ae
6 changed files with 151 additions and 22 deletions
+26 -8
View File
@@ -652,14 +652,14 @@ Completed after the architecture gate:
The practical next sequence is: The practical next sequence is:
1. Continue growing resource discovery with finite `ResourceNode` instances 1. Expand the structured event feed with actor/target/cause metadata so the
placed in meaningful foliage, animal-camp, berry, and village-stockpile inspector can show a readable timeline per NPC. Narrative events for task
contexts. The first distance/safety/comfort scoring pass, twelve authored starts, sleep, and death are recorded; next should add depletion notices,
resources, and placement validation exist; next additions should increase relationship seeds, and village-level event summaries filtered by relevance.
authored variety only when they preserve reachability, stable IDs, scoring 2. Seed relationship dimensions (familiarity, trust, obligation) from shared
metadata, and clear player interaction ranges. work, meal, and sleep proximity, then expose them through utility
2. Expand persistence only when schedules, player state, or a real save menu considerations so NPCs begin to prefer known colleagues and familiar routes.
creates a concrete requirement. Start with one lightweight dimension before building the full graph.
Recently completed: Recently completed:
@@ -693,6 +693,24 @@ Recently completed:
multi-layer UV flow, fresnel edges, foam highlights, and sparkle; wider river multi-layer UV flow, fresnel edges, foam highlights, and sparkle; wider river
and waterfall; fortress with crenellations, three turrets, and prominent and waterfall; fortress with crenellations, three turrets, and prominent
ridge banner. 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 This order strengthens the simulation while regularly producing visible
progress suitable for public development updates. progress suitable for public development updates.
+35
View File
@@ -126,6 +126,13 @@ func simulate_tick() -> void:
latest_decisions[npc.id] = selection latest_decisions[npc.id] = selection
npc_decision_recorded.emit(npc, selection) npc_decision_recorded.emit(npc, selection)
npc.set_task(selection.action_id, selection.duration_override) npc.set_task(selection.action_id, selection.duration_override)
var action_def := SimulationDefinitions.get_action(selection.action_id)
var action_display := String(selection.action_id)
if action_def != null:
action_display = action_def.display_name
record_narrative_event(
SimulationIds.EVENT_TASK_STARTED, npc.id, &"", action_display
)
if old_task != npc.current_task and old_target != &"": if old_task != npc.current_task and old_target != &"":
release_npc_reservation(npc.id) release_npc_reservation(npc.id)
@@ -135,6 +142,7 @@ func simulate_tick() -> void:
release_npc_reservation(npc.id) release_npc_reservation(npc.id)
npc_died.emit(npc) npc_died.emit(npc)
npc_task_changed.emit(npc, old_task, npc.current_task) npc_task_changed.emit(npc, old_task, npc.current_task)
record_narrative_event(SimulationIds.EVENT_NPC_DIED, npc.id)
if debug_logs: if debug_logs:
print("[SimulationManager] NPC died: ", npc.npc_name) print("[SimulationManager] NPC died: ", npc.npc_name)
@@ -218,6 +226,7 @@ func simulate_tick() -> void:
elif completed_task == SimulationIds.ACTION_SLEEP: elif completed_task == SimulationIds.ACTION_SLEEP:
npc.energy = minf(npc.energy + 40.0, 100.0) npc.energy = minf(npc.energy + 40.0, 100.0)
npc.position = npc.home_position npc.position = npc.home_position
record_narrative_event(SimulationIds.EVENT_NPC_SLEPT, npc.id)
if debug_logs: if debug_logs:
print("[SimulationManager] ", npc.npc_name, " completed sleep at home") print("[SimulationManager] ", npc.npc_name, " completed sleep at home")
elif ( elif (
@@ -460,6 +469,32 @@ func _record_economic_event(
economic_event_recorded.emit(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: func _initialize_storage() -> void:
if not storage_states.has(SimulationIds.STORAGE_VILLAGE_PANTRY): if not storage_states.has(SimulationIds.STORAGE_VILLAGE_PANTRY):
storage_states[SimulationIds.STORAGE_VILLAGE_PANTRY] = (StorageStateRecord.create( storage_states[SimulationIds.STORAGE_VILLAGE_PANTRY] = (StorageStateRecord.create(
+4
View File
@@ -35,3 +35,7 @@ const EVENT_RESOURCE_EXTRACTED := &"resource_extracted"
const EVENT_STORAGE_DEPOSITED := &"storage_deposited" const EVENT_STORAGE_DEPOSITED := &"storage_deposited"
const EVENT_STORAGE_WITHDRAWN := &"storage_withdrawn" const EVENT_STORAGE_WITHDRAWN := &"storage_withdrawn"
const EVENT_ITEM_CONSUMED := &"item_consumed" 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"
+53 -4
View File
@@ -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: static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION:
return null return null
@@ -65,10 +89,6 @@ static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
normalized["event_id"] < 0 normalized["event_id"] < 0
or normalized["tick"] < 0 or normalized["tick"] < 0
or normalized["event_type"].is_empty() 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 null
return EconomicEventRecord.new(normalized) return EconomicEventRecord.new(normalized)
@@ -76,3 +96,32 @@ static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
func to_dictionary() -> Dictionary: func to_dictionary() -> Dictionary:
return data.duplicate(true) 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]
+12 -8
View File
@@ -150,17 +150,21 @@ func _check_economic_event_chain(manager: Node) -> void:
SimulationIds.EVENT_STORAGE_WITHDRAWN, SimulationIds.EVENT_STORAGE_WITHDRAWN,
SimulationIds.EVENT_ITEM_CONSUMED 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( _check(
manager.economic_events.size() == expected_types.size(), transfer_events.size() == expected_types.size(),
"Food transfer loop should emit one structured event per transfer" "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 return
for index in expected_types.size(): for index in expected_types.size():
var event: EconomicEventRecord = manager.economic_events[index] var event: EconomicEventRecord = transfer_events[index]
_check( _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["event_type"]) == expected_types[index]
and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD and StringName(event.data["item_id"]) == SimulationIds.RESOURCE_FOOD
), ),
@@ -168,10 +172,10 @@ func _check_economic_event_chain(manager: Node) -> void:
) )
_check( _check(
( (
is_equal_approx(float(manager.economic_events[0].data["amount"]), 2.0) is_equal_approx(float(transfer_events[0].data["amount"]), 2.0)
and is_equal_approx(float(manager.economic_events[1].data["amount"]), 2.0) and is_equal_approx(float(transfer_events[1].data["amount"]), 2.0)
and is_equal_approx(float(manager.economic_events[2].data["amount"]), 1.0) and is_equal_approx(float(transfer_events[2].data["amount"]), 1.0)
and is_equal_approx(float(manager.economic_events[3].data["amount"]), 1.0) and is_equal_approx(float(transfer_events[3].data["amount"]), 1.0)
), ),
"Economic events should record exact transferred quantities" "Economic events should record exact transferred quantities"
) )
+21 -2
View File
@@ -118,13 +118,15 @@ func _refresh_npc_inspector() -> void:
) )
if not score_rows.is_empty(): if not score_rows.is_empty():
score_text = "\n\nUtility\n" + "\n".join(score_rows) score_text = "\n\nUtility\n" + "\n".join(score_rows)
var event_text := _build_event_history(npc)
npc_inspector_label.text = ( npc_inspector_label.text = (
"%s · %s\n" "%s · %s\n"
+ "Task: %s (%s)\n" + "Task: %s (%s)\n"
+ "Destination: %s\n" + "Destination: %s\n"
+ "Hunger: %.0f Energy: %.0f\n" + "Hunger: %.0f Energy: %.0f\n"
+ "Carrying food: %.0f\n\n" + "Carrying food: %.0f wood: %.0f\n\n"
+ "Why\n%s%s\n\n" + "Why\n%s%s\n\n"
+ "Recent\n%s\n\n"
+ "[Tab] Next villager [F10] Cinematic/debug [F12] Demo reset" + "[Tab] Next villager [F10] Cinematic/debug [F12] Demo reset"
) % [ ) % [
npc.npc_name, npc.npc_name,
@@ -135,8 +137,10 @@ func _refresh_npc_inspector() -> void:
npc.hunger, npc.hunger,
npc.energy, npc.energy,
npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD), npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD),
npc.get_inventory_amount(SimulationIds.RESOURCE_WOOD),
reason_text, reason_text,
score_text score_text,
event_text
] ]
@@ -156,3 +160,18 @@ func set_debug_overlay_visible(is_visible: bool) -> void:
visible = is_visible visible = is_visible
if is_visible: if is_visible:
_refresh_npc_inspector() _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)