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

+142 -1
View File
@@ -30,6 +30,7 @@ const SettingsPanel := preload("res://src/game/ui/settings_panel.gd")
var world_builder: WorldBuilder = null
var door := DoorState.new()
var flashlight_on := false
var body_camera_on := false
var coordinator: ShiftCoordinator = null
var guard: GuardController = null
var _sun: DirectionalLight3D = null
@@ -211,6 +212,9 @@ func _reset_shift_state() -> void:
inspection_open = false
wand_open = false
_wand_cooldown = 0.0
body_camera_on = false
if hud:
hud.set_body_camera(false)
scenario_duration_s = selected_scenario.shift_duration_s
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
_last_phase = -1
@@ -520,6 +524,11 @@ func _begin_incident(avatar: Node3D) -> void:
current_incident_npc_id = controller.npc_id
current_incident_active = true
current_npc_truth = _read_npc_truth(controller)
# Open a structured incident so player actions, access evidence, and
# escalation levels are actually recorded (ADR-0005). Without this the
# recorder drops every event and the review reports zero incidents.
if not coordinator.recorder.has_open_incident(controller.npc_id):
coordinator.recorder.start_incident(controller.npc_id, "player_focus")
var profile: Object = controller.profile
var label := "%s — %s" % [
profile.display_name if profile else "?",
@@ -602,6 +611,8 @@ func _handle_key(event: InputEvent) -> void:
_toggle_flashlight()
KEY_L:
_toggle_door_lock()
KEY_B:
_toggle_body_camera()
KEY_V:
_cycle_venue()
_:
@@ -720,6 +731,8 @@ func _on_radial_confirmed(slice_id: String) -> void:
match slice_id:
"raise_hand":
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand")
"eye_contact":
_eye_contact()
"point":
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point")
"head_shake":
@@ -734,6 +747,10 @@ func _on_radial_confirmed(slice_id: String) -> void:
_apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning")
"block":
_apply_interaction(Rules.Level.BLOCKING, "blocking")
"reason":
_request_reason()
"recognize":
_recognize_target()
"radio":
_open_radio(false)
@@ -768,6 +785,29 @@ func _toggle_flashlight() -> void:
hud.set_flashlight(flashlight_on)
hud.log(Strings.lookup("flashlight.on") if flashlight_on else Strings.lookup("flashlight.off"))
## Toggle the body camera (PLAN Level 2). The overlay is only visible while the
## camera is on, and turning it on or off is written to the incident log.
func _toggle_body_camera() -> void:
_set_body_camera(not body_camera_on, "manual")
func _set_body_camera(on: bool, reason: String) -> void:
if body_camera_on == on:
return
body_camera_on = on
if hud:
hud.set_body_camera(on)
var controller: Object = _targeted_controller()
if controller != null:
_ensure_incident(controller)
if coordinator and current_incident_active:
coordinator.recorder.add_event(current_incident_npc_id, {
"type": "body_camera",
"on": on,
"reason": reason,
})
if hud:
hud.log(Strings.lookup("bodycam.on") if on else Strings.lookup("bodycam.off"))
## Lock or unlock the restricted door (PLAN.md: Level 3). Locked doors thwart
## FORCE_ENTRY attempts during the reaction flow.
func _toggle_door_lock() -> void:
@@ -819,6 +859,7 @@ func _apply_interaction(level: int, action_name: String) -> void:
var controller: Object = _targeted_controller()
if controller == null or coordinator == null:
return
_ensure_incident(controller)
var outcome: Dictionary = controller.apply_player_action(level)
var npc_id: String = controller.npc_id
coordinator.recorder.add_event(npc_id, {
@@ -829,11 +870,88 @@ func _apply_interaction(level: int, action_name: String) -> void:
coordinator.recorder.add_escalation(npc_id, level, "%s on %s" % [action_name, npc_id])
if level >= Rules.Level.FORMAL_WARNING:
coordinator.reputation.apply("prop", -1.5, "escalation L%d %s" % [level, action_name])
# A formal warning is the documented point where the body camera / security
# light goes on: it creates a record of the interaction (PLAN Level 2).
if level == Rules.Level.FORMAL_WARNING and not body_camera_on:
_set_body_camera(true, "formal warning")
# Blocking also means physically standing between the person and the door
# (PLAN Level 3), not just sending the escalation level.
if level == Rules.Level.BLOCKING:
_station_between(controller, controller.profile.display_name if controller.profile else "them")
hud.set_escalation(level)
if audio:
audio.play_escalation(level)
_react_to_outcome(controller, outcome)
## Level 1 eye contact: a distinct, quieter cue than raise-hand (PLAN.md). It
## uses the same Level 1 reaction table but is logged as its own action.
func _eye_contact() -> void:
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "eye contact")
## "I recognize this person" is the player's own decision, recorded as access
## evidence rather than pre-attached to every staff/guest type (PLAN.md).
func _recognize_target() -> void:
var controller: Object = _targeted_controller()
if controller == null:
hud.log(Strings.lookup("hud.aim_first"))
return
var name: String = controller.profile.display_name if controller.profile else "them"
if controller.player_recognized:
hud.log(Strings.fmt("recognition.already", [name]))
return
_ensure_incident(controller)
controller.player_recognized = true
coordinator.recorder.add_access_evidence(controller.npc_id, {
"kind": "recognition",
"source": "guard_decision",
"recognized": true,
})
coordinator.recorder.add_event(controller.npc_id, {
"type": "recognition_decision",
"recognized": true,
"source": "player",
})
hud.log(Strings.fmt("recognition.declared", [name]))
## Level 3 ask: request the person's reason for entry. The reply comes from the
## NPC's authored `dialogue_lines`; silence is a valid, telling answer.
func _request_reason() -> void:
var controller: Object = _targeted_controller()
if controller == null:
hud.log(Strings.lookup("hud.aim_first"))
return
var name: String = controller.profile.display_name if controller.profile else "They"
_ensure_incident(controller)
var reply: String = controller.request_reason()
coordinator.recorder.add_event(controller.npc_id, {
"type": "reason_requested",
"reply": reply,
})
hud.set_escalation(Rules.Level.BLOCKING)
hud.log(Strings.fmt("reason.ask", [name]))
if reply == "":
hud.log(Strings.fmt("reason.silent", [name]))
else:
hud.log(Strings.fmt("reason.reply", [name, reply]))
## Physically move the guard between the person and the door (PLAN Level 3).
func _station_between(controller: Object, name: String) -> void:
if guard == null or targeted_npc == null:
return
var npc_pos: Vector3 = (targeted_npc as Node3D).global_position
var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2))
var to_door := door_pos - npc_pos
to_door.y = 0.0
if to_door.length() < 0.05:
return
var station: Vector3 = npc_pos + to_door.normalized() * 0.9
station.y = guard.global_position.y
guard.global_position = station
var look_at_point := Vector3(npc_pos.x, guard.global_position.y, npc_pos.z)
if (look_at_point - guard.global_position).length() > 0.05:
guard.look_at(look_at_point, Vector3.UP)
hud.log(Strings.fmt("station.blocking", [name]))
func _react_to_outcome(controller: Object, outcome: Dictionary) -> void:
var reaction: int = outcome.get("reaction", -1)
var name: String = (controller.profile.display_name if controller.profile else "Person")
@@ -1079,7 +1197,19 @@ func _artifacts_for(controller: Object) -> Array:
var profile: NpcProfile = controller.profile as NpcProfile
var override: Dictionary = controller.access_override
var policy = selected_scenario.access_policy if selected_scenario else null
return AccessLoadout.build(profile, policy, override)
var artifacts: Array = AccessLoadout.build(profile, policy, override)
# The guard's own recognition is evidence the player created, so it is
# appended here rather than baked into every staff/guest artifact set.
if controller.player_recognized and not _has_recognition_artifact(artifacts):
artifacts.push_front(AccessArtifact.new(
Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.VALID, {"known": true, "source": "player"}))
return artifacts
func _has_recognition_artifact(artifacts: Array) -> bool:
for artifact in artifacts:
if artifact is AccessArtifact and artifact.kind == Rules.AccessKind.RECOGNITION:
return true
return false
func _read_npc_truth(controller: Object) -> Dictionary:
var profile: Object = controller.profile
@@ -1096,6 +1226,17 @@ func _targeted_controller() -> Object:
return targeted_npc.get_npc_controller()
return null
## Guarantee an open incident for this NPC before recording evidence, so verbs
## work even if the player acted without a prior focus change.
func _ensure_incident(controller: Object) -> void:
if controller == null or coordinator == null:
return
if coordinator.recorder.has_open_incident(controller.npc_id):
return
coordinator.recorder.start_incident(controller.npc_id, "player_action")
current_incident_npc_id = controller.npc_id
current_incident_active = true
func _close_incident(outcome: String) -> void:
if current_incident_active and not current_incident_npc_id.is_empty():
coordinator.recorder.close_incident(current_incident_npc_id, outcome)