Files
gamedev-the-steward/tests/player_villager_inspection_test.gd
2026-08-12 21:16:15 +02:00

509 lines
17 KiB
GDScript

extends SceneTree
const INSPECTION_REASON := "Inspection proof: pantry stores need a careful hand."
const UPDATED_REASON := "Inspection proof: quiet study will help the village."
const STUDY_DESK_ID := &"study_desk"
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 3:
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 interaction_hud := main_scene.get_node_or_null(
"PlayerInteractionLayer/PlayerInteractionHud"
)
var field_note := main_scene.get_node_or_null("VillagerInspectionLayer/VillagerFieldNote")
manager.set_process(false)
player.set_physics_process(false)
_freeze_visuals(view)
_check(
(
manager.npcs.size() >= 2
and view.has_method("find_nearest_active_npc_id")
and player.has_method("get_nearby_villager_inspection")
and interaction_hud != null
and field_note != null
and field_note.has_method("refresh_note")
),
"The real main scene should expose loaded villager inspection beside the E prompt",
)
if (
manager.npcs.size() < 2
or not view.has_method("find_nearest_active_npc_id")
or not player.has_method("get_nearby_villager_inspection")
or interaction_hud == null
or field_note == null
or not field_note.has_method("refresh_note")
):
_finish()
return
var pantry_node := StorageNode.get_by_id(SimulationIds.STORAGE_VILLAGE_PANTRY) as StorageNode
_check(pantry_node != null, "The inspection proof needs the authored village pantry")
if pantry_node == null:
_finish()
return
var first: SimNPC = manager.npcs[0]
var second: SimNPC = manager.npcs[1]
var lower_id_npc := first if first.id < second.id else second
var higher_id_npc := second if lower_id_npc == first else first
var player_position: Vector3 = pantry_node.get_interaction_position()
player.global_position = player_position
player.set("interaction_range", 3.0)
_move_population_away(manager, view, player_position)
_set_loaded_position(manager, view, lower_id_npc, player_position + Vector3(1.5, 0.0, 0.0))
_set_loaded_position(manager, view, higher_id_npc, player_position + Vector3(-0.75, 0.0, 0.0))
_check(
view.find_nearest_active_npc_id(player_position, 3.0) == higher_id_npc.id,
"Loaded villager discovery should choose the nearest active presentation",
)
_set_loaded_position(manager, view, lower_id_npc, player_position + Vector3(1.0, 0.0, 0.0))
_set_loaded_position(manager, view, higher_id_npc, player_position + Vector3(-1.0, 0.0, 0.0))
_check(
view.find_nearest_active_npc_id(player_position, 3.0) == lower_id_npc.id,
"Equal-distance loaded villagers should resolve by stable NPC ID",
)
_set_loaded_position(manager, view, higher_id_npc, player_position + Vector3(30.0, 0.0, 0.0))
var selected := lower_id_npc
_configure_deposit_inspection(manager, selected, pantry_node.get_interaction_position())
_set_loaded_position(manager, view, selected, player_position + Vector3(1.0, 0.0, 0.0))
var pantry_context := player.call("get_interaction_context") as PlayerInteractionResult
var inspection := player.call("get_nearby_villager_inspection") as VillagerInspectionResult
field_note.call("refresh_note", true)
interaction_hud.call("refresh_prompt", true)
var labels := _get_note_labels(field_note)
var interaction_action := interaction_hud.get_node("Copy/Action") as Label
_check(
(
pantry_context != null
and pantry_context.kind == PlayerInteractionResult.KIND_DIALOGUE
and pantry_context.target_id == StringName(str(selected.id))
and "Talk" in pantry_context.prompt_text
and pantry_context.prompt_text in interaction_action.text
and inspection != null
and field_note.visible
),
"The automatic villager field note should coexist with the preferred nearby Talk action",
)
if inspection == null or labels.is_empty():
_finish()
return
_check_deposit_facts(inspection, selected)
_check_rendered_deposit_facts(labels, inspection, selected)
var woodpile_node := (
StorageNode.get_by_id(SimulationIds.STORAGE_VILLAGE_WOODPILE) as StorageNode
)
_check(woodpile_node != null, "The inspection proof needs the authored village woodpile")
if woodpile_node == null:
_finish()
return
selected.set_task(SimulationIds.ACTION_DEPOSIT_WOOD, 1000.0)
selected.target_id = SimulationIds.STORAGE_VILLAGE_WOODPILE
selected.travel_target_position = woodpile_node.get_interaction_position()
selected.has_travel_target = true
selected.inventory.clear()
selected.add_inventory(SimulationIds.RESOURCE_WOOD, 2.0)
manager.latest_decisions[selected.id] = (
ActionSelectionResult
. new(
SimulationIds.ACTION_DEPOSIT_WOOD,
-1.0,
"Inspection proof: carry timber to the authored woodpile.",
{SimulationIds.ACTION_DEPOSIT_WOOD: 5.0},
)
)
field_note.call("refresh_note", true)
var wood_inspection := player.call("get_nearby_villager_inspection") as VillagerInspectionResult
_check(
(
wood_inspection != null
and wood_inspection.target_id == SimulationIds.STORAGE_VILLAGE_WOODPILE
and wood_inspection.target_name == "Village Woodpile"
and woodpile_node.display_name == "Village Woodpile"
and String(labels["target"].text) == "Target · Village Woodpile"
),
"Wood delivery inspection should preserve the exact ID and authored woodpile name",
)
selected.set_task(SimulationIds.ACTION_STUDY, 1000.0)
selected.target_id = STUDY_DESK_ID
selected.travel_target_position = (
ActivitySite.get_by_id(STUDY_DESK_ID).get_interaction_position()
)
selected.has_travel_target = true
selected.inventory.clear()
selected.add_inventory(SimulationIds.RESOURCE_WOOD, 1.0)
(
manager
. emit_signal(
"npc_inventory_changed",
selected,
SimulationIds.RESOURCE_WOOD,
selected.get_inventory_amount(SimulationIds.RESOURCE_WOOD),
)
)
field_note.call("refresh_note", true)
var stale_reason_inspection := (
player.call("get_nearby_villager_inspection") as VillagerInspectionResult
)
_check(
(
stale_reason_inspection != null
and stale_reason_inspection.action_id == SimulationIds.ACTION_STUDY
and stale_reason_inspection.action_name == "Study"
and stale_reason_inspection.task_state == SimNPC.TASK_STATE_TRAVELING
and stale_reason_inspection.target_id == STUDY_DESK_ID
and stale_reason_inspection.target_name == "Study Desk"
and is_equal_approx(
_carried_amount(stale_reason_inspection, SimulationIds.RESOURCE_FOOD), 0.0
)
and is_equal_approx(
_carried_amount(stale_reason_inspection, SimulationIds.RESOURCE_WOOD), 1.0
)
and not stale_reason_inspection.has_decision_reason()
and "await" in String(labels["reason"].text).to_lower()
),
"Live task, target, and inventory should update while a stale decision reason is withheld",
)
manager.latest_decisions[selected.id] = (
ActionSelectionResult
. new(
SimulationIds.ACTION_STUDY,
-1.0,
UPDATED_REASON,
{SimulationIds.ACTION_STUDY: 4.0},
)
)
field_note.call("refresh_note", true)
var updated_inspection := (
player.call("get_nearby_villager_inspection") as VillagerInspectionResult
)
_check(
(
updated_inspection != null
and updated_inspection.has_decision_reason()
and updated_inspection.decision_reason == UPDATED_REASON
and UPDATED_REASON in labels["reason"].text
and "wood" in updated_inspection.carrying_text().to_lower()
and "1" in updated_inspection.carrying_text()
and "Study" in labels["activity"].text
and "Study Desk" in labels["target"].text
and "wood" in String(labels["carrying"].text).to_lower()
),
"A matching live decision should join the updated authoritative field-note facts",
)
var note_position_before_resize: Vector2 = field_note.position
root.size += Vector2i(120, 80)
await process_frame
field_note.call("refresh_note", true)
var resize_tween := field_note.get("motion_tween") as Tween
if resize_tween != null:
await resize_tween.finished
_check(
(
is_equal_approx(field_note.position.x, note_position_before_resize.x)
and is_equal_approx(field_note.position.y, note_position_before_resize.y + 80.0)
),
"Field-note motion should preserve its bottom-anchored position after a window resize",
)
field_note.set("player", null)
field_note.call("refresh_note", true)
var cancelled_exit := field_note.get("motion_tween") as Tween
field_note.set("player", player)
field_note.call("refresh_note", true)
var reentry_tween := field_note.get("motion_tween") as Tween
if reentry_tween != null:
await reentry_tween.finished
_check(
(
cancelled_exit != null
and not cancelled_exit.is_valid()
and field_note.visible
and not bool(field_note.get("is_hiding"))
and player.call("get_nearby_villager_inspection") != null
),
"Immediate re-entry should cancel the exit without leaving a suspended hide state",
)
var checksum_before_queries: String = manager.get_state_checksum()
var state_before_queries: String = manager.serialize_state()
var events_before_queries: int = manager.economic_events.size()
for _query in 3:
view.find_nearest_active_npc_id(player_position, 3.0)
player.call("get_nearby_villager_inspection")
field_note.call("refresh_note", true)
_check(
(
manager.get_state_checksum() == checksum_before_queries
and manager.serialize_state() == state_before_queries
and manager.economic_events.size() == events_before_queries
and UPDATED_REASON not in state_before_queries
),
"Inspection queries and presentation refreshes must not mutate or serialize transient state",
)
var selected_id := selected.id
var selected_before_restore := selected
var saved_json := state_before_queries
_check(view.despawn_npc_visual(selected_id), "The selected villager visual should unload")
await process_frame
field_note.call("refresh_note", true)
var hiding_tween := field_note.get("motion_tween") as Tween
if hiding_tween != null:
await hiding_tween.finished
_check(
(
view.find_nearest_active_npc_id(player_position, 3.0) == -1
and player.call("get_nearby_villager_inspection") == null
and not field_note.visible
and manager.get_state_checksum() == checksum_before_queries
),
"Unloading the selected presentation should clear only the ephemeral nearby field note",
)
selected.current_task = SimulationIds.ACTION_IDLE
selected.task_state = SimNPC.TASK_STATE_IDLE
selected.target_id = &""
selected.has_travel_target = false
selected.inventory.clear()
_check(
manager.get_latest_decision(selected_id) != null,
"The pre-restore proof should keep a transient decision available to discard",
)
_check(
manager.restore_state_from_json(saved_json),
"The authoritative inspected villager facts should restore through the existing schema",
)
await process_frame
await physics_frame
_freeze_visuals(view)
var restored_npc := _find_npc(manager.npcs, selected_id)
field_note.call("refresh_note", true)
var restored_inspection := (
player.call("get_nearby_villager_inspection") as VillagerInspectionResult
)
_check(
(
restored_npc != null
and restored_npc != selected_before_restore
and view.active_npc_visuals.has(selected_id)
and restored_inspection != null
and restored_inspection.npc_id == selected_id
and restored_inspection.npc_name == restored_npc.npc_name
and restored_inspection.action_id == SimulationIds.ACTION_STUDY
and restored_inspection.action_name == "Study"
and restored_inspection.task_state == SimNPC.TASK_STATE_TRAVELING
and restored_inspection.target_id == STUDY_DESK_ID
and restored_inspection.target_name == "Study Desk"
and is_equal_approx(
_carried_amount(restored_inspection, SimulationIds.RESOURCE_WOOD), 1.0
)
and manager.get_latest_decision(selected_id) == null
and not restored_inspection.has_decision_reason()
and restored_inspection.decision_reason == VillagerInspectionResult.AWAITING_REASON
and (
String(labels["reason"].text)
== "Why · %s" % VillagerInspectionResult.AWAITING_REASON
)
),
"Restore should reacquire the loaded villager and derive saved facts without reviving a reason",
)
if restored_npc == null or restored_inspection == null:
_finish()
return
var demo_controller := main_scene.get_node("DemoController")
var debug_ui := main_scene.get_node("UI") as CanvasLayer
demo_controller.call("set_debug_overlay_visible", false)
field_note.call("refresh_note", true)
interaction_hud.call("refresh_prompt", true)
_check(
(
not debug_ui.visible
and field_note.visible
and labels["kicker"].visible
and labels["name"].visible
and restored_npc.npc_name in labels["name"].text
and interaction_hud.visible
and pantry_context.prompt_text in interaction_action.text
),
"Cinematic mode should preserve both player-facing contexts while hiding debug UI",
)
field_note.set("player", null)
field_note.call("refresh_note", true)
var teardown_tween := field_note.get("motion_tween") as Tween
if teardown_tween != null:
await teardown_tween.finished
_check(
not field_note.visible,
"A missing player should complete one field-note exit instead of restarting it every frame",
)
_finish()
func _configure_deposit_inspection(manager: Node, npc: SimNPC, pantry_position: Vector3) -> void:
manager.release_npc_reservation(npc.id)
npc.set_task(SimulationIds.ACTION_DEPOSIT_FOOD, 1000.0)
npc.target_id = SimulationIds.STORAGE_VILLAGE_PANTRY
npc.travel_target_position = pantry_position
npc.has_travel_target = true
npc.inventory.clear()
npc.add_inventory(SimulationIds.RESOURCE_FOOD, 2.0)
manager.latest_decisions[npc.id] = (
ActionSelectionResult
. new(
SimulationIds.ACTION_DEPOSIT_FOOD,
-1.0,
INSPECTION_REASON,
{SimulationIds.ACTION_DEPOSIT_FOOD: 5.0},
)
)
(
manager
. emit_signal(
"npc_inventory_changed",
npc,
SimulationIds.RESOURCE_FOOD,
npc.get_inventory_amount(SimulationIds.RESOURCE_FOOD),
)
)
func _check_deposit_facts(inspection: VillagerInspectionResult, npc: SimNPC) -> void:
_check(
(
inspection.npc_id == npc.id
and inspection.npc_name == npc.npc_name
and inspection.action_id == SimulationIds.ACTION_DEPOSIT_FOOD
and inspection.action_name == "Deposit Food"
and inspection.task_state == SimNPC.TASK_STATE_TRAVELING
and inspection.target_id == SimulationIds.STORAGE_VILLAGE_PANTRY
and inspection.target_name == "Village Pantry"
and is_equal_approx(_carried_amount(inspection, SimulationIds.RESOURCE_FOOD), 2.0)
and inspection.has_decision_reason()
and inspection.decision_reason == INSPECTION_REASON
and "food" in inspection.carrying_text().to_lower()
and "2" in inspection.carrying_text()
),
"The typed inspection should expose the selected villager's exact authoritative facts",
)
func _check_rendered_deposit_facts(
labels: Dictionary, inspection: VillagerInspectionResult, npc: SimNPC
) -> void:
_check(
(
"nearby" in String(labels["kicker"].text).to_lower()
and npc.npc_name in labels["name"].text
and inspection.action_name in labels["activity"].text
and "Travel" in labels["activity"].text
and inspection.target_name in labels["target"].text
and "food" in String(labels["carrying"].text).to_lower()
and "2" in labels["carrying"].text
and inspection.decision_reason in labels["reason"].text
),
"The restrained field note should render name, activity, target, carrying, and reason",
)
func _move_population_away(manager: Node, view: Node, origin: Vector3) -> void:
manager.latest_decisions.clear()
for index in manager.npcs.size():
var npc: SimNPC = manager.npcs[index]
_set_loaded_position(
manager,
view,
npc,
origin + Vector3(40.0 + float(index) * 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 _get_note_labels(field_note: Node) -> Dictionary:
var paths := {
"kicker": "Copy/Kicker",
"name": "Copy/Name",
"activity": "Copy/Activity",
"target": "Copy/Target",
"carrying": "Copy/Carrying",
"reason": "Copy/Reason",
}
var labels := {}
for key in paths:
var label := field_note.get_node_or_null(paths[key]) as Label
_check(label != null, "Villager field note should expose %s" % paths[key])
if label == null:
return {}
labels[key] = label
return labels
func _carried_amount(inspection: VillagerInspectionResult, item_id: StringName) -> float:
if inspection.carried_amounts.has(item_id):
return float(inspection.carried_amounts[item_id])
return float(inspection.carried_amounts.get(String(item_id), 0.0))
func _find_npc(npcs: Array[SimNPC], npc_id: int) -> SimNPC:
for npc in npcs:
if npc.id == npc_id:
return npc
return null
func _check(condition: bool, message: String) -> void:
if not condition:
failures.append(message)
func _finish() -> void:
if failures.is_empty():
print("[TEST] Player villager inspection passed")
quit(0)
return
for failure in failures:
push_error("[TEST] " + failure)
quit(1)