feat: unify authored activity commands

This commit is contained in:
Rijad Zuzo
2026-08-13 00:08:30 +02:00
parent e5aecee0de
commit b3b98c6456
36 changed files with 3013 additions and 96 deletions
+26 -1
View File
@@ -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
+26 -4
View File
@@ -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
+15 -4
View File
@@ -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),
]
)
)
+67 -30
View File
@@ -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,25 +145,33 @@ 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 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_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 is_near_storage(pantry_storage):
if _get_carried_food() > 0.0:
@@ -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)
+666 -5
View File
@@ -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", &""))
)
)
+2 -2
View File
@@ -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)
+77
View File
@@ -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,10 +83,13 @@ 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):
if handler_id not in BUILTIN_HANDLER_IDS:
errors.append("Duplicate supported handler '%s'" % handler_id)
else:
supported_handlers[handler_id] = true
@@ -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)
+8
View File
@@ -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"
+13 -1
View File
@@ -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"
+13 -1
View File
@@ -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"
+4 -2
View File
@@ -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")]
+63 -16
View File
@@ -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:
+293
View File
@@ -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
+141 -5
View File
@@ -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
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
)
== null
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:
+4 -8
View File
@@ -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 [
+24 -1
View File
@@ -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]
+72 -1
View File
@@ -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:
+16 -1
View File
@@ -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()
+4 -4
View File
@@ -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()
+163 -2
View File
@@ -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
+1
View File
@@ -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,
+15
View File
@@ -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()
+2
View File
@@ -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")
+2 -2
View File
@@ -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"