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

+13
View File
@@ -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),
+15
View File
@@ -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.
+2
View File
@@ -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
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
+84
View File
@@ -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)
+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