feat: define action completion costs
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
_:
|
_:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
Reference in New Issue
Block a user