Add body-camera timeline to the shift review
Full build / Import + headless tests (push) Successful in 30s
Full build / Full desktop build + optional release (push) Failing after 3m38s

- New src/game/ui/event_timeline.gd flattens every recorded incident event
  (ADR-0005) into one time-ordered list and shows time, person, and a one-line
  summary. Step with Left/Right or the buttons; Esc returns to the review. No live
  3D replay in this first version.
- Shift review gains a "Body camera timeline" button that hands the review to the
  timeline; the review dim is hidden while it is open (stacked overlays made the
  screen nearly black) and restored on close.
- ShiftCoordinator records started_at_ms so the review can stamp events as mm:ss.
- 8 new string keys for the timeline UI and the review button.

Tests/docs
- Headless suite 113 -> 120 (test_event_timeline.gd), new smoke_timeline.gd that
  opens the timeline from the review, steps through every event, and closes back.
- README, TODO section moved to DONE, MEMORY notes. Version 0.2.6.
This commit is contained in:
zwitschi committed 2026-10-31 21:02:25 +01:00
1 parent 921bab796e
commit 1b976bf351
14 files changed
+614 -71

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
extends SceneTree
## Body-camera timeline smoke: records a few real interactions, opens the review's
## timeline, steps through every event, and closes back to the review.
##
## Usage:
## godot --headless --path . --script res://src/test/smoke_timeline.gd
const MAX_FRAMES := 900
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")
return true
if frame < 200:
return false
match step:
0:
if gm.npcs.is_empty():
return false
gm.call("_on_guard_target", gm.npcs[0])
gm.call("_apply_interaction", 1, "eye contact")
gm.call("_request_reason")
gm.call("_apply_interaction", 2, "formal warning")
var review: Dictionary = gm.coordinator.build_review()
# Real flow: the review screen shows first, then its timeline button.
gm.shift_review_panel.show_review(review, gm.selected_scenario, [])
var button: Button = gm.shift_review_panel.find_child("TimelineButton", true, false) as Button
_check(button != null, "review offers a body-camera timeline button")
if button:
button.pressed.emit()
var count: int = gm.event_timeline.events.size()
_check(count >= 3, "timeline collected the interaction events (%d)" % count)
_check(gm.event_timeline.visible, "timeline is showing")
_check(not gm.shift_review_panel.visible, "review is hidden behind the timeline")
_check(gm.guard.input_locked, "the guard is frozen while the timeline is open")
for i in count + 2:
gm.event_timeline.step(1)
_check(gm.event_timeline.index == count - 1, "stepping clamps at the last event")
gm.event_timeline.close()
step = 1
1:
_check(not gm.event_timeline.visible, "timeline closes")
_check(gm.shift_review_panel.visible, "closing returns to the review")
_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: body-camera timeline")
quit(0)
else:
for message in failures:
printerr("SMOKE FAIL: " + message)
quit(1)
+120
View File
@@ -0,0 +1,120 @@
extends RefCounted
## Body-camera timeline: the review screen steps through recorded incident events
## (ADR-0005). No live 3D replay in this version.
const EventTimeline := preload("res://src/game/ui/event_timeline.gd")
const Strings := preload("res://src/core/strings.gd")
const Coordinator := preload("res://src/core/shift_coordinator.gd")
const Factory := preload("res://src/content/scenario_factory.gd")
func _review() -> Dictionary:
return {
"started_at_ms": 1000,
"incidents": {
"open_count": 0,
"incidents": [
{
"id": "inc_a",
"npc_id": "npc_a",
"outcome": "peaceful",
"events": [
{"type": "player_action", "action": "eye contact", "level": 1, "at_ms": 3000},
{"type": "body_camera", "on": true, "reason": "formal warning", "at_ms": 8000},
],
},
{
"id": "inc_b",
"npc_id": "npc_b",
"outcome": "left",
"events": [
{"type": "reason_requested", "reply": "I have a ticket.", "at_ms": 5000},
{"type": "access_check", "evidence": {"decided": true, "verified_granted": true}, "at_ms": 9000},
],
},
],
},
"reputation": {"rating": 70.0, "grade": "C", "modifiers": []},
"crowd": {"mood": 0.1, "panic": false},
"radio": {"battery": 100.0, "emergency_cooldown_s": 0.0},
}
func test_flatten_orders_events_across_incidents() -> bool:
var timeline: Node = EventTimeline.new()
var flat: Array = timeline.flatten(_review())
var a := TestUtils.check_eq(flat.size(), 4, "every recorded event is flattened")
var stamps: Array[int] = []
for row in flat:
stamps.append(int(row.get("at_ms", 0)))
var sorted := stamps.duplicate()
sorted.sort()
var b := TestUtils.check_eq(stamps, sorted, "events are time ordered")
var c := TestUtils.check_eq(flat[0].get("npc_id", ""), "npc_a", "event keeps its NPC id")
var d := TestUtils.check_eq(flat[1].get("npc_id", ""), "npc_b", "second incident interleaves")
var e := TestUtils.check_eq(flat[0].get("incident_id", ""), "inc_a", "event keeps its incident id")
timeline.free()
return a and b and c and d and e
func test_describe_covers_event_types() -> bool:
var timeline: Node = EventTimeline.new()
var a := TestUtils.check(timeline.describe({"type": "player_action", "action": "eye contact", "level": 1, "at_ms": 3000}).contains("eye contact (L1)"), "player action line")
var b := TestUtils.check(timeline.describe({"type": "body_camera", "on": true, "reason": "formal warning"}).contains("body camera on"), "body camera line")
var c := TestUtils.check(timeline.describe({"type": "reason_requested", "reply": "I have a ticket."}).contains("I have a ticket."), "reason reply line")
var d := TestUtils.check(timeline.describe({"type": "access_check", "evidence": {"decided": false, "verified_granted": true}}).contains("deny"), "access decision line")
var e := TestUtils.check(timeline.describe({"type": "responder_action", "call_type": "emergency", "outcome": "evacuation"}).contains("evacuation"), "responder line")
var f := TestUtils.check(timeline.describe({"type": "investigation_opened", "reason": "unjustified"}).contains("investigation"), "investigation line")
timeline.free()
return a and b and c and d and e and f
func test_empty_timeline_is_safe() -> bool:
var timeline: Node = EventTimeline.new()
var flat: Array = timeline.flatten({})
var a := TestUtils.check_eq(flat.size(), 0, "no incidents means no events")
timeline.show_timeline({})
var b := TestUtils.check_eq(timeline.index, 0, "index stays at zero when empty")
timeline.step(1)
var c := TestUtils.check_eq(timeline.index, 0, "stepping an empty timeline does nothing")
timeline.free()
return a and b and c
func test_stepping_clamps() -> bool:
var timeline: Node = EventTimeline.new()
timeline.show_timeline(_review())
timeline.step(-1)
var a := TestUtils.check_eq(timeline.index, 0, "cannot step before the first event")
timeline.step(1)
var b := TestUtils.check_eq(timeline.index, 1, "next advances one event")
timeline.step(99)
var c := TestUtils.check_eq(timeline.index, 3, "cannot step past the last event")
timeline.free()
return a and b and c
func test_review_records_a_shift_start_stamp() -> bool:
var coordinator: Object = Coordinator.new()
coordinator.setup(Factory.hollywood_building(), 30.0, 7)
coordinator.start()
var review: Dictionary = coordinator.build_review()
var a := TestUtils.check(int(review.get("started_at_ms", 0)) > 0, "review carries the shift start stamp")
coordinator = null
return a
func test_timeline_strings_exist() -> bool:
var ok := true
for key in ["timeline.title", "timeline.position", "timeline.empty", "timeline.prev",
"timeline.next", "timeline.close", "timeline.hint", "review.timeline_button"]:
if Strings.lookup(key) == key:
ok = false
return TestUtils.check(ok, "timeline UI strings are localized")
func test_review_button_opens_the_timeline() -> bool:
var Review := load("res://src/game/ui/shift_review.gd")
var panel: Node = Review.new()
var got := {"review": {}}
panel.timeline_requested.connect(func(review): got["review"] = review)
panel.show_review(_review(), Factory.hollywood_building(), [])
var button: Button = panel.find_child("TimelineButton", true, false) as Button
var a := TestUtils.check(button != null, "review screen has a timeline button")
if button:
button.pressed.emit()
var b := TestUtils.check(not (got["review"] as Dictionary).is_empty(), "pressing it hands the review to the timeline")
panel.free()
return a and b