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)
+9 -3
View File
@@ -10,6 +10,7 @@ const Strings := preload("res://src/core/strings.gd")
const SLICE_DEFS := [
{"id": "raise_hand", "label_key": "radial.raise_hand", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "raise hand"},
{"id": "eye_contact", "label_key": "radial.eye_contact", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "eye contact"},
{"id": "point", "label_key": "radial.point", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "point"},
{"id": "head_shake", "label_key": "radial.head_shake", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "head shake"},
{"id": "polite", "label_key": "radial.polite", "level": Rules.Level.SIMPLE_DETERRENCE, "action": "polite direction"},
@@ -17,6 +18,8 @@ const SLICE_DEFS := [
{"id": "wand", "label_key": "radial.wand", "level": -1, "action": "wand"},
{"id": "warning", "label_key": "radial.warning", "level": Rules.Level.FORMAL_WARNING, "action": "formal warning"},
{"id": "block", "label_key": "radial.block", "level": Rules.Level.BLOCKING, "action": "blocking"},
{"id": "reason", "label_key": "radial.reason", "level": Rules.Level.BLOCKING, "action": "request reason"},
{"id": "recognize", "label_key": "radial.recognize", "level": -1, "action": "recognize"},
{"id": "radio", "label_key": "radial.radio", "level": -1, "action": "radio assist"},
]
@@ -146,8 +149,8 @@ func _on_wheel_draw() -> void:
return
var center := _wheel.size * 0.5
var n := SLICE_DEFS.size()
var outer := 150.0
var inner := 58.0
var outer := 174.0
var inner := 64.0
for i in n:
var a0 := -PI / 2.0 + TAU * float(i) / float(n)
var a1 := -PI / 2.0 + TAU * float(i + 1) / float(n)
@@ -160,7 +163,10 @@ func _on_wheel_draw() -> void:
var mid := (a0 + a1) * 0.5
var label_pos := center + Vector2(cos(mid), sin(mid)) * ((inner + outer) * 0.5)
var text := Strings.lookup(str(SLICE_DEFS[i]["label_key"]))
_wheel.draw_string(ThemeDB.fallback_font, label_pos + Vector2(-28, 4), text, HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color.WHITE)
# With 12 slices the label box is tight: center it on the slice midpoint.
var font := ThemeDB.fallback_font
var text_size := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12)
_wheel.draw_string(font, label_pos - Vector2(text_size.x * 0.5, -4), text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, Color.WHITE)
func _draw_slice(center: Vector2, inner: float, outer: float, a0: float, a1: float, color: Color) -> void:
var pts := PackedVector2Array()
+24 -3
View File
@@ -47,6 +47,8 @@ var escalation_label: Label
var _debug_enabled := false
var _log_lines: Array[String] = []
var _cam_overlay: Control = null
var _body_camera_on := false
func _init() -> void:
name = "HUD"
@@ -138,11 +140,19 @@ func _init() -> void:
add_child(vignette_rect)
# --- body-camera overlay (REC + shift clock + corner brackets, top right) ---
# Toggled by the body-camera flag (PLAN Level 2); off until the guard activates
# it or issues a formal warning.
_cam_overlay = Control.new()
_cam_overlay.name = "BodyCameraOverlay"
_cam_overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
_cam_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
_cam_overlay.visible = false
add_child(_cam_overlay)
var cam_box := VBoxContainer.new()
cam_box.set_anchors_preset(Control.PRESET_TOP_RIGHT)
cam_box.position = Vector2(-180, 50)
cam_box.alignment = BoxContainer.ALIGNMENT_END
add_child(cam_box)
_cam_overlay.add_child(cam_box)
var rec_row := HBoxContainer.new()
rec_row.alignment = BoxContainer.ALIGNMENT_END
cam_box.add_child(rec_row)
@@ -279,6 +289,16 @@ func set_wand_icon(visible: bool) -> void:
if wand_icon:
wand_icon.visible = visible
## Show or hide the body-camera overlay (REC + clock + brackets). The flag is a
## record of the interaction (PLAN Level 2), not a permanent HUD fixture.
func set_body_camera(on: bool) -> void:
_body_camera_on = on
if _cam_overlay:
_cam_overlay.visible = on
func is_body_camera_on() -> bool:
return _body_camera_on
## Scale the whole HUD text block (accessibility settings).
func set_font_scale(scale: float) -> void:
var clamped := clampf(scale, 0.75, 1.75)
@@ -308,6 +328,7 @@ func _add_corner_brackets(color: Color) -> void:
var corners := [
Vector2(OFF, OFF), Vector2(vp.x - OFF, OFF), Vector2(OFF, vp.y - OFF), Vector2(vp.x - OFF, vp.y - OFF),
]
var parent: Node = _cam_overlay if _cam_overlay != null else self
for i in 4:
var c: Vector2 = corners[i]
var flip_x: bool = (i % 2) == 1
@@ -317,13 +338,13 @@ func _add_corner_brackets(color: Color) -> void:
h.size = Vector2(LEN, TH)
h.position = Vector2(c.x - (LEN if flip_x else 0.0), c.y - (TH if flip_y else 0.0))
h.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(h)
parent.add_child(h)
var v := ColorRect.new()
v.color = color
v.size = Vector2(TH, LEN)
v.position = Vector2(c.x - (TH if flip_x else 0.0), c.y - (LEN if flip_y else 0.0))
v.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(v)
parent.add_child(v)
func set_action_hint(text: String) -> void:
hint_label.text = text
+1 -1
View File
@@ -176,7 +176,7 @@ func _build_skyline_backdrop() -> void:
plane.mesh = mesh
var mat := StandardMaterial3D.new()
mat.albedo_texture = T_SKYLINE
mat.albedo_texture_repeat = true
mat.texture_repeat = true
mat.uv1_scale = Vector3(2.0, 1.0, 1.0)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
mat.alpha_scissor_threshold = 0.3