Add the first-shift tutorial design and state machine
Full build / Import + headless tests (push) Successful in 38s
Full build / Full desktop build + optional release (push) Skipped

Write the eight-beat tutorial sheet, its safe visitor script, persistence and
replay rules. Add a deterministic, skippable/restartable Tutorial RefCounted
state machine with headless tests. UI/GameManager wiring remains the next task.
Headless 235/235, Python 94/94.
This commit is contained in:
zwitschi committed 2026-11-01 20:19:18 +01:00
1 parent 806fe78688
commit f4a53e2d4f
8 files changed
+225 -5

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
class_name Tutorial
extends RefCounted
## Deterministic first-shift tutorial state machine. It observes player actions
## but owns no UI or simulation nodes, so it can be tested headlessly and wired
## into GameManager without changing the shift FSM. `advance()` only accepts the
## event for the current beat; wrong actions are ignored.
signal beat_changed(index: int, beat: Dictionary)
signal completed(skipped: bool)
const BEATS := [
{
"id": "arrive",
"prompt": "Walk forward and look around. The entrance is behind you.",
"event": "moved",
},
{
"id": "focus",
"prompt": "Look at the visitor. Press Tab or the mouse wheel to cycle targets.",
"event": "cycled",
},
{
"id": "wheel",
"prompt": "Open the action wheel with Space or left click.",
"event": "wheel_opened",
},
{
"id": "inspect",
"prompt": "Choose Inspect and read the access evidence.",
"event": "inspected",
},
{
"id": "direction",
"prompt": "Close the panel and give a polite direction.",
"event": "polite_direction",
},
{
"id": "warning",
"prompt": "Give a formal warning if the visitor persists. It arms the body camera.",
"event": "formal_warning",
"alternate_event": "body_camera",
},
{
"id": "radio",
"prompt": "Open the assistance radio and submit a short report.",
"event": "radio_submitted",
},
{
"id": "review",
"prompt": "Finish the shift and review what happened.",
"event": "review_opened",
},
]
var index := -1
var complete := false
var skipped := false
func start() -> Dictionary:
index = 0
complete = false
skipped = false
var beat: Dictionary = BEATS[0]
beat_changed.emit(index, beat)
return beat
func current() -> Dictionary:
if index < 0 or index >= BEATS.size() or complete:
return {}
return BEATS[index]
func prompt() -> String:
return str(current().get("prompt", ""))
func advance(event: String) -> bool:
if complete:
return false
if index < 0:
start()
if index >= BEATS.size():
return false
var beat: Dictionary = BEATS[index]
var accepted: Array = [str(beat.get("event", ""))]
if beat.has("alternate_event"):
accepted.append(str(beat["alternate_event"]))
if event not in accepted:
return false
index += 1
if index >= BEATS.size():
complete = true
index = BEATS.size()
completed.emit(false)
else:
beat_changed.emit(index, BEATS[index])
return true
## Skip is recorded separately so Settings/Help can offer "Replay tutorial".
func skip() -> void:
if complete:
return
skipped = true
complete = true
index = BEATS.size()
completed.emit(true)
func restart() -> Dictionary:
return start()
func is_complete() -> bool:
return complete
func is_skipped() -> bool:
return skipped
## (current beat number, total beats); one-based for display, (total, total)
## after completion.
func progress() -> Vector2i:
return Vector2i(clampi(index + 1, 1, BEATS.size()), BEATS.size())
+49
View File
@@ -0,0 +1,49 @@
extends RefCounted
## Deterministic headless tests for the first-shift tutorial state machine.
const Tutorial := preload("res://src/game/tutorial.gd")
func test_tutorial_advances_only_on_the_expected_action() -> bool:
var tutorial := Tutorial.new()
tutorial.start()
var a := TestUtils.check(tutorial.current()["id"] == "arrive", "starts at arrival")
var b := TestUtils.check(not tutorial.advance("wheel_opened"), "wrong action is ignored")
var c := TestUtils.check(tutorial.advance("moved"), "movement advances")
var d := TestUtils.check(tutorial.current()["id"] == "focus", "moves to focus")
var e := TestUtils.check(tutorial.advance("cycled"), "target cycling advances")
var f := TestUtils.check(tutorial.current()["id"] == "wheel", "moves to the action wheel")
return a and b and c and d and e and f
func test_tutorial_runs_all_beats_and_completes() -> bool:
var tutorial := Tutorial.new()
tutorial.start()
var events := ["moved", "cycled", "wheel_opened", "inspected",
"polite_direction", "formal_warning", "radio_submitted", "review_opened"]
var ok := true
for event in events:
ok = ok and tutorial.advance(event)
ok = ok and tutorial.is_complete()
ok = ok and not tutorial.is_skipped()
ok = ok and tutorial.progress() == Vector2i(Tutorial.BEATS.size(), Tutorial.BEATS.size())
return TestUtils.check(ok, "all eight beats complete in order")
func test_tutorial_accepts_body_camera_for_warning() -> bool:
var tutorial := Tutorial.new()
tutorial.start()
for event in ["moved", "cycled", "wheel_opened", "inspected", "polite_direction"]:
tutorial.advance(event)
var a := TestUtils.check(tutorial.advance("body_camera"), "body camera satisfies warning")
var b := TestUtils.check(tutorial.current()["id"] == "radio", "advances to radio")
return a and b
func test_tutorial_skip_and_restart() -> bool:
var tutorial := Tutorial.new()
tutorial.start()
var a := TestUtils.check(tutorial.advance("moved"), "one beat completed")
tutorial.skip()
var b := TestUtils.check(tutorial.is_complete() and tutorial.is_skipped(), "skip completes tutorial")
var c := TestUtils.check(not tutorial.advance("moved"), "completed tutorial is inert")
tutorial.restart()
var d := TestUtils.check(not tutorial.is_complete() and not tutorial.is_skipped(), "restart clears flags")
var e := TestUtils.check(tutorial.current()["id"] == "arrive", "restart returns to beat one")
return a and b and c and d and e