merge: integrate remote player quest slices
This commit is contained in:
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bwyg38d3ieiex
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://jeax46jjayqj
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://s2vxgyidroc0
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bl3m6a3k78cxe
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user