509 lines
17 KiB
GDScript
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)
|