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
@@ -78,6 +78,19 @@ func close_incident(npc_id: String, outcome: String) -> Dictionary:
|
||||
func open_incidents_count() -> int:
|
||||
return _open_incidents.size()
|
||||
|
||||
## True when an incident is already open for this NPC (avoids duplicating one
|
||||
## incident every time the player re-focuses the same person).
|
||||
func has_open_incident(npc_id: String) -> bool:
|
||||
return _open_incidents.has(npc_id)
|
||||
|
||||
## Close every still-open incident at shift end so the review does not silently
|
||||
## drop interactions the player never resolved. Returns how many were closed.
|
||||
func close_all_open(outcome: String = "unresolved") -> int:
|
||||
var ids: Array = _open_incidents.keys()
|
||||
for npc_id in ids:
|
||||
close_incident(str(npc_id), outcome)
|
||||
return ids.size()
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"incidents": incidents.duplicate(true),
|
||||
|
||||
@@ -34,6 +34,11 @@ var guard_noticed := false
|
||||
|
||||
## Whether this NPC has passed the handheld wand screening (The Joke Shop).
|
||||
var screened := false
|
||||
## Whether the guard has declared they recognize this person (a player decision
|
||||
## recorded as evidence, PLAN.md: Authorized Access).
|
||||
var player_recognized := false
|
||||
## Last reply the NPC gave when asked for their reason for entry.
|
||||
var last_reason_reply := ""
|
||||
## Event-level access payload (`has_id`, `has_ticket`, statuses). Empty means
|
||||
## venue/type defaults from AccessLoadout.
|
||||
var access_override: Dictionary = {}
|
||||
@@ -150,6 +155,16 @@ func tick_return(delta: float) -> bool:
|
||||
func decide_leave() -> bool:
|
||||
return leave_requested
|
||||
|
||||
## Level 3 ask: request the person's reason for entry (PLAN.md). Pure and
|
||||
## headless-testable; returns the line this NPC answers with ("" if silent).
|
||||
func request_reason() -> String:
|
||||
var lines: Array[String] = profile.dialogue_lines if profile else []
|
||||
if lines.is_empty():
|
||||
last_reason_reply = ""
|
||||
return ""
|
||||
last_reason_reply = lines[rng.randi_range(0, lines.size() - 1)]
|
||||
return last_reason_reply
|
||||
|
||||
## Perception pass: given the guard's distance (meters), return a perception
|
||||
## result describing how the NPC reacts to the guard's presence. Pure and
|
||||
## headless-testable; the presentation layer feeds it the real distance.
|
||||
|
||||
@@ -119,6 +119,8 @@ func _finish_shift() -> void:
|
||||
return
|
||||
_shift_finished = true
|
||||
running = false
|
||||
# Interactions the player never resolved still belong in this shift's review.
|
||||
recorder.close_all_open("unresolved")
|
||||
var review := build_review()
|
||||
event_bus.shift_state_changed.emit({"running": false, "review": review})
|
||||
shift_ended.emit(review)
|
||||
|
||||
+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
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
extends SceneTree
|
||||
## PLAN-verb smoke: starts a shift, focuses one person, then exercises the verbs
|
||||
## added after the radial pass — recognize, request reason, body camera, formal
|
||||
## warning (which must arm the body camera), and blocking (which must move the
|
||||
## guard between the person and the door). Also checks that an incident is
|
||||
## actually recorded, then closing it at shift end.
|
||||
##
|
||||
## Usage:
|
||||
## godot --headless --path . --script res://src/test/smoke_plan_verbs.gd
|
||||
## Exit code 0 = pass, 1 = failure.
|
||||
|
||||
const MAX_FRAMES := 1200
|
||||
|
||||
var frame := 0
|
||||
var gm: Node = null
|
||||
var started := false
|
||||
var step := 0
|
||||
var failures: Array[String] = []
|
||||
|
||||
func _initialize() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
root.add_child(load("res://scenes/main.tscn").instantiate())
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
frame += 1
|
||||
if gm == null:
|
||||
gm = root.find_child("GameManager", true, false)
|
||||
return false
|
||||
if not started:
|
||||
gm.call("_on_venue_selected", "hollywood_building")
|
||||
started = true
|
||||
return false
|
||||
if frame > MAX_FRAMES:
|
||||
_fail("timeout before the verbs ran")
|
||||
return true
|
||||
if frame < 200:
|
||||
return false
|
||||
if step == 0:
|
||||
if gm.npcs.is_empty():
|
||||
_fail("no NPCs spawned to test the verbs on")
|
||||
return true
|
||||
var npc: Node = gm.npcs[0]
|
||||
gm.call("_on_guard_target", npc)
|
||||
var controller: Object = npc.get_npc_controller()
|
||||
gm.call("_recognize_target")
|
||||
_check(controller.player_recognized, "recognize marks the person as recognized")
|
||||
gm.call("_request_reason")
|
||||
_check(typeof(controller.last_reason_reply) == TYPE_STRING, "reason request returns a reply")
|
||||
gm.call("_set_body_camera", false, "smoke")
|
||||
_check(not gm.body_camera_on, "body camera can be switched off")
|
||||
gm.call("_apply_interaction", 2, "formal warning")
|
||||
_check(gm.body_camera_on, "formal warning arms the body camera")
|
||||
var before: Vector3 = gm.guard.global_position
|
||||
gm.call("_apply_interaction", 3, "blocking")
|
||||
_check(before.distance_to(gm.guard.global_position) > 0.05, "blocking moves the guard")
|
||||
_check(gm.coordinator.recorder.open_incidents_count() > 0 or gm.coordinator.recorder.incidents.size() > 0,
|
||||
"an incident was recorded")
|
||||
gm.coordinator.call("_finish_shift")
|
||||
_check(gm.coordinator.recorder.open_incidents_count() == 0, "open incidents close at shift end")
|
||||
_check(gm.coordinator.recorder.incidents.size() > 0, "shift history keeps the incident")
|
||||
step = 1
|
||||
if step == 1:
|
||||
_finish()
|
||||
return true
|
||||
return false
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
if condition:
|
||||
print("ok: " + message)
|
||||
else:
|
||||
_fail(message)
|
||||
|
||||
func _fail(message: String) -> void:
|
||||
failures.append(message)
|
||||
_finish()
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("SMOKE OK: PLAN verbs (recognize, reason, body camera, warning, blocking)")
|
||||
quit(0)
|
||||
else:
|
||||
for message in failures:
|
||||
printerr("SMOKE FAIL: " + message)
|
||||
quit(1)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user