Verbs (PLAN levels 1-3) - Eye contact: own Level 1 radial slice, logged as "eye contact". - Recognition is a player decision: "Recognize" sets NPCController.player_recognized, writes access evidence, and GameManager only then appends a RECOGNITION artifact. It is no longer implied by NPC type. - Body camera: real flag (off by default) driving the REC overlay; B toggles it and a formal warning arms it automatically, writing a body_camera incident event. - Blocking stations the guard 0.9 m from the person toward the entrance and turns to face them, instead of only emitting the escalation level. - Request reason: Level 3 ask answered from the NPC's seeded dialogue_lines. - Radial grows to 12 slices (174 px outer radius, centered labels). Incident recording fix - IncidentRecorder.start_incident() was never called, so add_event / add_access_evidence / add_escalation silently no-opped and the shift review always reported zero incidents. Incidents now open on focus (guarded by has_open_incident), verbs call _ensure_incident, and ShiftCoordinator closes leftovers as "unresolved" before building the review. Copy and docs - 9 new string keys (eye contact, body camera, recognition, reason, station). - README controls, 26-step smoke checklist, test count, status lists. - TODO section moved to DONE; MEMORY records the verb design and the recorder bug. Verification: headless 98/98, smoke_plan_verbs.gd + smoke_session_shell.gd + windowed smoke_focus_radial.gd all pass. Version bumped to 0.2.3.
109 lines
4.3 KiB
GDScript
109 lines
4.3 KiB
GDScript
extends RefCounted
|
|
## Focus cone math and radial menu wrap/cancel (ADR-0008, ADR-0009).
|
|
|
|
const NpcFocus := preload("res://src/game/npc_focus.gd")
|
|
const ActionRadial := preload("res://src/game/ui/action_radial.gd")
|
|
|
|
func test_cone_picks_centered_head() -> bool:
|
|
var origin := Vector3(0, 1.6, 0)
|
|
var forward := Vector3(0, 0, -1)
|
|
var heads: Array = [
|
|
Vector3(3, 1.6, -4),
|
|
Vector3(0.05, 1.6, -4),
|
|
Vector3(-2, 1.6, -4),
|
|
]
|
|
var idx := NpcFocus.pick_index(origin, forward, heads)
|
|
return TestUtils.check_eq(idx, 1, "head nearest the look axis wins")
|
|
|
|
func test_cone_rejects_behind_and_far() -> bool:
|
|
var origin := Vector3(0, 1.6, 0)
|
|
var forward := Vector3(0, 0, -1)
|
|
var behind := NpcFocus.score_head(origin, forward, Vector3(0, 1.6, 4))
|
|
var far := NpcFocus.score_head(origin, forward, Vector3(0, 1.6, -40))
|
|
var a := TestUtils.check_eq(behind, -1.0, "behind the camera is ignored")
|
|
var b := TestUtils.check_eq(far, -1.0, "out of range is ignored")
|
|
return a and b
|
|
|
|
func test_radial_wrap_and_skip_wand() -> bool:
|
|
var menu: Node = ActionRadial.new()
|
|
menu.open_menu("Guest", false)
|
|
var start: int = menu.selected
|
|
menu.step(-1)
|
|
var a := TestUtils.check(menu.selected != start, "step wrap moves selection")
|
|
var b := TestUtils.check(menu.is_slice_enabled(menu.selected), "landed on an enabled slice")
|
|
# Walk forward until we would hit wand; it must skip.
|
|
var wand_index := _slice_index("wand")
|
|
var warning_index := _slice_index("warning")
|
|
menu.selected = wand_index - 1
|
|
menu.step(1)
|
|
var c := TestUtils.check_eq(menu.slice_id(menu.selected), "warning", "wand skipped when venue has none")
|
|
menu.free()
|
|
return a and b and c
|
|
|
|
func test_radial_cancel_does_not_confirm() -> bool:
|
|
var menu: Node = ActionRadial.new()
|
|
menu.open_menu("Guest", true)
|
|
var got := {"id": ""}
|
|
menu.confirmed.connect(func(id): got["id"] = id)
|
|
menu.cancelled.connect(func(): got["id"] = "cancelled")
|
|
menu.close_menu()
|
|
var a := TestUtils.check_eq(got["id"], "", "closing without confirm emits nothing")
|
|
var slice: String = menu.confirm_selected()
|
|
var b := TestUtils.check_eq(slice, "", "confirm while closed is empty")
|
|
menu.free()
|
|
return a and b
|
|
|
|
func test_radial_confirm_returns_id() -> bool:
|
|
var menu: Node = ActionRadial.new()
|
|
menu.open_menu("Guest", true)
|
|
menu.selected = 0
|
|
var slice: String = menu.confirm_selected()
|
|
menu.free()
|
|
return TestUtils.check_eq(slice, "raise_hand", "space-confirm uses highlighted slice")
|
|
|
|
func test_angle_top_is_first_slice() -> bool:
|
|
var menu: Node = ActionRadial.new()
|
|
var idx: int = menu.angle_to_index(-PI / 2.0)
|
|
menu.free()
|
|
return TestUtils.check_eq(idx, 0, "top of the doughnut is slice 0")
|
|
|
|
func test_wand_not_on_w() -> bool:
|
|
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
|
var a := TestUtils.check(not src.contains("KEY_W:"), "W is not a game-manager action key")
|
|
var b := TestUtils.check(src.contains("is_action_pressed(\"wand\")"), "wand goes through the named action")
|
|
var c := TestUtils.check(not _action_has_key("wand", KEY_W), "wand action is not bound to W")
|
|
var d := TestUtils.check(_action_has_key("wand", KEY_C), "wand action is bound to C")
|
|
return a and b and c and d
|
|
|
|
func test_named_input_actions_exist() -> bool:
|
|
var menu_ok := _action_has_key("action_menu", KEY_SPACE)
|
|
var confirm_ok := _action_has_key("confirm", KEY_SPACE) and _action_has_key("confirm", KEY_ENTER)
|
|
var cancel_ok := _action_has_key("cancel", KEY_ESCAPE)
|
|
var inspect_ok := _action_has_key("inspect", KEY_E)
|
|
return TestUtils.check(menu_ok, "action_menu has Space") \
|
|
and TestUtils.check(confirm_ok, "confirm has Space + Enter") \
|
|
and TestUtils.check(cancel_ok, "cancel has Esc") \
|
|
and TestUtils.check(inspect_ok, "inspect has E")
|
|
|
|
func test_cancel_has_right_mouse() -> bool:
|
|
var events: Array = InputMap.action_get_events("cancel")
|
|
var found := false
|
|
for event in events:
|
|
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT:
|
|
found = true
|
|
return TestUtils.check(found, "cancel action includes right mouse button")
|
|
|
|
func _slice_index(id: String) -> int:
|
|
for i in ActionRadial.SLICE_DEFS.size():
|
|
if str(ActionRadial.SLICE_DEFS[i]["id"]) == id:
|
|
return i
|
|
return -1
|
|
|
|
func _action_has_key(action: String, keycode: Key) -> bool:
|
|
if not InputMap.has_action(action):
|
|
return false
|
|
for event in InputMap.action_get_events(action):
|
|
if event is InputEventKey and event.physical_keycode == keycode:
|
|
return true
|
|
return false
|