Add body-camera timeline to the shift review
- 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:
1 parent
921bab796e
commit
1b976bf351
14 files changed
+614
-71
No files matched your search
@@ -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)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user