From b5e2493c2a31cebf02abe17caa84b5c37d940d0e Mon Sep 17 00:00:00 2001
From: Rijad Zuzo <rijad.zuzo@matriq.ch>
Date: Sun, 21 Jun 2026 23:37:58 +0200
Subject: [PATCH] feat: add proper death from starvation and survival tasks
 urgency

---
 player/npc/NpcVisual.gd         | 28 +++++++++++++
 project.godot                   |  5 +++
 simulation/SimNPC.gd            | 73 +++++++++++++++++++++++++++------
 simulation/SimVillage.gd        | 24 +++++++++--
 simulation/SimulationManager.gd | 38 +++++++++++++++--
 world/world_view_manager.gd     | 17 ++++++++
 6 files changed, 165 insertions(+), 20 deletions(-)

diff --git a/player/npc/NpcVisual.gd b/player/npc/NpcVisual.gd
index 4b22fb7..0aac532 100644
--- a/player/npc/NpcVisual.gd
+++ b/player/npc/NpcVisual.gd
@@ -20,10 +20,16 @@ var path_index := 0
 var current_target := Vector3.INF
 var arrival_distance := 0.6
 
+var is_dead_visual := false
+var dead_material := StandardMaterial3D.new()
+
 func debug_log(message: String) -> void:
 	if DEBUG_LOGS:
 		print("[NPCVisual] ", name, " | ", message)
 
+func _ready() -> void:
+	dead_material.albedo_color = Color(0.25, 0.25, 0.25, 1.0)
+
 func setup_from_sim(npc: SimNPC) -> void:
 	sim_id = npc.id
 	npc_name = npc.npc_name
@@ -59,6 +65,11 @@ func set_target_position(pos: Vector3) -> void:
 		debug_log("New target: %s Path points: %s" % [pos, current_path.size()])
 
 func _physics_process(delta: float) -> void:
+	if is_dead_visual:
+		velocity = Vector3.ZERO
+		move_and_slide()
+		return
+
 	if current_target == Vector3.INF:
 		velocity = Vector3.ZERO
 		move_and_slide()
@@ -104,3 +115,20 @@ func _report_arrival_once() -> void:
 
 	debug_log("Arrived at target")
 	arrived_at_target.emit(sim_id)
+
+func apply_dead_visual_state() -> void:
+	is_dead_visual = true
+	velocity = Vector3.ZERO
+	current_path = PackedVector3Array()
+	path_index = 0
+	current_target = Vector3.INF
+	has_reported_arrival = true
+
+	scale = Vector3(0.8, 0.25, 1.2)
+	rotation_degrees.x = 90.0
+
+	for child in get_children():
+		if child is MeshInstance3D:
+			child.material_override = dead_material
+
+	debug_log("Applied dead visual state")
diff --git a/project.godot b/project.godot
index 3a4c829..80bab55 100644
--- a/project.godot
+++ b/project.godot
@@ -15,6 +15,11 @@ run/main_scene="uid://rs3hv73svbpa"
 config/features=PackedStringArray("4.5", "Forward Plus")
 config/icon="res://icon.svg"
 
+[display]
+
+window/size/viewport_width=1920
+window/size/viewport_height=1020
+
 [input]
 
 move_forward={
diff --git a/simulation/SimNPC.gd b/simulation/SimNPC.gd
index 84d04a7..e680364 100644
--- a/simulation/SimNPC.gd
+++ b/simulation/SimNPC.gd
@@ -1,7 +1,7 @@
 class_name SimNPC
 extends RefCounted
 
-@export var DEBUG_LOGS := true
+const DEBUG_LOGS := true
 
 const TASK_STATE_IDLE := "idle"
 const TASK_STATE_TRAVELING := "traveling"
@@ -20,6 +20,9 @@ var task_state: String = TASK_STATE_IDLE
 var position: Vector3
 
 var is_starving := false
+var starvation_ticks := 0
+var starvation_death_threshold := 20
+var is_dead := false
 
 var task_duration := 0.0
 var task_progress := 0.0
@@ -42,18 +45,13 @@ func _init(
 	position = Vector3(randf_range(-8.0, 8.0), 0.0, randf_range(-8.0, 8.0))
 
 func simulate_tick(village: SimVillage) -> void:
-	hunger += 5.0 * village.food_modifier
-	energy -= 2.0
+	if is_dead:
+		return
 
-	hunger = clamp(hunger, 0.0, 100.0)
-	energy = clamp(energy, 0.0, 100.0)
+	update_needs(village)
 
-	is_starving = hunger >= 90.0
-
-	if is_starving:
-		energy -= 8.0
-
-	energy = clamp(energy, 0.0, 100.0)
+	if is_dead:
+		return
 
 	if task_state == TASK_STATE_IDLE or task_state == TASK_STATE_COMPLETE:
 		choose_new_task(village)
@@ -68,8 +66,56 @@ func simulate_tick(village: SimVillage) -> void:
 		if task_progress >= task_duration:
 			task_complete = true
 			task_state = TASK_STATE_COMPLETE
+			
+func update_needs(village: SimVillage) -> void:
+	hunger += 3.0 * village.food_modifier
 
+	match task_state:
+		TASK_STATE_IDLE:
+			energy -= 0.5
+		TASK_STATE_TRAVELING:
+			energy -= 2.0
+		TASK_STATE_WORKING:
+			if current_task != "rest":
+				energy -= 3.0
+		TASK_STATE_COMPLETE:
+			energy -= 0.25
+
+	hunger = clamp(hunger, 0.0, 100.0)
+	energy = clamp(energy, 0.0, 100.0)
+
+	is_starving = hunger >= 90.0
+
+	if is_starving:
+		starvation_ticks += 1
+	else:
+		starvation_ticks = 0
+
+	if starvation_ticks >= starvation_death_threshold:
+		die_from_starvation()
+
+func die_from_starvation() -> void:
+	is_dead = true
+	current_task = "dead"
+	task_state = TASK_STATE_IDLE
+	task_progress = 0.0
+	task_duration = 0.0
+	task_complete = true
+
+	if DEBUG_LOGS:
+		print("[SimNPC] ", npc_name, " died from starvation.")
+		
 func choose_new_task(village: SimVillage) -> void:
+	if is_dead:
+		return
+
+	if is_starving:
+		if village.food > 0:
+			set_task("eat", 1.0)
+		else:
+			set_task("gather_food", 4.0)
+		return
+
 	if hunger > 75.0:
 		if village.food > 0:
 			set_task("eat", 2.0)
@@ -77,7 +123,7 @@ func choose_new_task(village: SimVillage) -> void:
 			set_task("gather_food", 5.0)
 		return
 
-	if energy < 30.0:
+	if energy < 25.0:
 		set_task("rest", 4.0)
 		return
 
@@ -94,6 +140,9 @@ func choose_new_task(village: SimVillage) -> void:
 			set_task("study", 6.0)
 		_:
 			set_task("wander", 4.0)
+			
+func should_eat_immediately_after_task() -> bool:
+	return current_task == "gather_food" and is_starving and not is_dead
 
 func choose_best_work_task(village: SimVillage) -> String:
 	var food_score := calculate_task_score(
diff --git a/simulation/SimVillage.gd b/simulation/SimVillage.gd
index 360bfd4..5bf64f4 100644
--- a/simulation/SimVillage.gd
+++ b/simulation/SimVillage.gd
@@ -70,9 +70,14 @@ func update_priorities() -> void:
 		print(get_priority_summary())
 
 func apply_npc_task(npc: SimNPC) -> void:
+	if npc.is_dead:
+		return
+
 	var productivity := 1.0
 
-	if npc.energy < 20.0:
+	if npc.is_starving:
+		productivity = 0.35
+	elif npc.energy < 20.0:
 		productivity = 0.25
 	elif npc.energy < 40.0:
 		productivity = 0.5
@@ -87,10 +92,21 @@ func apply_npc_task(npc: SimNPC) -> void:
 		"study":
 			knowledge += 1.0 * productivity
 		"eat":
-			food -= 1.0
-			npc.hunger = max(npc.hunger - 35.0, 0.0)
+			if food > 0:
+				food -= 1.0
+				npc.hunger = max(npc.hunger - 55.0, 0.0)
+				npc.starvation_ticks = 0
+				npc.is_starving = npc.hunger >= 90.0
+			else:
+				npc.hunger = min(npc.hunger + 5.0, 100.0)
+				npc.is_starving = npc.hunger >= 90.0
 		"rest":
-			npc.energy = min(npc.energy + 35.0, 100.0)
+			if npc.is_starving:
+				npc.energy = min(npc.energy + 2.0, 100.0)
+			elif npc.hunger > 75.0:
+				npc.energy = min(npc.energy + 12.0, 100.0)
+			else:
+				npc.energy = min(npc.energy + 35.0, 100.0)
 		"wander":
 			safety -= 0.2
 
diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd
index 7b6cf84..06ca920 100644
--- a/simulation/SimulationManager.gd
+++ b/simulation/SimulationManager.gd
@@ -2,6 +2,7 @@ extends Node
 
 signal npc_task_changed(npc: SimNPC, old_task: String, new_task: String)
 signal village_changed(village: SimVillage)
+signal npc_died(npc: SimNPC)
 
 var village := SimVillage.new()
 
@@ -26,9 +27,11 @@ var professions := [
 
 func _ready() -> void:
 	randomize()
+	village.update_modifiers()
+	village.update_priorities()
 	generate_npcs()
 	print("--- Simulation started ---")
-
+	
 func _process(delta: float) -> void:
 	tick_timer += delta
 
@@ -58,13 +61,24 @@ func simulate_tick() -> void:
 		var old_task := npc.current_task
 		var old_state := npc.task_state
 		var was_complete := npc.task_complete
+		var was_dead := npc.is_dead
 
 		npc.simulate_tick(village)
 
-		if old_task != npc.current_task:
+		if not was_dead and npc.is_dead:
+			npc_died.emit(npc)
 			npc_task_changed.emit(npc, old_task, npc.current_task)
 
-		if not was_complete and npc.task_complete:
+			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)
+
+		if not was_complete and npc.task_complete and not npc.is_dead:
+			var completed_task := npc.current_task
+			var needs_immediate_food_after_gather := npc.should_eat_immediately_after_task()
+
 			village.apply_npc_task(npc)
 			village_was_changed = true
 
@@ -72,6 +86,13 @@ func simulate_tick() -> void:
 			npc.task_state = SimNPC.TASK_STATE_IDLE
 			npc.current_task = "idle"
 
+			if needs_immediate_food_after_gather and village.food > 0:
+				npc.set_task("eat", 1.0)
+				npc_task_changed.emit(npc, completed_task, npc.current_task)
+
+				if DEBUG_LOGS:
+					print("[SimulationManager] ", npc.npc_name, " gathered food while starving and is going to eat immediately.")
+
 		if DEBUG_LOGS:
 			print(
 				npc.npc_name,
@@ -90,7 +111,13 @@ func simulate_tick() -> void:
 				" | hunger: ",
 				round(npc.hunger),
 				" | energy: ",
-				round(npc.energy)
+				round(npc.energy),
+				" | starving: ",
+				npc.is_starving,
+				" | starvation_ticks: ",
+				npc.starvation_ticks,
+				" | dead: ",
+				npc.is_dead
 			)
 
 			if old_state != npc.task_state:
@@ -105,6 +132,9 @@ func simulate_tick() -> void:
 func notify_npc_arrived(npc_id: int) -> void:
 	for npc in npcs:
 		if npc.id == npc_id:
+			if npc.is_dead:
+				return
+
 			if npc.task_state == SimNPC.TASK_STATE_TRAVELING:
 				npc.start_working()
 
diff --git a/world/world_view_manager.gd b/world/world_view_manager.gd
index fce741a..b0ac94d 100644
--- a/world/world_view_manager.gd
+++ b/world/world_view_manager.gd
@@ -30,6 +30,11 @@ func initialize_world_view() -> void:
 		simulation_manager.npc_task_changed.connect(_on_npc_task_changed)
 	else:
 		push_error("WorldViewManager: SimulationManager has no npc_task_changed signal")
+
+	if simulation_manager.has_signal("npc_died"):
+		simulation_manager.npc_died.connect(_on_npc_died)
+	else:
+		push_error("WorldViewManager: SimulationManager has no npc_died signal")
 	
 	update_npc_targets()
 
@@ -127,3 +132,15 @@ func _on_npc_visual_arrived(sim_id: int) -> void:
 		simulation_manager.notify_npc_arrived(sim_id)
 	else:
 		push_error("WorldViewManager: SimulationManager has no notify_npc_arrived method")
+		
+		
+func _on_npc_died(npc: SimNPC) -> void:
+	if not active_npc_visuals.has(npc.id):
+		return
+
+	var visual = active_npc_visuals[npc.id]
+
+	if visual.has_method("apply_dead_visual_state"):
+		visual.apply_dead_visual_state()
+	else:
+		push_error("WorldViewManager: NPCVisual has no apply_dead_visual_state method")
