feat: share village needs with helpers
This commit is contained in:
@@ -77,6 +77,15 @@ func _run() -> void:
|
||||
"Restarting the transient trust blossom must not mutate simulation state"
|
||||
)
|
||||
var village_ui := main_scene.get_node("UI")
|
||||
var village_whisper := (
|
||||
main_scene.get_node("VillageWhisperLayer/VillageWhisper") as VillageWhisperHud
|
||||
)
|
||||
var whisper_kicker := village_whisper.get_node("Copy/Kicker") as Label
|
||||
var whisper_message := village_whisper.get_node("Copy/Message") as Label
|
||||
_check(
|
||||
not village_whisper.visible,
|
||||
"The player-facing village whisper should begin quiet instead of replaying history"
|
||||
)
|
||||
village_ui.selected_npc_index = witness.id
|
||||
village_ui.call("_refresh_npc_inspector")
|
||||
_check(
|
||||
@@ -250,8 +259,9 @@ func _run() -> void:
|
||||
and pantry_visual.get_stock_level() == PantryStockVisual.StockLevel.EMPTY
|
||||
and pantry_visual.get_node("EmptyStock").visible
|
||||
and not pantry_visual.get_node("RefillResponse").visible
|
||||
and not village_whisper.visible
|
||||
),
|
||||
"Restoration should rebuild concern and empty stock without replaying the refill response"
|
||||
"Restore should rebuild stable cues without replaying refill or player-HUD feedback"
|
||||
)
|
||||
var player := main_scene.get_node("Player") as Node3D
|
||||
var opportunity_bush := (
|
||||
@@ -270,8 +280,10 @@ func _run() -> void:
|
||||
player_opportunity.get_status() == OpportunityStateRecord.STATUS_RESOLVED
|
||||
and "Resolved village need" in inspector_label.text
|
||||
and "◆ Player restocked the pantry" in inspector_label.text
|
||||
and whisper_kicker.text == "NEED MET"
|
||||
and "The player restocked the pantry" in whisper_message.text
|
||||
),
|
||||
"Player gathering should resolve the same need and be named from its real event"
|
||||
"Player gathering should resolve the restored need in debug detail and transient HUD"
|
||||
)
|
||||
|
||||
var woodpile_state: StorageStateRecord = simulation_manager.get_woodpile()
|
||||
@@ -283,7 +295,9 @@ func _run() -> void:
|
||||
main_scene.get_node("JajceWorld/WorldObjects/StorageSites/VillageWoodpile") as StorageNode
|
||||
)
|
||||
contributor.position = woodpile_node.get_interaction_position()
|
||||
witness.position = contributor.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
|
||||
@@ -292,14 +306,64 @@ func _run() -> void:
|
||||
simulation_manager.simulate_tick()
|
||||
village_ui.selected_npc_index = contributor.id
|
||||
village_ui.call("_refresh_npc_inspector")
|
||||
var runtime_opportunity: OpportunityStateRecord = simulation_manager.get_active_opportunity()
|
||||
var runtime_trigger_id := (
|
||||
runtime_opportunity.get_trigger_event_id() if runtime_opportunity != null else -1
|
||||
)
|
||||
var runtime_helper: OpportunityHelperResult = simulation_manager.get_active_opportunity_helper()
|
||||
_check(
|
||||
(
|
||||
runtime_helper != null
|
||||
runtime_opportunity != null
|
||||
and runtime_helper == null
|
||||
and not simulation_manager.npc_knows_event(witness.id, runtime_trigger_id)
|
||||
and whisper_kicker.text == "VILLAGE NEED"
|
||||
and contributor.npc_name in whisper_message.text
|
||||
and "needs wood" in whisper_message.text
|
||||
),
|
||||
"The real blocked-work need should surface while the distant helper remains uninformed"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
"◆ Supply wood for blocked work" in village_stats_label.text
|
||||
and "could not finish Study" in village_stats_label.text
|
||||
and (
|
||||
"Possible helper: none informed, trusted, and able to supply"
|
||||
in village_stats_label.text
|
||||
)
|
||||
and "◆ Find wood for Study" in inspector_label.text
|
||||
and not contributor_visual.get_node("OpportunityConcernRoot").visible
|
||||
),
|
||||
"The missing-wood UI should honestly show that no capable informed helper exists yet"
|
||||
)
|
||||
|
||||
contributor.set_task(SimulationIds.ACTION_PATROL)
|
||||
contributor.target_id = guard_site.site_id
|
||||
contributor.position = guard_site.get_interaction_position()
|
||||
contributor.start_working()
|
||||
witness.set_task(SimulationIds.ACTION_PATROL)
|
||||
witness.target_id = guard_site.site_id
|
||||
witness.position = contributor.position + Vector3(1.0, 0.0, 0.0)
|
||||
simulation_manager.notify_npc_arrived(witness.id)
|
||||
var communicated_trigger: KnownEventStateRecord = simulation_manager.get_known_event_record(
|
||||
witness.id, runtime_trigger_id
|
||||
)
|
||||
runtime_helper = simulation_manager.get_active_opportunity_helper()
|
||||
_check(
|
||||
(
|
||||
communicated_trigger != null
|
||||
and (
|
||||
communicated_trigger.get_acquisition_method()
|
||||
== SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED
|
||||
)
|
||||
and communicated_trigger.get_source_npc_id() == contributor.id
|
||||
and runtime_helper != null
|
||||
and runtime_helper.helper_npc_id == witness.id
|
||||
and runtime_helper.action_id == SimulationIds.ACTION_GATHER_WOOD
|
||||
and whisper_kicker.text == "NEWS TRAVELS"
|
||||
and contributor.npc_name in whisper_message.text
|
||||
and witness.npc_name in whisper_message.text
|
||||
),
|
||||
"Runtime helper query should derive the informed trusted woodcutter"
|
||||
"A real shared activity should report the exact need and reveal the trusted helper"
|
||||
)
|
||||
var runtime_helper_reason := (
|
||||
"Knows the need; trust %.2f toward %s" % [runtime_helper.trust, contributor.npc_name]
|
||||
@@ -315,7 +379,7 @@ func _run() -> void:
|
||||
and "◆ Find wood for Study" in inspector_label.text
|
||||
and not contributor_visual.get_node("OpportunityConcernRoot").visible
|
||||
),
|
||||
"A real missing-wood fact should surface through wood-specific UI without a food cue"
|
||||
"The compact summary should update from the newly communicated simulation fact"
|
||||
)
|
||||
witness.current_task = SimulationIds.ACTION_IDLE
|
||||
witness.task_state = SimNPC.TASK_STATE_IDLE
|
||||
@@ -338,9 +402,19 @@ func _run() -> void:
|
||||
and witness.task_state == SimNPC.TASK_STATE_TRAVELING
|
||||
and helper_resource != null
|
||||
and helper_resource.get_reserved_by() == witness.id
|
||||
and whisper_kicker.text == "HELP IS ON THE WAY"
|
||||
and witness.npc_name in whisper_message.text
|
||||
and "gather wood" in whisper_message.text
|
||||
),
|
||||
"The runtime helper should autonomously enter normal target resolution and reservation"
|
||||
"The informed helper and cozy HUD should reflect ordinary target resolution and reservation"
|
||||
)
|
||||
var demo_controller := main_scene.get_node("DemoController")
|
||||
demo_controller.set_debug_overlay_visible(false)
|
||||
_check(
|
||||
not village_ui.visible and village_whisper.visible,
|
||||
"The transient player HUD should remain legible when development overlays are hidden"
|
||||
)
|
||||
demo_controller.set_debug_overlay_visible(true)
|
||||
contributor.add_inventory(SimulationIds.RESOURCE_WOOD, 1.0)
|
||||
_check(
|
||||
is_equal_approx(
|
||||
@@ -354,8 +428,10 @@ func _run() -> void:
|
||||
(
|
||||
"Village need" not in village_stats_label.text
|
||||
and "◆ %s supplied the woodpile" % contributor.npc_name in inspector_label.text
|
||||
and whisper_kicker.text == "NEED MET"
|
||||
and "supplied the woodpile" in whisper_message.text
|
||||
),
|
||||
"The resolved wood need should name its exact supplier and leave the active summary"
|
||||
"Resolution should name its supplier in both debug detail and the transient player HUD"
|
||||
)
|
||||
_check(
|
||||
main_scene.has_node("JajceWorld/TerrainRoot/Terrain3D"),
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures: Array[String] = []
|
||||
var transfers: Array[Dictionary] = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var manager := _create_manager(1301)
|
||||
manager.event_knowledge_transferred.connect(_on_event_knowledge_transferred)
|
||||
var interested: SimNPC = manager.npcs[0]
|
||||
var speaker: SimNPC = manager.npcs[1]
|
||||
var helper: SimNPC = manager.npcs[2]
|
||||
interested.position = Vector3.ZERO
|
||||
speaker.position = Vector3(4.0, 0.0, 0.0)
|
||||
helper.position = Vector3(30.0, 0.0, 0.0)
|
||||
for npc in manager.npcs:
|
||||
npc.hunger = 20.0
|
||||
npc.energy = 80.0
|
||||
_set_wood_amount(manager, 0.5)
|
||||
|
||||
interested.set_task(SimulationIds.ACTION_STUDY, 1.0)
|
||||
interested.start_working()
|
||||
manager.simulate_tick()
|
||||
var opportunity: OpportunityStateRecord = manager.get_active_opportunity()
|
||||
var trigger := _latest_event_of_type(manager, SimulationIds.EVENT_TASK_BLOCKED, interested.id)
|
||||
_check(opportunity != null and trigger != null, "Blocked work should open a real wood need")
|
||||
if opportunity == null or trigger == null:
|
||||
manager.free()
|
||||
_finish()
|
||||
return
|
||||
var trigger_id := int(trigger.data["event_id"])
|
||||
_check(
|
||||
(
|
||||
manager.npc_knows_event(interested.id, trigger_id)
|
||||
and manager.npc_knows_event(speaker.id, trigger_id)
|
||||
and not manager.npc_knows_event(helper.id, trigger_id)
|
||||
and (
|
||||
manager.get_known_event_record(speaker.id, trigger_id).get_acquisition_method()
|
||||
== SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED
|
||||
)
|
||||
),
|
||||
"The future speaker should directly witness the need while the helper remains uninformed"
|
||||
)
|
||||
|
||||
helper.profession = SimulationIds.PROFESSION_WOODCUTTER
|
||||
_add_trusted_relationship(manager, helper.id, interested.id)
|
||||
var tree := _create_helper_tree()
|
||||
root.add_child(tree)
|
||||
await process_frame
|
||||
_check(manager.register_resource_node(tree), "The helper route should use a finite tree")
|
||||
_check(
|
||||
manager.get_active_opportunity_helper() == null,
|
||||
"Trust and supply should not make an uninformed villager a helper"
|
||||
)
|
||||
|
||||
interested.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0)
|
||||
_check(
|
||||
manager.economy.deposit_inventory(interested, SimulationIds.RESOURCE_FOOD) > 0.0,
|
||||
"Setup should create one newer directly witnessed distractor fact"
|
||||
)
|
||||
var distractor := _latest_event_of_type(
|
||||
manager, SimulationIds.EVENT_STORAGE_DEPOSITED, interested.id
|
||||
)
|
||||
_check(distractor != null, "The distractor deposit should enter objective history")
|
||||
if distractor == null:
|
||||
tree.queue_free()
|
||||
manager.free()
|
||||
_finish()
|
||||
return
|
||||
var distractor_id := int(distractor.data["event_id"])
|
||||
var no_lasting_events: Array[int] = []
|
||||
var default_order: Array[int] = manager.event_knowledge_system.get_communicable_event_ids(
|
||||
speaker.id, no_lasting_events
|
||||
)
|
||||
_check(
|
||||
not default_order.is_empty() and default_order[0] == distractor_id,
|
||||
"Without active-need priority, the newer direct fact would be discussed first"
|
||||
)
|
||||
|
||||
_prepare_shared_activity(speaker, helper)
|
||||
var event_count_before: int = manager.economic_events.size()
|
||||
var next_event_id_before: int = manager.next_event_id
|
||||
manager.notify_npc_arrived(helper.id)
|
||||
var communicated: KnownEventStateRecord = manager.get_known_event_record(helper.id, trigger_id)
|
||||
_check(
|
||||
(
|
||||
communicated != null
|
||||
and (
|
||||
communicated.get_acquisition_method()
|
||||
== SimulationIds.KNOWLEDGE_ACQUISITION_COMMUNICATED
|
||||
)
|
||||
and communicated.get_source_npc_id() == speaker.id
|
||||
and (
|
||||
communicated.get_source_acquisition_method()
|
||||
== SimulationIds.KNOWLEDGE_ACQUISITION_WITNESSED
|
||||
)
|
||||
and not manager.npc_knows_event(helper.id, distractor_id)
|
||||
and (
|
||||
transfers
|
||||
== [{"speaker_id": speaker.id, "listener_id": helper.id, "event_id": trigger_id}]
|
||||
)
|
||||
),
|
||||
"Shared work should communicate the exact active trigger before newer unrelated facts"
|
||||
)
|
||||
_check(
|
||||
(
|
||||
manager.economic_events.size() == event_count_before
|
||||
and manager.next_event_id == next_event_id_before
|
||||
),
|
||||
"Reporting the need should reference existing history without adding an event"
|
||||
)
|
||||
|
||||
var helper_result: OpportunityHelperResult = manager.get_active_opportunity_helper()
|
||||
_check(
|
||||
(
|
||||
helper_result != null
|
||||
and helper_result.helper_npc_id == helper.id
|
||||
and helper_result.trigger_event_id == trigger_id
|
||||
and helper_result.action_id == SimulationIds.ACTION_GATHER_WOOD
|
||||
),
|
||||
"The newly informed trusted woodcutter should be re-derived as the capable helper"
|
||||
)
|
||||
helper.current_task = SimulationIds.ACTION_IDLE
|
||||
helper.task_state = SimNPC.TASK_STATE_IDLE
|
||||
helper.task_complete = true
|
||||
helper.target_id = &""
|
||||
helper.has_travel_target = false
|
||||
manager.simulate_tick()
|
||||
var decision: ActionSelectionResult = manager.get_latest_decision(helper.id)
|
||||
_check(
|
||||
(
|
||||
decision != null
|
||||
and decision.action_id == SimulationIds.ACTION_GATHER_WOOD
|
||||
and "Responding to village need" in decision.reason
|
||||
and helper.current_task == SimulationIds.ACTION_GATHER_WOOD
|
||||
),
|
||||
"The communicated fact should flow into ordinary autonomous helper selection"
|
||||
)
|
||||
|
||||
var saved_json: String = manager.serialize_state()
|
||||
var restored := _create_manager(1302)
|
||||
_check(
|
||||
restored.restore_state_from_json(saved_json), "The communicated helper path should restore"
|
||||
)
|
||||
var restored_record: KnownEventStateRecord = restored.get_known_event_record(
|
||||
helper.id, trigger_id
|
||||
)
|
||||
var restored_helper: OpportunityHelperResult = restored.get_active_opportunity_helper()
|
||||
_check(
|
||||
(
|
||||
restored.get_state_checksum() == manager.get_state_checksum()
|
||||
and restored_record != null
|
||||
and restored_record.get_source_npc_id() == speaker.id
|
||||
and restored_helper != null
|
||||
and restored_helper.helper_npc_id == helper.id
|
||||
),
|
||||
"Restore should preserve provenance and re-derive capability without assignment state"
|
||||
)
|
||||
|
||||
restored.free()
|
||||
manager.free()
|
||||
tree.queue_free()
|
||||
await process_frame
|
||||
_finish()
|
||||
|
||||
|
||||
func _prepare_shared_activity(speaker: SimNPC, listener: SimNPC) -> void:
|
||||
speaker.set_task(SimulationIds.ACTION_PATROL)
|
||||
speaker.target_id = &"opportunity_guard_post"
|
||||
speaker.position = Vector3(20.0, 0.0, 0.0)
|
||||
speaker.start_working()
|
||||
listener.set_task(SimulationIds.ACTION_PATROL)
|
||||
listener.target_id = speaker.target_id
|
||||
listener.position = speaker.position + Vector3(1.0, 0.0, 0.0)
|
||||
|
||||
|
||||
func _add_trusted_relationship(manager: Node, observer_id: int, subject_id: int) -> void:
|
||||
var records: Array[RelationshipStateRecord] = manager.relationship_system.get_all_sorted()
|
||||
for index in records.size():
|
||||
var relationship := records[index]
|
||||
if (
|
||||
relationship.get_observer_id() == observer_id
|
||||
and relationship.get_subject_id() == subject_id
|
||||
):
|
||||
records[index] = RelationshipStateRecord.create(observer_id, subject_id, 0.7, 0.7)
|
||||
manager.relationship_system.restore(records)
|
||||
return
|
||||
records.append(RelationshipStateRecord.create(observer_id, subject_id, 0.7, 0.7))
|
||||
manager.relationship_system.restore(records)
|
||||
|
||||
|
||||
func _set_wood_amount(manager: Node, amount: float) -> void:
|
||||
var woodpile: StorageStateRecord = manager.get_woodpile()
|
||||
woodpile.withdraw(SimulationIds.RESOURCE_WOOD, woodpile.get_amount(SimulationIds.RESOURCE_WOOD))
|
||||
woodpile.deposit(SimulationIds.RESOURCE_WOOD, amount)
|
||||
manager.economy.sync_resource(SimulationIds.RESOURCE_WOOD)
|
||||
|
||||
|
||||
func _latest_event_of_type(
|
||||
manager: Node, event_type: StringName, actor_id: int
|
||||
) -> EconomicEventRecord:
|
||||
var events: Array = manager.get_npc_events(actor_id, 8)
|
||||
for index in range(events.size() - 1, -1, -1):
|
||||
var event := events[index] as EconomicEventRecord
|
||||
if StringName(event.data["event_type"]) == event_type:
|
||||
return event
|
||||
return null
|
||||
|
||||
|
||||
func _create_helper_tree() -> ResourceNode:
|
||||
var tree := ResourceNode.new()
|
||||
tree.name = "CommunicatedHelperTree"
|
||||
tree.node_id = &"communicated_helper_tree"
|
||||
tree.action_id = SimulationIds.ACTION_GATHER_WOOD
|
||||
tree.resource_id = SimulationIds.RESOURCE_WOOD
|
||||
tree.initial_amount = 2.0
|
||||
tree.yield_per_action = 1.0
|
||||
tree.can_npcs_use = true
|
||||
tree.can_player_use = false
|
||||
tree.debug_label_enabled = false
|
||||
var interaction_point := Marker3D.new()
|
||||
interaction_point.name = "InteractionPoint"
|
||||
tree.add_child(interaction_point)
|
||||
return tree
|
||||
|
||||
|
||||
func _create_manager(seed_value: int) -> Node:
|
||||
var manager: Node = load("res://simulation/SimulationManager.gd").new()
|
||||
manager.simulation_seed = seed_value
|
||||
manager.debug_logs = false
|
||||
var home_positions: Array[Vector3] = [
|
||||
Vector3(0.0, 0.0, 0.0),
|
||||
Vector3(2.0, 0.0, 0.0),
|
||||
Vector3(20.0, 0.0, 0.0),
|
||||
Vector3(30.0, 0.0, 0.0),
|
||||
Vector3(40.0, 0.0, 0.0),
|
||||
Vector3(50.0, 0.0, 0.0),
|
||||
]
|
||||
manager.home_positions = home_positions
|
||||
root.add_child(manager)
|
||||
manager.set_process(false)
|
||||
for npc in manager.npcs:
|
||||
npc.set_task(SimulationIds.ACTION_WANDER, 1000.0)
|
||||
npc.start_working()
|
||||
return manager
|
||||
|
||||
|
||||
func _on_event_knowledge_transferred(
|
||||
speaker_id: int, listener_id: int, event: EconomicEventRecord
|
||||
) -> void:
|
||||
transfers.append(
|
||||
{
|
||||
"speaker_id": speaker_id,
|
||||
"listener_id": listener_id,
|
||||
"event_id": int(event.data["event_id"])
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("[TEST] Opportunity communication helper passed: report -> response")
|
||||
quit(0)
|
||||
return
|
||||
for failure in failures:
|
||||
push_error("[TEST] " + failure)
|
||||
quit(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c3aeauhllrh1m
|
||||
Reference in New Issue
Block a user