Add PLAN verbs and fix dead incident recording
Full build / Import + headless tests (push) Successful in 26s
Full build / Full desktop build + optional release (push) Failing after 3m31s

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.
This commit is contained in:
zwitschi committed 2026-10-31 20:29:50 +01:00
1 parent 028f608298
commit f5ad1642b5
16 files changed
+500 -37

No files matched your search

+9 -1
View File
@@ -32,7 +32,9 @@ func test_radial_wrap_and_skip_wand() -> bool:
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.
menu.selected = 4
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()
@@ -91,6 +93,12 @@ func test_cancel_has_right_mouse() -> bool:
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
+132
View File
@@ -0,0 +1,132 @@
extends RefCounted
## PLAN verbs added after the radial pass: eye contact, recognition as a player
## decision, the body-camera flag, blocking station, and requesting a reason.
const Rules := preload("res://src/core/rules.gd")
const ActionRadial := preload("res://src/game/ui/action_radial.gd")
const IncidentRecorder := preload("res://src/core/incident_recorder.gd")
const NPCController := preload("res://src/core/npc_controller.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
const AccessLoadout := preload("res://src/core/access_loadout.gd")
const AccessArtifact := preload("res://src/core/access_artifact.gd")
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 test_radial_has_new_verbs() -> bool:
var a := TestUtils.check(_slice_index("eye_contact") >= 0, "eye contact slice exists")
var b := TestUtils.check(_slice_index("reason") >= 0, "request-reason slice exists")
var c := TestUtils.check(_slice_index("recognize") >= 0, "recognize slice exists")
return a and b and c
func test_eye_contact_is_level_one() -> bool:
var def: Dictionary = ActionRadial.SLICE_DEFS[_slice_index("eye_contact")]
var ok := TestUtils.check_eq(int(def.get("level", -1)), Rules.Level.SIMPLE_DETERRENCE,
"eye contact is a Level 1 action")
var action_ok := TestUtils.check_eq(str(def.get("action", "")), "eye contact", "logs as eye contact")
return ok and action_ok
func test_every_slice_has_a_string_key() -> bool:
var strings = load("res://src/core/strings.gd")
var ok := true
for def in ActionRadial.SLICE_DEFS:
var key: String = str(def.get("label_key", ""))
if key == "":
ok = false
continue
if strings.lookup(key) == key:
ok = false
return TestUtils.check(ok, "every radial slice resolves a localized label")
func _profile(name: String, lines: Array[String] = []) -> NpcProfile:
var p := NpcProfile.new()
p.id = "test"
p.display_name = name
p.dialogue_lines = lines
return p
func test_request_reason_uses_dialogue_lines() -> bool:
var p := _profile("Visitor", ["I have a ticket for tonight.", "I am meeting a friend."])
var npc := NPCController.new()
npc.setup("npc_1", p, "enter", 4242)
var a := TestUtils.check_eq(npc.request_reason(), npc.last_reason_reply, "reply is stored")
var b := TestUtils.check_eq(npc.last_reason_reply, p.dialogue_lines[0], "seeded pick is deterministic")
var c := TestUtils.check(not npc.last_reason_reply.is_empty(), "reply is non-empty")
npc = null
return a and b and c
func test_request_reason_silent_without_lines() -> bool:
var npc := NPCController.new()
npc.setup("npc_2", _profile("Silent"), "enter", 7)
var reply: String = npc.request_reason()
var ok := TestUtils.check_eq(reply, "", "no dialogue lines means silence")
npc = null
return ok
func test_player_recognition_is_not_default() -> bool:
var p := _profile("Staffer")
p.npc_type = Rules.NpcType.AUTHORIZED_GUEST
var npc := NPCController.new()
npc.setup("npc_3", p, "enter", 11)
var a := TestUtils.check(not npc.player_recognized, "guests are not pre-recognized")
var override := {}
var artifacts := AccessLoadout.build(p, null, override)
var has_recognition := false
for artifact in artifacts:
if artifact is AccessArtifact and artifact.kind == Rules.AccessKind.RECOGNITION:
has_recognition = true
var b := TestUtils.check(not has_recognition, "guest artifact set has no recognition")
npc = null
return a and b
func test_recorder_starts_and_closes_incidents() -> bool:
var recorder := IncidentRecorder.new()
var a := TestUtils.check(not recorder.has_open_incident("npc_a"), "no incident before start")
recorder.start_incident("npc_a", "player_focus")
var b := TestUtils.check(recorder.has_open_incident("npc_a"), "incident opens")
recorder.add_event("npc_a", {"type": "recognition_decision", "recognized": true})
var c := TestUtils.check_eq(recorder.incidents.size(), 0, "open incident is not yet in history")
var closed: int = recorder.close_all_open("unresolved")
var d := TestUtils.check_eq(closed, 1, "close_all_open closes the open incident")
var e := TestUtils.check_eq(recorder.incidents.size(), 1, "closed incident lands in history")
var events: Array = recorder.incidents[0].get("events", [])
var f := TestUtils.check_eq(events.size(), 1, "recorded event survived the close")
recorder = null
return a and b and c and d and e and f
func test_shift_end_closes_open_incidents_into_review() -> bool:
var Coordinator := load("res://src/core/shift_coordinator.gd")
var Factory := load("res://src/content/scenario_factory.gd")
var coordinator = Coordinator.new()
coordinator.setup(Factory.hollywood_building(), 60.0, 99)
coordinator.start()
coordinator.recorder.start_incident("npc_x", "player_action")
coordinator.recorder.add_event("npc_x", {"type": "reason_requested", "reply": ""})
coordinator.call("_finish_shift")
var review: Dictionary = coordinator.build_review()
var incidents: Dictionary = review.get("incidents", {})
var list: Array = incidents.get("incidents", [])
var a := TestUtils.check_eq(list.size(), 1, "unresolved incident appears in the review")
var b := TestUtils.check_eq(str(list[0].get("outcome", "")), "unresolved", "outcome is unresolved")
var open := TestUtils.check_eq(int(incidents.get("open_count", -1)), 0, "nothing left open")
coordinator = null
return a and b and open
func test_body_camera_not_on_by_default() -> bool:
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var a := TestUtils.check(src.contains("func _set_body_camera"), "body camera has a setter")
var b := TestUtils.check(src.contains("KEY_B"), "B toggles the body camera")
var c := TestUtils.check(
src.contains("level == Rules.Level.FORMAL_WARNING and not body_camera_on"),
"a formal warning turns the body camera on")
return a and b and c
func test_blocking_stations_the_guard() -> bool:
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
var a := TestUtils.check(src.contains("func _station_between"), "blocking has a stationing step")
var b := TestUtils.check(src.contains("_station_between(controller"), "blocking calls it")
var c := TestUtils.check(src.contains("to_door.normalized() * 0.9"), "station point is between NPC and door")
return a and b and c