- 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.
121 lines
5.3 KiB
GDScript
121 lines
5.3 KiB
GDScript
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
|