From 0f7b12080e1e397f662ca7213ca8b71a8c43ca2d Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Fri, 10 Jul 2026 10:36:08 +0200 Subject: [PATCH] feat: define action completion costs --- docs/ACTION_SYSTEM_ARCHITECTURE.md | 8 ++- docs/BUILD_IN_PUBLIC_PLAN.md | 15 ++-- docs/ECONOMIC_EVENTS.md | 12 ++-- docs/LEARNING_ROADMAP.md | 11 +-- docs/SIMULATION_DEFINITIONS.md | 19 +++-- simulation/SimulationManager.gd | 71 ++++++++++++++----- simulation/actions/ActionSelectionResult.gd | 5 +- simulation/actions/ActionSelectionSystem.gd | 34 +++++++-- simulation/definitions/ActionDefinition.gd | 10 +++ .../definitions/SimulationDefinitions.gd | 11 +++ simulation/definitions/SimulationIds.gd | 1 + simulation/definitions/actions/patrol.tres | 2 + simulation/definitions/actions/study.tres | 2 + simulation/state/EconomicEventRecord.gd | 2 + tests/action_system_boundaries_test.gd | 9 +++ tests/food_storage_loop_test.gd | 10 +++ tests/npc_visual_lifecycle_test.gd | 11 ++- tests/simulation_definitions_test.gd | 16 +++++ world/ui/ui.gd | 9 +++ 19 files changed, 213 insertions(+), 45 deletions(-) diff --git a/docs/ACTION_SYSTEM_ARCHITECTURE.md b/docs/ACTION_SYSTEM_ARCHITECTURE.md index 7e3ed9d..d2d8fa9 100644 --- a/docs/ACTION_SYSTEM_ARCHITECTURE.md +++ b/docs/ACTION_SYSTEM_ARCHITECTURE.md @@ -23,8 +23,8 @@ SimulationManager tick - reads NPC and village state; - evaluates current utility scores; - consumes the NPC's deterministic decision RNG; -- returns an action ID, optional urgent-duration override, branch reason, and - compared utility scores; +- returns an action ID, optional urgent-duration override, branch reason, + compared utility scores, and definition-backed rejection reasons; - does not mutate targets, navigation, or visuals. ### ActionExecutionSystem @@ -65,7 +65,9 @@ SimulationManager remains the orchestrator and event boundary. It owns simulation records, invokes the focused systems, stores selected actions and targets, and translates presentation callbacks into simulation transitions. It also publishes the latest `ActionSelectionResult` for presentation; the UI -does not recompute decisions. +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. diff --git a/docs/BUILD_IN_PUBLIC_PLAN.md b/docs/BUILD_IN_PUBLIC_PLAN.md index 8106a72..835e8ed 100644 --- a/docs/BUILD_IN_PUBLIC_PLAN.md +++ b/docs/BUILD_IN_PUBLIC_PLAN.md @@ -220,6 +220,12 @@ an asset catalog. Translate the desired animated-film warmth into an original style: +Keep the gameplay camera as an elevated third-person/top-down player follow +with gentle dead-zone lag. This supports the cozy farm-builder read and lets +the player watch several villagers at once. Presentation presets may stage the +same camera for repeatable captures, but should not replace gameplay with a +free orbit or close over-the-shoulder view. + | Element | Direction | | --- | --- | | Terrain | Broad painterly color regions and exaggerated silhouettes | @@ -665,11 +671,10 @@ Completed: Next: 1. Replace the remaining player capsule with a matching stylized presentation, - then revisit runtime camera framing so the waterfall and village action - remain readable in the same authored shot. -2. Continue with the action precondition/material-cost contract and - relationship consequence work in `LEARNING_ROADMAP.md` before expanding - visual scope further. + while preserving the elevated top-down follow, gentle dead-zone lag, and + cozy farm-builder readability. +2. Continue with the relationship consequence work in `LEARNING_ROADMAP.md` + before expanding visual scope further. Do not start with GIS data, a full city, a large asset pack, or more NPC mechanics. The next proof is a beautiful stage for the systems that already diff --git a/docs/ECONOMIC_EVENTS.md b/docs/ECONOMIC_EVENTS.md index 8a6f888..084c00f 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. +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 +transfer occurred. + ## Persistence and determinism `SimulationStateRecord` schema v3 stores the ordered event stream and @@ -47,7 +52,6 @@ NPC record, so unloading presentation does not lose the fact. The stream is currently kept in full for the small simulation garden. Before large populations or long-running worlds, add measured retention, -archival/summary rules, and query indexes. Denied attempts, depletion, -witnesses, secrecy, causal links, and memories belong in later event/history -slices; they should extend this record family without making prose -authoritative. +archival/summary rules, and query indexes. General denied attempts, witnesses, +secrecy, causal links, and memories belong in later event/history slices; they +should extend this record family without making prose authoritative. diff --git a/docs/LEARNING_ROADMAP.md b/docs/LEARNING_ROADMAP.md index ad145aa..fbc462f 100644 --- a/docs/LEARNING_ROADMAP.md +++ b/docs/LEARNING_ROADMAP.md @@ -652,12 +652,11 @@ Completed after the architecture gate: The practical next sequence is: -1. Move proven action preconditions and material costs into a reusable contract, - beginning with the patrol/study wood dependency, then expose unavailable - choices and interruption reasons in the NPC inspector. -2. Grow the existing familiarity seed into one consequence-bearing relationship +1. Grow the existing familiarity seed into one consequence-bearing relationship dimension, such as trust or obligation, driven by structured events rather than proximity alone. +2. Use that dimension in one real choice or response, then expose the cause in + the NPC inspector before expanding the relationship graph. Recently completed: @@ -706,6 +705,10 @@ Recently completed: wood deposits. Village wood syncs from storage. Economic events track all transfers. Patrol and study no longer produce benefits without paying their one-wood cost, and utility selection avoids those actions when wood is empty. +- Action completion costs are definition-backed: patrol and study share one + stored-resource contract across selection and execution. The inspector shows + readable unavailable reasons, and a late shortfall creates a persisted + `task_blocked` fact without applying the work effect. - 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 diff --git a/docs/SIMULATION_DEFINITIONS.md b/docs/SIMULATION_DEFINITIONS.md index c0645ed..1746758 100644 --- a/docs/SIMULATION_DEFINITIONS.md +++ b/docs/SIMULATION_DEFINITIONS.md @@ -20,15 +20,18 @@ Each executable action definition contains: - default duration; - optional preferred profession ID; - target type: resource, activity, or free movement; -- resource action ID when the action targets a ResourceNode. +- resource action ID when the action targets a ResourceNode; +- optional completion-cost resource ID and amount. -The current actions are gather food, gather wood, deposit food, withdraw food, -patrol, study, eat, rest, and wander. Idle and dead are stable state sentinels, -not executable action definitions. +The current actions are gather food, gather wood, deposit food, deposit wood, +withdraw food, patrol, study, eat, rest, sleep, and wander. Idle and dead are +stable state sentinels, not executable action definitions. SimNPC reads default duration and preferred-profession metadata from these definitions. WorldViewManager reads target type and resource-action metadata -instead of maintaining a separate gather-action map. +instead of maintaining a separate gather-action map. ActionSelectionSystem and +SimulationManager share the same completion-cost metadata, so patrol and study +both require one stored wood without duplicating that rule. ## Current profession contract @@ -49,6 +52,7 @@ The registry rejects: - non-positive action durations; - invalid target types; - resource actions without a resource-action ID; +- incomplete, non-positive, or unknown completion-cost resources; - references to unknown professions or resource actions; - definition resources that fail to load. @@ -59,10 +63,11 @@ round-tripping. ## Deliberate boundary Definitions currently own identity and the static metadata already proven by -the prototype. They do not yet own: +the prototype, including the patrol/study completion-cost contract. They do not +yet own: - utility thresholds and consideration curves; -- preconditions or costs; +- general preconditions beyond stored-resource completion costs; - completion effects; - interruption and failure policy; - reservation strategy; diff --git a/simulation/SimulationManager.gd b/simulation/SimulationManager.gd index 1ff336f..adb8f95 100644 --- a/simulation/SimulationManager.gd +++ b/simulation/SimulationManager.gd @@ -179,7 +179,13 @@ func simulate_tick() -> void: 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) - if ( + 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 @@ -271,11 +277,9 @@ func simulate_tick() -> void: ): village.apply_npc_task(npc) elif completed_task == SimulationIds.ACTION_PATROL: - if _consume_wood_for_work(npc, completed_task): - village.apply_npc_task(npc) + village.apply_npc_task(npc) elif completed_task == SimulationIds.ACTION_STUDY: - if _consume_wood_for_work(npc, completed_task): - village.apply_npc_task(npc) + village.apply_npc_task(npc) elif debug_logs: print( "[SimulationManager] ", @@ -635,6 +639,15 @@ 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 + + func register_loaded_storage_nodes() -> void: for candidate in StorageNode.get_all(): var node := candidate as StorageNode @@ -670,6 +683,14 @@ func _sync_village_wood() -> void: 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) @@ -711,26 +732,44 @@ func deposit_npc_wood(npc: SimNPC) -> float: return deposited -func _consume_wood_for_work(npc: SimNPC, action_id: StringName) -> bool: - var woodpile := get_woodpile() - if woodpile == null: +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 - if woodpile.get_amount(SimulationIds.RESOURCE_WOOD) < 1.0: - return false - var consumed := woodpile.withdraw(SimulationIds.RESOURCE_WOOD, 1.0) - _sync_village_wood() - if consumed < 1.0: + 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, - SimulationIds.STORAGE_VILLAGE_WOODPILE, + storage.get_storage_id(), &"consumed", - SimulationIds.RESOURCE_WOOD, + resource_id, consumed ) if debug_logs: - print("[SimulationManager] %s consumed wood for %s" % [npc.npc_name, action_id]) + print( + "[SimulationManager] %s consumed %.1f %s for %s" + % [npc.npc_name, consumed, resource_id, definition.action_id] + ) return true diff --git a/simulation/actions/ActionSelectionResult.gd b/simulation/actions/ActionSelectionResult.gd index 08a6dfe..b3efd17 100644 --- a/simulation/actions/ActionSelectionResult.gd +++ b/simulation/actions/ActionSelectionResult.gd @@ -5,15 +5,18 @@ var action_id: StringName var duration_override := -1.0 var reason: String var scores: Dictionary +var rejections: Dictionary func _init( selected_action_id: StringName, selected_duration_override: float = -1.0, decision_reason: String = "", - decision_scores: Dictionary = {} + decision_scores: Dictionary = {}, + decision_rejections: Dictionary = {} ) -> void: action_id = selected_action_id duration_override = selected_duration_override reason = decision_reason scores = decision_scores.duplicate(true) + rejections = decision_rejections.duplicate(true) diff --git a/simulation/actions/ActionSelectionSystem.gd b/simulation/actions/ActionSelectionSystem.gd index feed575..02397e8 100644 --- a/simulation/actions/ActionSelectionSystem.gd +++ b/simulation/actions/ActionSelectionSystem.gd @@ -182,9 +182,13 @@ func _choose_best_work_action(npc: SimNPC, village: SimVillage, all_npcs: Array) 0.75 ) } - if village.wood < 1.0: - scores[SimulationIds.ACTION_PATROL] = UNAVAILABLE_ACTION_SCORE - scores[SimulationIds.ACTION_STUDY] = UNAVAILABLE_ACTION_SCORE + var rejections := {} + for action_id in scores: + var definition := SimulationDefinitions.get_action(action_id) + var rejection := _get_cost_rejection(definition, village) + if not rejection.is_empty(): + scores[action_id] = UNAVAILABLE_ACTION_SCORE + rejections[action_id] = rejection for familiar_id in npc.familiarity: if not familiar_id is int: continue @@ -205,10 +209,32 @@ func _choose_best_work_action(npc: SimNPC, village: SimVillage, all_npcs: Array) best_action = action_id best_score = score return ActionSelectionResult.new( - best_action, -1.0, "Highest current work utility", scores + best_action, -1.0, "Highest current work utility", scores, rejections ) +func _get_cost_rejection(definition: ActionDefinition, village: SimVillage) -> String: + if definition == null or not definition.has_completion_cost(): + return "" + var available := _get_village_resource_amount(village, definition.completion_cost_resource_id) + if available >= definition.completion_cost_amount: + return "" + return "Needs %.0f %s (%.1f available)" % [ + definition.completion_cost_amount, + String(definition.completion_cost_resource_id).capitalize(), + available, + ] + + +func _get_village_resource_amount(village: SimVillage, resource_id: StringName) -> float: + match resource_id: + SimulationIds.RESOURCE_FOOD: + return village.food + SimulationIds.RESOURCE_WOOD: + return village.wood + return 0.0 + + func _calculate_score( npc: SimNPC, action_id: StringName, diff --git a/simulation/definitions/ActionDefinition.gd b/simulation/definitions/ActionDefinition.gd index 23c3419..94e7643 100644 --- a/simulation/definitions/ActionDefinition.gd +++ b/simulation/definitions/ActionDefinition.gd @@ -7,6 +7,8 @@ extends Resource @export var preferred_profession_id: StringName @export var target_type: StringName = SimulationIds.TARGET_ACTIVITY @export var resource_action_id: StringName +@export var completion_cost_resource_id: StringName +@export_range(0.0, 1000.0, 0.1) var completion_cost_amount := 0.0 func validate() -> Array[String]: @@ -26,4 +28,12 @@ func validate() -> Array[String]: errors.append("target_type '%s' is invalid for '%s'" % [target_type, action_id]) if target_type == SimulationIds.TARGET_RESOURCE and resource_action_id.is_empty(): errors.append("resource_action_id is required for '%s'" % action_id) + if completion_cost_resource_id.is_empty() and completion_cost_amount > 0.0: + errors.append("completion cost resource is required for '%s'" % action_id) + if not completion_cost_resource_id.is_empty() and completion_cost_amount <= 0.0: + errors.append("completion cost amount must be positive for '%s'" % action_id) return errors + + +func has_completion_cost() -> bool: + return not completion_cost_resource_id.is_empty() and completion_cost_amount > 0.0 diff --git a/simulation/definitions/SimulationDefinitions.gd b/simulation/definitions/SimulationDefinitions.gd index 167637a..428bd95 100644 --- a/simulation/definitions/SimulationDefinitions.gd +++ b/simulation/definitions/SimulationDefinitions.gd @@ -129,4 +129,15 @@ static func validate() -> Array[String]: % [definition.action_id, definition.resource_action_id] ) ) + if ( + not definition.completion_cost_resource_id.is_empty() + and definition.completion_cost_resource_id + not in [SimulationIds.RESOURCE_FOOD, SimulationIds.RESOURCE_WOOD] + ): + errors.append( + ( + "Action '%s' references unknown completion cost resource '%s'" + % [definition.action_id, definition.completion_cost_resource_id] + ) + ) return errors diff --git a/simulation/definitions/SimulationIds.gd b/simulation/definitions/SimulationIds.gd index da3c8ac..6c0a5e3 100644 --- a/simulation/definitions/SimulationIds.gd +++ b/simulation/definitions/SimulationIds.gd @@ -38,4 +38,5 @@ 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_TASK_BLOCKED := &"task_blocked" const EVENT_RESOURCE_DEPLETED := &"resource_depleted" diff --git a/simulation/definitions/actions/patrol.tres b/simulation/definitions/actions/patrol.tres index 6ae498c..6c94702 100644 --- a/simulation/definitions/actions/patrol.tres +++ b/simulation/definitions/actions/patrol.tres @@ -9,3 +9,5 @@ display_name = "Patrol" default_duration = 6.0 preferred_profession_id = &"guard" target_type = &"activity" +completion_cost_resource_id = &"wood" +completion_cost_amount = 1.0 diff --git a/simulation/definitions/actions/study.tres b/simulation/definitions/actions/study.tres index 3dbf537..bce600a 100644 --- a/simulation/definitions/actions/study.tres +++ b/simulation/definitions/actions/study.tres @@ -9,3 +9,5 @@ display_name = "Study" default_duration = 6.0 preferred_profession_id = &"scholar" target_type = &"activity" +completion_cost_resource_id = &"wood" +completion_cost_amount = 1.0 diff --git a/simulation/state/EconomicEventRecord.gd b/simulation/state/EconomicEventRecord.gd index 005101f..21aa5e1 100644 --- a/simulation/state/EconomicEventRecord.gd +++ b/simulation/state/EconomicEventRecord.gd @@ -121,6 +121,8 @@ func description(npc_names: Dictionary = {}) -> String: 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] + "task_blocked": + return "%s could not complete %s" % [actor_name, action_name] "resource_depleted": return "%s was depleted" % source _: diff --git a/tests/action_system_boundaries_test.gd b/tests/action_system_boundaries_test.gd index 00ae898..c3c971d 100644 --- a/tests/action_system_boundaries_test.gd +++ b/tests/action_system_boundaries_test.gd @@ -82,6 +82,15 @@ func _test_selection_and_execution_are_separate() -> void: unfunded_selection.action_id == SimulationIds.ACTION_GATHER_WOOD, "A guard should gather wood instead of selecting patrol when its material cost is unavailable" ) + _check( + ( + unfunded_selection.rejections.has(SimulationIds.ACTION_PATROL) + and "Needs 1 Wood" in String( + unfunded_selection.rejections[SimulationIds.ACTION_PATROL] + ) + ), + "Selection should expose a readable definition-backed rejection reason" + ) func _test_target_resolution_and_travel_are_separate() -> void: diff --git a/tests/food_storage_loop_test.gd b/tests/food_storage_loop_test.gd index ef84225..8d104ae 100644 --- a/tests/food_storage_loop_test.gd +++ b/tests/food_storage_loop_test.gd @@ -204,6 +204,16 @@ func _test_wood_work_requires_material() -> void: ), "Patrol should not consume partial wood or create safety when its full cost cannot be paid" ) + var blocked_events: Array[EconomicEventRecord] = manager.get_npc_events(npc.id, 2) + _check( + ( + not blocked_events.is_empty() + and StringName(blocked_events[0].data["event_type"]) + == SimulationIds.EVENT_TASK_BLOCKED + and "needs 1 Wood" in String(blocked_events[0].data.get("action_name", "")) + ), + "An unpaid completion cost should create a readable blocked-task fact" + ) manager.add_wood(1.5) _check( diff --git a/tests/npc_visual_lifecycle_test.gd b/tests/npc_visual_lifecycle_test.gd index 2d6da4a..5f7f0e2 100644 --- a/tests/npc_visual_lifecycle_test.gd +++ b/tests/npc_visual_lifecycle_test.gd @@ -63,7 +63,8 @@ func _run() -> void: SimulationIds.ACTION_GATHER_FOOD, -1.0, "Test shortage reason", - {SimulationIds.ACTION_GATHER_FOOD: 4.5} + {SimulationIds.ACTION_GATHER_FOOD: 4.5, SimulationIds.ACTION_PATROL: -1000000.0}, + {SimulationIds.ACTION_PATROL: "Needs 1 Wood (0.5 available)"} ) manager.latest_decisions[npc.id] = test_decision manager.emit_signal("npc_decision_recorded", npc, test_decision) @@ -75,6 +76,14 @@ func _run() -> void: and npc.npc_name in inspector_label.text, "NPC inspector should show the selected villager's real decision reason" ) + _check( + ( + "Unavailable" in inspector_label.text + and "Patrol — Needs 1 Wood" in inspector_label.text + and "-1000000" not in inspector_label.text + ), + "NPC inspector should explain rejected actions without sentinel utility scores" + ) var carried_food: MeshInstance3D = visual.get_node("CarriedFood") _check(not carried_food.visible, "NPC should begin without a carried-food prop") npc.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0) diff --git a/tests/simulation_definitions_test.gd b/tests/simulation_definitions_test.gd index c30a6dc..c447eb5 100644 --- a/tests/simulation_definitions_test.gd +++ b/tests/simulation_definitions_test.gd @@ -65,6 +65,22 @@ func _test_definition_backed_behavior() -> void: study.preferred_profession_id == SimulationIds.PROFESSION_SCHOLAR, "Study should reference the stable scholar profession ID" ) + _check( + ( + study.completion_cost_resource_id == SimulationIds.RESOURCE_WOOD + and is_equal_approx(study.completion_cost_amount, 1.0) + and study.has_completion_cost() + ), + "Study should declare its completion cost through definition metadata" + ) + var patrol := SimulationDefinitions.get_action(SimulationIds.ACTION_PATROL) + _check( + ( + patrol.completion_cost_resource_id == SimulationIds.RESOURCE_WOOD + and is_equal_approx(patrol.completion_cost_amount, 1.0) + ), + "Patrol should share the proven one-wood completion-cost contract" + ) var gather := SimulationDefinitions.get_action(SimulationIds.ACTION_GATHER_FOOD) _check( ( diff --git a/world/ui/ui.gd b/world/ui/ui.gd index e346cc2..54e24d8 100644 --- a/world/ui/ui.gd +++ b/world/ui/ui.gd @@ -145,12 +145,21 @@ func _refresh_npc_inspector() -> void: var score_keys: Array = decision.scores.keys() score_keys.sort() var score_rows: Array[String] = [] + var rejection_rows: Array[String] = [] for action_id in score_keys: + if decision.rejections.has(action_id): + rejection_rows.append( + "%s — %s" + % [_get_action_name(StringName(action_id)), String(decision.rejections[action_id])] + ) + continue score_rows.append( "%s %.2f" % [_get_action_name(StringName(action_id)), float(decision.scores[action_id])] ) if not score_rows.is_empty(): score_text = "\n\nUtility\n" + "\n".join(score_rows) + if not rejection_rows.is_empty(): + score_text += "\n\nUnavailable\n" + "\n".join(rejection_rows) var event_text := _build_event_history(npc) var housemate_text := _build_housemate_display(npc) npc_inspector_label.text = (