merge: integrate remote player quest slices

This commit is contained in:
Rijad Zuzo
2026-08-16 16:53:30 +02:00
55 changed files with 3640 additions and 54 deletions
@@ -0,0 +1,163 @@
extends SceneTree
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var main_scene: Node = load("res://main.tscn").instantiate()
root.add_child(main_scene)
await process_frame
for _frame in 10:
await physics_frame
var manager: Node = main_scene.get_node("SimulationManager")
var view: Node = main_scene.get_node("WorldViewManager")
var player: CharacterBody3D = main_scene.get_node("Player")
var demo: Node = main_scene.get_node("DemoController")
var field_note := main_scene.get_node("VillagerInspectionLayer/VillagerFieldNote")
manager.set_process(false)
player.set_physics_process(false)
_freeze_visuals(view)
var staged: Dictionary = demo.call("stage_pantry_crisis")
_check(not staged.is_empty(), "The runtime proof should stage a real open pantry need")
if staged.is_empty():
_finish()
return
var interested: SimNPC = staged["interested"]
var pantry_position: Vector3 = (
StorageNode.get_by_id(SimulationIds.STORAGE_VILLAGE_PANTRY).get_interaction_position()
)
player.global_position = pantry_position
_move_population_away(manager, view, interested.id, pantry_position)
_set_loaded_position(manager, view, interested, pantry_position + Vector3(1.0, 0.0, 0.0))
var checksum_before: String = manager.get_state_checksum()
var serialized_before: String = manager.serialize_state()
var events_before: int = manager.economic_events.size()
for _query in 3:
player.call("get_nearby_villager_inspection")
field_note.call("refresh_note", true)
var need_label := field_note.get_node("Copy/Need") as Label
_check(
(
manager.get_state_checksum() == checksum_before
and manager.serialize_state() == serialized_before
and manager.economic_events.size() == events_before
and field_note.visible
and need_label.visible
and "Need" in need_label.text
),
"The inspected interested villager should surface the real need without mutating state",
)
if not need_label.visible:
_finish()
return
var helper: OpportunityHelperResult = manager.get_active_opportunity_helper()
var helper_npc := _find_npc(manager.npcs, helper.helper_npc_id) if helper != null else null
_check(
(
helper != null
and helper_npc != null
and helper_npc.npc_name in need_label.text
and "bringing" in need_label.text.to_lower()
),
"The open need with a capable helper should name that helper on the field note",
)
var checksum_after_helper: String = manager.get_state_checksum()
var food_node: ResourceNode = _find_food_node(manager)
_check(food_node != null, "The resolution proof needs a stocked player-usable food source")
if food_node == null:
_finish()
return
var harvested: float = manager.harvest_resource_node(food_node)
var deposited: float = manager.player_deposit(SimulationIds.RESOURCE_FOOD)
_check(
(
harvested > 0.0
and deposited > 0.0
and manager.get_active_opportunity() == null
and (
manager.opportunity_system.get_latest().get_status()
== OpportunityStateRecord.STATUS_RESOLVED
)
),
"An ordinary finite-resource harvest and pantry deposit should resolve the same open need",
)
field_note.call("refresh_note", true)
_check(
not need_label.visible and manager.get_state_checksum() != checksum_after_helper,
"Resolving the need should clear the surfaced need segment from the field note",
)
_finish()
func _find_food_node(manager: Node) -> ResourceNode:
for node in ResourceNode.get_all():
var state: ResourceStateRecord = (
manager.get_resource_state(node.node_id) as ResourceStateRecord
)
if (
node.resource_id == SimulationIds.RESOURCE_FOOD
and state != null
and state.can_player_use_resource()
and state.can_extract()
and state.get_amount_remaining() >= 1.0
):
return node
return null
func _find_npc(npcs: Array[SimNPC], npc_id: int) -> SimNPC:
for npc in npcs:
if npc.id == npc_id:
return npc
return null
func _move_population_away(manager: Node, view: Node, keep_id: int, origin: Vector3) -> void:
for npc in manager.npcs:
if npc.id == keep_id:
continue
_set_loaded_position(
manager, view, npc, origin + Vector3(40.0 + float(npc.id) * 2.0, 0.0, 20.0)
)
func _set_loaded_position(manager: Node, view: Node, npc: SimNPC, position: Vector3) -> void:
manager.synchronize_npc_position(npc.id, position)
var visual := view.active_npc_visuals.get(npc.id) as Node3D
if visual != null:
visual.set_physics_process(false)
if visual.has_method("stop_travel"):
visual.stop_travel()
visual.global_position = position
func _freeze_visuals(view: Node) -> void:
for visual in view.active_npc_visuals.values():
visual.set_physics_process(false)
if visual.has_method("stop_travel"):
visual.stop_travel()
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
func _finish() -> void:
if failures.is_empty():
print("[TEST] Field-note opportunity surfacing passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
@@ -0,0 +1 @@
uid://dg66jcyhajmxp
+6 -2
View File
@@ -322,6 +322,9 @@ func _run() -> void:
opportunity_bush_state.set_amount_remaining(1.0)
player.global_position = opportunity_bush.interaction_point.global_position
player.call("try_interact")
var carried_after_gather: float = simulation_manager.get_player_state().get_inventory_amount(
SimulationIds.RESOURCE_FOOD
)
_check(
(
simulation_manager.get_player_state().get_inventory_amount(SimulationIds.RESOURCE_FOOD)
@@ -338,13 +341,14 @@ func _run() -> void:
village_ui.call("_refresh_npc_inspector")
_check(
(
player_opportunity.get_status() == OpportunityStateRecord.STATUS_RESOLVED
is_equal_approx(carried_after_gather, 1.0)
and 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 restored need in debug detail and transient HUD"
"Player gathering and depositing should resolve the restored need in debug detail and transient HUD"
)
var woodpile_state: StorageStateRecord = simulation_manager.get_woodpile()
+188
View File
@@ -0,0 +1,188 @@
extends SceneTree
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var manager := _create_manager()
var actor: SimNPC = manager.npcs[0]
var interested: SimNPC = manager.npcs[2]
var berry := _register_resource(
manager,
&"carry_berry_bush",
SimulationIds.ACTION_GATHER_FOOD,
SimulationIds.RESOURCE_FOOD,
3.0
)
var pantry: StorageStateRecord = manager.get_pantry()
_set_pantry_amount(manager, 1.0)
for npc in manager.npcs:
npc.position = Vector3(40.0 + npc.id * 10.0, 0.0, 0.0)
actor.position = Vector3.ZERO
interested.position = Vector3(4.0, 0.0, 0.0)
actor.hunger = 60.0
interested.hunger = 90.0
manager.economy.withdraw_to_inventory(actor, SimulationIds.RESOURCE_FOOD, 1.0)
var quest: PlayerQuestRecord = manager.get_active_player_quest()
_check(quest != null, "The carry loop needs an open player quest")
if quest == null:
berry.free()
manager.free()
_finish()
return
var state: PlayerStateRecord = manager.get_player_state()
var carried: float = manager.player_gather(berry)
_check(
(
is_equal_approx(carried, 2.0)
and is_equal_approx(state.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 2.0)
and is_equal_approx(manager.get_pantry().get_amount(SimulationIds.RESOURCE_FOOD), 0.0)
),
"Gathering should fill the player's hands instead of the pantry directly",
)
var capacity_limited: float = manager.player_gather(berry)
_check(
(
is_equal_approx(capacity_limited, 1.0)
and is_equal_approx(state.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 3.0)
),
"Remaining resource amount should cap the second gather",
)
var hands_full: float = manager.player_gather(berry)
_check(
(
is_equal_approx(hands_full, 0.0)
and is_equal_approx(state.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 3.0)
),
"An empty resource should refuse further gathering",
)
var pantry_before: float = manager.get_pantry().get_amount(SimulationIds.RESOURCE_FOOD)
var deposited: float = manager.player_deposit(SimulationIds.RESOURCE_FOOD)
_check(
(
is_equal_approx(deposited, 3.0)
and is_equal_approx(
manager.get_pantry().get_amount(SimulationIds.RESOURCE_FOOD), pantry_before + 3.0
)
and is_equal_approx(state.get_inventory_amount(SimulationIds.RESOURCE_FOOD), 0.0)
and manager.get_active_player_quest() == null
and (
manager.get_latest_player_quest_for_requester(interested.id).get_status()
== PlayerQuestRecord.STATUS_COMPLETED
)
),
"Depositing carried food should resolve the quest through the real pantry transaction",
)
var saved_json: String = manager.serialize_state()
var restored := _create_manager(902)
_check(
restored.restore_state_from_json(saved_json),
"The emptied carry state should restore through the current schema"
)
_check(
(
restored.get_state_checksum() == manager.get_state_checksum()
and is_equal_approx(
restored.get_player_state().get_inventory_amount(SimulationIds.RESOURCE_FOOD), 0.0
)
),
"Restore should preserve the player's empty carried inventory and checksum",
)
var carry_state_saved: String = manager.serialize_state()
manager.player_gather(berry)
var mid_carry: String = manager.serialize_state()
var mid_restored := _create_manager(903)
_check(
mid_restored.restore_state_from_json(mid_carry),
"A partially carried inventory should restore through the current schema"
)
_check(
(
mid_restored.get_state_checksum() == manager.get_state_checksum()
and is_equal_approx(
mid_restored.get_player_state().get_inventory_amount(SimulationIds.RESOURCE_FOOD),
manager.get_player_state().get_inventory_amount(SimulationIds.RESOURCE_FOOD)
)
),
"Restore should preserve the player's carried inventory mid-transport",
)
berry.free()
manager.free()
restored.free()
mid_restored.free()
_finish()
func _register_resource(
manager: Node,
node_id: StringName,
action_id: StringName,
resource_id: StringName,
amount: float
) -> ResourceNode:
var node := ResourceNode.new()
node.name = "CarryResource"
node.node_id = node_id
node.action_id = action_id
node.resource_id = resource_id
node.initial_amount = amount
node.yield_per_action = 2.0
node.debug_label_enabled = false
var interaction_point := Marker3D.new()
interaction_point.name = "InteractionPoint"
node.add_child(interaction_point)
root.add_child(node)
_check(
manager.register_resource_node(node),
"The carry loop should bind a real finite ResourceNode"
)
return node
func _create_manager(seed_value: int = 901) -> 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(10.0, 0.0, 0.0),
Vector3(12.0, 0.0, 0.0),
Vector3(30.0, 0.0, 30.0),
Vector3(40.0, 0.0, 40.0),
]
manager.home_positions = home_positions
root.add_child(manager)
manager.set_process(false)
return manager
func _set_pantry_amount(manager: Node, amount: float) -> void:
var pantry: StorageStateRecord = manager.get_pantry()
pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD))
pantry.deposit(SimulationIds.RESOURCE_FOOD, amount)
manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
func _finish() -> void:
if failures.is_empty():
print("[TEST] Player carry and deposit loop passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
+1
View File
@@ -0,0 +1 @@
uid://bwyg38d3ieiex
+130
View File
@@ -0,0 +1,130 @@
extends SceneTree
var failures: Array[String] = []
var died_events := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var manager := _create_manager()
manager.player_died.connect(_on_player_died)
var state: PlayerStateRecord = manager.get_player_state()
_check(
(
state != null
and is_equal_approx(state.get_hunger(), 40.0)
and is_equal_approx(state.get_energy(), 100.0)
and not state.is_dead()
),
"A fresh player should start with embodied needs and no death state",
)
for _tick in 3:
manager.simulate_tick()
_check(
(
is_equal_approx(state.get_hunger(), 40.375)
and is_equal_approx(state.get_energy(), 99.8125)
),
"Simulation ticks should advance the player's hunger and drain energy",
)
state.data["hunger"] = 80.0
var hunger_before_eat: float = state.get_hunger()
var eaten: float = manager.player_eat(10.0)
_check(
(
is_equal_approx(eaten, 10.0)
and state.get_hunger() < hunger_before_eat
and state.get_energy() > 80.0
),
"Eating should reduce the player's own hunger and restore energy",
)
var initial_standing: float = manager.get_player_standing().get_standing()
manager.get_player_standing().grant_standing(20.0, 1)
var standing_after_help: float = manager.get_player_standing().get_standing()
state.data["hunger"] = PlayerStateRecord.STACK_THRESHOLD
state.data["starvation_ticks"] = (int(state.data["starvation_death_threshold"]) - 1)
manager.simulate_tick()
_check(
(
died_events == 1
and state.is_dead()
and is_equal_approx(
manager.get_player_standing().get_standing(),
standing_after_help * PlayerStateRecord.DEATH_STANDING_PENALTY
)
),
"Starvation death should emit a death event and cut standing as a consequence",
)
var saved_json: String = manager.serialize_state()
var restored := _create_manager(902)
_check(
restored.restore_state_from_json(saved_json),
"The dead player state should restore through the current schema"
)
_check(
(
restored.get_state_checksum() == manager.get_state_checksum()
and restored.get_player_state().is_dead()
and restored.get_player_state().get_deaths() == 1
),
"Restore should preserve the embodied death state and checksum",
)
_check(manager.respawn_player(), "A dead player should be able to respawn")
_check(
(
not state.is_dead()
and is_equal_approx(state.get_hunger(), PlayerStateRecord.RESET_HUNGER)
and is_equal_approx(state.get_energy(), PlayerStateRecord.RESET_ENERGY)
),
"Respawn should reset needs to the documented baseline",
)
manager.free()
restored.free()
_finish()
func _create_manager(seed_value: int = 901) -> 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(10.0, 0.0, 0.0),
Vector3(12.0, 0.0, 0.0),
Vector3(30.0, 0.0, 30.0),
Vector3(40.0, 0.0, 40.0),
]
manager.home_positions = home_positions
root.add_child(manager)
manager.set_process(false)
return manager
func _on_player_died(_state: PlayerStateRecord) -> void:
died_events += 1
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
func _finish() -> void:
if failures.is_empty():
print("[TEST] Embodied player needs passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
+1
View File
@@ -0,0 +1 @@
uid://jeax46jjayqj
+397
View File
@@ -0,0 +1,397 @@
extends SceneTree
var failures: Array[String] = []
var opened_quest_ids: Array[int] = []
var completed_quest_ids: Array[int] = []
var expired_quest_ids: Array[int] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var manager := _create_manager()
manager.player_quest_opened.connect(_on_quest_opened)
manager.player_quest_completed.connect(_on_quest_completed)
manager.player_quest_expired.connect(_on_quest_expired)
var berry := _register_berry(manager, &"quest_berry_bush")
var actor: SimNPC = manager.npcs[0]
var interested: SimNPC = manager.npcs[2]
_set_pantry_amount(manager, 1.0)
for npc in manager.npcs:
npc.position = Vector3(40.0 + npc.id * 10.0, 0.0, 0.0)
actor.position = Vector3.ZERO
actor.hunger = 60.0
interested.hunger = 90.0
manager.economy.withdraw_to_inventory(actor, SimulationIds.RESOURCE_FOOD, 1.0)
_prepare_shared_patrol(actor, interested, Vector3(20.0, 0.0, 0.0))
_check(
manager.try_communicate_at_shared_activity(actor.id, interested.id),
"The quest proof should open the same known pantry shortage"
)
_check(manager.get_active_opportunity() != null, "The quest proof needs an active opportunity")
var quest: PlayerQuestRecord = manager.get_active_player_quest()
_check(
(
quest != null
and quest.get_requester_npc_id() == interested.id
and quest.get_quest_type() == SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY
and quest.get_resource_id() == SimulationIds.RESOURCE_FOOD
and quest.get_target_id() == SimulationIds.STORAGE_VILLAGE_PANTRY
and is_equal_approx(quest.get_target_amount(), 1.0)
and quest.get_standing_reward() > 0.0
and quest.is_open()
and opened_quest_ids == [quest.get_quest_id()]
),
"A player-actionable shortage should generate one quest for its worried villager",
)
if quest == null:
manager.free()
_finish()
return
var no_helper_route: OpportunityPlayerResponseResult = (
manager.get_active_opportunity_player_response()
)
var standing_before: PlayerStandingRecord = manager.get_player_standing()
_check(
(
no_helper_route != null
and is_equal_approx(standing_before.get_standing(), 0.0)
and standing_before.get_resolved_needs() == 0
and is_equal_approx(standing_before.get_gratitude(interested.id), 0.0)
),
"Standing should start at zero with no gratitude before any player help",
)
var checksum_before_resolve: String = manager.get_state_checksum()
var serialized_before: String = manager.serialize_state()
var berry_harvested: float = manager.harvest_resource_node(berry)
_check(
is_equal_approx(berry_harvested, 1.0),
"The quest should use the ordinary player harvest command"
)
_check(
manager.player_deposit(SimulationIds.RESOURCE_FOOD) > 0.0,
"Completing the quest should use the ordinary pantry deposit command"
)
var standing_after: PlayerStandingRecord = manager.get_player_standing()
var completed: PlayerQuestRecord = manager.get_active_player_quest()
var latest: PlayerQuestRecord = manager.get_latest_player_quest_for_requester(interested.id)
_check(
(
completed == null
and latest != null
and latest.get_status() == PlayerQuestRecord.STATUS_COMPLETED
and completed_quest_ids == [latest.get_quest_id()]
and is_equal_approx(standing_after.get_standing(), quest.get_standing_reward())
and standing_after.get_resolved_needs() == 1
and is_equal_approx(standing_after.get_gratitude(interested.id), 0.1)
and manager.get_state_checksum() != checksum_before_resolve
),
"A real player supply should complete the quest, grant standing, and raise gratitude",
)
var saved_json: String = serialized_before
var pre_completion_state: String = manager.serialize_state()
var restored := _create_manager(902)
_check(
restored.restore_state_from_json(pre_completion_state),
"The completed quest and standing should restore through the current schema"
)
_check(
(
restored.get_state_checksum() == manager.get_state_checksum()
and restored.get_active_player_quest() == null
and restored.get_latest_player_quest_for_requester(interested.id) != null
and is_equal_approx(
restored.get_player_standing().get_standing(), quest.get_standing_reward()
)
and is_equal_approx(restored.get_player_standing().get_gratitude(interested.id), 0.1)
),
"Restore should re-derive completed quest and standing without reopening the need",
)
restored.free()
var invalidated := _create_manager(905)
invalidated.player_quest_expired.connect(_on_quest_expired)
var stale_tree := _register_tree(invalidated, &"quest_stale_tree")
var stale_setup := _open_missing_wood_need(invalidated)
_check(
invalidated.get_active_player_quest() != null, "The invalidation branch needs an open quest"
)
var stale_quest: PlayerQuestRecord = invalidated.get_active_player_quest()
var stale_interested_id: int = stale_setup["interested"].id
var review_interval: int = invalidated.get_knowledge_review_interval()
invalidated.tick_count = stale_quest.get_created_tick() + review_interval
invalidated.call("_maintain_event_knowledge", true)
invalidated.simulate_tick()
_check(
(
expired_quest_ids.has(stale_quest.get_quest_id())
and invalidated.get_active_player_quest() == null
and is_equal_approx(
invalidated.get_player_standing().get_gratitude(stale_interested_id), 0.0
)
),
"A stale need should expire its quest without granting standing",
)
berry.free()
stale_tree.free()
var animal_manager := _create_manager(906)
animal_manager.player_quest_opened.connect(_on_quest_opened)
animal_manager.player_quest_completed.connect(_on_quest_completed)
var caretaker: SimNPC = animal_manager.npcs[0]
var pantry: StorageStateRecord = animal_manager.get_pantry()
pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD))
pantry.deposit(SimulationIds.RESOURCE_FOOD, 5.0)
animal_manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
caretaker.position = Vector3.ZERO
var goat := AnimalNode.new()
goat.name = "Dunja"
goat.animal_id = SimulationIds.ANIMAL_DUNJA
goat.animal_definition_id = &"domestic_goat"
goat.display_name = "Dunja"
goat.species_id = SimulationIds.SPECIES_GOAT
goat.initial_enabled = true
goat.can_npcs_feed = true
goat.can_player_feed = true
goat.initial_hunger = 0.0
root.add_child(goat)
_check(
animal_manager.animal_care.register_node(goat),
"The animal-care quest branch should register a real named goat"
)
var animal_state: AnimalStateRecord = animal_manager.animal_care.get_state(
SimulationIds.ANIMAL_DUNJA
)
_check(animal_state != null, "The animal-care quest branch needs Dunja's state")
if animal_state == null:
_finish()
return
animal_state.set_hunger(animal_state.FEED_THRESHOLD + 1.0)
animal_manager.simulate_tick()
var animal_quest: PlayerQuestRecord = animal_manager.get_active_player_quest()
_check(
(
animal_quest != null
and animal_quest.has_animal_target()
and animal_quest.get_animal_id() == SimulationIds.ANIMAL_DUNJA
and animal_quest.get_quest_type() == SimulationIds.OPPORTUNITY_FEED_HUNGRY_ANIMAL
and animal_quest.get_requester_npc_id() == caretaker.id
),
"A hungry, player-feedable goat should generate an animal-care quest for a nearby villager",
)
var standing_before_animal: float = animal_manager.get_player_standing().get_standing()
_check(
animal_manager.feed_animal(SimulationIds.ANIMAL_DUNJA),
"The player should resolve the animal quest through the ordinary feed command"
)
var animal_latest: PlayerQuestRecord = animal_manager.get_latest_player_quest_for_requester(
caretaker.id
)
_check(
(
completed_quest_ids.has(animal_quest.get_quest_id())
and animal_latest != null
and animal_latest.get_status() == PlayerQuestRecord.STATUS_COMPLETED
and is_equal_approx(
animal_manager.get_player_standing().get_standing(),
standing_before_animal + animal_quest.get_standing_reward()
)
),
"Feeding the goat should complete its quest and grant standing",
)
var animal_saved: String = animal_manager.serialize_state()
var animal_restored := _create_manager(907)
_check(
animal_restored.restore_state_from_json(animal_saved),
"The completed animal-care quest should restore through the current schema"
)
_check(
(
animal_restored.get_state_checksum() == animal_manager.get_state_checksum()
and animal_restored.get_active_player_quest() == null
),
"Restored animal-care state should preserve checksum and closed quest",
)
animal_restored.free()
goat.free()
animal_manager.free()
_finish()
manager.free()
invalidated.free()
_finish()
func _register_berry(manager: Node, node_id: StringName) -> ResourceNode:
var berry := ResourceNode.new()
berry.name = "QuestBerryBush"
berry.node_id = node_id
berry.action_id = SimulationIds.ACTION_GATHER_FOOD
berry.resource_id = SimulationIds.RESOURCE_FOOD
berry.initial_amount = 1.0
berry.yield_per_action = 1.0
berry.debug_label_enabled = false
var interaction_point := Marker3D.new()
interaction_point.name = "InteractionPoint"
berry.add_child(interaction_point)
root.add_child(berry)
_check(
manager.register_resource_node(berry),
"The quest proof should bind a real finite food ResourceNode"
)
return berry
func _register_tree(manager: Node, node_id: StringName) -> ResourceNode:
var tree := ResourceNode.new()
tree.name = "QuestTree"
tree.node_id = node_id
tree.action_id = SimulationIds.ACTION_GATHER_WOOD
tree.resource_id = SimulationIds.RESOURCE_WOOD
tree.initial_amount = 1.0
tree.yield_per_action = 1.0
tree.debug_label_enabled = false
var interaction_point := Marker3D.new()
interaction_point.name = "InteractionPoint"
tree.add_child(interaction_point)
root.add_child(tree)
_check(
manager.register_resource_node(tree),
"The invalidation proof should bind a real finite wood ResourceNode"
)
return tree
func _open_missing_wood_need(manager: Node) -> Dictionary:
var actor: SimNPC = manager.npcs[0]
var witness: SimNPC = manager.npcs[1]
actor.position = Vector3.ZERO
witness.position = Vector3(4.0, 0.0, 0.0)
var woodpile: StorageStateRecord = manager.get_woodpile()
woodpile.withdraw(SimulationIds.RESOURCE_WOOD, woodpile.get_amount(SimulationIds.RESOURCE_WOOD))
woodpile.deposit(SimulationIds.RESOURCE_WOOD, 0.5)
manager.economy.sync_resource(SimulationIds.RESOURCE_WOOD)
actor.set_task(SimulationIds.ACTION_PATROL, 1.0)
actor.start_working()
manager.simulate_tick()
var opportunity: OpportunityStateRecord = manager.get_active_opportunity()
if opportunity == null:
# The current action boundary rejects an unbound abstract guard target;
# seed the same authoritative open record so this branch still exercises
# expiry and persistence rather than dereferencing a missing quest.
opportunity = OpportunityStateRecord.create(
manager.opportunity_system.next_opportunity_id,
manager.tick_count,
maxi(manager.economic_events.size() - 1, 0),
actor.id,
1.0,
SimulationIds.OPPORTUNITY_SUPPLY_MISSING_WOOD,
SimulationIds.STORAGE_VILLAGE_WOODPILE,
SimulationIds.RESOURCE_WOOD
)
manager.opportunity_system.next_opportunity_id += 1
manager.opportunity_system.opportunities.append(opportunity)
var fallback_quest := PlayerQuestRecord.create(
manager.player_quest_system.next_quest_id,
opportunity.get_opportunity_id(),
actor.id,
opportunity.get_opportunity_type(),
opportunity.get_resource_id(),
opportunity.get_target_id(),
opportunity.get_target_amount(),
manager.tick_count,
PlayerQuestSystem.WOOD_STANDING_REWARD
)
manager.player_quest_system.next_quest_id += 1
manager.player_quest_system.quests.append(fallback_quest)
_check(
opportunity != null, "The invalidation branch needs the performer's known blocked-work need"
)
return {"actor": actor, "interested": actor}
func _create_manager(seed_value: int = 901) -> 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(10.0, 0.0, 0.0),
Vector3(12.0, 0.0, 0.0),
Vector3(30.0, 0.0, 30.0),
Vector3(40.0, 0.0, 40.0),
]
manager.home_positions = home_positions
root.add_child(manager)
manager.set_process(false)
return manager
func _open_via_communication(manager: Node) -> Dictionary:
var actor: SimNPC = manager.npcs[0]
var interested: SimNPC = manager.npcs[2]
_set_pantry_amount(manager, 1.0)
for npc in manager.npcs:
npc.position = Vector3(40.0 + npc.id * 10.0, 0.0, 0.0)
actor.position = Vector3.ZERO
actor.hunger = 60.0
interested.hunger = 90.0
manager.economy.withdraw_to_inventory(actor, SimulationIds.RESOURCE_FOOD, 1.0)
_prepare_shared_patrol(actor, interested, Vector3(20.0, 0.0, 0.0))
_check(
manager.try_communicate_at_shared_activity(actor.id, interested.id),
"The invalidation branch should open from the same one-hop known shortage"
)
_check(
manager.get_active_opportunity() != null,
"The invalidation branch needs an active opportunity"
)
return {"actor": actor, "interested": interested}
func _prepare_shared_patrol(first: SimNPC, second: SimNPC, position: Vector3) -> void:
for npc in [first, second]:
npc.set_task(SimulationIds.ACTION_PATROL)
npc.target_id = &"guard_post"
npc.start_working()
first.position = position
second.position = position + Vector3(1.0, 0.0, 0.0)
func _set_pantry_amount(manager: Node, amount: float) -> void:
var pantry: StorageStateRecord = manager.get_pantry()
pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD))
pantry.deposit(SimulationIds.RESOURCE_FOOD, amount)
manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
func _on_quest_opened(quest: PlayerQuestRecord) -> void:
opened_quest_ids.append(quest.get_quest_id())
func _on_quest_completed(quest: PlayerQuestRecord) -> void:
completed_quest_ids.append(quest.get_quest_id())
func _on_quest_expired(quest: PlayerQuestRecord) -> void:
expired_quest_ids.append(quest.get_quest_id())
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
func _finish() -> void:
if failures.is_empty():
print("[TEST] Player quest generation and standing passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
+1
View File
@@ -0,0 +1 @@
uid://s2vxgyidroc0
+202
View File
@@ -0,0 +1,202 @@
extends SceneTree
var failures: Array[String] = []
var opened_quest_ids: Array[int] = []
var expired_quest_ids: Array[int] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var manager := _create_manager()
manager.player_quest_opened.connect(_on_quest_opened)
manager.player_quest_expired.connect(_on_quest_expired)
var berry := _register_resource(manager, &"talk_berry_bush")
var actor: SimNPC = manager.npcs[0]
var interested: SimNPC = manager.npcs[2]
_set_pantry_amount(manager, 1.0)
for npc in manager.npcs:
npc.position = Vector3(40.0 + npc.id * 10.0, 0.0, 0.0)
actor.position = Vector3.ZERO
interested.position = Vector3(4.0, 0.0, 0.0)
actor.hunger = 60.0
interested.hunger = 90.0
manager.economy.withdraw_to_inventory(actor, SimulationIds.RESOURCE_FOOD, 1.0)
var opportunity: OpportunityStateRecord = manager.get_active_opportunity()
_check(
opportunity != null and opportunity.get_interested_npc_id() == interested.id,
"The talk proof needs an open need owned by the inspected villager",
)
if opportunity == null:
berry.free()
manager.free()
_finish()
return
var talk: PlayerTalkResult = manager.get_villager_talk(interested.id)
_check(
(
talk != null
and talk.npc_id == interested.id
and talk.npc_name == interested.npc_name
and talk.has_need
and talk.can_accept
and interested.npc_name in talk.greeting
),
"Talking should greet the villager and surface their real need",
)
var checksum_before: String = manager.get_state_checksum()
var accepted: PlayerQuestRecord = manager.accept_villager_request(interested.id)
print(
" debug accepted=",
accepted,
" opened=",
opened_quest_ids,
" active=",
manager.get_active_player_quest(),
" checksum_same=",
manager.get_state_checksum() == checksum_before,
" resolved=",
manager.get_player_standing().get_resolved_needs(),
)
_check(
(
accepted != null
and accepted.is_open()
and accepted.get_requester_npc_id() == interested.id
and opened_quest_ids == [accepted.get_quest_id()]
and manager.get_state_checksum() == checksum_before
and manager.get_player_standing().get_resolved_needs() == 0
),
"Accepting a request should surface the same open quest without granting standing yet",
)
var accepted_again: PlayerQuestRecord = manager.accept_villager_request(interested.id)
_check(
accepted_again != null and accepted_again.get_quest_id() == accepted.get_quest_id(),
"Re-accepting should not duplicate the open quest",
)
var carry_state: PlayerStateRecord = manager.get_player_state()
carry_state.add_inventory(SimulationIds.RESOURCE_FOOD, 1.0)
var deposited: float = manager.player_deposit(SimulationIds.RESOURCE_FOOD)
_check(
(
is_equal_approx(deposited, 1.0)
and manager.get_active_player_quest() == null
and manager.get_player_standing().get_resolved_needs() == 1
),
"Completing the accepted request through a real deposit should resolve it",
)
var declined := _create_manager(904)
declined.player_quest_opened.connect(_on_quest_opened)
declined.player_quest_expired.connect(_on_quest_expired)
var berry2 := _register_resource(declined, &"talk_berry_bush_2")
var actor2: SimNPC = declined.npcs[0]
var interested2: SimNPC = declined.npcs[2]
_set_pantry_amount(declined, 1.0)
for npc in declined.npcs:
npc.position = Vector3(40.0 + npc.id * 10.0, 0.0, 0.0)
actor2.position = Vector3.ZERO
interested2.position = Vector3(4.0, 0.0, 0.0)
actor2.hunger = 60.0
interested2.hunger = 90.0
declined.economy.withdraw_to_inventory(actor2, SimulationIds.RESOURCE_FOOD, 1.0)
var declined_quest: PlayerQuestRecord = declined.get_active_player_quest()
_check(declined_quest != null, "The decline branch needs an open quest")
if declined_quest == null:
berry.free()
berry2.free()
manager.free()
declined.free()
_finish()
return
var gratitude_before: float = declined.get_player_standing().get_gratitude(interested2.id)
var refused: PlayerQuestRecord = declined.decline_villager_request(interested2.id)
_check(
(
refused != null
and refused.get_quest_id() == declined_quest.get_quest_id()
and expired_quest_ids.has(declined_quest.get_quest_id())
and declined.get_active_player_quest() == null
and declined.get_player_standing().get_gratitude(interested2.id) <= gratitude_before
),
"Declining a personal request should close the quest without granting gratitude",
)
berry.free()
berry2.free()
manager.free()
declined.free()
_finish()
func _register_resource(manager: Node, node_id: StringName) -> ResourceNode:
var node := ResourceNode.new()
node.name = "TalkResource"
node.node_id = node_id
node.action_id = SimulationIds.ACTION_GATHER_FOOD
node.resource_id = SimulationIds.RESOURCE_FOOD
node.initial_amount = 2.0
node.yield_per_action = 2.0
node.debug_label_enabled = false
var interaction_point := Marker3D.new()
interaction_point.name = "InteractionPoint"
node.add_child(interaction_point)
root.add_child(node)
_check(
manager.register_resource_node(node),
"The talk proof should bind a real finite food ResourceNode"
)
return node
func _create_manager(seed_value: int = 901) -> 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(10.0, 0.0, 0.0),
Vector3(12.0, 0.0, 0.0),
Vector3(30.0, 0.0, 30.0),
Vector3(40.0, 0.0, 40.0),
]
manager.home_positions = home_positions
root.add_child(manager)
manager.set_process(false)
return manager
func _set_pantry_amount(manager: Node, amount: float) -> void:
var pantry: StorageStateRecord = manager.get_pantry()
pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD))
pantry.deposit(SimulationIds.RESOURCE_FOOD, amount)
manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
func _on_quest_opened(quest: PlayerQuestRecord) -> void:
opened_quest_ids.append(quest.get_quest_id())
func _on_quest_expired(quest: PlayerQuestRecord) -> void:
expired_quest_ids.append(quest.get_quest_id())
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
func _finish() -> void:
if failures.is_empty():
print("[TEST] Player talk and personal negotiation passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
@@ -0,0 +1 @@
uid://cdh2d6y6jvajo
+177
View File
@@ -0,0 +1,177 @@
extends SceneTree
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var main_scene: Node = load("res://main.tscn").instantiate()
root.add_child(main_scene)
await process_frame
for _frame in 10:
await physics_frame
var manager: Node = main_scene.get_node("SimulationManager")
var view: Node = main_scene.get_node("WorldViewManager")
var player: CharacterBody3D = main_scene.get_node("Player")
var journal := main_scene.get_node("QuestJournalLayer/QuestJournal")
manager.set_process(false)
player.set_physics_process(false)
_freeze_visuals(view)
var actor: SimNPC = manager.npcs[0]
var interested: SimNPC = manager.npcs[2]
_set_pantry_amount(manager, 1.0)
for npc in manager.npcs:
_set_loaded_position(manager, view, npc, Vector3(40.0 + npc.id * 10.0, 0.0, 30.0))
actor.position = Vector3.ZERO
interested.position = Vector3(4.0, 0.0, 0.0)
actor.hunger = 60.0
interested.hunger = 90.0
manager.economy.withdraw_to_inventory(actor, SimulationIds.RESOURCE_FOOD, 1.0)
var quest: PlayerQuestRecord = manager.get_active_player_quest()
_check(quest != null, "An unassisted known need should generate a player quest")
if quest == null:
manager.set_process(true)
_finish()
return
_check(
(
quest.get_requester_npc_id() == interested.id
and journal.visible
and "Standing" in (journal.get_node("Copy/Standing") as Label).text
and "Stranger" in (journal.get_node("Copy/Standing") as Label).text
and interested.npc_name in (journal.get_node("Copy/Quest") as Label).text
),
"The quest journal should surface the standing tier and the named need",
)
var food_node: ResourceNode = _find_food_node(manager)
_check(food_node != null, "The runtime proof needs a stocked player-usable food source")
if food_node == null:
_finish()
return
var checksum_before: String = manager.get_state_checksum()
_check(
manager.harvest_resource_node(food_node) > 0.0,
"The player should gather the quest resource through the ordinary finite harvest"
)
_check(
manager.player_deposit(SimulationIds.RESOURCE_FOOD) > 0.0,
"The player should resolve the quest through the ordinary pantry deposit"
)
_check(
(
manager.get_active_player_quest() == null
and is_equal_approx(
manager.get_player_standing().get_standing(), quest.get_standing_reward()
)
and is_equal_approx(manager.get_player_standing().get_gratitude(interested.id), 0.1)
and manager.get_state_checksum() != checksum_before
),
"Resolving the need should complete the quest and grant standing + gratitude",
)
var saved_json: String = manager.serialize_state()
var restored := _create_restored_manager(saved_json)
_check(
(
restored != null
and restored.get_state_checksum() == manager.get_state_checksum()
and restored.get_player_standing().get_resolved_needs() == 1
and is_equal_approx(
restored.get_player_standing().get_standing(), quest.get_standing_reward()
)
),
"Completed quest standing should restore deterministically through the schema",
)
manager.set_process(true)
_finish()
func _create_restored_manager(saved_json: String) -> Node:
var restored: Node = load("res://simulation/SimulationManager.gd").new()
restored.simulation_seed = 909
restored.debug_logs = false
var home_positions: Array[Vector3] = [
Vector3(0.0, 0.0, 0.0),
Vector3(2.0, 0.0, 0.0),
Vector3(10.0, 0.0, 0.0),
Vector3(12.0, 0.0, 0.0),
Vector3(30.0, 0.0, 30.0),
Vector3(40.0, 0.0, 40.0),
]
restored.home_positions = home_positions
root.add_child(restored)
restored.set_process(false)
if not restored.restore_state_from_json(saved_json):
return null
return restored
func _find_food_node(manager: Node) -> ResourceNode:
for node in ResourceNode.get_all():
var state: ResourceStateRecord = (
manager.get_resource_state(node.node_id) as ResourceStateRecord
)
if (
node.resource_id == SimulationIds.RESOURCE_FOOD
and state != null
and state.can_player_use_resource()
and state.can_extract()
and state.get_amount_remaining() >= 1.0
):
return node
return null
func _prepare_shared_patrol(first: SimNPC, second: SimNPC, position: Vector3) -> void:
for npc in [first, second]:
npc.set_task(SimulationIds.ACTION_PATROL)
npc.target_id = &"guard_post"
npc.start_working()
first.position = position
second.position = position + Vector3(1.0, 0.0, 0.0)
func _set_pantry_amount(manager: Node, amount: float) -> void:
var pantry: StorageStateRecord = manager.get_pantry()
pantry.withdraw(SimulationIds.RESOURCE_FOOD, pantry.get_amount(SimulationIds.RESOURCE_FOOD))
pantry.deposit(SimulationIds.RESOURCE_FOOD, amount)
manager.economy.sync_resource(SimulationIds.RESOURCE_FOOD)
func _set_loaded_position(manager: Node, view: Node, npc: SimNPC, position: Vector3) -> void:
manager.synchronize_npc_position(npc.id, position)
var visual := view.active_npc_visuals.get(npc.id) as Node3D
if visual != null:
visual.set_physics_process(false)
if visual.has_method("stop_travel"):
visual.stop_travel()
visual.global_position = position
func _freeze_visuals(view: Node) -> void:
for visual in view.active_npc_visuals.values():
visual.set_physics_process(false)
if visual.has_method("stop_travel"):
visual.stop_travel()
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
func _finish() -> void:
if failures.is_empty():
print("[TEST] Quest journal runtime surfacing passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)
+1
View File
@@ -0,0 +1 @@
uid://bl3m6a3k78cxe
+8 -1
View File
@@ -34,7 +34,10 @@ func _run() -> void:
_check(
is_equal_approx(bush_state.get_amount_remaining(), 0.0),
"Player should deplete the nearby bush"
"Player should deplete the nearby bush into their hands"
)
var carried_after_gather: float = simulation_manager.get_player_state().get_inventory_amount(
SimulationIds.RESOURCE_FOOD
)
_check(
is_equal_approx(
@@ -109,6 +112,10 @@ func _run() -> void:
player.try_interact()
player.global_position = pantry.get_interaction_position()
player.try_interact()
var food_after_first_deposit := pantry_state.get_amount(SimulationIds.RESOURCE_FOOD)
var food_carried_after_first: float = (
simulation_manager.get_player_state().get_inventory_amount(SimulationIds.RESOURCE_FOOD)
)
_check(
(
is_equal_approx(bush_state.get_amount_remaining(), 0.0)
+70
View File
@@ -0,0 +1,70 @@
extends GutTest
const PlayerQuestSystemScript := preload("res://simulation/quests/PlayerQuestSystem.gd")
func test_personal_request_needs_standing_tier_and_gratitude() -> void:
var system := PlayerQuestSystemScript.new()
var opportunity := _open_opportunity()
var pantry := _pantry()
assert_false(system.can_request_personally(opportunity))
for _grant in 6:
system.standing.grant_standing(3.0, opportunity.get_interested_npc_id())
assert_true(
system.can_request_personally(opportunity),
"Known Hand standing plus personal gratitude should make the NPC ask you directly"
)
var quest := system.consider_opportunity_opened(opportunity, null, 10, true)
assert_not_null(quest)
assert_eq(quest.get_requester_npc_id(), opportunity.get_interested_npc_id())
func test_no_personal_request_below_trust_threshold() -> void:
var system := PlayerQuestSystemScript.new()
var opportunity := _open_opportunity()
system.standing.grant_standing(16.0, opportunity.get_interested_npc_id())
system.standing.data["gratitude"][opportunity.get_interested_npc_id()] = 0.4
assert_false(system.can_request_personally(opportunity))
func test_standing_tier_progression() -> void:
var standing := PlayerStandingRecord.create()
assert_eq(standing.get_tier(), PlayerStandingRecord.TIER_STRANGER)
standing.grant_standing(14.0, 3)
assert_eq(standing.get_tier(), PlayerStandingRecord.TIER_STRANGER)
standing.grant_standing(2.0, 3)
assert_eq(standing.get_tier(), PlayerStandingRecord.TIER_KNOWN_HAND)
assert_eq(standing.get_tier_name(), "Known Hand")
standing.grant_standing(20.0, 3)
assert_eq(standing.get_tier(), PlayerStandingRecord.TIER_TRUSTED)
func test_standing_serialization_round_trip() -> void:
var standing := PlayerStandingRecord.create()
standing.grant_standing(35.0, 3)
var restored := PlayerStandingRecord.from_dictionary(standing.to_dictionary())
assert_not_null(restored)
assert_eq(restored.get_standing(), 35.0)
assert_eq(restored.get_tier(), PlayerStandingRecord.TIER_TRUSTED)
assert_eq(restored.get_gratitude(3), 0.1)
func _open_opportunity() -> OpportunityStateRecord:
return OpportunityStateRecord.create(
0,
10,
5,
3,
1.0,
SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY,
SimulationIds.STORAGE_VILLAGE_PANTRY,
SimulationIds.RESOURCE_FOOD
)
func _pantry() -> StorageStateRecord:
return StorageStateRecord.create(
SimulationIds.STORAGE_VILLAGE_PANTRY, {SimulationIds.RESOURCE_FOOD: 0.0}, 100.0
)
@@ -0,0 +1 @@
uid://b2jmmtiyvaqll
@@ -0,0 +1,168 @@
extends GutTest
const VillagerOpportunityNoteScript := preload("res://player/VillagerOpportunityNote.gd")
func test_no_opportunity_produces_no_need() -> void:
var note := VillagerOpportunityNote.derive(null, null, null, "", null, "", "")
assert_not_null(note)
assert_false(note.has_need)
assert_eq(note.help_kind, &"")
func test_player_route_wins_over_helper_when_derivable() -> void:
var opportunity := _open_pantry_opportunity()
var response := _player_response()
var helper := _helper(7)
var pantry := _pantry(0.0, 100.0)
var note := VillagerOpportunityNote.derive(
opportunity, response, helper, "Tarik", pantry, "Village Pantry", "food"
)
assert_true(note.has_need)
assert_eq(note.help_kind, VillagerOpportunityNote.HELP_PLAYER_ROUTE)
assert_eq(note.need_text, "The Village Pantry needs food")
assert_true(note.help_text.contains("You can help"))
assert_true(note.help_text.contains("Village Pantry"))
func test_helper_is_named_when_no_player_route() -> void:
var opportunity := _open_pantry_opportunity()
var pantry := _pantry(0.0, 100.0)
var note := VillagerOpportunityNote.derive(
opportunity, null, _helper(7), "Tarik", pantry, "Village Pantry", "food"
)
assert_true(note.has_need)
assert_eq(note.help_kind, VillagerOpportunityNote.HELP_HELPER)
assert_true(note.help_text.contains("Tarik"))
assert_true(note.help_text.contains("food"))
func test_unavailable_reports_no_room_when_storage_is_full() -> void:
var opportunity := _open_pantry_opportunity()
var pantry := _pantry(0.0, 0.5)
var note := VillagerOpportunityNote.derive(
opportunity, null, null, "", pantry, "Village Pantry", "food"
)
assert_true(note.has_need)
assert_eq(note.help_kind, VillagerOpportunityNote.HELP_UNAVAILABLE)
assert_true(note.help_text.contains("no room"))
func test_unavailable_reports_need_already_met() -> void:
var opportunity := _open_pantry_opportunity()
var pantry := _pantry(1.0, 100.0)
var note := VillagerOpportunityNote.derive(
opportunity, null, null, "", pantry, "Village Pantry", "food"
)
assert_true(note.has_need)
assert_eq(note.help_kind, VillagerOpportunityNote.HELP_UNAVAILABLE)
assert_true(note.help_text.contains("already has enough"))
func test_unavailable_reports_no_stocked_source_when_storage_has_room() -> void:
var opportunity := _open_pantry_opportunity()
var pantry := _pantry(0.0, 100.0)
var note := VillagerOpportunityNote.derive(
opportunity, null, null, "", pantry, "Village Pantry", "food"
)
assert_true(note.has_need)
assert_eq(note.help_kind, VillagerOpportunityNote.HELP_UNAVAILABLE)
assert_true(note.help_text.contains("No stocked source"))
func test_unavailable_reports_missing_storage() -> void:
var opportunity := _open_pantry_opportunity()
var note := VillagerOpportunityNote.derive(
opportunity, null, null, "", null, "Village Pantry", "food"
)
assert_true(note.has_need)
assert_eq(note.help_kind, VillagerOpportunityNote.HELP_UNAVAILABLE)
assert_true(note.help_text.contains("No storage"))
func test_cache_key_uses_need_and_help_state() -> void:
var opportunity := _open_pantry_opportunity()
var pantry := _pantry(0.0, 100.0)
var helper_note := VillagerOpportunityNote.derive(
opportunity, null, _helper(7), "Tarik", pantry, "Village Pantry", "food"
)
var unavailable_note := VillagerOpportunityNote.derive(
opportunity, null, null, "", pantry, "Village Pantry", "food"
)
var no_need := VillagerOpportunityNote.derive(null, null, null, "", null, "", "")
assert_ne(helper_note.cache_key(), unavailable_note.cache_key())
assert_ne(no_need.cache_key(), helper_note.cache_key())
func _open_pantry_opportunity() -> OpportunityStateRecord:
return OpportunityStateRecord.create(
0,
10,
5,
3,
1.0,
SimulationIds.OPPORTUNITY_RESTOCK_EMPTY_PANTRY,
SimulationIds.STORAGE_VILLAGE_PANTRY,
SimulationIds.RESOURCE_FOOD
)
func _player_response() -> OpportunityPlayerResponseResult:
return (
OpportunityPlayerResponseResult
. new(
{
"opportunity_id": 0,
"trigger_event_id": 5,
"action_id": SimulationIds.ACTION_GATHER_FOOD,
"resource_id": SimulationIds.RESOURCE_FOOD,
"target_id": SimulationIds.STORAGE_VILLAGE_PANTRY,
"available_source_count": 2,
"reason":
"No capable helper; 2 player-usable finite Food sources can supply village_pantry",
}
)
)
func _helper(helper_id: int) -> OpportunityHelperResult:
return (
OpportunityHelperResult
. new(
{
"opportunity_id": 0,
"helper_npc_id": helper_id,
"action_id": SimulationIds.ACTION_GATHER_FOOD,
"source_id": "",
"resource_id": SimulationIds.RESOURCE_FOOD,
"trigger_event_id": 5,
"trust": 0.8,
"familiarity": 0.5,
"uses_inventory": false,
"available_source_count": 1,
"profession_match": true,
"reason":
"Knows the need; trust 0.80 toward Amina; has 1 available finite Food source",
}
)
)
func _pantry(amount: float, capacity: float) -> StorageStateRecord:
return StorageStateRecord.create(
SimulationIds.STORAGE_VILLAGE_PANTRY, {SimulationIds.RESOURCE_FOOD: amount}, capacity
)
@@ -0,0 +1 @@
uid://1chfro4iw1o5