feat: define action completion costs

This commit is contained in:
Rijad Zuzo
2026-07-10 10:36:08 +02:00
parent 39989002ca
commit 0f7b12080e
19 changed files with 213 additions and 45 deletions
+5 -3
View File
@@ -23,8 +23,8 @@ SimulationManager tick
- reads NPC and village state; - reads NPC and village state;
- evaluates current utility scores; - evaluates current utility scores;
- consumes the NPC's deterministic decision RNG; - consumes the NPC's deterministic decision RNG;
- returns an action ID, optional urgent-duration override, branch reason, and - returns an action ID, optional urgent-duration override, branch reason,
compared utility scores; compared utility scores, and definition-backed rejection reasons;
- does not mutate targets, navigation, or visuals. - does not mutate targets, navigation, or visuals.
### ActionExecutionSystem ### ActionExecutionSystem
@@ -65,7 +65,9 @@ SimulationManager remains the orchestrator and event boundary. It owns
simulation records, invokes the focused systems, stores selected actions and simulation records, invokes the focused systems, stores selected actions and
targets, and translates presentation callbacks into simulation transitions. targets, and translates presentation callbacks into simulation transitions.
It also publishes the latest `ActionSelectionResult` for presentation; the UI 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 Further decomposition should follow measured pressure rather than splitting it
into managers for their own sake. into managers for their own sake.
+10 -5
View File
@@ -220,6 +220,12 @@ an asset catalog.
Translate the desired animated-film warmth into an original style: 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 | | Element | Direction |
| --- | --- | | --- | --- |
| Terrain | Broad painterly color regions and exaggerated silhouettes | | Terrain | Broad painterly color regions and exaggerated silhouettes |
@@ -665,11 +671,10 @@ Completed:
Next: Next:
1. Replace the remaining player capsule with a matching stylized presentation, 1. Replace the remaining player capsule with a matching stylized presentation,
then revisit runtime camera framing so the waterfall and village action while preserving the elevated top-down follow, gentle dead-zone lag, and
remain readable in the same authored shot. cozy farm-builder readability.
2. Continue with the action precondition/material-cost contract and 2. Continue with the relationship consequence work in `LEARNING_ROADMAP.md`
relationship consequence work in `LEARNING_ROADMAP.md` before expanding before expanding visual scope further.
visual scope further.
Do not start with GIS data, a full city, a large asset pack, or more NPC 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 mechanics. The next proof is a beautiful stage for the systems that already
+8 -4
View File
@@ -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, transaction and are not replayed to reconstruct current state. Resource,
inventory, and storage records remain authoritative. 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 ## Persistence and determinism
`SimulationStateRecord` schema v3 stores the ordered event stream and `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 The stream is currently kept in full for the small simulation garden. Before
large populations or long-running worlds, add measured retention, large populations or long-running worlds, add measured retention,
archival/summary rules, and query indexes. Denied attempts, depletion, archival/summary rules, and query indexes. General denied attempts, witnesses,
witnesses, secrecy, causal links, and memories belong in later event/history secrecy, causal links, and memories belong in later event/history slices; they
slices; they should extend this record family without making prose should extend this record family without making prose authoritative.
authoritative.
+7 -4
View File
@@ -652,12 +652,11 @@ Completed after the architecture gate:
The practical next sequence is: The practical next sequence is:
1. Move proven action preconditions and material costs into a reusable contract, 1. Grow the existing familiarity seed into one consequence-bearing relationship
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
dimension, such as trust or obligation, driven by structured events rather dimension, such as trust or obligation, driven by structured events rather
than proximity alone. 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: Recently completed:
@@ -706,6 +705,10 @@ Recently completed:
wood deposits. Village wood syncs from storage. Economic events track all wood deposits. Village wood syncs from storage. Economic events track all
transfers. Patrol and study no longer produce benefits without paying their transfers. Patrol and study no longer produce benefits without paying their
one-wood cost, and utility selection avoids those actions when wood is empty. 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 - Structured event feed: EconomicEventRecord now supports narrative events
(task started, NPC slept, NPC died) alongside economic transfers. (task started, NPC slept, NPC died) alongside economic transfers.
Per-NPC event history with human-readable descriptions exposed in the Per-NPC event history with human-readable descriptions exposed in the
+12 -7
View File
@@ -20,15 +20,18 @@ Each executable action definition contains:
- default duration; - default duration;
- optional preferred profession ID; - optional preferred profession ID;
- target type: resource, activity, or free movement; - 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, The current actions are gather food, gather wood, deposit food, deposit wood,
patrol, study, eat, rest, and wander. Idle and dead are stable state sentinels, withdraw food, patrol, study, eat, rest, sleep, and wander. Idle and dead are
not executable action definitions. stable state sentinels, not executable action definitions.
SimNPC reads default duration and preferred-profession metadata from these SimNPC reads default duration and preferred-profession metadata from these
definitions. WorldViewManager reads target type and resource-action metadata 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 ## Current profession contract
@@ -49,6 +52,7 @@ The registry rejects:
- non-positive action durations; - non-positive action durations;
- invalid target types; - invalid target types;
- resource actions without a resource-action ID; - resource actions without a resource-action ID;
- incomplete, non-positive, or unknown completion-cost resources;
- references to unknown professions or resource actions; - references to unknown professions or resource actions;
- definition resources that fail to load. - definition resources that fail to load.
@@ -59,10 +63,11 @@ round-tripping.
## Deliberate boundary ## Deliberate boundary
Definitions currently own identity and the static metadata already proven by 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; - utility thresholds and consideration curves;
- preconditions or costs; - general preconditions beyond stored-resource completion costs;
- completion effects; - completion effects;
- interruption and failure policy; - interruption and failure policy;
- reservation strategy; - reservation strategy;
+53 -14
View File
@@ -179,7 +179,13 @@ func simulate_tick() -> void:
if not was_complete and npc.task_complete and not npc.is_dead: if not was_complete and npc.task_complete and not npc.is_dead:
var completed_task := npc.current_task var completed_task := npc.current_task
var completed_definition := SimulationDefinitions.get_action(completed_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 != &"" npc.target_id != &""
and completed_definition != null and completed_definition != null
and completed_definition.target_type == SimulationIds.TARGET_RESOURCE and completed_definition.target_type == SimulationIds.TARGET_RESOURCE
@@ -271,10 +277,8 @@ func simulate_tick() -> void:
): ):
village.apply_npc_task(npc) village.apply_npc_task(npc)
elif completed_task == SimulationIds.ACTION_PATROL: 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: 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: elif debug_logs:
print( print(
@@ -635,6 +639,15 @@ func get_woodpile() -> StorageStateRecord:
return storage_states.get(SimulationIds.STORAGE_VILLAGE_WOODPILE) as 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: func register_loaded_storage_nodes() -> void:
for candidate in StorageNode.get_all(): for candidate in StorageNode.get_all():
var node := candidate as StorageNode var node := candidate as StorageNode
@@ -670,6 +683,14 @@ func _sync_village_wood() -> void:
village.update_priorities() 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: func deposit_npc_inventory(npc: SimNPC, item_id: StringName) -> float:
var available := npc.get_inventory_amount(item_id) var available := npc.get_inventory_amount(item_id)
var deposited := get_pantry().deposit(item_id, available) var deposited := get_pantry().deposit(item_id, available)
@@ -711,26 +732,44 @@ func deposit_npc_wood(npc: SimNPC) -> float:
return deposited return deposited
func _consume_wood_for_work(npc: SimNPC, action_id: StringName) -> bool: func _consume_action_completion_cost(npc: SimNPC, definition: ActionDefinition) -> bool:
var woodpile := get_woodpile() if definition == null or not definition.has_completion_cost():
if woodpile == null: 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 return false
if woodpile.get_amount(SimulationIds.RESOURCE_WOOD) < 1.0: var consumed := storage.withdraw(resource_id, required_amount)
return false _sync_village_resource(resource_id)
var consumed := woodpile.withdraw(SimulationIds.RESOURCE_WOOD, 1.0) if consumed < required_amount:
_sync_village_wood()
if consumed < 1.0:
return false return false
_record_economic_event( _record_economic_event(
SimulationIds.EVENT_ITEM_CONSUMED, SimulationIds.EVENT_ITEM_CONSUMED,
npc.id, npc.id,
SimulationIds.STORAGE_VILLAGE_WOODPILE, storage.get_storage_id(),
&"consumed", &"consumed",
SimulationIds.RESOURCE_WOOD, resource_id,
consumed consumed
) )
if debug_logs: 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 return true
+4 -1
View File
@@ -5,15 +5,18 @@ var action_id: StringName
var duration_override := -1.0 var duration_override := -1.0
var reason: String var reason: String
var scores: Dictionary var scores: Dictionary
var rejections: Dictionary
func _init( func _init(
selected_action_id: StringName, selected_action_id: StringName,
selected_duration_override: float = -1.0, selected_duration_override: float = -1.0,
decision_reason: String = "", decision_reason: String = "",
decision_scores: Dictionary = {} decision_scores: Dictionary = {},
decision_rejections: Dictionary = {}
) -> void: ) -> void:
action_id = selected_action_id action_id = selected_action_id
duration_override = selected_duration_override duration_override = selected_duration_override
reason = decision_reason reason = decision_reason
scores = decision_scores.duplicate(true) scores = decision_scores.duplicate(true)
rejections = decision_rejections.duplicate(true)
+30 -4
View File
@@ -182,9 +182,13 @@ func _choose_best_work_action(npc: SimNPC, village: SimVillage, all_npcs: Array)
0.75 0.75
) )
} }
if village.wood < 1.0: var rejections := {}
scores[SimulationIds.ACTION_PATROL] = UNAVAILABLE_ACTION_SCORE for action_id in scores:
scores[SimulationIds.ACTION_STUDY] = UNAVAILABLE_ACTION_SCORE 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: for familiar_id in npc.familiarity:
if not familiar_id is int: if not familiar_id is int:
continue continue
@@ -205,10 +209,32 @@ func _choose_best_work_action(npc: SimNPC, village: SimVillage, all_npcs: Array)
best_action = action_id best_action = action_id
best_score = score best_score = score
return ActionSelectionResult.new( 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( func _calculate_score(
npc: SimNPC, npc: SimNPC,
action_id: StringName, action_id: StringName,
@@ -7,6 +7,8 @@ extends Resource
@export var preferred_profession_id: StringName @export var preferred_profession_id: StringName
@export var target_type: StringName = SimulationIds.TARGET_ACTIVITY @export var target_type: StringName = SimulationIds.TARGET_ACTIVITY
@export var resource_action_id: StringName @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]: 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]) 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(): if target_type == SimulationIds.TARGET_RESOURCE and resource_action_id.is_empty():
errors.append("resource_action_id is required for '%s'" % action_id) 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 return errors
func has_completion_cost() -> bool:
return not completion_cost_resource_id.is_empty() and completion_cost_amount > 0.0
@@ -129,4 +129,15 @@ static func validate() -> Array[String]:
% [definition.action_id, definition.resource_action_id] % [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 return errors
+1
View File
@@ -38,4 +38,5 @@ const EVENT_ITEM_CONSUMED := &"item_consumed"
const EVENT_NPC_SLEPT := &"npc_slept" const EVENT_NPC_SLEPT := &"npc_slept"
const EVENT_NPC_DIED := &"npc_died" const EVENT_NPC_DIED := &"npc_died"
const EVENT_TASK_STARTED := &"task_started" const EVENT_TASK_STARTED := &"task_started"
const EVENT_TASK_BLOCKED := &"task_blocked"
const EVENT_RESOURCE_DEPLETED := &"resource_depleted" const EVENT_RESOURCE_DEPLETED := &"resource_depleted"
@@ -9,3 +9,5 @@ display_name = "Patrol"
default_duration = 6.0 default_duration = 6.0
preferred_profession_id = &"guard" preferred_profession_id = &"guard"
target_type = &"activity" target_type = &"activity"
completion_cost_resource_id = &"wood"
completion_cost_amount = 1.0
@@ -9,3 +9,5 @@ display_name = "Study"
default_duration = 6.0 default_duration = 6.0
preferred_profession_id = &"scholar" preferred_profession_id = &"scholar"
target_type = &"activity" target_type = &"activity"
completion_cost_resource_id = &"wood"
completion_cost_amount = 1.0
+2
View File
@@ -121,6 +121,8 @@ func description(npc_names: Dictionary = {}) -> String:
return "%s died from starvation" % actor_name return "%s died from starvation" % actor_name
"task_started": "task_started":
return "%s began %s" % [actor_name, action_name if not action_name.is_empty() else item] 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": "resource_depleted":
return "%s was depleted" % source return "%s was depleted" % source
_: _:
+9
View File
@@ -82,6 +82,15 @@ func _test_selection_and_execution_are_separate() -> void:
unfunded_selection.action_id == SimulationIds.ACTION_GATHER_WOOD, unfunded_selection.action_id == SimulationIds.ACTION_GATHER_WOOD,
"A guard should gather wood instead of selecting patrol when its material cost is unavailable" "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: func _test_target_resolution_and_travel_are_separate() -> void:
+10
View File
@@ -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" "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) manager.add_wood(1.5)
_check( _check(
+10 -1
View File
@@ -63,7 +63,8 @@ func _run() -> void:
SimulationIds.ACTION_GATHER_FOOD, SimulationIds.ACTION_GATHER_FOOD,
-1.0, -1.0,
"Test shortage reason", "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.latest_decisions[npc.id] = test_decision
manager.emit_signal("npc_decision_recorded", npc, 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, and npc.npc_name in inspector_label.text,
"NPC inspector should show the selected villager's real decision reason" "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") var carried_food: MeshInstance3D = visual.get_node("CarriedFood")
_check(not carried_food.visible, "NPC should begin without a carried-food prop") _check(not carried_food.visible, "NPC should begin without a carried-food prop")
npc.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0) npc.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0)
+16
View File
@@ -65,6 +65,22 @@ func _test_definition_backed_behavior() -> void:
study.preferred_profession_id == SimulationIds.PROFESSION_SCHOLAR, study.preferred_profession_id == SimulationIds.PROFESSION_SCHOLAR,
"Study should reference the stable scholar profession ID" "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) var gather := SimulationDefinitions.get_action(SimulationIds.ACTION_GATHER_FOOD)
_check( _check(
( (
+9
View File
@@ -145,12 +145,21 @@ func _refresh_npc_inspector() -> void:
var score_keys: Array = decision.scores.keys() var score_keys: Array = decision.scores.keys()
score_keys.sort() score_keys.sort()
var score_rows: Array[String] = [] var score_rows: Array[String] = []
var rejection_rows: Array[String] = []
for action_id in score_keys: 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( score_rows.append(
"%s %.2f" % [_get_action_name(StringName(action_id)), float(decision.scores[action_id])] "%s %.2f" % [_get_action_name(StringName(action_id)), float(decision.scores[action_id])]
) )
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)
if not rejection_rows.is_empty():
score_text += "\n\nUnavailable\n" + "\n".join(rejection_rows)
var event_text := _build_event_history(npc) var event_text := _build_event_history(npc)
var housemate_text := _build_housemate_display(npc) var housemate_text := _build_housemate_display(npc)
npc_inspector_label.text = ( npc_inspector_label.text = (