feat: unify authored activity commands
This commit is contained in:
@@ -9,7 +9,8 @@ state, systems, world adapters, scenes, tests, and save migrations.
|
||||
IDs. `SimulationContentPack` is the no-code authoring manifest and
|
||||
`ContentCatalog` transactionally validates and publishes typed definitions in
|
||||
stable-ID order. The current core pack contains actions, professions,
|
||||
capability tags, items, resources, storage policies, and enemies. A failed pack
|
||||
capability tags, items, resources, storage policies, enemies, animals,
|
||||
situations, dialogue, event types, and social consequences. A failed pack
|
||||
publishes nothing: duplicate IDs, unknown references, unsupported handlers, or
|
||||
missing presentation cues are authoring errors rather than silent overrides.
|
||||
|
||||
@@ -57,6 +58,30 @@ are copy-only values. `ActionCommandService` is the authority boundary and must
|
||||
revalidate range, capabilities, reservation, permission, cost, and target state
|
||||
at execution time for either a player or NPC caller.
|
||||
|
||||
`activity_metric_delta` is the first concrete shared command handler. Patrol
|
||||
and study targets publish capabilities through the context-owned
|
||||
`WorldTargetRegistry`; player offers carry the current state revision, target
|
||||
generation, context ID, and registry-instance identity. The command revalidates
|
||||
all four, the live provider, actor availability, target kind, capability, and
|
||||
interaction range, then atomically consumes the authored cost, applies the
|
||||
bounded real metric delta, and records one `activity_completed` fact. Registry
|
||||
identity prevents an old offer from matching a replacement target whose local
|
||||
generation restarted. NPC completion submits the same command and must match
|
||||
the NPC's authoritative current action, target, and work state. Unloaded NPC
|
||||
work may use an explicitly registered simulation-owned activity target, but it
|
||||
never invents a missing target or treats presentation position as travel
|
||||
authority. A failed cost check records and returns the exact ordinary
|
||||
`task_blocked` fact; a capped metric consumes nothing and records no false
|
||||
completion. The completion fact keeps the first event ID while synchronous
|
||||
situation or commitment consequences may append a deterministic causal suffix;
|
||||
the command result reports the final revision rather than misclassifying that
|
||||
valid event chain as failure.
|
||||
|
||||
The catalog validates this handler as a closed typed contract: an activity
|
||||
target, exactly one positive `metric_delta` effect for safety or knowledge,
|
||||
typed optional player/NPC modifiers, a known completion event, and a cost item
|
||||
with an authored storage route. A malformed additive pack publishes nothing.
|
||||
|
||||
The current actions are gather food, gather wood, feed animal, deposit food,
|
||||
deposit wood, withdraw food, patrol, study, eat, rest, sleep, wander, and
|
||||
defend. Idle and dead are state sentinels, not executable definitions. The
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
## Current contract
|
||||
|
||||
`SimulationStateRecord` is the versioned JSON boundary for the current
|
||||
simulation. The current world schema is v15 and captures:
|
||||
simulation. The current world schema is v16 and captures:
|
||||
|
||||
- simulation seed, tick interval, tick count, clock remainder, and elapsed
|
||||
clock ticks;
|
||||
- simulation seed, stable regional world/location scope, tick interval, tick
|
||||
count, clock remainder, and elapsed clock ticks;
|
||||
- every NPC's identity, needs, attributes, task lifecycle, target, position,
|
||||
starvation state, and decision RNG stream;
|
||||
- village resource counters;
|
||||
@@ -45,7 +45,7 @@ The top-level identity is:
|
||||
```json
|
||||
{
|
||||
"schema": "the_steward.simulation",
|
||||
"schema_version": 15
|
||||
"schema_version": 16
|
||||
}
|
||||
```
|
||||
|
||||
@@ -213,6 +213,15 @@ nested v1/v2 events receive empty/zero defaults, while a new `task_blocked`
|
||||
event requires its stable action, source storage, resource, and positive
|
||||
requirement contract.
|
||||
|
||||
The v3 event envelope also carries validated extension fields for authored
|
||||
activity facts without adding a parallel quest record. An
|
||||
`activity_completed` fact stores the target/action IDs, world and location
|
||||
scope, metric ID, actual bounded delta, and exact item cost. Loading derives
|
||||
the expected handler, effect, event type, metric, maximum delta, and cost from
|
||||
the current action definition and rejects forged actor, discriminator, scope,
|
||||
or amount combinations even when no knowledge record references the event.
|
||||
Player and NPC performed knowledge points to that same immutable fact.
|
||||
|
||||
SimulationStateRecord v10 adds the top-level `animals` array and the first
|
||||
`AnimalStateRecord` schema. Each animal record stores its stable animal and
|
||||
species IDs, display name, authoritative world position, hunger,
|
||||
@@ -294,6 +303,19 @@ templates, balloon state, and prose are transient. Reopening dialogue plans from
|
||||
current facts plus the retained semantic acts, so wording can evolve without
|
||||
changing authoritative history.
|
||||
|
||||
SimulationStateRecord v16 adds non-empty `world_id` and `location_id` fields to
|
||||
the top-level simulation record. Activity completion and blocked-action facts
|
||||
retain that same scope in their immutable payload, so event-store indexes,
|
||||
knowledge validation, and save restoration cannot reinterpret a local position
|
||||
as belonging to another loaded context. World v15 migrates explicitly to the
|
||||
existing Bosnia/Jajce IDs. An event log that already contains facts refuses a
|
||||
different configured scope instead of silently reindexing history. Restore
|
||||
preflights this scope against the active adapter before changing clock,
|
||||
economy, NPC, or event state, so both empty- and populated-history mismatches
|
||||
fail atomically. Older unstructured `task_blocked` facts are retained with
|
||||
explicit legacy provenance for save continuity, but they are not promoted into
|
||||
knowable evidence or new situation triggers.
|
||||
|
||||
Nested `KnownEventStateRecord` v4 adds the player as a valid knower/source and
|
||||
records hop count, confidence, salience, and explicit pinning. Communicated
|
||||
facts remain exactly one hop from a performed or witnessed source. Nested
|
||||
|
||||
@@ -5,8 +5,7 @@ const KIND_ANIMAL := &"animal"
|
||||
const KIND_RESOURCE := &"resource"
|
||||
const KIND_PANTRY := &"pantry"
|
||||
const KIND_STORAGE_DEPOSIT := &"storage_deposit"
|
||||
const KIND_GUARD := &"guard"
|
||||
const KIND_STUDY := &"study"
|
||||
const KIND_ACTIVITY := &"activity"
|
||||
const KIND_DIALOGUE := &"dialogue"
|
||||
|
||||
var kind: StringName
|
||||
@@ -17,6 +16,10 @@ var prompt_text: String
|
||||
var detail_text: String
|
||||
var blocked_reason: String
|
||||
var target_node: Node
|
||||
var expected_state_revision: int
|
||||
var target_generation: int
|
||||
var target_context_id: StringName
|
||||
var target_registry_instance_id: int
|
||||
|
||||
|
||||
func _init(
|
||||
@@ -27,7 +30,7 @@ func _init(
|
||||
interaction_prompt_text: String,
|
||||
interaction_detail_text: String,
|
||||
interaction_target_node: Node,
|
||||
interaction_blocked_reason := ""
|
||||
metadata: Dictionary = {}
|
||||
) -> void:
|
||||
kind = interaction_kind
|
||||
action_id = interaction_action_id
|
||||
@@ -36,7 +39,11 @@ func _init(
|
||||
prompt_text = interaction_prompt_text
|
||||
detail_text = interaction_detail_text
|
||||
target_node = interaction_target_node
|
||||
blocked_reason = interaction_blocked_reason
|
||||
blocked_reason = String(metadata.get("blocked_reason", ""))
|
||||
expected_state_revision = int(metadata.get("expected_state_revision", -1))
|
||||
target_generation = int(metadata.get("target_generation", -1))
|
||||
target_context_id = StringName(metadata.get("target_context_id", &""))
|
||||
target_registry_instance_id = int(metadata.get("target_registry_instance_id", 0))
|
||||
|
||||
|
||||
func is_available() -> bool:
|
||||
@@ -54,6 +61,10 @@ func cache_key() -> String:
|
||||
prompt_text,
|
||||
detail_text,
|
||||
blocked_reason,
|
||||
str(expected_state_revision),
|
||||
str(target_generation),
|
||||
String(target_context_id),
|
||||
str(target_registry_instance_id),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
+68
-31
@@ -117,16 +117,8 @@ func try_interact() -> void:
|
||||
_execute_animal_interaction(context)
|
||||
PlayerInteractionResult.KIND_RESOURCE:
|
||||
_execute_resource_interaction(context)
|
||||
PlayerInteractionResult.KIND_GUARD:
|
||||
simulation_manager.add_safety(3.0)
|
||||
interaction_feedback.emit(
|
||||
"Village guarded", "Safety increased through real guard-post work.", true
|
||||
)
|
||||
PlayerInteractionResult.KIND_STUDY:
|
||||
simulation_manager.add_knowledge(2.0)
|
||||
interaction_feedback.emit(
|
||||
"Knowledge shared", "Village knowledge increased at the study desk.", true
|
||||
)
|
||||
PlayerInteractionResult.KIND_ACTIVITY:
|
||||
_execute_activity_interaction(context)
|
||||
PlayerInteractionResult.KIND_PANTRY:
|
||||
_execute_pantry_interaction(context)
|
||||
PlayerInteractionResult.KIND_STORAGE_DEPOSIT:
|
||||
@@ -153,26 +145,34 @@ func get_interaction_context() -> PlayerInteractionResult:
|
||||
var resource := _find_resource_node()
|
||||
if resource != null:
|
||||
return _build_resource_context(resource)
|
||||
if is_near_activity_site(guard_site):
|
||||
return PlayerInteractionResult.new(
|
||||
PlayerInteractionResult.KIND_GUARD,
|
||||
SimulationIds.ACTION_PATROL,
|
||||
guard_site.site_id,
|
||||
guard_site.display_name,
|
||||
"Help guard the village",
|
||||
"Work here · +3 safety",
|
||||
guard_site
|
||||
)
|
||||
if is_near_activity_site(study_site):
|
||||
return PlayerInteractionResult.new(
|
||||
PlayerInteractionResult.KIND_STUDY,
|
||||
SimulationIds.ACTION_STUDY,
|
||||
study_site.site_id,
|
||||
study_site.display_name,
|
||||
"Share knowledge",
|
||||
"Work here · +2 knowledge",
|
||||
study_site
|
||||
if simulation_manager.has_method("get_player_activity_offer"):
|
||||
var activity_offer: Dictionary = simulation_manager.call(
|
||||
"get_player_activity_offer", global_position, interaction_range
|
||||
)
|
||||
if not activity_offer.is_empty():
|
||||
var effect: Dictionary = activity_offer.get("effect", {})
|
||||
var amount := float(effect.get("amount", 0.0))
|
||||
var metric_name := String(effect.get("metric_id", "benefit")).capitalize()
|
||||
return (
|
||||
PlayerInteractionResult
|
||||
. new(
|
||||
PlayerInteractionResult.KIND_ACTIVITY,
|
||||
StringName(activity_offer["action_id"]),
|
||||
StringName(activity_offer["target_id"]),
|
||||
String(activity_offer["display_name"]),
|
||||
String(activity_offer["action_name"]),
|
||||
"Work here · +%.1f %s" % [amount, metric_name.to_lower()],
|
||||
null,
|
||||
{
|
||||
"blocked_reason": String(activity_offer.get("blocked_reason", "")),
|
||||
"expected_state_revision": int(activity_offer.get("state_revision", -1)),
|
||||
"target_generation": int(activity_offer.get("target_generation", -1)),
|
||||
"target_context_id": String(activity_offer.get("target_context_id", "")),
|
||||
"target_registry_instance_id":
|
||||
int(activity_offer.get("target_registry_instance_id", 0)),
|
||||
}
|
||||
)
|
||||
)
|
||||
if is_near_storage(pantry_storage):
|
||||
if _get_carried_food() > 0.0:
|
||||
return _build_deposit_context(SimulationIds.RESOURCE_FOOD, pantry_storage)
|
||||
@@ -429,7 +429,7 @@ func _build_animal_context(node: AnimalNode) -> PlayerInteractionResult:
|
||||
"Feed %s" % node.display_name,
|
||||
detail,
|
||||
node,
|
||||
blocked_reason
|
||||
{"blocked_reason": blocked_reason}
|
||||
)
|
||||
|
||||
|
||||
@@ -469,7 +469,7 @@ func _build_pantry_context() -> PlayerInteractionResult:
|
||||
"Eat from the pantry",
|
||||
detail,
|
||||
pantry_storage,
|
||||
blocked_reason
|
||||
{"blocked_reason": blocked_reason}
|
||||
)
|
||||
|
||||
|
||||
@@ -545,6 +545,43 @@ func _execute_resource_interaction(context: PlayerInteractionResult) -> void:
|
||||
)
|
||||
|
||||
|
||||
func _execute_activity_interaction(context: PlayerInteractionResult) -> void:
|
||||
if not context.is_available():
|
||||
interaction_feedback.emit(
|
||||
"%s unavailable" % context.display_name, context.blocked_reason, false
|
||||
)
|
||||
return
|
||||
if not simulation_manager.has_method("execute_player_activity_action"):
|
||||
push_error("Player: SimulationManager cannot execute authored activity actions")
|
||||
return
|
||||
if simulation_manager.has_method("update_player_combatant"):
|
||||
simulation_manager.call("update_player_combatant", global_position)
|
||||
var result := (
|
||||
simulation_manager.call(
|
||||
"execute_player_activity_action",
|
||||
context.action_id,
|
||||
context.target_id,
|
||||
context.expected_state_revision,
|
||||
context.target_generation,
|
||||
context.target_context_id,
|
||||
context.target_registry_instance_id
|
||||
)
|
||||
as ActionResult
|
||||
)
|
||||
if result == null or not result.did_succeed():
|
||||
var reason := result.get_message() if result != null else "Activity service unavailable."
|
||||
interaction_feedback.emit("%s unavailable" % context.display_name, reason, false)
|
||||
return
|
||||
var payload := result.get_payload()
|
||||
var amount := float(payload.get("amount", 0.0))
|
||||
var metric_name := String(payload.get("metric_id", "benefit")).capitalize()
|
||||
interaction_feedback.emit(
|
||||
String(context.action_id).capitalize(),
|
||||
"%s increased by %.1f through authoritative work." % [metric_name, amount],
|
||||
true
|
||||
)
|
||||
|
||||
|
||||
func _execute_pantry_interaction(context: PlayerInteractionResult) -> void:
|
||||
if not context.is_available():
|
||||
interaction_feedback.emit("The pantry is empty", context.blocked_reason, false)
|
||||
|
||||
@@ -86,6 +86,11 @@ var latest_decisions: Dictionary = {}
|
||||
var action_selector := ActionSelectionSystem.new()
|
||||
var action_executor := ActionExecutionSystem.new()
|
||||
var target_resolver := ActionTargetResolver.new()
|
||||
var activity_command_service: ActivityActionCommandService
|
||||
var interaction_service: CatalogInteractionService
|
||||
var _abstract_activity_registry: WorldTargetRegistry
|
||||
var _abstract_activity_command_service: ActivityActionCommandService
|
||||
var _active_activity_registry_instance_id := 0
|
||||
var last_player_resource_query_stats: Dictionary = {}
|
||||
var _population_view := SimulationPopulationView.new()
|
||||
var speed_index := 2
|
||||
@@ -129,6 +134,7 @@ func _ready() -> void:
|
||||
push_error("SimulationManager: " + error)
|
||||
set_process(false)
|
||||
return
|
||||
_configure_interaction_services()
|
||||
clock = SimulationClock.new(tick_interval)
|
||||
clock.cycle_duration_seconds = cycle_duration_seconds
|
||||
clock.elapsed_ticks = int(0.25 * cycle_duration_seconds / tick_interval)
|
||||
@@ -395,11 +401,21 @@ func _complete_current_action(npc: SimNPC) -> void:
|
||||
var completed_task := npc.current_task
|
||||
var definition := SimulationDefinitions.get_action(completed_task)
|
||||
var completion_succeeded := false
|
||||
if completed_task == SimulationIds.ACTION_FEED_ANIMAL:
|
||||
var completion_already_applied := false
|
||||
if _uses_activity_command_handler(definition):
|
||||
var activity_result := execute_activity_action(
|
||||
WorldEntityRef.create(SimulationIds.ENTITY_PERSON, StringName(str(npc.id))),
|
||||
completed_task,
|
||||
npc.target_id,
|
||||
get_action_state_revision()
|
||||
)
|
||||
completion_succeeded = activity_result != null and activity_result.did_succeed()
|
||||
completion_already_applied = completion_succeeded
|
||||
elif completed_task == SimulationIds.ACTION_FEED_ANIMAL:
|
||||
completion_succeeded = feed_animal(npc.target_id, npc.id)
|
||||
else:
|
||||
completion_succeeded = economy.consume_completion_cost(npc, definition)
|
||||
if completion_succeeded:
|
||||
if completion_succeeded and not completion_already_applied:
|
||||
_apply_action_completion(npc, completed_task, definition)
|
||||
elif not npc.target_id.is_empty():
|
||||
release_npc_reservation(npc.id)
|
||||
@@ -436,7 +452,7 @@ func _apply_action_completion(
|
||||
record_narrative_event(SimulationIds.EVENT_HOME_DAMAGED, npc.id)
|
||||
SimulationIds.ACTION_FEED_ANIMAL:
|
||||
pass
|
||||
SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY, SimulationIds.ACTION_REST, SimulationIds.ACTION_WANDER:
|
||||
SimulationIds.ACTION_REST, SimulationIds.ACTION_WANDER:
|
||||
village.apply_npc_task(npc)
|
||||
SimulationIds.ACTION_DEFEND:
|
||||
village.apply_metric_delta(&"safety", 0.5)
|
||||
@@ -619,12 +635,24 @@ func resolve_npc_target(npc_id: int, origin: Vector3) -> bool:
|
||||
if active_world_adapter == null:
|
||||
push_error("SimulationManager: active_world_adapter is missing")
|
||||
return false
|
||||
var definition := SimulationDefinitions.get_action(npc.current_task)
|
||||
if _uses_activity_command_handler(definition) and not _ensure_interaction_services_current():
|
||||
return false
|
||||
var result := target_resolver.resolve(npc, origin, self, active_world_adapter)
|
||||
if result.is_empty():
|
||||
notify_npc_navigation_failed(npc.id)
|
||||
return false
|
||||
npc.target_id = StringName(result.get("target_id", ""))
|
||||
npc.travel_target_position = result["position"]
|
||||
if (
|
||||
definition != null
|
||||
and _uses_activity_command_handler(definition)
|
||||
and not _bind_abstract_activity_target(
|
||||
npc.target_id, npc.current_task, npc.travel_target_position
|
||||
)
|
||||
):
|
||||
notify_npc_navigation_failed(npc.id)
|
||||
return false
|
||||
npc.has_travel_target = true
|
||||
npc_travel_requested.emit(npc, npc.travel_target_position)
|
||||
return true
|
||||
@@ -661,6 +689,612 @@ func get_activity_target_claim_count(target_id: StringName, except_npc_id: int =
|
||||
return count
|
||||
|
||||
|
||||
func get_action_state_revision() -> int:
|
||||
return event_log.next_event_id
|
||||
|
||||
|
||||
func get_player_activity_offer(from_position: Vector3, max_distance: float) -> Dictionary:
|
||||
if not _ensure_interaction_services_current():
|
||||
return {}
|
||||
if (
|
||||
interaction_service == null
|
||||
or active_world_adapter == null
|
||||
or not from_position.is_finite()
|
||||
or not is_finite(max_distance)
|
||||
or max_distance <= 0.0
|
||||
or not active_world_adapter.has_method("get_target_registry")
|
||||
):
|
||||
return {}
|
||||
var registry := active_world_adapter.call("get_target_registry") as WorldTargetRegistry
|
||||
if registry == null:
|
||||
return {}
|
||||
var actor := WorldEntityRef.create(
|
||||
SimulationIds.ENTITY_PLAYER, StringName(str(SimulationIds.PLAYER_ACTOR_ID))
|
||||
)
|
||||
var candidates: Array[Dictionary] = []
|
||||
for descriptor in registry.get_descriptors(ActiveWorldAdapter.TARGET_KIND_ACTIVITY):
|
||||
var distance := from_position.distance_to(descriptor.get_local_position())
|
||||
if distance > max_distance:
|
||||
continue
|
||||
var handle := registry.get_handle(descriptor.get_target_id())
|
||||
for offer in interaction_service.get_offers(actor, handle):
|
||||
var definition := SimulationDefinitions.get_action(offer.get_action_id())
|
||||
if not _uses_activity_command_handler(definition):
|
||||
continue
|
||||
var capability := descriptor.get_capability(offer.get_action_id())
|
||||
var raw_range: Variant = capability.get_attribute(&"interaction_range", 0.0)
|
||||
if raw_range is not int and raw_range is not float:
|
||||
continue
|
||||
var capability_range := float(raw_range)
|
||||
if (
|
||||
not is_finite(capability_range)
|
||||
or capability_range <= 0.0
|
||||
or distance > capability_range
|
||||
):
|
||||
continue
|
||||
(
|
||||
candidates
|
||||
. append(
|
||||
{
|
||||
"action_id": String(offer.get_action_id()),
|
||||
"target_id": String(descriptor.get_target_id()),
|
||||
"display_name": descriptor.get_display_name(),
|
||||
"action_name": offer.get_display_name(),
|
||||
"distance": distance,
|
||||
"priority": offer.get_priority(),
|
||||
"state_revision": get_action_state_revision(),
|
||||
"target_generation": handle.get_generation(),
|
||||
"target_context_id": String(handle.get_context_id()),
|
||||
"target_registry_instance_id": registry.get_instance_id(),
|
||||
"target_kind": String(handle.get_target_kind()),
|
||||
"effect": _player_activity_effect_summary(definition),
|
||||
"enabled": offer.is_enabled(),
|
||||
"blocked_reason": offer.get_rejection_reason(),
|
||||
"offer_parameters": offer.get_parameters(),
|
||||
}
|
||||
)
|
||||
)
|
||||
if candidates.is_empty():
|
||||
return {}
|
||||
candidates.sort_custom(_activity_offer_precedes)
|
||||
return candidates[0].duplicate(true)
|
||||
|
||||
|
||||
func execute_activity_action(
|
||||
actor: WorldEntityRef,
|
||||
action_id: StringName,
|
||||
target_id: StringName,
|
||||
expected_state_revision: int = -1
|
||||
) -> ActionResult:
|
||||
if (
|
||||
actor == null
|
||||
or not actor.is_valid()
|
||||
or actor.get_entity_type() != SimulationIds.ENTITY_PERSON
|
||||
or expected_state_revision < 0
|
||||
):
|
||||
return ActionResult.rejected(&"activity_invalid_actor", &"invalid_actor")
|
||||
return _execute_authorized_activity_action(actor, action_id, target_id, expected_state_revision)
|
||||
|
||||
|
||||
func _execute_authorized_activity_action(
|
||||
actor: WorldEntityRef,
|
||||
action_id: StringName,
|
||||
target_id: StringName,
|
||||
expected_state_revision: int
|
||||
) -> ActionResult:
|
||||
if not _ensure_interaction_services_current():
|
||||
return ActionResult.rejected(&"activity_scope_mismatch", &"service_unavailable")
|
||||
var selected_service := activity_command_service
|
||||
var handle: WorldTargetHandle
|
||||
if actor.get_entity_type() == SimulationIds.ENTITY_PERSON:
|
||||
var npc_id_text := String(actor.get_entity_id())
|
||||
var npc := _find_npc_by_id(npc_id_text.to_int()) if npc_id_text.is_valid_int() else null
|
||||
if (
|
||||
npc == null
|
||||
or npc.is_dead
|
||||
or npc.task_state != SimNPC.TASK_STATE_COMPLETE
|
||||
or not npc.task_complete
|
||||
or npc.current_task != action_id
|
||||
or npc.target_id.is_empty()
|
||||
or npc.target_id != target_id
|
||||
):
|
||||
return ActionResult.rejected(
|
||||
&"activity_assignment_mismatch",
|
||||
ActivityActionCommandService.REASON_UNSUPPORTED_ACTOR
|
||||
)
|
||||
selected_service = _abstract_activity_command_service
|
||||
handle = _ensure_abstract_activity_handle(action_id, target_id)
|
||||
elif active_world_adapter != null and active_world_adapter.has_method("get_target_registry"):
|
||||
var registry := active_world_adapter.call("get_target_registry") as WorldTargetRegistry
|
||||
handle = registry.get_handle(target_id) if registry != null else null
|
||||
if selected_service == null or handle == null:
|
||||
return ActionResult.rejected(&"activity_unavailable", &"service_unavailable")
|
||||
var actor_key := actor.index_key() if actor != null else "invalid"
|
||||
var command_revision := get_action_state_revision()
|
||||
var command_id := StringName(
|
||||
(
|
||||
"activity_%s_%s_%s_%d_%d"
|
||||
% [
|
||||
actor_key.sha256_text().substr(0, 8),
|
||||
action_id,
|
||||
target_id,
|
||||
tick_count,
|
||||
command_revision,
|
||||
]
|
||||
)
|
||||
)
|
||||
return selected_service.submit_command(
|
||||
ActionCommand.new(command_id, actor, action_id, handle, {}, expected_state_revision)
|
||||
)
|
||||
|
||||
|
||||
func execute_player_activity_action(
|
||||
action_id: StringName,
|
||||
target_id: StringName,
|
||||
expected_state_revision: int = -1,
|
||||
expected_target_generation: int = -1,
|
||||
expected_context_id: StringName = &"",
|
||||
expected_registry_instance_id: int = 0
|
||||
) -> ActionResult:
|
||||
if not _ensure_interaction_services_current():
|
||||
return ActionResult.rejected(&"player_activity_stale", &"service_unavailable")
|
||||
if (
|
||||
expected_state_revision < 0
|
||||
or expected_target_generation < 1
|
||||
or expected_context_id.is_empty()
|
||||
or expected_registry_instance_id == 0
|
||||
or active_world_adapter == null
|
||||
or not active_world_adapter.has_method("get_target_registry")
|
||||
):
|
||||
return ActionResult.rejected(
|
||||
&"player_activity_stale",
|
||||
ActivityActionCommandService.REASON_STALE_TARGET,
|
||||
"The activity offer is incomplete; refresh it"
|
||||
)
|
||||
var registry := active_world_adapter.call("get_target_registry") as WorldTargetRegistry
|
||||
var current_handle := registry.get_handle(target_id) if registry != null else null
|
||||
if (
|
||||
current_handle == null
|
||||
or registry.get_instance_id() != expected_registry_instance_id
|
||||
or current_handle.get_generation() != expected_target_generation
|
||||
or current_handle.get_context_id() != expected_context_id
|
||||
or current_handle.get_target_kind() != SimulationIds.TARGET_ACTIVITY
|
||||
):
|
||||
return ActionResult.rejected(
|
||||
&"player_activity_stale",
|
||||
ActivityActionCommandService.REASON_STALE_TARGET,
|
||||
"The activity changed; refresh the interaction offer"
|
||||
)
|
||||
return _execute_authorized_activity_action(
|
||||
WorldEntityRef.create(
|
||||
SimulationIds.ENTITY_PLAYER, StringName(str(SimulationIds.PLAYER_ACTOR_ID))
|
||||
),
|
||||
action_id,
|
||||
target_id,
|
||||
expected_state_revision
|
||||
)
|
||||
|
||||
|
||||
func _configure_interaction_services() -> bool:
|
||||
activity_command_service = null
|
||||
interaction_service = null
|
||||
_active_activity_registry_instance_id = 0
|
||||
var current_scope := event_log.get_scope()
|
||||
var scope_world_id := StringName(
|
||||
current_scope.get("world_id", SimulationIds.REGIONAL_WORLD_BOSNIA)
|
||||
)
|
||||
var scope_location_id := StringName(
|
||||
current_scope.get("location_id", SimulationIds.REGIONAL_LOCATION_JAJCE)
|
||||
)
|
||||
if active_world_adapter != null and active_world_adapter.has_method("get_context_identity"):
|
||||
var identity: Dictionary = active_world_adapter.call("get_context_identity")
|
||||
scope_world_id = StringName(identity.get("world_id", scope_world_id))
|
||||
scope_location_id = StringName(identity.get("location_id", scope_location_id))
|
||||
if not event_log.configure_scope(scope_world_id, scope_location_id):
|
||||
return false
|
||||
_abstract_activity_registry = WorldTargetRegistry.new(
|
||||
&"simulation_jajce", scope_world_id, scope_location_id
|
||||
)
|
||||
_abstract_activity_command_service = ActivityActionCommandService.new(
|
||||
_abstract_activity_registry,
|
||||
SimulationDefinitions.get_action,
|
||||
_get_activity_actor_position,
|
||||
get_action_state_revision,
|
||||
_complete_activity_command,
|
||||
false,
|
||||
false
|
||||
)
|
||||
register_abstract_activity_target(
|
||||
SimulationIds.ACTIVITY_GUARD_POST, SimulationIds.ACTION_PATROL, Vector3.ZERO
|
||||
)
|
||||
register_abstract_activity_target(
|
||||
SimulationIds.ACTIVITY_STUDY_DESK, SimulationIds.ACTION_STUDY, Vector3.ZERO
|
||||
)
|
||||
_rebuild_abstract_activity_targets_from_npcs()
|
||||
var catalog := ContentCatalog.create_core()
|
||||
if not catalog.is_valid():
|
||||
return false
|
||||
if active_world_adapter == null or not active_world_adapter.has_method("get_target_registry"):
|
||||
return true
|
||||
var registry := active_world_adapter.call("get_target_registry") as WorldTargetRegistry
|
||||
if registry == null:
|
||||
return false
|
||||
activity_command_service = ActivityActionCommandService.new(
|
||||
registry,
|
||||
SimulationDefinitions.get_action,
|
||||
_get_activity_actor_position,
|
||||
get_action_state_revision,
|
||||
_complete_activity_command,
|
||||
true
|
||||
)
|
||||
interaction_service = CatalogInteractionService.new(
|
||||
registry, catalog, _evaluate_activity_availability, true
|
||||
)
|
||||
_active_activity_registry_instance_id = registry.get_instance_id()
|
||||
return true
|
||||
|
||||
|
||||
func register_abstract_activity_target(
|
||||
target_id: StringName, action_id: StringName, position: Vector3, interaction_range: float = 0.5
|
||||
) -> bool:
|
||||
if (
|
||||
_abstract_activity_registry == null
|
||||
or target_id.is_empty()
|
||||
or not position.is_finite()
|
||||
or not is_finite(interaction_range)
|
||||
or interaction_range <= 0.0
|
||||
or _abstract_activity_registry.has_target(target_id)
|
||||
):
|
||||
return false
|
||||
var definition := SimulationDefinitions.get_action(action_id)
|
||||
if not _uses_activity_command_handler(definition):
|
||||
return false
|
||||
var scope := _abstract_activity_registry.get_identity()
|
||||
var handle := (
|
||||
_abstract_activity_registry
|
||||
. register_target(
|
||||
(
|
||||
WorldTargetDescriptor
|
||||
. new(
|
||||
target_id,
|
||||
ActiveWorldAdapter.TARGET_KIND_ACTIVITY,
|
||||
[
|
||||
WorldTargetCapability.new(
|
||||
action_id, {"interaction_range": interaction_range}
|
||||
)
|
||||
],
|
||||
position,
|
||||
definition.display_name,
|
||||
{
|
||||
"authority": "simulation",
|
||||
"world_id": String(scope["world_id"]),
|
||||
"location_id": String(scope["location_id"]),
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
return handle != null
|
||||
|
||||
|
||||
func _bind_abstract_activity_target(
|
||||
target_id: StringName, action_id: StringName, position: Vector3
|
||||
) -> bool:
|
||||
if _abstract_activity_registry == null or target_id.is_empty() or not position.is_finite():
|
||||
return false
|
||||
var existing := _abstract_activity_registry.get_handle(target_id)
|
||||
if existing == null:
|
||||
return register_abstract_activity_target(target_id, action_id, position)
|
||||
var descriptor := _abstract_activity_registry.resolve_handle(existing)
|
||||
if (
|
||||
descriptor == null
|
||||
or descriptor.get_target_kind() != SimulationIds.TARGET_ACTIVITY
|
||||
or not descriptor.has_capability(action_id)
|
||||
):
|
||||
return false
|
||||
return _abstract_activity_registry.update_target_position(existing, position)
|
||||
|
||||
|
||||
func _rebuild_abstract_activity_targets_from_npcs() -> void:
|
||||
for npc in npcs:
|
||||
if npc.is_dead or npc.target_id.is_empty():
|
||||
continue
|
||||
var definition := SimulationDefinitions.get_action(npc.current_task)
|
||||
if not _uses_activity_command_handler(definition):
|
||||
continue
|
||||
_bind_abstract_activity_target(npc.target_id, npc.current_task, npc.travel_target_position)
|
||||
|
||||
|
||||
func _ensure_interaction_services_current() -> bool:
|
||||
if active_world_adapter == null or not active_world_adapter.has_method("get_target_registry"):
|
||||
return _abstract_activity_command_service != null
|
||||
var registry := active_world_adapter.call("get_target_registry") as WorldTargetRegistry
|
||||
if registry == null:
|
||||
return false
|
||||
var identity := registry.get_identity()
|
||||
var event_scope := event_log.get_scope()
|
||||
if (
|
||||
StringName(identity["world_id"]) != StringName(event_scope["world_id"])
|
||||
or StringName(identity["location_id"]) != StringName(event_scope["location_id"])
|
||||
):
|
||||
if not economic_events.is_empty():
|
||||
activity_command_service = null
|
||||
interaction_service = null
|
||||
return false
|
||||
if registry.get_instance_id() == _active_activity_registry_instance_id:
|
||||
return activity_command_service != null and _abstract_activity_command_service != null
|
||||
return _configure_interaction_services()
|
||||
|
||||
|
||||
func _ensure_abstract_activity_handle(
|
||||
action_id: StringName, requested_target_id: StringName
|
||||
) -> WorldTargetHandle:
|
||||
if _abstract_activity_registry == null:
|
||||
return null
|
||||
var target_id := requested_target_id
|
||||
if target_id.is_empty():
|
||||
var candidates := _abstract_activity_registry.get_target_ids(
|
||||
ActiveWorldAdapter.TARGET_KIND_ACTIVITY, action_id
|
||||
)
|
||||
if candidates.size() != 1:
|
||||
return null
|
||||
target_id = candidates[0]
|
||||
var existing := _abstract_activity_registry.get_handle(target_id)
|
||||
if existing == null:
|
||||
return null
|
||||
var descriptor := _abstract_activity_registry.resolve_handle(existing)
|
||||
if descriptor == null or not descriptor.has_capability(action_id):
|
||||
return null
|
||||
return _abstract_activity_registry.get_handle(target_id)
|
||||
|
||||
|
||||
func _evaluate_activity_availability(
|
||||
actor: WorldEntityRef,
|
||||
_descriptor: WorldTargetDescriptor,
|
||||
definition: ActionDefinition,
|
||||
_capability: WorldTargetCapability
|
||||
) -> InteractionAvailabilityDecision:
|
||||
if not _uses_activity_command_handler(definition):
|
||||
return InteractionAvailabilityDecision.denied(
|
||||
&"unsupported_handler",
|
||||
"This activity has no supported completion handler",
|
||||
[&"handler_missing"]
|
||||
)
|
||||
var effect := ActivityActionCommandService._metric_effect(definition)
|
||||
var actor_id := _activity_actor_id(actor)
|
||||
if effect == null or actor_id < SimulationIds.PLAYER_ACTOR_ID:
|
||||
return InteractionAvailabilityDecision.denied(
|
||||
&"invalid_effect", "This activity has no valid effect", [&"effect_rejected"]
|
||||
)
|
||||
var requested_amount := _activity_effect_amount(actor_id, effect.value, effect.parameters)
|
||||
if _bounded_activity_effect_amount(effect.subject_key, requested_amount) <= 0.0:
|
||||
return InteractionAvailabilityDecision.denied(
|
||||
&"no_effect", "This activity cannot improve the village further", [&"effect_capped"]
|
||||
)
|
||||
if definition.has_completion_cost():
|
||||
var storage := economy.get_storage_for_resource(definition.completion_cost_resource_id)
|
||||
var available := (
|
||||
storage.get_amount(definition.completion_cost_resource_id) if storage != null else 0.0
|
||||
)
|
||||
if available < definition.completion_cost_amount:
|
||||
return InteractionAvailabilityDecision.denied(
|
||||
&"insufficient_cost",
|
||||
(
|
||||
"Needs %.0f %s"
|
||||
% [definition.completion_cost_amount, definition.completion_cost_resource_id]
|
||||
),
|
||||
[&"cost_rejected"],
|
||||
{"available": available, "required": definition.completion_cost_amount}
|
||||
)
|
||||
return InteractionAvailabilityDecision.allowed([&"cost_allowed"])
|
||||
|
||||
|
||||
func _get_activity_actor_position(actor: WorldEntityRef) -> Variant:
|
||||
var actor_id := _activity_actor_id(actor)
|
||||
if actor_id == SimulationIds.PLAYER_ACTOR_ID:
|
||||
var combatant := conflict_system.get_combatant(ConflictSystemScript.PLAYER_COMBATANT_ID)
|
||||
return combatant.get_position() if combatant != null else null
|
||||
var npc := _find_npc_by_id(actor_id)
|
||||
return npc.position if npc != null and not npc.is_dead else null
|
||||
|
||||
|
||||
func _complete_activity_command(
|
||||
actor: WorldEntityRef,
|
||||
action_id: StringName,
|
||||
target_id: StringName,
|
||||
metric_id: StringName,
|
||||
base_amount: float,
|
||||
effect_parameters: Dictionary,
|
||||
world_position: Vector3
|
||||
) -> Dictionary:
|
||||
var actor_id := _activity_actor_id(actor)
|
||||
var definition := SimulationDefinitions.get_action(action_id)
|
||||
if actor_id < SimulationIds.PLAYER_ACTOR_ID or not _uses_activity_command_handler(definition):
|
||||
return {"succeeded": false, "event_id": -1, "state_revision": get_action_state_revision()}
|
||||
var requested_amount := _activity_effect_amount(actor_id, base_amount, effect_parameters)
|
||||
var applied_amount := _bounded_activity_effect_amount(metric_id, requested_amount)
|
||||
if not is_finite(applied_amount) or applied_amount <= 0.0:
|
||||
return {
|
||||
"succeeded": false,
|
||||
"reason_code": "no_effect",
|
||||
"message": "This activity cannot improve the village further",
|
||||
"event_id": -1,
|
||||
"state_revision": get_action_state_revision(),
|
||||
}
|
||||
var cost_storage: StorageStateRecord
|
||||
var cost_item_id: StringName
|
||||
var cost_amount := 0.0
|
||||
if definition.has_completion_cost():
|
||||
cost_item_id = definition.completion_cost_resource_id
|
||||
cost_amount = definition.completion_cost_amount
|
||||
cost_storage = economy.get_storage_for_resource(cost_item_id)
|
||||
var available := cost_storage.get_amount(cost_item_id) if cost_storage != null else 0.0
|
||||
if available < cost_amount:
|
||||
var blocked := event_log.record_activity_blocked(
|
||||
tick_count,
|
||||
actor_id,
|
||||
target_id,
|
||||
action_id,
|
||||
(
|
||||
"%s: needs %.0f %s"
|
||||
% [definition.display_name, cost_amount, String(cost_item_id).capitalize()]
|
||||
),
|
||||
world_position,
|
||||
cost_storage.get_storage_id() if cost_storage != null else &"",
|
||||
cost_item_id,
|
||||
cost_amount
|
||||
)
|
||||
return {
|
||||
"succeeded": false,
|
||||
"reason_code": "insufficient_cost",
|
||||
"message": "Needs %.0f %s" % [cost_amount, cost_item_id],
|
||||
"event_id": int(blocked.data["event_id"]) if blocked != null else -1,
|
||||
"state_revision": get_action_state_revision(),
|
||||
}
|
||||
var previous_metric := village.safety if metric_id == &"safety" else village.knowledge
|
||||
if cost_storage != null and cost_storage.withdraw(cost_item_id, cost_amount) < cost_amount:
|
||||
return {"succeeded": false, "event_id": -1, "state_revision": get_action_state_revision()}
|
||||
if cost_storage != null:
|
||||
economy.sync_resource(cost_item_id)
|
||||
village.apply_metric_delta(metric_id, applied_amount)
|
||||
var actual_amount := (
|
||||
village.safety - previous_metric
|
||||
if metric_id == &"safety"
|
||||
else village.knowledge - previous_metric
|
||||
)
|
||||
if not is_finite(actual_amount) or actual_amount <= 0.0:
|
||||
if cost_storage != null:
|
||||
cost_storage.deposit(cost_item_id, cost_amount)
|
||||
economy.sync_resource(cost_item_id)
|
||||
_restore_village_metric(metric_id, previous_metric)
|
||||
return {
|
||||
"succeeded": false,
|
||||
"reason_code": "no_effect",
|
||||
"message": "This activity cannot improve the village further",
|
||||
"event_id": -1,
|
||||
"state_revision": get_action_state_revision(),
|
||||
}
|
||||
var event := event_log.record_activity_completed(
|
||||
tick_count,
|
||||
actor_id,
|
||||
target_id,
|
||||
action_id,
|
||||
definition.event_type_id,
|
||||
metric_id,
|
||||
actual_amount,
|
||||
world_position,
|
||||
cost_item_id,
|
||||
cost_amount
|
||||
)
|
||||
if event == null:
|
||||
if cost_storage != null:
|
||||
cost_storage.deposit(cost_item_id, cost_amount)
|
||||
economy.sync_resource(cost_item_id)
|
||||
_restore_village_metric(metric_id, previous_metric)
|
||||
return {"succeeded": false, "event_id": -1, "state_revision": get_action_state_revision()}
|
||||
village_changed.emit(village)
|
||||
return {
|
||||
"succeeded": true,
|
||||
"event_id": int(event.data["event_id"]),
|
||||
"state_revision": get_action_state_revision(),
|
||||
"applied_amount": actual_amount,
|
||||
}
|
||||
|
||||
|
||||
func _activity_actor_id(actor: WorldEntityRef) -> int:
|
||||
if actor == null or not actor.is_valid():
|
||||
return SimulationIds.PLAYER_ACTOR_ID - 1
|
||||
var id_text := String(actor.get_entity_id())
|
||||
if not id_text.is_valid_int():
|
||||
return SimulationIds.PLAYER_ACTOR_ID - 1
|
||||
var actor_id := id_text.to_int()
|
||||
if actor.get_entity_type() == SimulationIds.ENTITY_PLAYER:
|
||||
return (
|
||||
actor_id
|
||||
if (
|
||||
actor_id == SimulationIds.PLAYER_ACTOR_ID
|
||||
and player_system.player_state != null
|
||||
and not player_system.player_state.is_downed()
|
||||
)
|
||||
else -2
|
||||
)
|
||||
if actor.get_entity_type() != SimulationIds.ENTITY_PERSON or actor_id < 0:
|
||||
return -2
|
||||
var npc := _find_npc_by_id(actor_id)
|
||||
return actor_id if npc != null and not npc.is_dead else -2
|
||||
|
||||
|
||||
func _activity_effect_amount(actor_id: int, base_amount: float, parameters: Dictionary) -> float:
|
||||
var result := base_amount
|
||||
if actor_id == SimulationIds.PLAYER_ACTOR_ID:
|
||||
var raw_multiplier: Variant = parameters.get("player_multiplier", 1.0)
|
||||
if raw_multiplier is int or raw_multiplier is float:
|
||||
result *= float(raw_multiplier)
|
||||
return result
|
||||
if bool(parameters.get("npc_productivity", false)):
|
||||
var npc := _find_npc_by_id(actor_id)
|
||||
if npc == null:
|
||||
return 0.0
|
||||
if npc.is_starving:
|
||||
result *= 0.35
|
||||
elif npc.energy < 20.0:
|
||||
result *= 0.25
|
||||
elif npc.energy < 40.0:
|
||||
result *= 0.5
|
||||
return result
|
||||
|
||||
|
||||
func _player_activity_effect_summary(definition: ActionDefinition) -> Dictionary:
|
||||
if definition == null:
|
||||
return {}
|
||||
var effect := ActivityActionCommandService._metric_effect(definition)
|
||||
if effect == null:
|
||||
return {}
|
||||
var requested_amount := _activity_effect_amount(
|
||||
SimulationIds.PLAYER_ACTOR_ID, effect.value, effect.parameters
|
||||
)
|
||||
return {
|
||||
"metric_id": String(effect.subject_key),
|
||||
"amount": _bounded_activity_effect_amount(effect.subject_key, requested_amount),
|
||||
}
|
||||
|
||||
|
||||
func _bounded_activity_effect_amount(metric_id: StringName, requested_amount: float) -> float:
|
||||
if not is_finite(requested_amount) or requested_amount <= 0.0:
|
||||
return 0.0
|
||||
if metric_id == &"safety":
|
||||
return minf(requested_amount, maxf(100.0 - village.safety, 0.0))
|
||||
if metric_id == &"knowledge":
|
||||
return requested_amount
|
||||
return 0.0
|
||||
|
||||
|
||||
func _restore_village_metric(metric_id: StringName, value: float) -> void:
|
||||
if metric_id == &"safety":
|
||||
village.safety = value
|
||||
elif metric_id == &"knowledge":
|
||||
village.knowledge = value
|
||||
village.update_modifiers()
|
||||
village.update_priorities()
|
||||
|
||||
|
||||
static func _uses_activity_command_handler(definition: ActionDefinition) -> bool:
|
||||
return (
|
||||
definition != null
|
||||
and definition.completion_handler_id == ActivityActionCommandService.HANDLER_METRIC_DELTA
|
||||
)
|
||||
|
||||
|
||||
static func _activity_offer_precedes(first: Dictionary, second: Dictionary) -> bool:
|
||||
if not is_equal_approx(float(first["distance"]), float(second["distance"])):
|
||||
return float(first["distance"]) < float(second["distance"])
|
||||
if float(first["priority"]) != float(second["priority"]):
|
||||
return float(first["priority"]) > float(second["priority"])
|
||||
if String(first["target_id"]) != String(second["target_id"]):
|
||||
return String(first["target_id"]) < String(second["target_id"])
|
||||
return String(first["action_id"]) < String(second["action_id"])
|
||||
|
||||
|
||||
func _notify_mourning(dead_npc: SimNPC) -> void:
|
||||
var mourner := relationship_system.get_most_familiar_living(dead_npc.id, npcs, _population_view)
|
||||
if mourner == null:
|
||||
@@ -1653,6 +2287,7 @@ func get_latest_decision(npc_id: int) -> ActionSelectionResult:
|
||||
|
||||
func create_state_record() -> SimulationStateRecord:
|
||||
var record := SimulationStateRecord.new()
|
||||
var event_scope := event_log.get_scope()
|
||||
var wander_streams: Array[Dictionary] = []
|
||||
var sorted_npc_ids: Array = wander_random_sources.keys()
|
||||
sorted_npc_ids.sort()
|
||||
@@ -1674,6 +2309,8 @@ func create_state_record() -> SimulationStateRecord:
|
||||
"next_journal_entry_id": quest_journal_system.get_next_entry_id(),
|
||||
"next_commitment_id": commitment_system.get_next_commitment_id(),
|
||||
"next_conversation_act_id": _next_conversation_act_id,
|
||||
"world_id": String(event_scope["world_id"]),
|
||||
"location_id": String(event_scope["location_id"]),
|
||||
"cycle_duration_seconds": clock.cycle_duration_seconds
|
||||
}
|
||||
record.village = VillageStateRecord.capture(village)
|
||||
@@ -1724,7 +2361,7 @@ func restore_state_from_json(json_text: String) -> bool:
|
||||
|
||||
|
||||
func restore_state(record: SimulationStateRecord) -> bool:
|
||||
if record == null:
|
||||
if record == null or not _saved_scope_matches_active_adapter(record):
|
||||
return false
|
||||
simulation_seed = int(record.simulation["seed"])
|
||||
tick_interval = float(record.simulation["tick_interval"])
|
||||
@@ -1740,12 +2377,20 @@ func restore_state(record: SimulationStateRecord) -> bool:
|
||||
# Rebuild them before the next decision so save/load cannot change outcomes.
|
||||
village.update_modifiers()
|
||||
village.update_priorities()
|
||||
event_log.restore(record.economic_events, int(record.simulation["next_event_id"]))
|
||||
if not event_log.restore(
|
||||
record.economic_events,
|
||||
int(record.simulation["next_event_id"]),
|
||||
StringName(record.simulation["world_id"]),
|
||||
StringName(record.simulation["location_id"])
|
||||
):
|
||||
return false
|
||||
latest_decisions.clear()
|
||||
npcs.clear()
|
||||
for npc_record in record.npcs:
|
||||
npcs.append(npc_record.restore(debug_logs))
|
||||
refresh_population_index()
|
||||
if not _configure_interaction_services():
|
||||
return false
|
||||
relationship_system.restore(record.relationships)
|
||||
event_knowledge_system.restore(record.event_knowledge)
|
||||
opportunity_system.restore(record.opportunities, int(record.simulation["next_opportunity_id"]))
|
||||
@@ -1798,3 +2443,19 @@ func restore_state(record: SimulationStateRecord) -> bool:
|
||||
village_changed.emit(village)
|
||||
state_restored.emit()
|
||||
return true
|
||||
|
||||
|
||||
func _saved_scope_matches_active_adapter(record: SimulationStateRecord) -> bool:
|
||||
if active_world_adapter == null or not active_world_adapter.has_method("get_context_identity"):
|
||||
return true
|
||||
var identity: Dictionary = active_world_adapter.call("get_context_identity")
|
||||
return (
|
||||
(
|
||||
StringName(identity.get("world_id", &""))
|
||||
== StringName(record.simulation.get("world_id", &""))
|
||||
)
|
||||
and (
|
||||
StringName(identity.get("location_id", &""))
|
||||
== StringName(record.simulation.get("location_id", &""))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -73,9 +73,9 @@ static func accepted(command_id: StringName, payload: Dictionary = {}) -> Action
|
||||
|
||||
|
||||
static func rejected(
|
||||
command_id: StringName, reason_code: StringName, message: String = ""
|
||||
command_id: StringName, reason_code: StringName, message: String = "", payload: Dictionary = {}
|
||||
) -> ActionResult:
|
||||
return ActionResult.new(command_id, STATUS_REJECTED, reason_code, message)
|
||||
return ActionResult.new(command_id, STATUS_REJECTED, reason_code, message, payload)
|
||||
|
||||
|
||||
static func completed(command_id: StringName, payload: Dictionary = {}) -> ActionResult:
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
class_name ActivityActionCommandService
|
||||
extends ActionCommandService
|
||||
|
||||
const HANDLER_METRIC_DELTA := SimulationIds.ACTION_HANDLER_ACTIVITY_METRIC_DELTA
|
||||
const ACTOR_KIND_PLAYER := SimulationIds.ENTITY_PLAYER
|
||||
const ACTOR_KIND_PERSON := SimulationIds.ENTITY_PERSON
|
||||
const REASON_INVALID_COMMAND := &"invalid_command"
|
||||
const REASON_STALE_TARGET := &"stale_target"
|
||||
const REASON_STALE_REVISION := &"stale_revision"
|
||||
const REASON_UNSUPPORTED_ACTION := &"unsupported_action"
|
||||
const REASON_UNSUPPORTED_ACTOR := &"unsupported_actor"
|
||||
const REASON_TARGET_CAPABILITY := &"target_capability_missing"
|
||||
const REASON_OUT_OF_RANGE := &"out_of_range"
|
||||
const REASON_INVALID_EFFECT := &"invalid_effect"
|
||||
const REASON_COMPLETION_FAILED := &"completion_failed"
|
||||
|
||||
var _registry: WorldTargetRegistry
|
||||
var _definition_lookup: Callable
|
||||
var _actor_position_lookup: Callable
|
||||
var _state_revision_lookup: Callable
|
||||
var _completion_handler: Callable
|
||||
var _effect_planner := ActionEffectPlanner.new()
|
||||
var _require_target_provider := false
|
||||
var _enforce_spatial_range := true
|
||||
|
||||
|
||||
func _init(
|
||||
registry: WorldTargetRegistry = null,
|
||||
definition_lookup: Callable = Callable(),
|
||||
actor_position_lookup: Callable = Callable(),
|
||||
state_revision_lookup: Callable = Callable(),
|
||||
completion_handler: Callable = Callable(),
|
||||
require_target_provider: bool = false,
|
||||
enforce_spatial_range: bool = true
|
||||
) -> void:
|
||||
_registry = registry
|
||||
_definition_lookup = definition_lookup
|
||||
_actor_position_lookup = actor_position_lookup
|
||||
_state_revision_lookup = state_revision_lookup
|
||||
_completion_handler = completion_handler
|
||||
_require_target_provider = require_target_provider
|
||||
_enforce_spatial_range = enforce_spatial_range
|
||||
|
||||
|
||||
func try_execute(command: ActionCommand) -> ActionResult:
|
||||
var result := _execute(command)
|
||||
command_resolved.emit(result)
|
||||
return result
|
||||
|
||||
|
||||
func _execute(command: ActionCommand) -> ActionResult:
|
||||
if command == null or not command.is_valid() or not _services_are_valid():
|
||||
return _reject(command, REASON_INVALID_COMMAND, "Command or service wiring is invalid")
|
||||
var definition := _definition_lookup.call(command.get_action_id()) as ActionDefinition
|
||||
if (
|
||||
definition == null
|
||||
or definition.target_type != SimulationIds.TARGET_ACTIVITY
|
||||
or definition.completion_handler_id != HANDLER_METRIC_DELTA
|
||||
):
|
||||
return _reject(command, REASON_UNSUPPORTED_ACTION, "Action has no activity metric handler")
|
||||
var actor := command.get_actor()
|
||||
if actor.get_entity_type() not in [ACTOR_KIND_PLAYER, ACTOR_KIND_PERSON]:
|
||||
return _reject(command, REASON_UNSUPPORTED_ACTOR, "Actor kind cannot perform activity work")
|
||||
var effect_plan := _effect_planner.plan(definition, actor)
|
||||
var metric_operation := _metric_operation(effect_plan)
|
||||
if metric_operation.is_empty():
|
||||
return _reject(command, REASON_INVALID_EFFECT, "Action metric effect plan is invalid")
|
||||
var handle := command.get_target()
|
||||
if not _registry.is_handle_valid(handle, _require_target_provider):
|
||||
return _reject(command, REASON_STALE_TARGET, "Activity target is no longer available")
|
||||
var descriptor := _registry.resolve_handle(handle)
|
||||
if (
|
||||
handle.get_target_kind() != SimulationIds.TARGET_ACTIVITY
|
||||
or descriptor == null
|
||||
or descriptor.get_target_kind() != SimulationIds.TARGET_ACTIVITY
|
||||
or not descriptor.has_capability(command.get_action_id())
|
||||
):
|
||||
return _reject(command, REASON_TARGET_CAPABILITY, "Activity target lacks the action")
|
||||
var current_revision := int(_state_revision_lookup.call())
|
||||
if (
|
||||
command.get_expected_state_revision() >= 0
|
||||
and command.get_expected_state_revision() != current_revision
|
||||
):
|
||||
return _reject(command, REASON_STALE_REVISION, "Authoritative state revision changed")
|
||||
var capability := descriptor.get_capability(command.get_action_id())
|
||||
var actor_position: Variant = _actor_position_lookup.call(actor)
|
||||
if actor_position is not Vector3 or not actor_position.is_finite():
|
||||
return _reject(command, REASON_UNSUPPORTED_ACTOR, "Actor has no valid world position")
|
||||
if _enforce_spatial_range:
|
||||
var raw_maximum_range: Variant = capability.get_attribute(&"interaction_range", 0.0)
|
||||
if raw_maximum_range is not int and raw_maximum_range is not float:
|
||||
return _reject(command, REASON_INVALID_EFFECT, "Activity range metadata is invalid")
|
||||
var maximum_range := float(raw_maximum_range)
|
||||
if (
|
||||
not is_finite(maximum_range)
|
||||
or maximum_range <= 0.0
|
||||
or actor_position.distance_to(descriptor.get_local_position()) > maximum_range
|
||||
):
|
||||
return _reject(command, REASON_OUT_OF_RANGE, "Actor is outside the activity range")
|
||||
var effect_payload: Dictionary = metric_operation["payload"]
|
||||
var metric_id := StringName(effect_payload["subject_key"])
|
||||
var amount := float(effect_payload["delta"])
|
||||
var completion: Variant = _completion_handler.call(
|
||||
actor,
|
||||
command.get_action_id(),
|
||||
descriptor.get_target_id(),
|
||||
metric_id,
|
||||
amount,
|
||||
(effect_payload["parameters"] as Dictionary).duplicate(true),
|
||||
actor_position
|
||||
)
|
||||
if not _completion_shape_is_valid(completion):
|
||||
return ActionResult.failed(
|
||||
command.get_command_id(),
|
||||
REASON_COMPLETION_FAILED,
|
||||
"Authoritative activity completion returned an invalid result"
|
||||
)
|
||||
var completion_result := completion as Dictionary
|
||||
if not bool(completion_result["succeeded"]):
|
||||
var failure_reason := StringName(completion_result.get("reason_code", ""))
|
||||
var failure_message := String(completion_result.get("message", ""))
|
||||
if failure_reason.is_empty() or failure_message.is_empty():
|
||||
return ActionResult.failed(
|
||||
command.get_command_id(),
|
||||
REASON_COMPLETION_FAILED,
|
||||
"Authoritative activity completion was rejected"
|
||||
)
|
||||
var failure_event_id := int(completion_result["event_id"])
|
||||
var failure_revision := int(completion_result["state_revision"])
|
||||
var failure_payload := {}
|
||||
if failure_event_id >= 0:
|
||||
if failure_event_id != current_revision or failure_revision < current_revision + 1:
|
||||
return ActionResult.failed(
|
||||
command.get_command_id(),
|
||||
REASON_COMPLETION_FAILED,
|
||||
"Authoritative failure fact did not advance state"
|
||||
)
|
||||
failure_payload = {
|
||||
"event_id": failure_event_id,
|
||||
"state_revision": failure_revision,
|
||||
"reason_trace": ["command_rejected", "authoritative_failure_fact_recorded"],
|
||||
}
|
||||
elif failure_revision != current_revision:
|
||||
return ActionResult.failed(
|
||||
command.get_command_id(),
|
||||
REASON_COMPLETION_FAILED,
|
||||
"Rejected completion mutated state without a failure fact"
|
||||
)
|
||||
return ActionResult.rejected(
|
||||
command.get_command_id(), failure_reason, failure_message, failure_payload
|
||||
)
|
||||
var event_id := int(completion_result["event_id"])
|
||||
var resulting_revision := int(completion_result["state_revision"])
|
||||
var applied_amount := float(completion_result.get("applied_amount", amount))
|
||||
if (
|
||||
event_id != current_revision
|
||||
or resulting_revision < current_revision + 1
|
||||
or not is_finite(applied_amount)
|
||||
):
|
||||
return ActionResult.failed(
|
||||
command.get_command_id(),
|
||||
REASON_COMPLETION_FAILED,
|
||||
"Authoritative activity completion did not advance state"
|
||||
)
|
||||
var spatial_trace := (
|
||||
"range_revalidated" if _enforce_spatial_range else "abstract_completion_authorized"
|
||||
)
|
||||
return (
|
||||
ActionResult
|
||||
. completed(
|
||||
command.get_command_id(),
|
||||
{
|
||||
"action_id": String(command.get_action_id()),
|
||||
"target_id": String(descriptor.get_target_id()),
|
||||
"metric_id": String(metric_id),
|
||||
"amount": applied_amount,
|
||||
"event_id": event_id,
|
||||
"state_revision": resulting_revision,
|
||||
"reason_trace":
|
||||
[
|
||||
"command_shape_valid",
|
||||
"actor_authorized",
|
||||
"target_handle_live",
|
||||
"target_capability_matched",
|
||||
"revision_matched",
|
||||
spatial_trace,
|
||||
"authoritative_metric_mutated",
|
||||
"completion_fact_recorded",
|
||||
"causal_followup_facts_committed",
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
func _services_are_valid() -> bool:
|
||||
return (
|
||||
_registry != null
|
||||
and _definition_lookup.is_valid()
|
||||
and _actor_position_lookup.is_valid()
|
||||
and _state_revision_lookup.is_valid()
|
||||
and _completion_handler.is_valid()
|
||||
)
|
||||
|
||||
|
||||
static func _metric_effect(definition: ActionDefinition) -> ActionEffect:
|
||||
if definition == null or definition.effects.size() != 1:
|
||||
return null
|
||||
var selected: ActionEffect
|
||||
for resource in definition.effects:
|
||||
var effect := resource as ActionEffect
|
||||
if effect == null or effect.effect_kind != ActionEffect.KIND_METRIC_DELTA:
|
||||
continue
|
||||
if selected != null:
|
||||
return null
|
||||
selected = effect
|
||||
if selected == null:
|
||||
return null
|
||||
if (
|
||||
selected.subject_key not in [&"safety", &"knowledge"]
|
||||
or not is_finite(selected.value)
|
||||
or selected.value <= 0.0
|
||||
):
|
||||
return null
|
||||
for raw_key in selected.parameters:
|
||||
if String(raw_key) not in ["player_multiplier", "npc_productivity"]:
|
||||
return null
|
||||
var player_multiplier: Variant = selected.parameters.get("player_multiplier", 1.0)
|
||||
if (
|
||||
(player_multiplier is not int and player_multiplier is not float)
|
||||
or not is_finite(float(player_multiplier))
|
||||
or float(player_multiplier) <= 0.0
|
||||
):
|
||||
return null
|
||||
if selected.parameters.get("npc_productivity", false) is not bool:
|
||||
return null
|
||||
return selected
|
||||
|
||||
|
||||
static func _metric_operation(plan: ActionEffectPlan) -> Dictionary:
|
||||
if plan == null or not plan.is_valid() or plan.get_operation_count() != 1:
|
||||
return {}
|
||||
var operation: Dictionary = plan.get_operations()[0]
|
||||
if StringName(operation.get("operation_kind", &"")) != ActionEffect.KIND_METRIC_DELTA:
|
||||
return {}
|
||||
return operation.duplicate(true)
|
||||
|
||||
|
||||
static func _completion_shape_is_valid(completion: Variant) -> bool:
|
||||
if completion is not Dictionary:
|
||||
return false
|
||||
var result := completion as Dictionary
|
||||
if not result.has_all(["succeeded", "event_id", "state_revision"]):
|
||||
return false
|
||||
return (
|
||||
result["succeeded"] is bool
|
||||
and result["event_id"] is int
|
||||
and result["state_revision"] is int
|
||||
and not WorldTargetCapability._contains_object(result)
|
||||
)
|
||||
|
||||
|
||||
static func _reject(command: ActionCommand, reason: StringName, message: String) -> ActionResult:
|
||||
var command_id := command.get_command_id() if command != null else &"invalid_command"
|
||||
return ActionResult.rejected(command_id, reason, message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ce4eqg4gbqcf8
|
||||
@@ -17,16 +17,19 @@ var _registry: WorldTargetRegistry
|
||||
var _catalog: ContentCatalog
|
||||
var _availability_evaluator: Callable
|
||||
var _last_query_reason_trace: Array[StringName] = []
|
||||
var _require_target_provider := false
|
||||
|
||||
|
||||
func _init(
|
||||
registry: WorldTargetRegistry = null,
|
||||
catalog: ContentCatalog = null,
|
||||
availability_evaluator: Callable = Callable()
|
||||
availability_evaluator: Callable = Callable(),
|
||||
require_target_provider: bool = false
|
||||
) -> void:
|
||||
_registry = registry
|
||||
_catalog = catalog
|
||||
_availability_evaluator = availability_evaluator
|
||||
_require_target_provider = require_target_provider
|
||||
|
||||
|
||||
func is_configured() -> bool:
|
||||
@@ -49,7 +52,7 @@ func get_offers(actor: WorldEntityRef, target: WorldTargetHandle) -> Array[Actio
|
||||
if not _actor_is_supported(actor):
|
||||
_last_query_reason_trace.append(REASON_ACTOR_INVALID)
|
||||
return []
|
||||
if not _registry.is_handle_valid(target):
|
||||
if not _registry.is_handle_valid(target, _require_target_provider):
|
||||
_last_query_reason_trace.append(REASON_TARGET_STALE)
|
||||
return []
|
||||
var descriptor := _registry.resolve_handle(target)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class_name ContentCatalog
|
||||
extends RefCounted
|
||||
# gdlint: disable=max-file-lines
|
||||
|
||||
const CORE_PACK_PATH := "res://simulation/definitions/packs/core.tres"
|
||||
const DEFAULT_SITUATION_EVENT_PREDICATE_IDS: Array[StringName] = [
|
||||
@@ -11,6 +12,7 @@ const DEFAULT_SITUATION_STATE_PREDICATE_IDS: Array[StringName] = [
|
||||
const DEFAULT_SITUATION_EXPIRY_PREDICATE_IDS: Array[StringName] = [
|
||||
&"state_number_gt", &"age_reached"
|
||||
]
|
||||
const BUILTIN_HANDLER_IDS: Array[StringName] = [SimulationIds.ACTION_HANDLER_ACTIVITY_METRIC_DELTA]
|
||||
|
||||
var _valid := false
|
||||
var _errors: Array[String] = []
|
||||
@@ -81,11 +83,14 @@ func rebuild(
|
||||
var pack_list := content_packs.duplicate()
|
||||
pack_list.sort_custom(_sort_packs)
|
||||
var supported_handlers := {}
|
||||
for handler_id in BUILTIN_HANDLER_IDS:
|
||||
supported_handlers[handler_id] = true
|
||||
for handler_id in supported_handler_ids:
|
||||
if handler_id.is_empty():
|
||||
errors.append("Supported handler ID is empty")
|
||||
elif supported_handlers.has(handler_id):
|
||||
errors.append("Duplicate supported handler '%s'" % handler_id)
|
||||
if handler_id not in BUILTIN_HANDLER_IDS:
|
||||
errors.append("Duplicate supported handler '%s'" % handler_id)
|
||||
else:
|
||||
supported_handlers[handler_id] = true
|
||||
var cue_ids := (
|
||||
@@ -202,7 +207,9 @@ func rebuild(
|
||||
actions_by_id,
|
||||
professions_by_id,
|
||||
items_by_id,
|
||||
storages_by_id,
|
||||
capability_tags_by_id,
|
||||
event_types_by_id,
|
||||
supported_handlers,
|
||||
supported_cues,
|
||||
errors
|
||||
@@ -670,7 +677,9 @@ static func _validate_action_references(
|
||||
actions_by_id: Dictionary,
|
||||
professions_by_id: Dictionary,
|
||||
items_by_id: Dictionary,
|
||||
storages_by_id: Dictionary,
|
||||
capability_tags_by_id: Dictionary,
|
||||
event_types_by_id: Dictionary,
|
||||
supported_handlers: Dictionary,
|
||||
supported_cues: Dictionary,
|
||||
errors: Array[String]
|
||||
@@ -696,6 +705,16 @@ static func _validate_action_references(
|
||||
supported_cues,
|
||||
errors
|
||||
)
|
||||
if (
|
||||
not definition.event_type_id.is_empty()
|
||||
and not event_types_by_id.has(definition.event_type_id)
|
||||
):
|
||||
errors.append(
|
||||
(
|
||||
"Action '%s' references unknown event type '%s'"
|
||||
% [definition.action_id, definition.event_type_id]
|
||||
)
|
||||
)
|
||||
for handler_field in [
|
||||
["selection policy", definition.selection_policy_id],
|
||||
["target policy", definition.target_policy_id],
|
||||
@@ -769,6 +788,64 @@ static func _validate_action_references(
|
||||
% [definition.action_id, scheduled_action_id]
|
||||
)
|
||||
)
|
||||
_validate_builtin_action_handler_contract(definition, items_by_id, storages_by_id, errors)
|
||||
|
||||
|
||||
static func _validate_builtin_action_handler_contract(
|
||||
definition: ActionDefinition,
|
||||
items_by_id: Dictionary,
|
||||
storages_by_id: Dictionary,
|
||||
errors: Array[String]
|
||||
) -> void:
|
||||
if definition.completion_handler_id != SimulationIds.ACTION_HANDLER_ACTIVITY_METRIC_DELTA:
|
||||
return
|
||||
var label := "Action '%s' activity_metric_delta" % definition.action_id
|
||||
if definition.target_type != SimulationIds.TARGET_ACTIVITY:
|
||||
errors.append("%s handler requires an activity target" % label)
|
||||
if definition.effects.size() != 1:
|
||||
errors.append("%s handler requires exactly one effect" % label)
|
||||
return
|
||||
var effect := definition.effects[0] as ActionEffect
|
||||
if effect == null or effect.effect_kind != ActionEffect.KIND_METRIC_DELTA:
|
||||
errors.append("%s handler requires one metric_delta effect" % label)
|
||||
return
|
||||
if effect.subject_key not in [&"safety", &"knowledge"]:
|
||||
errors.append("%s metric must be safety or knowledge" % label)
|
||||
if not is_finite(effect.value) or effect.value <= 0.0:
|
||||
errors.append("%s effect value must be finite and positive" % label)
|
||||
var allowed_parameters := {"player_multiplier": true, "npc_productivity": true}
|
||||
for raw_key in effect.parameters:
|
||||
var key := String(raw_key)
|
||||
if not allowed_parameters.has(key):
|
||||
errors.append("%s has unsupported effect parameter '%s'" % [label, key])
|
||||
var player_multiplier: Variant = effect.parameters.get("player_multiplier", 1.0)
|
||||
if player_multiplier is not int and player_multiplier is not float:
|
||||
errors.append("%s player_multiplier must be numeric" % label)
|
||||
elif not is_finite(float(player_multiplier)) or float(player_multiplier) <= 0.0:
|
||||
errors.append("%s player_multiplier must be finite and positive" % label)
|
||||
var npc_productivity: Variant = effect.parameters.get("npc_productivity", false)
|
||||
if npc_productivity is not bool:
|
||||
errors.append("%s npc_productivity must be a bool" % label)
|
||||
if definition.event_type_id.is_empty():
|
||||
errors.append("%s handler requires a nonempty event_type_id" % label)
|
||||
if not definition.has_completion_cost():
|
||||
return
|
||||
var cost_item := items_by_id.get(definition.completion_cost_resource_id) as ItemDefinition
|
||||
if cost_item == null:
|
||||
return
|
||||
var has_storage_route := false
|
||||
for storage_value in storages_by_id.values():
|
||||
var storage := storage_value as StorageDefinition
|
||||
if storage != null and storage.accepts_item(cost_item):
|
||||
has_storage_route = true
|
||||
break
|
||||
if not has_storage_route:
|
||||
errors.append(
|
||||
(
|
||||
"%s completion cost item '%s' has no authored storage route"
|
||||
% [label, definition.completion_cost_resource_id]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
static func _validate_item_references(
|
||||
|
||||
@@ -28,6 +28,7 @@ const PROFESSION_PATHS := [
|
||||
static var _action_list: Array[ActionDefinition] = []
|
||||
static var _profession_list: Array[ProfessionDefinition] = []
|
||||
static var _action_map: Dictionary = {}
|
||||
static var _action_event_type_ids: Dictionary = {}
|
||||
static var _profession_map: Dictionary = {}
|
||||
static var _profession_id_list: Array[StringName] = []
|
||||
static var _catalog_errors: Array[String] = []
|
||||
@@ -40,6 +41,7 @@ static func _ensure_cache() -> void:
|
||||
_action_list = []
|
||||
_profession_list = []
|
||||
_action_map = {}
|
||||
_action_event_type_ids = {}
|
||||
_profession_map = {}
|
||||
_profession_id_list = []
|
||||
_catalog_errors = []
|
||||
@@ -52,6 +54,8 @@ static func _ensure_cache() -> void:
|
||||
_profession_list = catalog.get_professions()
|
||||
for definition in _action_list:
|
||||
_action_map[definition.action_id] = definition
|
||||
if not definition.event_type_id.is_empty():
|
||||
_action_event_type_ids[definition.event_type_id] = true
|
||||
for definition in _profession_list:
|
||||
_profession_map[definition.profession_id] = definition
|
||||
_profession_id_list.append(definition.profession_id)
|
||||
@@ -78,6 +82,11 @@ static func get_action(action_id: StringName) -> ActionDefinition:
|
||||
return _action_map.get(action_id)
|
||||
|
||||
|
||||
static func has_action_event_type(event_type_id: StringName) -> bool:
|
||||
_ensure_cache()
|
||||
return _action_event_type_ids.has(event_type_id)
|
||||
|
||||
|
||||
static func get_profession(profession_id: StringName) -> ProfessionDefinition:
|
||||
_ensure_cache()
|
||||
return _profession_map.get(profession_id)
|
||||
|
||||
@@ -6,6 +6,13 @@ const PLAYER_INVENTORY_ID := &"player_inventory"
|
||||
|
||||
const WORLD_CORE := &"world_core"
|
||||
const LOCATION_JAJCE := &"settlement_jajce"
|
||||
const REGIONAL_WORLD_BOSNIA := &"regional_bosnia"
|
||||
const REGIONAL_LOCATION_JAJCE := &"location_jajce"
|
||||
const ACTIVITY_GUARD_POST := &"guard_post"
|
||||
const ACTIVITY_STUDY_DESK := &"study_desk"
|
||||
const ACTION_HANDLER_ACTIVITY_METRIC_DELTA := &"activity_metric_delta"
|
||||
const BLOCKED_CONTRACT_ACTION_COST := &"action_cost"
|
||||
const BLOCKED_CONTRACT_LEGACY_UNSTRUCTURED := &"legacy_unstructured"
|
||||
|
||||
const ENTITY_PERSON := &"person"
|
||||
const ENTITY_PLAYER := &"player"
|
||||
@@ -72,6 +79,7 @@ const EVENT_COMMITMENT_FULFILLED := &"commitment_fulfilled"
|
||||
const EVENT_COMMITMENT_BROKEN := &"commitment_broken"
|
||||
const EVENT_COMMITMENT_RELEASED := &"commitment_released"
|
||||
const EVENT_COMMITMENT_SUPERSEDED := &"commitment_superseded"
|
||||
const EVENT_ACTIVITY_COMPLETED := &"activity_completed"
|
||||
|
||||
const OPPORTUNITY_RESTOCK_EMPTY_PANTRY := &"restock_empty_pantry"
|
||||
const OPPORTUNITY_SUPPLY_MISSING_WOOD := &"supply_missing_wood"
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
[gd_resource type="Resource" script_class="ActionDefinition" load_steps=2 format=3]
|
||||
[gd_resource type="Resource" script_class="ActionDefinition" load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://simulation/definitions/ActionDefinition.gd" id="1"]
|
||||
[ext_resource type="Script" path="res://simulation/commands/ActionEffect.gd" id="2"]
|
||||
|
||||
[sub_resource type="Resource" id="Effect_patrol_safety"]
|
||||
script = ExtResource("2")
|
||||
effect_id = &"patrol_safety"
|
||||
effect_kind = &"metric_delta"
|
||||
subject_key = &"safety"
|
||||
value = 1.0
|
||||
parameters = {"npc_productivity": true, "player_multiplier": 3.0}
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
@@ -11,3 +20,6 @@ preferred_profession_id = &"guard"
|
||||
target_type = &"activity"
|
||||
completion_cost_resource_id = &"wood"
|
||||
completion_cost_amount = 1.0
|
||||
completion_handler_id = &"activity_metric_delta"
|
||||
effects = [SubResource("Effect_patrol_safety")]
|
||||
event_type_id = &"activity_completed"
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
[gd_resource type="Resource" script_class="ActionDefinition" load_steps=2 format=3]
|
||||
[gd_resource type="Resource" script_class="ActionDefinition" load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://simulation/definitions/ActionDefinition.gd" id="1"]
|
||||
[ext_resource type="Script" path="res://simulation/commands/ActionEffect.gd" id="2"]
|
||||
|
||||
[sub_resource type="Resource" id="Effect_study_knowledge"]
|
||||
script = ExtResource("2")
|
||||
effect_id = &"study_knowledge"
|
||||
effect_kind = &"metric_delta"
|
||||
subject_key = &"knowledge"
|
||||
value = 1.0
|
||||
parameters = {"npc_productivity": true, "player_multiplier": 2.0}
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
@@ -11,3 +20,6 @@ preferred_profession_id = &"scholar"
|
||||
target_type = &"activity"
|
||||
completion_cost_resource_id = &"wood"
|
||||
completion_cost_amount = 1.0
|
||||
completion_handler_id = &"activity_metric_delta"
|
||||
effects = [SubResource("Effect_study_knowledge")]
|
||||
event_type_id = &"activity_completed"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[gd_resource type="Resource" script_class="EventTypeDefinition" load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://simulation/definitions/EventTypeDefinition.gd" id="1"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
event_type_id = &"activity_completed"
|
||||
display_name = "Activity Completed"
|
||||
description = "An actor completed a world-backed activity and changed authoritative state."
|
||||
category = "activity"
|
||||
@@ -1,4 +1,4 @@
|
||||
[gd_resource type="Resource" script_class="SimulationContentPack" load_steps=93 format=3]
|
||||
[gd_resource type="Resource" script_class="SimulationContentPack" load_steps=94 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://simulation/definitions/SimulationContentPack.gd" id="1_pack"]
|
||||
[ext_resource type="Resource" path="res://simulation/definitions/actions/defend.tres" id="2_defend"]
|
||||
@@ -81,6 +81,7 @@
|
||||
[ext_resource type="Resource" path="res://simulation/definitions/event_types/war_resolved.tres" id="79_war_resolved"]
|
||||
[ext_resource type="Resource" path="res://simulation/definitions/event_types/war_aborted.tres" id="80_war_aborted"]
|
||||
[ext_resource type="Resource" path="res://simulation/definitions/event_types/wolf_hunt.tres" id="81_wolf_hunt"]
|
||||
[ext_resource type="Resource" path="res://simulation/definitions/event_types/activity_completed.tres" id="92_activity_completed"]
|
||||
[ext_resource type="Resource" path="res://simulation/definitions/social_consequences/storage_food_aid_trust.tres" id="82_storage_food_aid"]
|
||||
[ext_resource type="Resource" path="res://simulation/definitions/social_consequences/player_food_aid_trust.tres" id="83_player_food_aid"]
|
||||
[ext_resource type="Resource" path="res://simulation/definitions/social_consequences/commitment_accepted_obligation.tres" id="84_accepted_obligation"]
|
||||
@@ -96,6 +97,7 @@
|
||||
script = ExtResource("1_pack")
|
||||
pack_id = &"core"
|
||||
display_name = "The Steward Core"
|
||||
required_handler_ids = [&"activity_metric_delta"]
|
||||
actions = [ExtResource("2_defend"), ExtResource("3_deposit_food"), ExtResource("4_deposit_wood"), ExtResource("5_eat"), ExtResource("6_feed_animal"), ExtResource("7_gather_food"), ExtResource("8_gather_wood"), ExtResource("9_patrol"), ExtResource("10_rest"), ExtResource("11_sleep"), ExtResource("12_study"), ExtResource("13_wander"), ExtResource("14_withdraw_food")]
|
||||
professions = [ExtResource("15_farmer"), ExtResource("16_guard"), ExtResource("17_scholar"), ExtResource("18_wanderer"), ExtResource("19_woodcutter")]
|
||||
capability_tags = [ExtResource("28_item_food"), ExtResource("29_item_medicine"), ExtResource("30_item_wood"), ExtResource("31_resource_harvestable"), ExtResource("32_storage_village")]
|
||||
@@ -107,5 +109,5 @@ animals = [ExtResource("39_goat"), ExtResource("40_sheep")]
|
||||
situations = [ExtResource("41_pantry_situation")]
|
||||
dialogue_intents = [ExtResource("42_accept"), ExtResource("43_acknowledge"), ExtResource("44_wellbeing"), ExtResource("45_happened"), ExtResource("46_who"), ExtResource("47_work"), ExtResource("48_decline"), ExtResource("49_need"), ExtResource("50_goodbye"), ExtResource("51_greet"), ExtResource("52_offer"), ExtResource("53_renegotiate"), ExtResource("54_progress"), ExtResource("55_reproach"), ExtResource("56_share"), ExtResource("57_thank")]
|
||||
dialogue_template_catalogs = [ExtResource("58_templates")]
|
||||
event_types = [ExtResource("59_resource_extracted"), ExtResource("60_storage_deposited"), ExtResource("61_storage_withdrawn"), ExtResource("62_item_consumed"), ExtResource("63_npc_slept"), ExtResource("64_npc_died"), ExtResource("65_task_started"), ExtResource("66_task_blocked"), ExtResource("67_resource_depleted"), ExtResource("68_animal_fed"), ExtResource("69_villager_weak"), ExtResource("70_home_damaged"), ExtResource("71_commitment_accepted"), ExtResource("72_commitment_fulfilled"), ExtResource("73_commitment_broken"), ExtResource("74_commitment_released"), ExtResource("75_commitment_superseded"), ExtResource("76_combatant_hurt"), ExtResource("77_combatant_killed"), ExtResource("78_raid_started"), ExtResource("79_war_resolved"), ExtResource("80_war_aborted"), ExtResource("81_wolf_hunt")]
|
||||
event_types = [ExtResource("59_resource_extracted"), ExtResource("60_storage_deposited"), ExtResource("61_storage_withdrawn"), ExtResource("62_item_consumed"), ExtResource("63_npc_slept"), ExtResource("64_npc_died"), ExtResource("65_task_started"), ExtResource("66_task_blocked"), ExtResource("67_resource_depleted"), ExtResource("68_animal_fed"), ExtResource("69_villager_weak"), ExtResource("70_home_damaged"), ExtResource("71_commitment_accepted"), ExtResource("72_commitment_fulfilled"), ExtResource("73_commitment_broken"), ExtResource("74_commitment_released"), ExtResource("75_commitment_superseded"), ExtResource("76_combatant_hurt"), ExtResource("77_combatant_killed"), ExtResource("78_raid_started"), ExtResource("79_war_resolved"), ExtResource("80_war_aborted"), ExtResource("81_wolf_hunt"), ExtResource("92_activity_completed")]
|
||||
social_consequences = [ExtResource("82_storage_food_aid"), ExtResource("83_player_food_aid"), ExtResource("84_accepted_obligation"), ExtResource("85_fulfilled_obligation"), ExtResource("86_fulfilled_trust"), ExtResource("87_broken_obligation"), ExtResource("88_broken_trust"), ExtResource("89_broken_hostility"), ExtResource("90_released_obligation"), ExtResource("91_superseded_obligation")]
|
||||
|
||||
@@ -22,26 +22,43 @@ signal narrative_event_requested(
|
||||
var village: SimVillage
|
||||
var storage_states: Dictionary = {}
|
||||
var debug_logs := false
|
||||
var content_catalog: ContentCatalog
|
||||
var storage_routing := StorageRoutingPolicy.new()
|
||||
var _storage_id_by_item_id: Dictionary = {}
|
||||
|
||||
|
||||
func configure(village_state: SimVillage, should_debug: bool) -> void:
|
||||
func configure(
|
||||
village_state: SimVillage, should_debug: bool, configured_catalog: ContentCatalog = null
|
||||
) -> void:
|
||||
village = village_state
|
||||
debug_logs = should_debug
|
||||
content_catalog = (
|
||||
configured_catalog if configured_catalog != null else ContentCatalog.create_core()
|
||||
)
|
||||
storage_routing = StorageRoutingPolicy.new(content_catalog)
|
||||
_storage_id_by_item_id.clear()
|
||||
|
||||
|
||||
func initialize_storage() -> void:
|
||||
if village == null:
|
||||
return
|
||||
if not storage_states.has(SimulationIds.STORAGE_VILLAGE_PANTRY):
|
||||
storage_states[SimulationIds.STORAGE_VILLAGE_PANTRY] = StorageStateRecord.create(
|
||||
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
||||
{String(SimulationIds.RESOURCE_FOOD): village.food}
|
||||
)
|
||||
if not storage_states.has(SimulationIds.STORAGE_VILLAGE_WOODPILE):
|
||||
storage_states[SimulationIds.STORAGE_VILLAGE_WOODPILE] = StorageStateRecord.create(
|
||||
SimulationIds.STORAGE_VILLAGE_WOODPILE,
|
||||
{String(SimulationIds.RESOURCE_WOOD): village.wood}
|
||||
)
|
||||
if content_catalog != null and content_catalog.is_valid():
|
||||
for definition in content_catalog.get_storages():
|
||||
if storage_states.has(definition.storage_id):
|
||||
continue
|
||||
var initial_amounts := {}
|
||||
if definition.storage_id == SimulationIds.STORAGE_VILLAGE_PANTRY:
|
||||
initial_amounts[String(SimulationIds.RESOURCE_FOOD)] = village.food
|
||||
elif definition.storage_id == SimulationIds.STORAGE_VILLAGE_WOODPILE:
|
||||
initial_amounts[String(SimulationIds.RESOURCE_WOOD)] = village.wood
|
||||
var initial_total := 0.0
|
||||
for amount in initial_amounts.values():
|
||||
initial_total += float(amount)
|
||||
storage_states[definition.storage_id] = StorageStateRecord.create(
|
||||
definition.storage_id, initial_amounts, maxf(definition.capacity, initial_total)
|
||||
)
|
||||
_ensure_legacy_village_storage()
|
||||
_rebuild_storage_route_cache()
|
||||
sync_all()
|
||||
|
||||
|
||||
@@ -67,12 +84,42 @@ func get_woodpile() -> 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
|
||||
var storage_id := StringName(_storage_id_by_item_id.get(resource_id, &""))
|
||||
return get_storage(storage_id) if not storage_id.is_empty() else null
|
||||
|
||||
|
||||
func _ensure_legacy_village_storage() -> void:
|
||||
if not storage_states.has(SimulationIds.STORAGE_VILLAGE_PANTRY):
|
||||
storage_states[SimulationIds.STORAGE_VILLAGE_PANTRY] = StorageStateRecord.create(
|
||||
SimulationIds.STORAGE_VILLAGE_PANTRY,
|
||||
{String(SimulationIds.RESOURCE_FOOD): village.food},
|
||||
maxf(StorageStateRecord.DEFAULT_CAPACITY, village.food)
|
||||
)
|
||||
if not storage_states.has(SimulationIds.STORAGE_VILLAGE_WOODPILE):
|
||||
storage_states[SimulationIds.STORAGE_VILLAGE_WOODPILE] = StorageStateRecord.create(
|
||||
SimulationIds.STORAGE_VILLAGE_WOODPILE,
|
||||
{String(SimulationIds.RESOURCE_WOOD): village.wood},
|
||||
maxf(StorageStateRecord.DEFAULT_CAPACITY, village.wood)
|
||||
)
|
||||
|
||||
|
||||
func _rebuild_storage_route_cache() -> void:
|
||||
_storage_id_by_item_id.clear()
|
||||
if storage_states.has(SimulationIds.STORAGE_VILLAGE_PANTRY):
|
||||
_storage_id_by_item_id[SimulationIds.RESOURCE_FOOD] = (SimulationIds.STORAGE_VILLAGE_PANTRY)
|
||||
if storage_states.has(SimulationIds.STORAGE_VILLAGE_WOODPILE):
|
||||
_storage_id_by_item_id[SimulationIds.RESOURCE_WOOD] = (
|
||||
SimulationIds.STORAGE_VILLAGE_WOODPILE
|
||||
)
|
||||
if content_catalog == null or not content_catalog.is_valid():
|
||||
return
|
||||
var available_ids: Array[StringName] = []
|
||||
for storage_key in storage_states:
|
||||
available_ids.append(StringName(storage_key))
|
||||
for item in content_catalog.get_items():
|
||||
var definition := storage_routing.resolve_definition(item.item_id, available_ids)
|
||||
if definition != null:
|
||||
_storage_id_by_item_id[item.item_id] = definition.storage_id
|
||||
|
||||
|
||||
func sync_all() -> void:
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
class_name ActivityEventValidator
|
||||
extends RefCounted
|
||||
|
||||
static var _core_catalog: ContentCatalog
|
||||
static var _core_storage_routing: StorageRoutingPolicy
|
||||
|
||||
|
||||
static func is_candidate(event: EconomicEventRecord) -> bool:
|
||||
if event == null:
|
||||
return false
|
||||
if StringName(event.data.get("event_type", &"")) == SimulationIds.EVENT_TASK_BLOCKED:
|
||||
return false
|
||||
if StringName(event.data.get("target_kind", &"")) == SimulationIds.TARGET_ACTIVITY:
|
||||
return true
|
||||
var event_type := StringName(event.data.get("event_type", &""))
|
||||
return SimulationDefinitions.has_action_event_type(event_type)
|
||||
|
||||
|
||||
static func is_valid(
|
||||
event: EconomicEventRecord,
|
||||
expected_world_id: StringName = &"",
|
||||
expected_location_id: StringName = &""
|
||||
) -> bool:
|
||||
if event == null or not _has_activity_fields(event):
|
||||
return false
|
||||
var actor_id := int(event.data["actor_id"])
|
||||
var action_id := StringName(event.data["action_id"])
|
||||
var definition := SimulationDefinitions.get_action(action_id)
|
||||
var effect := ActivityActionCommandService._metric_effect(definition)
|
||||
if (
|
||||
not KnownEventStateRecord.is_valid_actor_id(actor_id)
|
||||
or definition == null
|
||||
or effect == null
|
||||
or definition.target_type != SimulationIds.TARGET_ACTIVITY
|
||||
or definition.completion_handler_id != ActivityActionCommandService.HANDLER_METRIC_DELTA
|
||||
or StringName(event.data["event_type"]) != definition.event_type_id
|
||||
or StringName(event.data["source_id"]).is_empty()
|
||||
or not StringName(event.data["destination_id"]).is_empty()
|
||||
or StringName(event.data["target_kind"]) != SimulationIds.TARGET_ACTIVITY
|
||||
or StringName(event.data["target_action_id"]) != action_id
|
||||
or StringName(event.data["world_id"]).is_empty()
|
||||
or StringName(event.data["location_id"]).is_empty()
|
||||
or (
|
||||
not expected_world_id.is_empty()
|
||||
and StringName(event.data["world_id"]) != expected_world_id
|
||||
)
|
||||
or (
|
||||
not expected_location_id.is_empty()
|
||||
and StringName(event.data["location_id"]) != expected_location_id
|
||||
)
|
||||
):
|
||||
return false
|
||||
var metric_delta_value: Variant = event.data["metric_delta"]
|
||||
var cost_amount_value: Variant = event.data["cost_amount"]
|
||||
if (
|
||||
(metric_delta_value is not int and metric_delta_value is not float)
|
||||
or (cost_amount_value is not int and cost_amount_value is not float)
|
||||
):
|
||||
return false
|
||||
var metric_delta := float(metric_delta_value)
|
||||
var cost_amount := float(cost_amount_value)
|
||||
var maximum_delta := _maximum_delta(effect, actor_id)
|
||||
return (
|
||||
is_finite(metric_delta)
|
||||
and metric_delta > 0.0
|
||||
and is_finite(maximum_delta)
|
||||
and maximum_delta > 0.0
|
||||
and (metric_delta < maximum_delta or is_equal_approx(metric_delta, maximum_delta))
|
||||
and is_finite(cost_amount)
|
||||
and StringName(event.data["metric_id"]) == effect.subject_key
|
||||
and StringName(event.data["cost_item_id"]) == definition.completion_cost_resource_id
|
||||
and StringName(event.data["item_id"]) == definition.completion_cost_resource_id
|
||||
and is_equal_approx(cost_amount, definition.completion_cost_amount)
|
||||
and is_equal_approx(float(event.data["required_amount"]), definition.completion_cost_amount)
|
||||
)
|
||||
|
||||
|
||||
static func is_valid_for_state(
|
||||
event: EconomicEventRecord,
|
||||
npc_ids: Dictionary,
|
||||
current_tick: int,
|
||||
expected_world_id: StringName = &"",
|
||||
expected_location_id: StringName = &""
|
||||
) -> bool:
|
||||
if (
|
||||
not is_valid(event, expected_world_id, expected_location_id)
|
||||
or int(event.data["tick"]) > current_tick
|
||||
):
|
||||
return false
|
||||
var actor_id := int(event.data["actor_id"])
|
||||
return actor_id == SimulationIds.PLAYER_ACTOR_ID or npc_ids.has(actor_id)
|
||||
|
||||
|
||||
static func is_blocked_candidate(event: EconomicEventRecord) -> bool:
|
||||
return (
|
||||
event != null
|
||||
and StringName(event.data.get("event_type", &"")) == SimulationIds.EVENT_TASK_BLOCKED
|
||||
)
|
||||
|
||||
|
||||
static func is_valid_blocked(
|
||||
event: EconomicEventRecord,
|
||||
expected_world_id: StringName = &"",
|
||||
expected_location_id: StringName = &""
|
||||
) -> bool:
|
||||
if event == null or not _has_blocked_fields(event):
|
||||
return false
|
||||
if is_legacy_unstructured_blocked(event):
|
||||
return _is_valid_legacy_unstructured_blocked(event, expected_world_id, expected_location_id)
|
||||
var actor_id := int(event.data["actor_id"])
|
||||
var action_id := StringName(event.data["action_id"])
|
||||
var definition := SimulationDefinitions.get_action(action_id)
|
||||
if (
|
||||
StringName(event.data["event_type"]) != SimulationIds.EVENT_TASK_BLOCKED
|
||||
or not KnownEventStateRecord.is_valid_actor_id(actor_id)
|
||||
or definition == null
|
||||
or not definition.has_completion_cost()
|
||||
or (
|
||||
StringName(event.data["blocked_contract_kind"])
|
||||
!= SimulationIds.BLOCKED_CONTRACT_ACTION_COST
|
||||
)
|
||||
or StringName(event.data["item_id"]) != definition.completion_cost_resource_id
|
||||
or not is_equal_approx(
|
||||
float(event.data["required_amount"]), definition.completion_cost_amount
|
||||
)
|
||||
or StringName(event.data["source_id"]).is_empty()
|
||||
or not StringName(event.data["destination_id"]).is_empty()
|
||||
or not is_zero_approx(float(event.data["amount"]))
|
||||
or StringName(event.data["world_id"]).is_empty()
|
||||
or StringName(event.data["location_id"]).is_empty()
|
||||
or (
|
||||
not expected_world_id.is_empty()
|
||||
and StringName(event.data["world_id"]) != expected_world_id
|
||||
)
|
||||
or (
|
||||
not expected_location_id.is_empty()
|
||||
and StringName(event.data["location_id"]) != expected_location_id
|
||||
)
|
||||
):
|
||||
return false
|
||||
var route := _get_core_storage_routing().resolve_definition(
|
||||
definition.completion_cost_resource_id
|
||||
)
|
||||
if route == null or not _blocked_source_matches(event, action_id, actor_id, route.storage_id):
|
||||
return false
|
||||
if event.data.has("activity_target_id"):
|
||||
return (
|
||||
definition.target_type == SimulationIds.TARGET_ACTIVITY
|
||||
and (
|
||||
definition.completion_handler_id
|
||||
== SimulationIds.ACTION_HANDLER_ACTIVITY_METRIC_DELTA
|
||||
)
|
||||
and not StringName(event.data["activity_target_id"]).is_empty()
|
||||
and StringName(event.data.get("target_kind", &"")) == SimulationIds.TARGET_ACTIVITY
|
||||
and StringName(event.data.get("target_action_id", &"")) == action_id
|
||||
)
|
||||
return true
|
||||
|
||||
|
||||
static func _blocked_source_matches(
|
||||
event: EconomicEventRecord, action_id: StringName, actor_id: int, routed_storage_id: StringName
|
||||
) -> bool:
|
||||
var source_id := StringName(event.data["source_id"])
|
||||
if source_id == routed_storage_id:
|
||||
return true
|
||||
return (
|
||||
action_id == SimulationIds.ACTION_FEED_ANIMAL
|
||||
and actor_id >= 0
|
||||
and source_id == SimulationIds.npc_inventory_id(actor_id)
|
||||
)
|
||||
|
||||
|
||||
static func is_valid_blocked_for_state(
|
||||
event: EconomicEventRecord,
|
||||
npc_ids: Dictionary,
|
||||
storage_records_by_id: Dictionary,
|
||||
current_tick: int,
|
||||
expected_world_id: StringName = &"",
|
||||
expected_location_id: StringName = &""
|
||||
) -> bool:
|
||||
if (
|
||||
not is_valid_blocked(event, expected_world_id, expected_location_id)
|
||||
or int(event.data["tick"]) > current_tick
|
||||
):
|
||||
return false
|
||||
var actor_id := int(event.data["actor_id"])
|
||||
if actor_id != SimulationIds.PLAYER_ACTOR_ID and not npc_ids.has(actor_id):
|
||||
return false
|
||||
if is_legacy_unstructured_blocked(event):
|
||||
return true
|
||||
var source_id := StringName(event.data["source_id"])
|
||||
return (
|
||||
storage_records_by_id.has(source_id)
|
||||
or (
|
||||
StringName(event.data["action_id"]) == SimulationIds.ACTION_FEED_ANIMAL
|
||||
and actor_id >= 0
|
||||
and source_id == SimulationIds.npc_inventory_id(actor_id)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
static func is_legacy_unstructured_blocked(event: EconomicEventRecord) -> bool:
|
||||
return (
|
||||
event != null
|
||||
and StringName(event.data.get("event_type", &"")) == SimulationIds.EVENT_TASK_BLOCKED
|
||||
and (
|
||||
StringName(event.data.get("blocked_contract_kind", &""))
|
||||
== SimulationIds.BLOCKED_CONTRACT_LEGACY_UNSTRUCTURED
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
static func _is_valid_legacy_unstructured_blocked(
|
||||
event: EconomicEventRecord, expected_world_id: StringName, expected_location_id: StringName
|
||||
) -> bool:
|
||||
var legacy_version := int(event.data.get("legacy_world_schema_version", -1))
|
||||
return (
|
||||
(
|
||||
legacy_version
|
||||
in range(
|
||||
EconomicEventRecord.LEGACY_BLOCKED_WORLD_SCHEMA_MIN,
|
||||
EconomicEventRecord.LEGACY_BLOCKED_WORLD_SCHEMA_MAX + 1
|
||||
)
|
||||
)
|
||||
and KnownEventStateRecord.is_valid_actor_id(int(event.data["actor_id"]))
|
||||
and StringName(event.data["action_id"]).is_empty()
|
||||
and StringName(event.data["item_id"]).is_empty()
|
||||
and is_zero_approx(float(event.data["required_amount"]))
|
||||
and StringName(event.data["destination_id"]).is_empty()
|
||||
and is_zero_approx(float(event.data["amount"]))
|
||||
and not StringName(event.data["world_id"]).is_empty()
|
||||
and not StringName(event.data["location_id"]).is_empty()
|
||||
and (
|
||||
expected_world_id.is_empty() or StringName(event.data["world_id"]) == expected_world_id
|
||||
)
|
||||
and (
|
||||
expected_location_id.is_empty()
|
||||
or StringName(event.data["location_id"]) == expected_location_id
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
static func _has_activity_fields(event: EconomicEventRecord) -> bool:
|
||||
return (
|
||||
event
|
||||
. data
|
||||
. has_all(
|
||||
[
|
||||
"metric_id",
|
||||
"metric_delta",
|
||||
"cost_item_id",
|
||||
"cost_amount",
|
||||
"target_kind",
|
||||
"target_action_id",
|
||||
"world_id",
|
||||
"location_id",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
static func _has_blocked_fields(event: EconomicEventRecord) -> bool:
|
||||
return (
|
||||
event
|
||||
. data
|
||||
. has_all(
|
||||
[
|
||||
"blocked_contract_kind",
|
||||
"world_id",
|
||||
"location_id",
|
||||
"action_id",
|
||||
"item_id",
|
||||
"required_amount",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
static func _maximum_delta(effect: ActionEffect, actor_id: int) -> float:
|
||||
var maximum_delta := effect.value
|
||||
if actor_id != SimulationIds.PLAYER_ACTOR_ID:
|
||||
return maximum_delta
|
||||
var raw_multiplier: Variant = effect.parameters.get("player_multiplier", 1.0)
|
||||
if raw_multiplier is not int and raw_multiplier is not float:
|
||||
return -1.0
|
||||
return maximum_delta * float(raw_multiplier)
|
||||
|
||||
|
||||
static func _get_core_storage_routing() -> StorageRoutingPolicy:
|
||||
if _core_storage_routing == null:
|
||||
_core_catalog = ContentCatalog.create_core()
|
||||
_core_storage_routing = StorageRoutingPolicy.new(_core_catalog)
|
||||
return _core_storage_routing
|
||||
@@ -0,0 +1 @@
|
||||
uid://spr5p8ebsyc1
|
||||
@@ -28,6 +28,23 @@ var _events_by_id: Dictionary = {}
|
||||
var _event_ids_by_actor: Dictionary = {}
|
||||
var _event_ids_by_type: Dictionary = {}
|
||||
var _event_ids_by_tick: Dictionary = {}
|
||||
var _world_id := SimulationIds.REGIONAL_WORLD_BOSNIA
|
||||
var _location_id := SimulationIds.REGIONAL_LOCATION_JAJCE
|
||||
|
||||
|
||||
func configure_scope(world_id: StringName, location_id: StringName) -> bool:
|
||||
if world_id.is_empty() or location_id.is_empty():
|
||||
return false
|
||||
if not events.is_empty():
|
||||
return world_id == _world_id and location_id == _location_id
|
||||
_world_id = world_id
|
||||
_location_id = location_id
|
||||
world_events = WorldEventStore.new()
|
||||
return true
|
||||
|
||||
|
||||
func get_scope() -> Dictionary:
|
||||
return {"world_id": _world_id, "location_id": _location_id}
|
||||
|
||||
|
||||
func record_economic(
|
||||
@@ -80,6 +97,14 @@ func record_narrative(
|
||||
item_id,
|
||||
required_amount
|
||||
)
|
||||
if event_type == SimulationIds.EVENT_TASK_BLOCKED:
|
||||
event.data["blocked_contract_kind"] = String(SimulationIds.BLOCKED_CONTRACT_ACTION_COST)
|
||||
event.data["world_id"] = String(_world_id)
|
||||
event.data["location_id"] = String(_location_id)
|
||||
var normalized := EconomicEventRecord.from_dictionary(event.to_dictionary())
|
||||
if normalized == null:
|
||||
return null
|
||||
event = normalized
|
||||
_append(event)
|
||||
return event
|
||||
|
||||
@@ -115,11 +140,105 @@ func record_fact(
|
||||
var normalized := EconomicEventRecord.from_dictionary(event.to_dictionary())
|
||||
if normalized == null:
|
||||
return null
|
||||
if WorldEventRecord.from_economic_event(normalized, _world_id, _location_id) == null:
|
||||
return null
|
||||
_append(normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
func record_activity_completed(
|
||||
tick: int,
|
||||
actor_id: int,
|
||||
target_id: StringName,
|
||||
action_id: StringName,
|
||||
event_type_id: StringName,
|
||||
metric_id: StringName,
|
||||
metric_delta: float,
|
||||
world_position: Vector3 = Vector3.ZERO,
|
||||
cost_item_id: StringName = &"",
|
||||
cost_amount: float = 0.0
|
||||
) -> EconomicEventRecord:
|
||||
if (
|
||||
WorldEventRecord.from_economic_event(
|
||||
normalized, SimulationIds.WORLD_CORE, SimulationIds.LOCATION_JAJCE
|
||||
)
|
||||
== null
|
||||
tick < 0
|
||||
or actor_id < SimulationIds.PLAYER_ACTOR_ID
|
||||
or target_id.is_empty()
|
||||
or action_id.is_empty()
|
||||
or event_type_id.is_empty()
|
||||
or metric_id not in [&"safety", &"knowledge"]
|
||||
or not is_finite(metric_delta)
|
||||
or metric_delta <= 0.0
|
||||
or not world_position.is_finite()
|
||||
or not is_finite(cost_amount)
|
||||
or cost_amount < 0.0
|
||||
or (cost_item_id.is_empty() and cost_amount > 0.0)
|
||||
or (not cost_item_id.is_empty() and cost_amount <= 0.0)
|
||||
):
|
||||
return null
|
||||
var event := EconomicEventRecord.create_narrative(
|
||||
next_event_id,
|
||||
event_type_id,
|
||||
tick,
|
||||
actor_id,
|
||||
target_id,
|
||||
"",
|
||||
world_position,
|
||||
action_id,
|
||||
cost_item_id,
|
||||
cost_amount
|
||||
)
|
||||
event.data["metric_id"] = String(metric_id)
|
||||
event.data["metric_delta"] = metric_delta
|
||||
event.data["cost_item_id"] = String(cost_item_id)
|
||||
event.data["cost_amount"] = cost_amount
|
||||
event.data["target_kind"] = String(SimulationIds.TARGET_ACTIVITY)
|
||||
event.data["target_action_id"] = String(action_id)
|
||||
event.data["world_id"] = String(_world_id)
|
||||
event.data["location_id"] = String(_location_id)
|
||||
var normalized := EconomicEventRecord.from_dictionary(event.to_dictionary())
|
||||
if (
|
||||
normalized == null
|
||||
or (WorldEventRecord.from_economic_event(normalized, _world_id, _location_id) == null)
|
||||
):
|
||||
return null
|
||||
_append(normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
func record_activity_blocked(
|
||||
tick: int,
|
||||
actor_id: int,
|
||||
target_id: StringName,
|
||||
action_id: StringName,
|
||||
action_display: String,
|
||||
world_position: Vector3,
|
||||
cost_storage_id: StringName,
|
||||
cost_item_id: StringName,
|
||||
cost_amount: float
|
||||
) -> EconomicEventRecord:
|
||||
var event := EconomicEventRecord.create_narrative(
|
||||
next_event_id,
|
||||
SimulationIds.EVENT_TASK_BLOCKED,
|
||||
tick,
|
||||
actor_id,
|
||||
cost_storage_id,
|
||||
action_display,
|
||||
world_position,
|
||||
action_id,
|
||||
cost_item_id,
|
||||
cost_amount
|
||||
)
|
||||
if target_id.is_empty():
|
||||
return null
|
||||
event.data["blocked_contract_kind"] = String(SimulationIds.BLOCKED_CONTRACT_ACTION_COST)
|
||||
event.data["world_id"] = String(_world_id)
|
||||
event.data["location_id"] = String(_location_id)
|
||||
event.data["activity_target_id"] = String(target_id)
|
||||
event.data["target_kind"] = String(SimulationIds.TARGET_ACTIVITY)
|
||||
event.data["target_action_id"] = String(action_id)
|
||||
var normalized := EconomicEventRecord.from_dictionary(event.to_dictionary())
|
||||
if (
|
||||
normalized == null
|
||||
or WorldEventRecord.from_economic_event(normalized, _world_id, _location_id) == null
|
||||
):
|
||||
return null
|
||||
_append(normalized)
|
||||
@@ -207,16 +326,27 @@ func get_consumption_rates(
|
||||
}
|
||||
|
||||
|
||||
func restore(restored_events: Array[EconomicEventRecord], restored_next_id: int) -> void:
|
||||
func restore(
|
||||
restored_events: Array[EconomicEventRecord],
|
||||
restored_next_id: int,
|
||||
restored_world_id: StringName = &"",
|
||||
restored_location_id: StringName = &""
|
||||
) -> bool:
|
||||
if restored_world_id.is_empty() != restored_location_id.is_empty():
|
||||
return false
|
||||
events.clear()
|
||||
_events_by_id.clear()
|
||||
_event_ids_by_actor.clear()
|
||||
_event_ids_by_type.clear()
|
||||
_event_ids_by_tick.clear()
|
||||
if not restored_world_id.is_empty():
|
||||
_world_id = restored_world_id
|
||||
_location_id = restored_location_id
|
||||
world_events = WorldEventStore.new()
|
||||
for event in restored_events:
|
||||
_index(event)
|
||||
next_event_id = restored_next_id
|
||||
return true
|
||||
|
||||
|
||||
func _append(event: EconomicEventRecord) -> void:
|
||||
@@ -236,7 +366,13 @@ func _index(event: EconomicEventRecord) -> void:
|
||||
_append_index(_event_ids_by_actor, int(event.data["actor_id"]), event_id)
|
||||
_append_index(_event_ids_by_type, StringName(event.data["event_type"]), event_id)
|
||||
_append_index(_event_ids_by_tick, int(event.data["tick"]), event_id)
|
||||
world_events.append_economic(event, SimulationIds.WORLD_CORE, SimulationIds.LOCATION_JAJCE)
|
||||
var event_world_id := StringName(event.data.get("world_id", _world_id))
|
||||
var event_location_id := StringName(event.data.get("location_id", _location_id))
|
||||
if event_world_id.is_empty():
|
||||
event_world_id = _world_id
|
||||
if event_location_id.is_empty():
|
||||
event_location_id = _location_id
|
||||
world_events.append_economic(event, event_world_id, event_location_id)
|
||||
|
||||
|
||||
static func _append_index(index: Dictionary, key: Variant, event_id: int) -> void:
|
||||
|
||||
@@ -336,15 +336,11 @@ static func is_knowable_event(event: EconomicEventRecord) -> bool:
|
||||
)
|
||||
if event_type == SimulationIds.EVENT_TASK_BLOCKED:
|
||||
return (
|
||||
int(event.data["actor_id"]) >= 0
|
||||
and StringName(event.data["source_id"]) == SimulationIds.STORAGE_VILLAGE_WOODPILE
|
||||
and item_id == SimulationIds.RESOURCE_WOOD
|
||||
and (
|
||||
StringName(event.data.get("action_id", &""))
|
||||
in [SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY]
|
||||
)
|
||||
and float(event.data.get("required_amount", 0.0)) > 0.0
|
||||
not ActivityEventValidator.is_legacy_unstructured_blocked(event)
|
||||
and ActivityEventValidator.is_valid_blocked(event)
|
||||
)
|
||||
if ActivityEventValidator.is_candidate(event):
|
||||
return ActivityEventValidator.is_valid(event)
|
||||
if (
|
||||
event_type
|
||||
in [
|
||||
|
||||
@@ -4,6 +4,8 @@ extends RefCounted
|
||||
const SCHEMA_VERSION := 3
|
||||
const POSITION_LEGACY_SCHEMA_VERSION := 2
|
||||
const LEGACY_SCHEMA_VERSION := 1
|
||||
const LEGACY_BLOCKED_WORLD_SCHEMA_MIN := 3
|
||||
const LEGACY_BLOCKED_WORLD_SCHEMA_MAX := 15
|
||||
|
||||
var data: Dictionary
|
||||
|
||||
@@ -117,7 +119,17 @@ static func from_dictionary(record_data: Dictionary) -> EconomicEventRecord:
|
||||
and not normalized["item_id"].is_empty()
|
||||
and normalized["required_amount"] > 0.0
|
||||
)
|
||||
if version == SCHEMA_VERSION and not has_stable_contract:
|
||||
var has_legacy_marker := (
|
||||
(
|
||||
StringName(normalized.get("blocked_contract_kind", &""))
|
||||
== SimulationIds.BLOCKED_CONTRACT_LEGACY_UNSTRUCTURED
|
||||
)
|
||||
and (
|
||||
int(normalized.get("legacy_world_schema_version", -1))
|
||||
in range(LEGACY_BLOCKED_WORLD_SCHEMA_MIN, LEGACY_BLOCKED_WORLD_SCHEMA_MAX + 1)
|
||||
)
|
||||
)
|
||||
if version == SCHEMA_VERSION and not has_stable_contract and not has_legacy_marker:
|
||||
return null
|
||||
return EconomicEventRecord.new(normalized)
|
||||
|
||||
@@ -160,5 +172,16 @@ func description(npc_names: Dictionary = {}) -> String:
|
||||
return "%s was depleted" % source
|
||||
"animal_fed":
|
||||
return "%s fed %s %.0f %s" % [actor_name, destination, amount, item]
|
||||
"activity_completed":
|
||||
return (
|
||||
"%s completed %s at %s (+%.1f %s)"
|
||||
% [
|
||||
actor_name,
|
||||
String(data.get("action_id", "activity")).capitalize(),
|
||||
source,
|
||||
float(data.get("metric_delta", 0.0)),
|
||||
String(data.get("metric_id", "benefit")),
|
||||
]
|
||||
)
|
||||
_:
|
||||
return "tick %d: %s" % [int(data["tick"]), event_type]
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
class_name SimulationStateRecord
|
||||
extends RefCounted
|
||||
# gdlint: disable=max-file-lines
|
||||
|
||||
const SCHEMA_NAME := "the_steward.simulation"
|
||||
const SCHEMA_VERSION := 15
|
||||
const SCHEMA_VERSION := 16
|
||||
const LEGACY_SCHEMA_VERSION := 1
|
||||
const EVENT_LEGACY_SCHEMA_VERSION := 2
|
||||
const RELATIONSHIP_LEGACY_SCHEMA_VERSION := 3
|
||||
@@ -16,6 +17,7 @@ const PLAYER_RELATIONSHIP_SCHEMA_VERSION := 11
|
||||
const PLAYER_NEEDS_SCHEMA_VERSION := 12
|
||||
const CONFLICT_SCHEMA_VERSION := 13
|
||||
const PRE_EMERGENT_SCHEMA_VERSION := 14
|
||||
const EMERGENT_SCHEMA_VERSION := 15
|
||||
const PREVIOUS_SCHEMA_VERSION := 7
|
||||
|
||||
var simulation: Dictionary
|
||||
@@ -137,6 +139,7 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
PLAYER_NEEDS_SCHEMA_VERSION,
|
||||
CONFLICT_SCHEMA_VERSION,
|
||||
PRE_EMERGENT_SCHEMA_VERSION,
|
||||
EMERGENT_SCHEMA_VERSION,
|
||||
]
|
||||
):
|
||||
record_data = _migrate_legacy(record_data, version)
|
||||
@@ -187,6 +190,8 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
"next_journal_entry_id",
|
||||
"next_commitment_id",
|
||||
"next_conversation_act_id",
|
||||
"world_id",
|
||||
"location_id",
|
||||
]
|
||||
)
|
||||
):
|
||||
@@ -200,6 +205,8 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
var saved_next_journal_entry_id := int(simulation_data["next_journal_entry_id"])
|
||||
var saved_next_commitment_id := int(simulation_data["next_commitment_id"])
|
||||
var saved_next_conversation_act_id := int(simulation_data["next_conversation_act_id"])
|
||||
var saved_world_id := StringName(simulation_data["world_id"])
|
||||
var saved_location_id := StringName(simulation_data["location_id"])
|
||||
if (
|
||||
not is_finite(saved_tick_interval)
|
||||
or saved_tick_interval <= 0.0
|
||||
@@ -212,6 +219,8 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
or saved_next_journal_entry_id < 0
|
||||
or saved_next_commitment_id < 0
|
||||
or saved_next_conversation_act_id < 0
|
||||
or saved_world_id.is_empty()
|
||||
or saved_location_id.is_empty()
|
||||
):
|
||||
return null
|
||||
if simulation_data.has("cycle_duration_seconds"):
|
||||
@@ -447,6 +456,25 @@ static func from_dictionary(record_data: Dictionary) -> SimulationStateRecord:
|
||||
int(latest_feed_ticks.get(fed_animal_id, AnimalStateRecord.NEVER_FED_TICK)),
|
||||
int(event_record.data["tick"])
|
||||
)
|
||||
elif ActivityEventValidator.is_candidate(event_record):
|
||||
if not ActivityEventValidator.is_valid_for_state(
|
||||
event_record,
|
||||
npc_ids,
|
||||
int(record.simulation["tick_count"]),
|
||||
saved_world_id,
|
||||
saved_location_id
|
||||
):
|
||||
return null
|
||||
elif ActivityEventValidator.is_blocked_candidate(event_record):
|
||||
if not ActivityEventValidator.is_valid_blocked_for_state(
|
||||
event_record,
|
||||
npc_ids,
|
||||
storage_records_by_id,
|
||||
int(record.simulation["tick_count"]),
|
||||
saved_world_id,
|
||||
saved_location_id
|
||||
):
|
||||
return null
|
||||
record.economic_events.append(event_record)
|
||||
if int(record.simulation["next_event_id"]) <= highest_event_id:
|
||||
return null
|
||||
@@ -932,6 +960,13 @@ static func _is_valid_supply_resolution(
|
||||
static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary:
|
||||
var migrated := legacy_data.duplicate(true)
|
||||
migrated["schema_version"] = SCHEMA_VERSION
|
||||
if version == EMERGENT_SCHEMA_VERSION:
|
||||
var scoped_simulation_data: Dictionary = migrated.get("simulation", {})
|
||||
scoped_simulation_data["world_id"] = String(SimulationIds.REGIONAL_WORLD_BOSNIA)
|
||||
scoped_simulation_data["location_id"] = String(SimulationIds.REGIONAL_LOCATION_JAJCE)
|
||||
migrated["simulation"] = scoped_simulation_data
|
||||
_migrate_blocked_event_scope(migrated, scoped_simulation_data, version)
|
||||
return migrated
|
||||
migrated["situations"] = []
|
||||
migrated["quest_journal"] = []
|
||||
migrated["commitments"] = []
|
||||
@@ -941,7 +976,10 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
|
||||
emergent_simulation_data["next_journal_entry_id"] = 0
|
||||
emergent_simulation_data["next_commitment_id"] = 0
|
||||
emergent_simulation_data["next_conversation_act_id"] = 0
|
||||
emergent_simulation_data["world_id"] = String(SimulationIds.REGIONAL_WORLD_BOSNIA)
|
||||
emergent_simulation_data["location_id"] = String(SimulationIds.REGIONAL_LOCATION_JAJCE)
|
||||
migrated["simulation"] = emergent_simulation_data
|
||||
_migrate_blocked_event_scope(migrated, emergent_simulation_data, version)
|
||||
if not migrated.has("player"):
|
||||
migrated["player"] = PlayerStateRecord.create_default().to_dictionary()
|
||||
if version < CONFLICT_SCHEMA_VERSION:
|
||||
@@ -1031,6 +1069,39 @@ static func _migrate_legacy(legacy_data: Dictionary, version: int) -> Dictionary
|
||||
return migrated
|
||||
|
||||
|
||||
static func _migrate_blocked_event_scope(
|
||||
migrated: Dictionary, simulation_data: Dictionary, source_world_schema_version: int
|
||||
) -> void:
|
||||
var migrated_events: Variant = migrated.get("economic_events", [])
|
||||
if migrated_events is not Array:
|
||||
return
|
||||
for event_value in migrated_events:
|
||||
if (
|
||||
event_value is Dictionary
|
||||
and StringName(event_value.get("event_type", &"")) == SimulationIds.EVENT_TASK_BLOCKED
|
||||
):
|
||||
var event := event_value as Dictionary
|
||||
var raw_required_amount: Variant = event.get("required_amount", 0.0)
|
||||
var has_stable_contract := (
|
||||
not String(event.get("action_id", "")).is_empty()
|
||||
and not String(event.get("item_id", "")).is_empty()
|
||||
and (raw_required_amount is int or raw_required_amount is float)
|
||||
and is_finite(float(raw_required_amount))
|
||||
and float(raw_required_amount) > 0.0
|
||||
)
|
||||
event["blocked_contract_kind"] = String(
|
||||
(
|
||||
SimulationIds.BLOCKED_CONTRACT_ACTION_COST
|
||||
if has_stable_contract
|
||||
else SimulationIds.BLOCKED_CONTRACT_LEGACY_UNSTRUCTURED
|
||||
)
|
||||
)
|
||||
if not has_stable_contract:
|
||||
event["legacy_world_schema_version"] = source_world_schema_version
|
||||
event["world_id"] = String(simulation_data["world_id"])
|
||||
event["location_id"] = String(simulation_data["location_id"])
|
||||
|
||||
|
||||
static func _migrate_npc_familiarity(npc_data: Variant) -> Array[Dictionary]:
|
||||
var migrated_relationships: Array[Dictionary] = []
|
||||
if not npc_data is Array:
|
||||
|
||||
@@ -197,6 +197,7 @@ func _test_wood_work_requires_material() -> void:
|
||||
var safety_before: float = manager.village.safety
|
||||
|
||||
npc.set_task(SimulationIds.ACTION_PATROL, 1.0)
|
||||
npc.target_id = SimulationIds.ACTIVITY_GUARD_POST
|
||||
npc.start_working()
|
||||
manager.simulate_tick()
|
||||
_check(
|
||||
@@ -227,13 +228,27 @@ func _test_wood_work_requires_material() -> void:
|
||||
)
|
||||
var knowledge_before: float = manager.village.knowledge
|
||||
npc.set_task(SimulationIds.ACTION_STUDY, 1.0)
|
||||
npc.target_id = SimulationIds.ACTIVITY_STUDY_DESK
|
||||
npc.start_working()
|
||||
manager.simulate_tick()
|
||||
var completed_events: Array[EconomicEventRecord] = manager.get_npc_events(npc.id, 2)
|
||||
var completed_event: EconomicEventRecord = (
|
||||
completed_events.back() if not completed_events.is_empty() else null
|
||||
)
|
||||
_check(
|
||||
(
|
||||
is_equal_approx(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), 1.0)
|
||||
and manager.village.knowledge > knowledge_before
|
||||
and completed_event != null
|
||||
and (
|
||||
StringName(completed_event.data["event_type"])
|
||||
== SimulationIds.EVENT_ACTIVITY_COMPLETED
|
||||
)
|
||||
and StringName(completed_event.data["action_id"]) == SimulationIds.ACTION_STUDY
|
||||
and StringName(completed_event.data["metric_id"]) == &"knowledge"
|
||||
and StringName(completed_event.data["cost_item_id"]) == SimulationIds.RESOURCE_WOOD
|
||||
and is_equal_approx(float(completed_event.data["cost_amount"]), 1.0)
|
||||
),
|
||||
"Funded study should consume one wood and produce knowledge"
|
||||
"Funded study should consume one wood and produce one exact authored activity fact"
|
||||
)
|
||||
manager.free()
|
||||
|
||||
@@ -352,16 +352,16 @@ func _run() -> void:
|
||||
SimulationIds.RESOURCE_WOOD, woodpile_state.get_amount(SimulationIds.RESOURCE_WOOD)
|
||||
)
|
||||
simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_WOOD)
|
||||
var woodpile_node := (
|
||||
main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillageWoodpile") as StorageNode
|
||||
var study_site := (
|
||||
main_scene.get_node("JajceWorld/WorldObjects/ActivitySites/StudyDesk") as ActivitySite
|
||||
)
|
||||
contributor.position = woodpile_node.get_interaction_position()
|
||||
contributor.position = study_site.get_interaction_position()
|
||||
for npc in simulation_manager.npcs:
|
||||
if npc.id != contributor.id:
|
||||
npc.position = contributor.position + Vector3(20.0 + npc.id * 2.0, 0.0, 0.0)
|
||||
witness.profession = SimulationIds.PROFESSION_WOODCUTTER
|
||||
contributor.set_task(SimulationIds.ACTION_STUDY, 1.0)
|
||||
contributor.target_id = SimulationIds.STORAGE_VILLAGE_WOODPILE
|
||||
contributor.target_id = study_site.site_id
|
||||
contributor.travel_target_position = contributor.position
|
||||
contributor.start_working()
|
||||
simulation_manager.simulate_tick()
|
||||
|
||||
@@ -23,6 +23,7 @@ func _run() -> void:
|
||||
_set_wood_amount(manager, 0.5)
|
||||
|
||||
interested.set_task(SimulationIds.ACTION_STUDY, 1.0)
|
||||
interested.target_id = SimulationIds.ACTIVITY_STUDY_DESK
|
||||
interested.start_working()
|
||||
manager.simulate_tick()
|
||||
var opportunity: OpportunityStateRecord = manager.get_active_opportunity()
|
||||
|
||||
@@ -126,11 +126,172 @@ func _run() -> void:
|
||||
main_scene.get_node("JajceWorld/WorldObjects/ActivitySites/GuardPost") as ActivitySite
|
||||
)
|
||||
player.global_position = guard_site.get_interaction_position()
|
||||
var adapter := main_scene.get_node("ActiveWorldAdapter") as ActiveWorldAdapter
|
||||
var adapter_identity := adapter.get_context_identity()
|
||||
_check(
|
||||
adapter.configure_context_identity(
|
||||
StringName(adapter_identity["context_id"]),
|
||||
StringName(adapter_identity["world_id"]),
|
||||
StringName(adapter_identity["location_id"])
|
||||
),
|
||||
"Activity registry ABA setup should create a clean first registry"
|
||||
)
|
||||
var guard_context := player.get_interaction_context() as PlayerInteractionResult
|
||||
var stale_safety: float = simulation_manager.village.safety
|
||||
var stale_woodpile := simulation_manager.get_woodpile() as StorageStateRecord
|
||||
var stale_wood: float = stale_woodpile.get_amount(SimulationIds.RESOURCE_WOOD)
|
||||
var stale_event_count: int = simulation_manager.economic_events.size()
|
||||
_check(
|
||||
(
|
||||
guard_context != null
|
||||
and adapter.configure_context_identity(
|
||||
StringName(adapter_identity["context_id"]),
|
||||
StringName(adapter_identity["world_id"]),
|
||||
StringName(adapter_identity["location_id"])
|
||||
)
|
||||
and adapter.get_target_handle(guard_site.site_id) != null
|
||||
and (
|
||||
adapter.get_target_handle(guard_site.site_id).get_generation()
|
||||
== guard_context.target_generation
|
||||
)
|
||||
),
|
||||
"Activity registry replacement should recreate the same visible target generation"
|
||||
)
|
||||
simulation_manager.update_player_combatant(player.global_position)
|
||||
var stale_result: ActionResult = simulation_manager.execute_player_activity_action(
|
||||
guard_context.action_id,
|
||||
guard_context.target_id,
|
||||
guard_context.expected_state_revision,
|
||||
guard_context.target_generation,
|
||||
guard_context.target_context_id,
|
||||
guard_context.target_registry_instance_id
|
||||
)
|
||||
var incomplete_result: ActionResult = simulation_manager.execute_player_activity_action(
|
||||
guard_context.action_id, guard_context.target_id
|
||||
)
|
||||
_check(
|
||||
(
|
||||
stale_result != null
|
||||
and not stale_result.did_succeed()
|
||||
and stale_result.get_reason_code() == ActivityActionCommandService.REASON_STALE_TARGET
|
||||
and is_equal_approx(simulation_manager.village.safety, stale_safety)
|
||||
and is_equal_approx(stale_woodpile.get_amount(SimulationIds.RESOURCE_WOOD), stale_wood)
|
||||
and simulation_manager.economic_events.size() == stale_event_count
|
||||
and incomplete_result != null
|
||||
and not incomplete_result.did_succeed()
|
||||
and (
|
||||
incomplete_result.get_reason_code()
|
||||
== ActivityActionCommandService.REASON_STALE_TARGET
|
||||
)
|
||||
),
|
||||
(
|
||||
"A replaced registry and an incomplete direct call must reject without mutating "
|
||||
+ "authority"
|
||||
)
|
||||
)
|
||||
guard_context = player.get_interaction_context() as PlayerInteractionResult
|
||||
var safety_before: float = simulation_manager.village.safety
|
||||
var woodpile := simulation_manager.get_woodpile() as StorageStateRecord
|
||||
var wood_before := woodpile.get_amount(SimulationIds.RESOURCE_WOOD)
|
||||
var event_count_before: int = simulation_manager.economic_events.size()
|
||||
player.try_interact()
|
||||
var activity_event := simulation_manager.economic_events.back() as EconomicEventRecord
|
||||
_check(
|
||||
(
|
||||
guard_context != null
|
||||
and guard_context.kind == PlayerInteractionResult.KIND_ACTIVITY
|
||||
and guard_context.action_id == SimulationIds.ACTION_PATROL
|
||||
and guard_context.target_id == guard_site.site_id
|
||||
and guard_context.expected_state_revision == event_count_before
|
||||
and simulation_manager.village.safety > safety_before
|
||||
and is_equal_approx(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), wood_before - 1.0)
|
||||
and simulation_manager.economic_events.size() == event_count_before + 1
|
||||
and (
|
||||
StringName(activity_event.data["event_type"])
|
||||
== SimulationIds.EVENT_ACTIVITY_COMPLETED
|
||||
)
|
||||
and int(activity_event.data["actor_id"]) == SimulationIds.PLAYER_ACTOR_ID
|
||||
and StringName(activity_event.data["source_id"]) == guard_site.site_id
|
||||
and StringName(activity_event.data["action_id"]) == SimulationIds.ACTION_PATROL
|
||||
and StringName(activity_event.data["metric_id"]) == &"safety"
|
||||
and is_equal_approx(float(activity_event.data["metric_delta"]), 3.0)
|
||||
and StringName(activity_event.data["cost_item_id"]) == SimulationIds.RESOURCE_WOOD
|
||||
and is_equal_approx(float(activity_event.data["cost_amount"]), 1.0)
|
||||
and StringName(activity_event.data["world_id"]) == SimulationIds.REGIONAL_WORLD_BOSNIA
|
||||
and (
|
||||
StringName(activity_event.data["location_id"])
|
||||
== SimulationIds.REGIONAL_LOCATION_JAJCE
|
||||
)
|
||||
and simulation_manager.event_knowledge_system.knows_event(
|
||||
SimulationIds.PLAYER_ACTOR_ID, int(activity_event.data["event_id"])
|
||||
)
|
||||
),
|
||||
(
|
||||
"Player activity should use the same revisioned command, cost, metric, fact, and "
|
||||
+ "knowledge path as NPC work"
|
||||
)
|
||||
)
|
||||
var activity_checksum: String = simulation_manager.get_state_checksum()
|
||||
var activity_snapshot: String = simulation_manager.serialize_state()
|
||||
_check(
|
||||
(
|
||||
simulation_manager.restore_state_from_json(activity_snapshot)
|
||||
and simulation_manager.get_state_checksum() == activity_checksum
|
||||
and simulation_manager.event_knowledge_system.knows_event(
|
||||
SimulationIds.PLAYER_ACTOR_ID, int(activity_event.data["event_id"])
|
||||
)
|
||||
),
|
||||
"Player activity facts and performed knowledge should survive exact save/restore"
|
||||
)
|
||||
woodpile = simulation_manager.get_woodpile()
|
||||
woodpile.withdraw(SimulationIds.RESOURCE_WOOD, woodpile.get_amount(SimulationIds.RESOURCE_WOOD))
|
||||
simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_WOOD)
|
||||
var blocked_guard_context := player.get_interaction_context() as PlayerInteractionResult
|
||||
var blocked_guard_safety: float = simulation_manager.village.safety
|
||||
var blocked_guard_events: int = simulation_manager.economic_events.size()
|
||||
player.try_interact()
|
||||
_check(
|
||||
simulation_manager.village.safety > safety_before,
|
||||
"Player should help guard from the typed GuardPost ActivitySite"
|
||||
(
|
||||
blocked_guard_context != null
|
||||
and blocked_guard_context.kind == PlayerInteractionResult.KIND_ACTIVITY
|
||||
and blocked_guard_context.target_id == guard_site.site_id
|
||||
and not blocked_guard_context.is_available()
|
||||
and "wood" in blocked_guard_context.blocked_reason.to_lower()
|
||||
and is_equal_approx(simulation_manager.village.safety, blocked_guard_safety)
|
||||
and simulation_manager.economic_events.size() == blocked_guard_events
|
||||
),
|
||||
"An unfunded authored activity offer should explain the cost without mutating authority"
|
||||
)
|
||||
woodpile.deposit(SimulationIds.RESOURCE_WOOD, 1.0)
|
||||
simulation_manager.economy.sync_resource(SimulationIds.RESOURCE_WOOD)
|
||||
var downed_context := player.get_interaction_context() as PlayerInteractionResult
|
||||
var downed_wood := woodpile.get_amount(SimulationIds.RESOURCE_WOOD)
|
||||
var downed_safety: float = simulation_manager.village.safety
|
||||
var downed_event_count: int = simulation_manager.economic_events.size()
|
||||
simulation_manager.get_player_state().take_damage(
|
||||
simulation_manager.get_player_state().get_max_health()
|
||||
)
|
||||
var downed_result: ActionResult
|
||||
if downed_context != null:
|
||||
downed_result = simulation_manager.execute_player_activity_action(
|
||||
downed_context.action_id,
|
||||
downed_context.target_id,
|
||||
downed_context.expected_state_revision,
|
||||
downed_context.target_generation,
|
||||
downed_context.target_context_id,
|
||||
downed_context.target_registry_instance_id
|
||||
)
|
||||
_check(
|
||||
(
|
||||
downed_context != null
|
||||
and downed_context.kind == PlayerInteractionResult.KIND_ACTIVITY
|
||||
and downed_result != null
|
||||
and not downed_result.did_succeed()
|
||||
and is_equal_approx(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), downed_wood)
|
||||
and is_equal_approx(simulation_manager.village.safety, downed_safety)
|
||||
and simulation_manager.economic_events.size() == downed_event_count
|
||||
),
|
||||
"A downed player must not work, consume cost, mutate metrics, or record a fact"
|
||||
)
|
||||
|
||||
if failures.is_empty():
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
extends GutTest
|
||||
|
||||
var registry: WorldTargetRegistry
|
||||
var village: SimVillage
|
||||
var revision := 0
|
||||
var next_event_id := 0
|
||||
var events: Array[Dictionary] = []
|
||||
var player_position := Vector3.ZERO
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
registry = WorldTargetRegistry.new(&"jajce")
|
||||
village = SimVillage.new()
|
||||
revision = 0
|
||||
next_event_id = 0
|
||||
events.clear()
|
||||
player_position = Vector3.ZERO
|
||||
|
||||
|
||||
func test_player_and_npc_use_one_revalidated_activity_command_path() -> void:
|
||||
var guard := _register_activity(
|
||||
&"guard_post", SimulationIds.ACTION_PATROL, Vector3(2.0, 0.0, 0.0)
|
||||
)
|
||||
var study := _register_activity(
|
||||
&"study_desk", SimulationIds.ACTION_STUDY, Vector3(2.0, 0.0, 1.0)
|
||||
)
|
||||
var service := _service()
|
||||
var safety_before := village.safety
|
||||
var player_result := service.submit_command(
|
||||
_command(&"player_guard", _player_ref(), SimulationIds.ACTION_PATROL, guard, 0)
|
||||
)
|
||||
assert_true(player_result.did_succeed())
|
||||
assert_eq(village.safety, safety_before + 3.0)
|
||||
assert_eq(player_result.get_payload()["event_id"], 0)
|
||||
assert_eq(player_result.get_payload()["state_revision"], 1)
|
||||
assert_eq(events[0]["actor"], _player_ref().index_key())
|
||||
assert_eq(events[0]["target_id"], "guard_post")
|
||||
assert_eq(events[0]["metric_id"], "safety")
|
||||
|
||||
var npc_result := service.submit_command(
|
||||
_command(
|
||||
&"npc_guard",
|
||||
WorldEntityRef.create(SimulationIds.ENTITY_PERSON, &"7"),
|
||||
SimulationIds.ACTION_PATROL,
|
||||
guard,
|
||||
1
|
||||
)
|
||||
)
|
||||
assert_true(npc_result.did_succeed())
|
||||
assert_eq(village.safety, safety_before + 6.0)
|
||||
assert_eq(
|
||||
events[1]["actor"], WorldEntityRef.create(SimulationIds.ENTITY_PERSON, &"7").index_key()
|
||||
)
|
||||
|
||||
var study_result := service.submit_command(
|
||||
_command(&"player_study", _player_ref(), SimulationIds.ACTION_STUDY, study, 2)
|
||||
)
|
||||
assert_true(study_result.did_succeed())
|
||||
assert_eq(village.knowledge, 2.0)
|
||||
assert_eq(events[2]["metric_id"], "knowledge")
|
||||
|
||||
|
||||
func test_stale_revision_handle_capability_and_range_reject_without_mutation() -> void:
|
||||
var guard := _register_activity(
|
||||
&"guard_post", SimulationIds.ACTION_PATROL, Vector3(2.0, 0.0, 0.0)
|
||||
)
|
||||
var study := _register_activity(
|
||||
&"study_desk", SimulationIds.ACTION_STUDY, Vector3(2.0, 0.0, 1.0)
|
||||
)
|
||||
var service := _service()
|
||||
var before := _snapshot()
|
||||
var stale_revision := service.submit_command(
|
||||
_command(&"stale_revision", _player_ref(), SimulationIds.ACTION_PATROL, guard, 4)
|
||||
)
|
||||
assert_eq(stale_revision.get_reason_code(), ActivityActionCommandService.REASON_STALE_REVISION)
|
||||
assert_eq(_snapshot(), before)
|
||||
|
||||
var wrong_capability := service.submit_command(
|
||||
_command(&"wrong_capability", _player_ref(), SimulationIds.ACTION_PATROL, study, 0)
|
||||
)
|
||||
assert_eq(
|
||||
wrong_capability.get_reason_code(), ActivityActionCommandService.REASON_TARGET_CAPABILITY
|
||||
)
|
||||
assert_eq(_snapshot(), before)
|
||||
|
||||
player_position = Vector3(20.0, 0.0, 0.0)
|
||||
var out_of_range := service.submit_command(
|
||||
_command(&"out_of_range", _player_ref(), SimulationIds.ACTION_PATROL, guard, 0)
|
||||
)
|
||||
assert_eq(out_of_range.get_reason_code(), ActivityActionCommandService.REASON_OUT_OF_RANGE)
|
||||
assert_eq(_snapshot(), before)
|
||||
|
||||
player_position = Vector3.ZERO
|
||||
assert_true(registry.unregister_target(guard))
|
||||
var stale_handle := service.submit_command(
|
||||
_command(&"stale_handle", _player_ref(), SimulationIds.ACTION_PATROL, guard, 0)
|
||||
)
|
||||
assert_eq(stale_handle.get_reason_code(), ActivityActionCommandService.REASON_STALE_TARGET)
|
||||
assert_eq(_snapshot(), before)
|
||||
|
||||
|
||||
func test_unsupported_action_definition_cannot_smuggle_a_metric_effect() -> void:
|
||||
var guard := _register_activity(&"guard_post", SimulationIds.ACTION_PATROL, Vector3.ZERO)
|
||||
var before := _snapshot()
|
||||
var result := _service().submit_command(
|
||||
_command(&"wrong_action", _player_ref(), SimulationIds.ACTION_REST, guard, 0)
|
||||
)
|
||||
assert_eq(result.get_reason_code(), ActivityActionCommandService.REASON_UNSUPPORTED_ACTION)
|
||||
assert_eq(_snapshot(), before)
|
||||
|
||||
|
||||
func test_compound_effects_fail_closed_until_one_transactional_handler_owns_them() -> void:
|
||||
var guard := _register_activity(&"guard_post", SimulationIds.ACTION_PATROL, Vector3.ZERO)
|
||||
var service := ActivityActionCommandService.new(
|
||||
registry,
|
||||
_compound_definition,
|
||||
_actor_position,
|
||||
func() -> int: return revision,
|
||||
_complete_activity
|
||||
)
|
||||
var before := _snapshot()
|
||||
var result := service.submit_command(
|
||||
_command(&"compound_action", _player_ref(), SimulationIds.ACTION_PATROL, guard, 0)
|
||||
)
|
||||
assert_eq(result.get_reason_code(), ActivityActionCommandService.REASON_INVALID_EFFECT)
|
||||
assert_eq(_snapshot(), before)
|
||||
|
||||
|
||||
func test_abstract_npc_target_does_not_require_a_loaded_presentation_provider() -> void:
|
||||
var abstract_guard := _register_activity(
|
||||
&"abstract_guard_post", SimulationIds.ACTION_PATROL, Vector3(1.0, 0.0, 0.0), false
|
||||
)
|
||||
player_position = Vector3(100.0, 0.0, 0.0)
|
||||
var result := _service(false, false).submit_command(
|
||||
_command(
|
||||
&"abstract_npc_guard",
|
||||
WorldEntityRef.create(SimulationIds.ENTITY_PERSON, &"12"),
|
||||
SimulationIds.ACTION_PATROL,
|
||||
abstract_guard,
|
||||
0
|
||||
)
|
||||
)
|
||||
assert_true(result.did_succeed())
|
||||
assert_eq(village.safety, 53.0)
|
||||
assert_true(registry.is_handle_valid(abstract_guard))
|
||||
assert_false(registry.is_handle_valid(abstract_guard, true))
|
||||
assert_has(result.get_payload()["reason_trace"], "abstract_completion_authorized")
|
||||
|
||||
|
||||
func test_loaded_service_rejects_a_dead_provider_even_before_registry_pruning() -> void:
|
||||
var provider := Node.new()
|
||||
var handle := registry.register_target(
|
||||
WorldTargetDescriptor.new(
|
||||
&"guard_post",
|
||||
&"activity",
|
||||
[WorldTargetCapability.new(SimulationIds.ACTION_PATROL, {"interaction_range": 3.0})],
|
||||
Vector3.ZERO
|
||||
),
|
||||
provider
|
||||
)
|
||||
provider.free()
|
||||
var result := _service(true).submit_command(
|
||||
_command(&"dead_provider", _player_ref(), SimulationIds.ACTION_PATROL, handle, 0)
|
||||
)
|
||||
assert_eq(result.get_reason_code(), ActivityActionCommandService.REASON_STALE_TARGET)
|
||||
assert_true(events.is_empty())
|
||||
|
||||
|
||||
func test_invalid_actor_position_rejects_in_loaded_and_abstract_modes() -> void:
|
||||
var guard := _register_activity(
|
||||
&"guard_post", SimulationIds.ACTION_PATROL, Vector3(2.0, 0.0, 0.0)
|
||||
)
|
||||
var dead_position_lookup := func(_actor: WorldEntityRef) -> Variant: return null
|
||||
for enforce_spatial_range in [true, false]:
|
||||
var service := ActivityActionCommandService.new(
|
||||
registry,
|
||||
_definition,
|
||||
dead_position_lookup,
|
||||
func() -> int: return revision,
|
||||
_complete_activity,
|
||||
false,
|
||||
enforce_spatial_range
|
||||
)
|
||||
var result := service.submit_command(
|
||||
_command(
|
||||
StringName("dead_%s" % enforce_spatial_range),
|
||||
WorldEntityRef.create(SimulationIds.ENTITY_PERSON, &"12"),
|
||||
SimulationIds.ACTION_PATROL,
|
||||
guard,
|
||||
0
|
||||
)
|
||||
)
|
||||
assert_eq(result.get_reason_code(), ActivityActionCommandService.REASON_UNSUPPORTED_ACTOR)
|
||||
assert_true(events.is_empty())
|
||||
assert_eq(revision, 0)
|
||||
|
||||
|
||||
func test_non_activity_target_cannot_smuggle_an_activity_capability() -> void:
|
||||
var provider := Node.new()
|
||||
add_child_autofree(provider)
|
||||
var storage_handle := registry.register_target(
|
||||
WorldTargetDescriptor.new(
|
||||
&"false_guard",
|
||||
&"storage",
|
||||
[WorldTargetCapability.new(SimulationIds.ACTION_PATROL)],
|
||||
Vector3.ZERO
|
||||
),
|
||||
provider
|
||||
)
|
||||
var result := _service().submit_command(
|
||||
_command(
|
||||
&"wrong_kind", _player_ref(), SimulationIds.ACTION_PATROL, storage_handle, revision
|
||||
)
|
||||
)
|
||||
assert_eq(result.get_reason_code(), ActivityActionCommandService.REASON_TARGET_CAPABILITY)
|
||||
assert_true(events.is_empty())
|
||||
|
||||
|
||||
func test_event_position_comes_from_authoritative_actor_not_presentation_target() -> void:
|
||||
player_position = Vector3(7.0, 0.0, -3.0)
|
||||
var guard := _register_activity(
|
||||
&"guard_post", SimulationIds.ACTION_PATROL, Vector3(200.0, 0.0, 200.0)
|
||||
)
|
||||
var result := _service(false, false).submit_command(
|
||||
_command(&"abstract_position", _player_ref(), SimulationIds.ACTION_PATROL, guard, 0)
|
||||
)
|
||||
assert_true(result.did_succeed())
|
||||
assert_eq(events[0]["world_position"], player_position)
|
||||
|
||||
|
||||
func test_rejected_atomic_completion_leaves_authority_unchanged() -> void:
|
||||
var guard := _register_activity(
|
||||
&"guard_post", SimulationIds.ACTION_PATROL, Vector3(1.0, 0.0, 0.0)
|
||||
)
|
||||
var service := ActivityActionCommandService.new(
|
||||
registry,
|
||||
_definition,
|
||||
_actor_position,
|
||||
func() -> int: return revision,
|
||||
func(
|
||||
_actor: WorldEntityRef,
|
||||
_action_id: StringName,
|
||||
_target_id: StringName,
|
||||
_metric_id: StringName,
|
||||
_amount: float,
|
||||
_parameters: Dictionary,
|
||||
_world_position: Vector3
|
||||
) -> Dictionary:
|
||||
return {"succeeded": false, "event_id": -1, "state_revision": revision}
|
||||
)
|
||||
var before := _snapshot()
|
||||
var result := service.submit_command(
|
||||
_command(&"rejected_completion", _player_ref(), SimulationIds.ACTION_PATROL, guard, 0)
|
||||
)
|
||||
assert_eq(result.get_status(), ActionResult.STATUS_FAILED)
|
||||
assert_eq(result.get_reason_code(), ActivityActionCommandService.REASON_COMPLETION_FAILED)
|
||||
assert_eq(_snapshot(), before)
|
||||
|
||||
|
||||
func test_completion_results_cannot_hide_or_forge_revision_changes() -> void:
|
||||
var guard := _register_activity(
|
||||
&"guard_post", SimulationIds.ACTION_PATROL, Vector3(1.0, 0.0, 0.0)
|
||||
)
|
||||
var hidden_mutation_service := ActivityActionCommandService.new(
|
||||
registry,
|
||||
_definition,
|
||||
_actor_position,
|
||||
func() -> int: return revision,
|
||||
func(
|
||||
_actor: WorldEntityRef,
|
||||
_action_id: StringName,
|
||||
_target_id: StringName,
|
||||
_metric_id: StringName,
|
||||
_amount: float,
|
||||
_parameters: Dictionary,
|
||||
_world_position: Vector3
|
||||
) -> Dictionary:
|
||||
return {
|
||||
"succeeded": false,
|
||||
"reason_code": "rejected",
|
||||
"message": "Rejected",
|
||||
"event_id": -1,
|
||||
"state_revision": revision + 1,
|
||||
}
|
||||
)
|
||||
var hidden_mutation := hidden_mutation_service.submit_command(
|
||||
_command(&"hidden_mutation", _player_ref(), SimulationIds.ACTION_PATROL, guard, 0)
|
||||
)
|
||||
assert_eq(hidden_mutation.get_status(), ActionResult.STATUS_FAILED)
|
||||
assert_eq(
|
||||
hidden_mutation.get_reason_code(), ActivityActionCommandService.REASON_COMPLETION_FAILED
|
||||
)
|
||||
|
||||
var forged_fact_service := ActivityActionCommandService.new(
|
||||
registry,
|
||||
_definition,
|
||||
_actor_position,
|
||||
func() -> int: return revision,
|
||||
func(
|
||||
_actor: WorldEntityRef,
|
||||
_action_id: StringName,
|
||||
_target_id: StringName,
|
||||
_metric_id: StringName,
|
||||
_amount: float,
|
||||
_parameters: Dictionary,
|
||||
_world_position: Vector3
|
||||
) -> Dictionary:
|
||||
return {
|
||||
"succeeded": true,
|
||||
"event_id": revision + 4,
|
||||
"state_revision": revision + 1,
|
||||
}
|
||||
)
|
||||
var forged_fact := forged_fact_service.submit_command(
|
||||
_command(&"forged_fact", _player_ref(), SimulationIds.ACTION_PATROL, guard, 0)
|
||||
)
|
||||
assert_eq(forged_fact.get_status(), ActionResult.STATUS_FAILED)
|
||||
assert_eq(forged_fact.get_reason_code(), ActivityActionCommandService.REASON_COMPLETION_FAILED)
|
||||
|
||||
|
||||
func test_completion_accepts_ordered_causal_followup_facts() -> void:
|
||||
var guard := _register_activity(
|
||||
&"guard_post", SimulationIds.ACTION_PATROL, Vector3(1.0, 0.0, 0.0)
|
||||
)
|
||||
var service := ActivityActionCommandService.new(
|
||||
registry,
|
||||
_definition,
|
||||
_actor_position,
|
||||
func() -> int: return revision,
|
||||
func(
|
||||
_actor: WorldEntityRef,
|
||||
_action_id: StringName,
|
||||
_target_id: StringName,
|
||||
_metric_id: StringName,
|
||||
_amount: float,
|
||||
_parameters: Dictionary,
|
||||
_world_position: Vector3
|
||||
) -> Dictionary:
|
||||
var completion_event_id := revision
|
||||
revision += 2
|
||||
return {
|
||||
"succeeded": true,
|
||||
"event_id": completion_event_id,
|
||||
"state_revision": revision,
|
||||
"applied_amount": 3.0,
|
||||
}
|
||||
)
|
||||
var result := service.submit_command(
|
||||
_command(&"causal_chain", _player_ref(), SimulationIds.ACTION_PATROL, guard, 0)
|
||||
)
|
||||
assert_true(result.did_succeed())
|
||||
assert_eq(int(result.get_payload()["event_id"]), 0)
|
||||
assert_eq(int(result.get_payload()["state_revision"]), 2)
|
||||
assert_has(result.get_payload()["reason_trace"], "causal_followup_facts_committed")
|
||||
|
||||
|
||||
func _service(
|
||||
require_target_provider: bool = false, enforce_spatial_range: bool = true
|
||||
) -> ActivityActionCommandService:
|
||||
return ActivityActionCommandService.new(
|
||||
registry,
|
||||
_definition,
|
||||
_actor_position,
|
||||
func() -> int: return revision,
|
||||
_complete_activity,
|
||||
require_target_provider,
|
||||
enforce_spatial_range
|
||||
)
|
||||
|
||||
|
||||
func _definition(action_id: StringName) -> ActionDefinition:
|
||||
if action_id not in [SimulationIds.ACTION_PATROL, SimulationIds.ACTION_STUDY]:
|
||||
return null
|
||||
var definition := ActionDefinition.new()
|
||||
definition.action_id = action_id
|
||||
definition.display_name = String(action_id)
|
||||
definition.completion_handler_id = ActivityActionCommandService.HANDLER_METRIC_DELTA
|
||||
var effect := ActionEffect.new()
|
||||
var is_patrol := action_id == SimulationIds.ACTION_PATROL
|
||||
effect.effect_id = &"patrol_safety" if is_patrol else &"study_knowledge"
|
||||
effect.effect_kind = ActionEffect.KIND_METRIC_DELTA
|
||||
effect.subject_key = &"safety" if is_patrol else &"knowledge"
|
||||
effect.value = 3.0 if is_patrol else 2.0
|
||||
definition.effects = [effect]
|
||||
return definition
|
||||
|
||||
|
||||
func _compound_definition(action_id: StringName) -> ActionDefinition:
|
||||
var definition := _definition(action_id)
|
||||
var healing := ActionEffect.new()
|
||||
healing.effect_id = &"smuggled_healing"
|
||||
healing.effect_kind = ActionEffect.KIND_HEALING
|
||||
healing.amount = 2.0
|
||||
definition.effects.append(healing)
|
||||
return definition
|
||||
|
||||
|
||||
func _actor_position(_actor: WorldEntityRef) -> Vector3:
|
||||
return player_position
|
||||
|
||||
|
||||
func _complete_activity(
|
||||
actor: WorldEntityRef,
|
||||
_action_id: StringName,
|
||||
target_id: StringName,
|
||||
metric_id: StringName,
|
||||
amount: float,
|
||||
_parameters: Dictionary,
|
||||
_world_position: Vector3
|
||||
) -> Dictionary:
|
||||
village.apply_metric_delta(metric_id, amount)
|
||||
revision += 1
|
||||
var event_id := next_event_id
|
||||
next_event_id += 1
|
||||
(
|
||||
events
|
||||
. append(
|
||||
{
|
||||
"event_id": event_id,
|
||||
"actor": actor.index_key(),
|
||||
"target_id": String(target_id),
|
||||
"metric_id": String(metric_id),
|
||||
"amount": amount,
|
||||
"world_position": _world_position,
|
||||
}
|
||||
)
|
||||
)
|
||||
return {"succeeded": true, "event_id": event_id, "state_revision": revision}
|
||||
|
||||
|
||||
func _register_activity(
|
||||
target_id: StringName, action_id: StringName, position: Vector3, with_provider: bool = true
|
||||
) -> WorldTargetHandle:
|
||||
var provider: Node
|
||||
if with_provider:
|
||||
provider = Node.new()
|
||||
add_child_autofree(provider)
|
||||
var capability := WorldTargetCapability.new(action_id, {"interaction_range": 3.0})
|
||||
return registry.register_target(
|
||||
WorldTargetDescriptor.new(target_id, &"activity", [capability], position), provider
|
||||
)
|
||||
|
||||
|
||||
func _command(
|
||||
command_id: StringName,
|
||||
actor: WorldEntityRef,
|
||||
action_id: StringName,
|
||||
target: WorldTargetHandle,
|
||||
expected_revision: int
|
||||
) -> ActionCommand:
|
||||
return ActionCommand.new(command_id, actor, action_id, target, {}, expected_revision)
|
||||
|
||||
|
||||
func _player_ref() -> WorldEntityRef:
|
||||
return WorldEntityRef.create(SimulationIds.ENTITY_PLAYER, &"-1")
|
||||
|
||||
|
||||
func _snapshot() -> Dictionary:
|
||||
return {
|
||||
"safety": village.safety,
|
||||
"knowledge": village.knowledge,
|
||||
"revision": revision,
|
||||
"events": events.duplicate(true),
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://b3o3qhh73no24
|
||||
@@ -0,0 +1,148 @@
|
||||
extends GutTest
|
||||
|
||||
const ACTION_ID := &"test_activity_metric_action"
|
||||
|
||||
|
||||
func test_valid_activity_metric_action_uses_the_builtin_contract() -> void:
|
||||
var action := _valid_action()
|
||||
action.completion_cost_resource_id = SimulationIds.RESOURCE_WOOD
|
||||
action.completion_cost_amount = 1.0
|
||||
|
||||
var catalog := _catalog_with_action(action)
|
||||
assert_true(catalog.is_valid(), "%s" % [catalog.get_errors()])
|
||||
assert_not_null(catalog.get_action(ACTION_ID))
|
||||
|
||||
|
||||
func test_activity_metric_handler_rejects_non_activity_targets() -> void:
|
||||
var action := _valid_action()
|
||||
action.target_type = SimulationIds.TARGET_FREE
|
||||
|
||||
_assert_contract_error(action, "activity")
|
||||
|
||||
|
||||
func test_activity_metric_handler_requires_exactly_one_effect() -> void:
|
||||
var without_effects := _valid_action()
|
||||
without_effects.effects = []
|
||||
_assert_contract_error(without_effects, "exactly one")
|
||||
|
||||
var with_multiple_effects := _valid_action()
|
||||
var second_effect := _valid_effect()
|
||||
second_effect.effect_id = &"second_metric_effect"
|
||||
with_multiple_effects.effects = [_valid_effect(), second_effect]
|
||||
_assert_contract_error(with_multiple_effects, "exactly one")
|
||||
|
||||
|
||||
func test_activity_metric_handler_rejects_wrong_effect_kind() -> void:
|
||||
var action := _valid_action()
|
||||
var damage := ActionEffect.new()
|
||||
damage.effect_id = &"damage_instead_of_metric"
|
||||
damage.effect_kind = ActionEffect.KIND_DAMAGE
|
||||
damage.amount = 1.0
|
||||
action.effects = [damage]
|
||||
|
||||
_assert_contract_error(action, "metric_delta")
|
||||
|
||||
|
||||
func test_activity_metric_handler_rejects_unsupported_metric() -> void:
|
||||
var action := _valid_action()
|
||||
var effect := _valid_effect()
|
||||
effect.subject_key = &"morale"
|
||||
action.effects = [effect]
|
||||
|
||||
_assert_contract_error(action, "safety")
|
||||
|
||||
|
||||
func test_activity_metric_handler_rejects_malformed_player_multiplier() -> void:
|
||||
var action := _valid_action()
|
||||
var effect := _valid_effect()
|
||||
effect.parameters = {"player_multiplier": "three", "npc_productivity": true}
|
||||
action.effects = [effect]
|
||||
|
||||
_assert_contract_error(action, "player_multiplier")
|
||||
|
||||
|
||||
func test_activity_metric_handler_rejects_malformed_npc_productivity() -> void:
|
||||
var action := _valid_action()
|
||||
var effect := _valid_effect()
|
||||
effect.parameters = {"player_multiplier": 2.0, "npc_productivity": "yes"}
|
||||
action.effects = [effect]
|
||||
|
||||
_assert_contract_error(action, "npc_productivity")
|
||||
|
||||
|
||||
func test_activity_metric_handler_requires_a_completion_event_type() -> void:
|
||||
var action := _valid_action()
|
||||
action.event_type_id = &""
|
||||
|
||||
_assert_contract_error(action, "event_type_id")
|
||||
|
||||
|
||||
func test_activity_metric_completion_cost_must_have_a_storage_route() -> void:
|
||||
var action := _valid_action()
|
||||
action.completion_cost_resource_id = &"unrouted_activity_cost"
|
||||
action.completion_cost_amount = 1.0
|
||||
var unrouted_item := ItemDefinition.new()
|
||||
unrouted_item.item_id = action.completion_cost_resource_id
|
||||
unrouted_item.display_name = "Unrouted Activity Cost"
|
||||
unrouted_item.category = ItemDefinition.CATEGORY_RESOURCE
|
||||
|
||||
var catalog := _catalog_with_action(action, [unrouted_item])
|
||||
assert_false(catalog.is_valid(), "An executable cost needs an authoritative storage route")
|
||||
assert_true(
|
||||
_has_error(catalog.get_errors(), "storage"),
|
||||
"Expected a storage routing error, got %s" % [catalog.get_errors()]
|
||||
)
|
||||
|
||||
|
||||
func _assert_contract_error(action: ActionDefinition, expected_fragment: String) -> void:
|
||||
var catalog := _catalog_with_action(action)
|
||||
assert_false(catalog.is_valid(), "Invalid '%s' contract was published" % action.action_id)
|
||||
assert_true(
|
||||
_has_error(catalog.get_errors(), expected_fragment),
|
||||
"Expected '%s' in %s" % [expected_fragment, catalog.get_errors()]
|
||||
)
|
||||
|
||||
|
||||
func _catalog_with_action(
|
||||
action: ActionDefinition, items: Array[ItemDefinition] = []
|
||||
) -> ContentCatalog:
|
||||
var core := load(ContentCatalog.CORE_PACK_PATH) as SimulationContentPack
|
||||
var addon := SimulationContentPack.new()
|
||||
addon.pack_id = &"activity_contract_test"
|
||||
addon.display_name = "Activity Contract Test"
|
||||
addon.required_pack_ids = [&"core"]
|
||||
addon.required_handler_ids = [SimulationIds.ACTION_HANDLER_ACTIVITY_METRIC_DELTA]
|
||||
addon.actions = [action]
|
||||
addon.items = items
|
||||
var packs: Array[SimulationContentPack] = [addon, core]
|
||||
var catalog := ContentCatalog.new()
|
||||
catalog.rebuild(packs)
|
||||
return catalog
|
||||
|
||||
|
||||
func _valid_action() -> ActionDefinition:
|
||||
var action := ActionDefinition.new()
|
||||
action.action_id = ACTION_ID
|
||||
action.display_name = "Test Activity Metric Action"
|
||||
action.target_type = SimulationIds.TARGET_ACTIVITY
|
||||
action.completion_handler_id = SimulationIds.ACTION_HANDLER_ACTIVITY_METRIC_DELTA
|
||||
action.effects = [_valid_effect()]
|
||||
action.event_type_id = SimulationIds.EVENT_ACTIVITY_COMPLETED
|
||||
return action
|
||||
|
||||
|
||||
func _valid_effect() -> ActionEffect:
|
||||
var effect := ActionEffect.new()
|
||||
effect.effect_id = &"test_metric_effect"
|
||||
effect.effect_kind = ActionEffect.KIND_METRIC_DELTA
|
||||
effect.subject_key = &"safety"
|
||||
effect.value = 1.0
|
||||
effect.parameters = {"player_multiplier": 2.0, "npc_productivity": true}
|
||||
return effect
|
||||
|
||||
|
||||
func _has_error(errors: Array[String], fragment: String) -> bool:
|
||||
for error in errors:
|
||||
if fragment in error:
|
||||
return true
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://m58cgatc3ub
|
||||
@@ -32,6 +32,70 @@ func test_world_v14_migrates_to_empty_emergent_collections() -> void:
|
||||
assert_eq(int(migrated.simulation["next_conversation_act_id"]), 0)
|
||||
|
||||
|
||||
func test_world_v15_migrates_to_the_existing_explicit_scope() -> void:
|
||||
var previous: Dictionary = manager.create_state_record().to_dictionary()
|
||||
previous["schema_version"] = SimulationStateRecord.EMERGENT_SCHEMA_VERSION
|
||||
previous["simulation"].erase("world_id")
|
||||
previous["simulation"].erase("location_id")
|
||||
|
||||
var migrated := SimulationStateRecord.from_dictionary(previous)
|
||||
assert_not_null(migrated)
|
||||
assert_eq(StringName(migrated.simulation["world_id"]), SimulationIds.REGIONAL_WORLD_BOSNIA)
|
||||
assert_eq(StringName(migrated.simulation["location_id"]), SimulationIds.REGIONAL_LOCATION_JAJCE)
|
||||
var npc: SimNPC = manager.npcs[0]
|
||||
manager.record_narrative_event(
|
||||
SimulationIds.EVENT_TASK_BLOCKED,
|
||||
npc.id,
|
||||
SimulationIds.npc_inventory_id(npc.id),
|
||||
"Feed animal: needs 1 food",
|
||||
SimulationIds.ACTION_FEED_ANIMAL,
|
||||
SimulationIds.RESOURCE_FOOD,
|
||||
1.0
|
||||
)
|
||||
var previous_with_blocked_fact: Dictionary = manager.create_state_record().to_dictionary()
|
||||
previous_with_blocked_fact["schema_version"] = SimulationStateRecord.EMERGENT_SCHEMA_VERSION
|
||||
previous_with_blocked_fact["simulation"].erase("world_id")
|
||||
previous_with_blocked_fact["simulation"].erase("location_id")
|
||||
for field in ["blocked_contract_kind", "world_id", "location_id"]:
|
||||
previous_with_blocked_fact["economic_events"][0].erase(field)
|
||||
assert_not_null(
|
||||
SimulationStateRecord.from_dictionary(previous_with_blocked_fact),
|
||||
"A legacy compound-action inventory failure must remain loadable under v16"
|
||||
)
|
||||
|
||||
|
||||
func test_legacy_unstructured_blocked_fact_is_preserved_but_not_made_knowable() -> void:
|
||||
var legacy_state: Dictionary = manager.create_state_record().to_dictionary()
|
||||
legacy_state["schema_version"] = SimulationStateRecord.PRE_EMERGENT_SCHEMA_VERSION
|
||||
var legacy_event: Dictionary = (
|
||||
EconomicEventRecord
|
||||
. create_narrative(
|
||||
0,
|
||||
SimulationIds.EVENT_TASK_BLOCKED,
|
||||
0,
|
||||
manager.npcs[0].id,
|
||||
&"legacy_unknown_source",
|
||||
"Legacy work was blocked"
|
||||
)
|
||||
. to_dictionary()
|
||||
)
|
||||
legacy_event["schema_version"] = EconomicEventRecord.POSITION_LEGACY_SCHEMA_VERSION
|
||||
legacy_event.erase("action_id")
|
||||
legacy_event.erase("required_amount")
|
||||
legacy_state["economic_events"] = [legacy_event]
|
||||
legacy_state["simulation"]["next_event_id"] = 1
|
||||
|
||||
var migrated := SimulationStateRecord.from_dictionary(legacy_state)
|
||||
assert_not_null(migrated)
|
||||
var preserved: EconomicEventRecord = migrated.economic_events[0]
|
||||
assert_true(ActivityEventValidator.is_legacy_unstructured_blocked(preserved))
|
||||
assert_false(EventKnowledgeSystem.is_knowable_event(preserved))
|
||||
assert_not_null(
|
||||
SimulationStateRecord.from_dictionary(migrated.to_dictionary()),
|
||||
"A grandfathered fact must survive its next current-schema save"
|
||||
)
|
||||
|
||||
|
||||
func test_orphan_journal_and_situation_evidence_are_rejected() -> void:
|
||||
var orphan_journal: Dictionary = manager.create_state_record().to_dictionary()
|
||||
orphan_journal["quest_journal"] = [
|
||||
@@ -84,3 +148,325 @@ func test_conversation_history_requires_valid_actors_and_monotonic_id() -> void:
|
||||
assert_not_null(SimulationStateRecord.from_dictionary(state))
|
||||
state["conversation_history"][0]["speaker"]["entity_id"] = "missing_npc"
|
||||
assert_null(SimulationStateRecord.from_dictionary(state))
|
||||
|
||||
|
||||
func test_activity_facts_validate_actor_target_effect_and_exact_cost_without_knowledge() -> void:
|
||||
var npc: SimNPC = manager.npcs[0]
|
||||
_assign_activity(npc, SimulationIds.ACTION_STUDY, SimulationIds.ACTIVITY_STUDY_DESK)
|
||||
var result: ActionResult = manager.execute_activity_action(
|
||||
WorldEntityRef.create(SimulationIds.ENTITY_PERSON, StringName(str(npc.id))),
|
||||
SimulationIds.ACTION_STUDY,
|
||||
SimulationIds.ACTIVITY_STUDY_DESK,
|
||||
manager.get_action_state_revision()
|
||||
)
|
||||
assert_true(result.did_succeed())
|
||||
var state: Dictionary = manager.create_state_record().to_dictionary()
|
||||
state["event_knowledge"] = []
|
||||
assert_not_null(SimulationStateRecord.from_dictionary(state))
|
||||
|
||||
var invalid_actor := state.duplicate(true)
|
||||
invalid_actor["economic_events"][0]["actor_id"] = 999
|
||||
assert_null(SimulationStateRecord.from_dictionary(invalid_actor))
|
||||
var invalid_target := state.duplicate(true)
|
||||
invalid_target["economic_events"][0]["target_kind"] = "storage"
|
||||
assert_null(SimulationStateRecord.from_dictionary(invalid_target))
|
||||
var invalid_metric := state.duplicate(true)
|
||||
invalid_metric["economic_events"][0]["metric_id"] = "safety"
|
||||
assert_null(SimulationStateRecord.from_dictionary(invalid_metric))
|
||||
var invalid_cost := state.duplicate(true)
|
||||
invalid_cost["economic_events"][0]["cost_amount"] = 2.0
|
||||
assert_null(SimulationStateRecord.from_dictionary(invalid_cost))
|
||||
var invalid_scope := state.duplicate(true)
|
||||
invalid_scope["economic_events"][0]["location_id"] = "location_elsewhere"
|
||||
assert_null(SimulationStateRecord.from_dictionary(invalid_scope))
|
||||
var impossible_delta := state.duplicate(true)
|
||||
impossible_delta["economic_events"][0]["metric_delta"] = 99.0
|
||||
assert_null(SimulationStateRecord.from_dictionary(impossible_delta))
|
||||
|
||||
|
||||
func test_capped_activity_never_consumes_cost_or_claims_a_false_delta() -> void:
|
||||
manager.village.safety = 100.0
|
||||
manager.village.update_modifiers()
|
||||
manager.village.update_priorities()
|
||||
var woodpile: StorageStateRecord = manager.get_woodpile()
|
||||
var wood_before := woodpile.get_amount(SimulationIds.RESOURCE_WOOD)
|
||||
var events_before: int = manager.economic_events.size()
|
||||
var npc: SimNPC = manager.npcs[0]
|
||||
_assign_activity(npc, SimulationIds.ACTION_PATROL, SimulationIds.ACTIVITY_GUARD_POST)
|
||||
var capped: ActionResult = manager.execute_activity_action(
|
||||
WorldEntityRef.create(SimulationIds.ENTITY_PERSON, StringName(str(npc.id))),
|
||||
SimulationIds.ACTION_PATROL,
|
||||
SimulationIds.ACTIVITY_GUARD_POST,
|
||||
manager.get_action_state_revision()
|
||||
)
|
||||
assert_false(capped.did_succeed())
|
||||
assert_eq(capped.get_reason_code(), &"no_effect")
|
||||
assert_eq(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), wood_before)
|
||||
assert_eq(manager.economic_events.size(), events_before)
|
||||
|
||||
manager.village.safety = 99.0
|
||||
var partial: ActionResult = manager.execute_activity_action(
|
||||
WorldEntityRef.create(SimulationIds.ENTITY_PERSON, StringName(str(npc.id))),
|
||||
SimulationIds.ACTION_PATROL,
|
||||
SimulationIds.ACTIVITY_GUARD_POST,
|
||||
manager.get_action_state_revision()
|
||||
)
|
||||
assert_true(partial.did_succeed())
|
||||
assert_eq(manager.village.safety, 100.0)
|
||||
assert_eq(float(partial.get_payload()["amount"]), 1.0)
|
||||
assert_eq(float(manager.economic_events.back().data["metric_delta"]), 1.0)
|
||||
|
||||
|
||||
func test_rejected_activity_exposes_its_exact_failure_fact_and_revision() -> void:
|
||||
var woodpile: StorageStateRecord = manager.get_woodpile()
|
||||
var available := woodpile.get_amount(SimulationIds.RESOURCE_WOOD)
|
||||
assert_eq(woodpile.withdraw(SimulationIds.RESOURCE_WOOD, available), available)
|
||||
manager.economy.sync_resource(SimulationIds.RESOURCE_WOOD)
|
||||
var revision_before: int = manager.get_action_state_revision()
|
||||
var npc: SimNPC = manager.npcs[0]
|
||||
_assign_activity(npc, SimulationIds.ACTION_PATROL, SimulationIds.ACTIVITY_GUARD_POST)
|
||||
var result: ActionResult = manager.execute_activity_action(
|
||||
WorldEntityRef.create(SimulationIds.ENTITY_PERSON, StringName(str(npc.id))),
|
||||
SimulationIds.ACTION_PATROL,
|
||||
SimulationIds.ACTIVITY_GUARD_POST,
|
||||
revision_before
|
||||
)
|
||||
assert_false(result.did_succeed())
|
||||
assert_eq(result.get_reason_code(), &"insufficient_cost")
|
||||
var payload := result.get_payload()
|
||||
assert_eq(int(payload["event_id"]), revision_before)
|
||||
assert_eq(int(payload["state_revision"]), revision_before + 1)
|
||||
assert_has(payload["reason_trace"], "authoritative_failure_fact_recorded")
|
||||
var failure_fact: EconomicEventRecord = manager.event_log.get_by_id(int(payload["event_id"]))
|
||||
assert_not_null(failure_fact)
|
||||
assert_eq(StringName(failure_fact.data["event_type"]), SimulationIds.EVENT_TASK_BLOCKED)
|
||||
assert_eq(StringName(failure_fact.data["action_id"]), SimulationIds.ACTION_PATROL)
|
||||
assert_eq(int(failure_fact.data["actor_id"]), npc.id)
|
||||
var state: Dictionary = manager.create_state_record().to_dictionary()
|
||||
assert_not_null(
|
||||
SimulationStateRecord.from_dictionary(state), "Original blocked state must parse"
|
||||
)
|
||||
state["event_knowledge"] = []
|
||||
state["opportunities"] = []
|
||||
state["situations"] = []
|
||||
state["quest_journal"] = []
|
||||
state["commitments"] = []
|
||||
var npc_ids := {}
|
||||
for saved_npc in state["npcs"]:
|
||||
npc_ids[int(saved_npc["id"])] = true
|
||||
var storage_records := {}
|
||||
for saved_storage in state["storages"]:
|
||||
var storage := StorageStateRecord.from_dictionary(saved_storage)
|
||||
storage_records[storage.get_storage_id()] = storage
|
||||
assert_true(ActivityEventValidator.is_valid_blocked(failure_fact))
|
||||
assert_true(
|
||||
ActivityEventValidator.is_valid_blocked_for_state(
|
||||
failure_fact,
|
||||
npc_ids,
|
||||
storage_records,
|
||||
int(state["simulation"]["tick_count"]),
|
||||
StringName(state["simulation"]["world_id"]),
|
||||
StringName(state["simulation"]["location_id"])
|
||||
)
|
||||
)
|
||||
assert_not_null(SimulationStateRecord.from_dictionary(state))
|
||||
for mutation in [
|
||||
["actor_id", 999],
|
||||
["action_id", String(SimulationIds.ACTION_STUDY)],
|
||||
["source_id", String(SimulationIds.STORAGE_VILLAGE_PANTRY)],
|
||||
["item_id", String(SimulationIds.RESOURCE_FOOD)],
|
||||
["required_amount", 2.0],
|
||||
["world_id", "wrong_world"],
|
||||
["location_id", "wrong_location"],
|
||||
]:
|
||||
var tampered: Dictionary = state.duplicate(true)
|
||||
tampered["economic_events"][0][mutation[0]] = mutation[1]
|
||||
assert_null(
|
||||
SimulationStateRecord.from_dictionary(tampered),
|
||||
"Tampered task_blocked field '%s' must be rejected" % mutation[0]
|
||||
)
|
||||
|
||||
|
||||
func test_unloaded_activity_requires_a_registered_capability_target() -> void:
|
||||
var npc: SimNPC = manager.npcs[0]
|
||||
var actor := WorldEntityRef.create(SimulationIds.ENTITY_PERSON, StringName(str(npc.id)))
|
||||
_assign_activity(npc, SimulationIds.ACTION_STUDY, &"invented_library")
|
||||
var woodpile: StorageStateRecord = manager.get_woodpile()
|
||||
var wood_before := woodpile.get_amount(SimulationIds.RESOURCE_WOOD)
|
||||
var rejected: ActionResult = manager.execute_activity_action(
|
||||
actor, SimulationIds.ACTION_STUDY, &"invented_library", manager.get_action_state_revision()
|
||||
)
|
||||
assert_false(rejected.did_succeed())
|
||||
assert_eq(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), wood_before)
|
||||
assert_true(manager.economic_events.is_empty())
|
||||
|
||||
assert_true(
|
||||
manager.register_abstract_activity_target(
|
||||
&"library_annex", SimulationIds.ACTION_STUDY, npc.position
|
||||
)
|
||||
)
|
||||
npc.target_id = &"library_annex"
|
||||
var completed: ActionResult = manager.execute_activity_action(
|
||||
actor, SimulationIds.ACTION_STUDY, &"library_annex", manager.get_action_state_revision()
|
||||
)
|
||||
assert_true(completed.did_succeed())
|
||||
assert_eq(StringName(manager.economic_events.back().data["source_id"]), &"library_annex")
|
||||
assert_not_null(
|
||||
SimulationStateRecord.from_dictionary(manager.create_state_record().to_dictionary())
|
||||
)
|
||||
|
||||
|
||||
func test_dead_npc_cannot_complete_an_abstract_activity() -> void:
|
||||
var npc: SimNPC = manager.npcs[0]
|
||||
npc.die_from_combat()
|
||||
var actor := WorldEntityRef.create(SimulationIds.ENTITY_PERSON, StringName(str(npc.id)))
|
||||
var woodpile: StorageStateRecord = manager.get_woodpile()
|
||||
var wood_before := woodpile.get_amount(SimulationIds.RESOURCE_WOOD)
|
||||
var safety_before: float = manager.village.safety
|
||||
var events_before: int = manager.economic_events.size()
|
||||
var result: ActionResult = manager.execute_activity_action(
|
||||
actor,
|
||||
SimulationIds.ACTION_PATROL,
|
||||
SimulationIds.ACTIVITY_GUARD_POST,
|
||||
manager.get_action_state_revision()
|
||||
)
|
||||
assert_false(result.did_succeed())
|
||||
assert_eq(result.get_reason_code(), ActivityActionCommandService.REASON_UNSUPPORTED_ACTOR)
|
||||
assert_eq(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), wood_before)
|
||||
assert_eq(manager.village.safety, safety_before)
|
||||
assert_eq(manager.economic_events.size(), events_before)
|
||||
|
||||
|
||||
func test_npc_activity_requires_the_exact_authoritative_work_assignment() -> void:
|
||||
var npc: SimNPC = manager.npcs[0]
|
||||
var actor := WorldEntityRef.create(SimulationIds.ENTITY_PERSON, StringName(str(npc.id)))
|
||||
var woodpile: StorageStateRecord = manager.get_woodpile()
|
||||
var wood_before := woodpile.get_amount(SimulationIds.RESOURCE_WOOD)
|
||||
var safety_before: float = manager.village.safety
|
||||
var events_before: int = manager.economic_events.size()
|
||||
var idle_result: ActionResult = manager.execute_activity_action(
|
||||
actor,
|
||||
SimulationIds.ACTION_PATROL,
|
||||
SimulationIds.ACTIVITY_GUARD_POST,
|
||||
manager.get_action_state_revision()
|
||||
)
|
||||
assert_false(idle_result.did_succeed())
|
||||
var omitted_revision: ActionResult = manager.execute_activity_action(
|
||||
actor, SimulationIds.ACTION_PATROL, SimulationIds.ACTIVITY_GUARD_POST
|
||||
)
|
||||
assert_false(omitted_revision.did_succeed())
|
||||
var player_bypass: ActionResult = manager.execute_activity_action(
|
||||
WorldEntityRef.create(
|
||||
SimulationIds.ENTITY_PLAYER, StringName(str(SimulationIds.PLAYER_ACTOR_ID))
|
||||
),
|
||||
SimulationIds.ACTION_PATROL,
|
||||
SimulationIds.ACTIVITY_GUARD_POST,
|
||||
manager.get_action_state_revision()
|
||||
)
|
||||
assert_false(player_bypass.did_succeed())
|
||||
npc.set_task(SimulationIds.ACTION_STUDY)
|
||||
npc.target_id = SimulationIds.ACTIVITY_STUDY_DESK
|
||||
npc.travel_target_position = npc.position
|
||||
npc.has_travel_target = false
|
||||
npc.start_working()
|
||||
var early_result: ActionResult = manager.execute_activity_action(
|
||||
actor,
|
||||
SimulationIds.ACTION_STUDY,
|
||||
SimulationIds.ACTIVITY_STUDY_DESK,
|
||||
manager.get_action_state_revision()
|
||||
)
|
||||
assert_false(early_result.did_succeed())
|
||||
npc.task_progress = npc.task_duration
|
||||
npc.task_complete = true
|
||||
npc.task_state = SimNPC.TASK_STATE_COMPLETE
|
||||
var wrong_task: ActionResult = manager.execute_activity_action(
|
||||
actor,
|
||||
SimulationIds.ACTION_PATROL,
|
||||
SimulationIds.ACTIVITY_GUARD_POST,
|
||||
manager.get_action_state_revision()
|
||||
)
|
||||
assert_false(wrong_task.did_succeed())
|
||||
npc.target_id = SimulationIds.ACTIVITY_GUARD_POST
|
||||
var wrong_target: ActionResult = manager.execute_activity_action(
|
||||
actor,
|
||||
SimulationIds.ACTION_STUDY,
|
||||
SimulationIds.ACTIVITY_STUDY_DESK,
|
||||
manager.get_action_state_revision()
|
||||
)
|
||||
assert_false(wrong_target.did_succeed())
|
||||
assert_eq(woodpile.get_amount(SimulationIds.RESOURCE_WOOD), wood_before)
|
||||
assert_eq(manager.village.safety, safety_before)
|
||||
assert_eq(manager.economic_events.size(), events_before)
|
||||
|
||||
|
||||
func test_custom_activity_scope_round_trips_and_rebuilds_the_world_event_index() -> void:
|
||||
var adapter := ActiveWorldAdapter.new()
|
||||
assert_true(
|
||||
adapter.configure_context_identity(&"test_context", &"test_world", &"test_location")
|
||||
)
|
||||
add_child_autofree(adapter)
|
||||
var scoped_manager := MANAGER_SCRIPT.new()
|
||||
scoped_manager.active_world_adapter = adapter
|
||||
add_child_autofree(scoped_manager)
|
||||
var npc: SimNPC = scoped_manager.npcs[0]
|
||||
_assign_activity(npc, SimulationIds.ACTION_STUDY, SimulationIds.ACTIVITY_STUDY_DESK)
|
||||
var result: ActionResult = scoped_manager.execute_activity_action(
|
||||
WorldEntityRef.create(SimulationIds.ENTITY_PERSON, StringName(str(npc.id))),
|
||||
SimulationIds.ACTION_STUDY,
|
||||
SimulationIds.ACTIVITY_STUDY_DESK,
|
||||
scoped_manager.get_action_state_revision()
|
||||
)
|
||||
assert_true(result.did_succeed())
|
||||
var snapshot := scoped_manager.serialize_state()
|
||||
var checksum := scoped_manager.get_state_checksum()
|
||||
var parsed := SimulationStateRecord.from_json(snapshot)
|
||||
assert_not_null(parsed)
|
||||
assert_eq(StringName(parsed.simulation["world_id"]), &"test_world")
|
||||
assert_eq(StringName(parsed.simulation["location_id"]), &"test_location")
|
||||
assert_true(scoped_manager.restore_state_from_json(snapshot))
|
||||
assert_eq(scoped_manager.get_state_checksum(), checksum)
|
||||
var location := SpatialAddress.create(&"test_world", &"test_location", Vector3.ZERO)
|
||||
assert_eq(scoped_manager.event_log.world_events.get_for_location(location).size(), 1)
|
||||
|
||||
|
||||
func test_restore_rejects_adapter_scope_mismatch_before_mutating_empty_history() -> void:
|
||||
var source_adapter := ActiveWorldAdapter.new()
|
||||
assert_true(source_adapter.configure_context_identity(&"source", &"world_a", &"location_a"))
|
||||
add_child_autofree(source_adapter)
|
||||
var source_manager := MANAGER_SCRIPT.new()
|
||||
source_manager.active_world_adapter = source_adapter
|
||||
add_child_autofree(source_manager)
|
||||
var source_record: SimulationStateRecord = source_manager.create_state_record()
|
||||
assert_true(source_record.economic_events.is_empty())
|
||||
|
||||
var target_adapter := ActiveWorldAdapter.new()
|
||||
assert_true(target_adapter.configure_context_identity(&"target", &"world_b", &"location_b"))
|
||||
add_child_autofree(target_adapter)
|
||||
var target_manager := MANAGER_SCRIPT.new()
|
||||
target_manager.active_world_adapter = target_adapter
|
||||
add_child_autofree(target_manager)
|
||||
var checksum_before: String = target_manager.get_state_checksum()
|
||||
var scope_before: Dictionary = target_manager.event_log.get_scope()
|
||||
|
||||
assert_false(target_manager.restore_state(source_record))
|
||||
assert_eq(target_manager.get_state_checksum(), checksum_before)
|
||||
assert_eq(target_manager.event_log.get_scope(), scope_before)
|
||||
source_manager.record_narrative_event(
|
||||
SimulationIds.EVENT_TASK_STARTED, source_manager.npcs[0].id, &"", "Started scoped work"
|
||||
)
|
||||
var populated_source_record: SimulationStateRecord = source_manager.create_state_record()
|
||||
assert_false(populated_source_record.economic_events.is_empty())
|
||||
assert_false(target_manager.restore_state(populated_source_record))
|
||||
assert_eq(target_manager.get_state_checksum(), checksum_before)
|
||||
assert_eq(target_manager.event_log.get_scope(), scope_before)
|
||||
|
||||
|
||||
func _assign_activity(npc: SimNPC, action_id: StringName, target_id: StringName) -> void:
|
||||
npc.set_task(action_id)
|
||||
npc.target_id = target_id
|
||||
npc.travel_target_position = npc.position
|
||||
npc.has_travel_target = false
|
||||
npc.start_working()
|
||||
npc.task_progress = npc.task_duration
|
||||
npc.task_complete = true
|
||||
npc.task_state = SimNPC.TASK_STATE_COMPLETE
|
||||
|
||||
@@ -3,6 +3,7 @@ extends GutTest
|
||||
const RelationshipSystemScript := preload("res://simulation/relationships/RelationshipSystem.gd")
|
||||
|
||||
const CORE_EVENT_TYPE_IDS: Array[StringName] = [
|
||||
SimulationIds.EVENT_ACTIVITY_COMPLETED,
|
||||
SimulationIds.EVENT_RESOURCE_EXTRACTED,
|
||||
SimulationIds.EVENT_STORAGE_DEPOSITED,
|
||||
SimulationIds.EVENT_STORAGE_WITHDRAWN,
|
||||
|
||||
@@ -114,6 +114,21 @@ func test_economy_moves_inventory_into_authoritative_storage() -> void:
|
||||
assert_eq(village.food, 5.0)
|
||||
|
||||
|
||||
func test_economy_routes_authored_items_without_resource_switches() -> void:
|
||||
var village := SimVillage.new()
|
||||
var economy := VillageEconomyScript.new()
|
||||
economy.configure(village, false)
|
||||
economy.initialize_storage()
|
||||
|
||||
var apothecary := economy.get_storage_for_resource(&"herb")
|
||||
assert_not_null(apothecary)
|
||||
assert_eq(apothecary.get_storage_id(), &"village_apothecary")
|
||||
assert_same(economy.get_storage_for_resource(SimulationIds.RESOURCE_FOOD), economy.get_pantry())
|
||||
assert_same(
|
||||
economy.get_storage_for_resource(SimulationIds.RESOURCE_WOOD), economy.get_woodpile()
|
||||
)
|
||||
|
||||
|
||||
func test_failed_food_consumption_preserves_fractional_inventory() -> void:
|
||||
var village := SimVillage.new()
|
||||
var economy := VillageEconomyScript.new()
|
||||
|
||||
@@ -22,6 +22,7 @@ func _run() -> void:
|
||||
var event_count_before: int = manager.economic_events.size()
|
||||
|
||||
actor.set_task(SimulationIds.ACTION_PATROL, 1.0)
|
||||
actor.target_id = SimulationIds.ACTIVITY_GUARD_POST
|
||||
actor.start_working()
|
||||
manager.simulate_tick()
|
||||
var trigger := _latest_event_of_type(manager, SimulationIds.EVENT_TASK_BLOCKED, actor.id)
|
||||
@@ -365,6 +366,7 @@ func _open_missing_wood_need(manager: Node) -> SimNPC:
|
||||
actor.position = Vector3.ZERO
|
||||
_set_wood_amount(manager, 0.0)
|
||||
actor.set_task(SimulationIds.ACTION_STUDY, 1.0)
|
||||
actor.target_id = SimulationIds.ACTIVITY_STUDY_DESK
|
||||
actor.start_working()
|
||||
manager.simulate_tick()
|
||||
_check(manager.get_active_opportunity() != null, "Setup should open a missing-wood need")
|
||||
|
||||
@@ -6,8 +6,8 @@ const LoadedResourceSpatialIndexScript := preload(
|
||||
)
|
||||
|
||||
const DEFAULT_CONTEXT_ID := &"jajce_interior"
|
||||
const DEFAULT_WORLD_ID := &"regional_bosnia"
|
||||
const DEFAULT_LOCATION_ID := &"location_jajce"
|
||||
const DEFAULT_WORLD_ID := SimulationIds.REGIONAL_WORLD_BOSNIA
|
||||
const DEFAULT_LOCATION_ID := SimulationIds.REGIONAL_LOCATION_JAJCE
|
||||
const TARGET_KIND_RESOURCE := &"resource"
|
||||
const TARGET_KIND_STORAGE := &"storage"
|
||||
const TARGET_KIND_ACTIVITY := &"activity"
|
||||
|
||||
Reference in New Issue
Block a user