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;
- 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.
+10 -5
View File
@@ -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
+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,
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.
+7 -4
View File
@@ -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
+12 -7
View File
@@ -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;
+55 -16
View File
@@ -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
+4 -1
View File
@@ -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)
+30 -4
View File
@@ -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,
@@ -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
@@ -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
+1
View File
@@ -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"
@@ -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
@@ -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
+2
View File
@@ -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
_:
+9
View File
@@ -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:
+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"
)
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(
+10 -1
View File
@@ -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)
+16
View File
@@ -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(
(
+9
View File
@@ -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 = (