Add PLAN verbs and fix dead incident recording
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:
1 parent
028f608298
commit
f5ad1642b5
16 files changed
+500
-37
No files matched your search
+142
-1
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user