Compare commits

..
2 Commits
Author SHA1 Message Date
zwitschi 76eb5e3a79 Fix mouse look swallowing by the HUD
Full build / Import + headless tests (push) Successful in 35s
Full build / Full desktop build + optional release (push) Successful in 4m39s
Move guard look handling to the _input stage, recapture the cursor on
window-focus return and when clicking back into gameplay, and make HUD controls
mouse-transparent. Add regression tests for yaw/pitch and HUD mouse filters.
Verified under Xvfb with real relative mouse motion. Headless 237/237, Python
94/94, movement smoke 60 fps.
2026-11-01 20:38:36 +01:00
zwitschi f4a53e2d4f Add the first-shift tutorial design and state machine
Full build / Full desktop build + optional release (push) Skipped
Full build / Import + headless tests (push) Successful in 38s
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.
2026-11-01 20:19:18 +01:00
15 changed files with 321 additions and 18 deletions

No files matched your search

+11
View File
@@ -4,6 +4,17 @@ All notable user-facing changes to Gig Line. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions use
Semantic Versioning.
## [0.7.3] — 2026-11-01
### Fixed
- Mouse look now reads relative motion at the `_input` stage, so a full-screen
HUD or focused control cannot swallow it while the cursor is captured.
- Mouse capture is restored after window/compositor focus returns, and clicking
back into a gameplay window recaptures it instead of opening the radial menu.
- The HUD is mouse-transparent; its presentation controls no longer block
gameplay mouse input.
## [0.7.2] — 2026-11-01
### Added
+2
View File
@@ -42,6 +42,8 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
- Shift length is the scenario's `shift_duration_s` (900 s in all three venues). Tests and the screenshot tool override elapsed time; they do not change the default.
- RNG: NPC reactions and event scheduling are seedable for deterministic tests.
- **Mouse look (v0.7.3):** guard look is handled in `GuardController._input()` (before GUI), not `_unhandled_input`; focus/click-back recaptures the cursor and the HUD is made mouse-transparent recursively. Regression tests cover yaw/pitch and HUD filters.
- **Tutorial foundation (0.8.0 work):** `docs/tutorial.md` is the eight-beat design; `src/game/tutorial.gd` is a pure `RefCounted` state machine with skip/restart and tests. It is not yet wired into `GameManager`; when wiring it, add an explicit QA bypass because captures/smokes would otherwise see prompts.
- **Documentation ownership (2026-11-01):** `docs/architecture.md` §12 defines the source-of-truth map and consolidation rules. Obsolete/historical plan documents are removed rather than kept; current work belongs in `TODO.md`. README/development-status/Blender AO/Forward+ stale claims were corrected. The v1.0.0 gap register and 0.8/0.9/0.9.5/1.0 roadmap live in `TODO.md`.
- **Vision judge (v0.7.2):** `tools/openrouter_judge.py` reads `/api/v1/models`, filters image-input models with `response_format`, and ranks by advertised prompt/image/completion prices. `--model auto` (default) uses the cheapest (`z-ai/glm-5.3-flash` on 2026-11-01) with fallbacks; explicit `--model`/`GIG_LINE_JUDGE_MODEL` wins. Auth errors abort, provider/model errors fall through. Full screen judge 13/13 match, 0 diff; A/B judge ~$0.006.
- **Desktop HQ preset (v0.7.1):** `packaging/desktop_hq/override.cfg` switches a
+1 -1
View File
@@ -172,7 +172,7 @@ Primary decisions: [ADR-0007](docs/adr/0007-locomotion-and-input-layers.md), [AD
## Versioning and Delivery
`config/version` in `project.godot` is the single version source (currently `0.7.2`). Releases are tagged `v<version>`; the CI pipeline builds and publishes the standard artifacts plus the optional Desktop HQ zip. Version bumps follow [Semantic Versioning](https://semver.org/). See [`docs/RELEASING.md`](docs/RELEASING.md) and [`README.md`](README.md) for commands.
`config/version` in `project.godot` is the single version source (currently `0.7.3`). Releases are tagged `v<version>`; the CI pipeline builds and publishes the standard artifacts plus the optional Desktop HQ zip. Version bumps follow [Semantic Versioning](https://semver.org/). See [`docs/RELEASING.md`](docs/RELEASING.md) and [`README.md`](README.md) for commands.
## Design Direction
+4 -3
View File
@@ -38,9 +38,9 @@ precision walk, the Joke Shop alley, the TV Studio arrival, and the prop lineup.
> Downloads are public (no sign-in required) since the host's instance-wide
> `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31.
>
> **Latest: v0.7.2** — a cost-aware OpenRouter screenshot judge, an optional
> Forward+ Desktop HQ build with volumetric fog/SDFGI/flashlight, distinct Joke
> Shop and TV Studio geometry, and fitted NPC gear.
> **Latest: v0.7.3** — mouse-look input reliability fixes, plus the v0.7.2
> cost-aware OpenRouter judge, optional Forward+ Desktop HQ build, distinct
> venues and fitted NPC gear.
> Releases are built automatically when a `vX.Y.Z` tag is pushed
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline
@@ -108,6 +108,7 @@ Full setup, every run/test/smoke command, and the controls table are in
- [`CHANGELOG.md`](CHANGELOG.md) — User-facing changes by version.
- [`docs/architecture.md`](docs/architecture.md) — arc42-style architecture documentation.
- [`docs/gameplay.md`](docs/gameplay.md) — Gameplay systems, venues, and the escalation model.
- [`docs/tutorial.md`](docs/tutorial.md) — First-shift tutorial beats and rules.
- [`docs/asset-tooling.md`](docs/asset-tooling.md) — Asset pipelines, model tools, and screenshot gates.
- [`docs/getting-started.md`](docs/getting-started.md) — Setup, run/test commands, and controls.
- [`docs/development-status.md`](docs/development-status.md) — Implemented features and what's next.
+7 -4
View File
@@ -54,11 +54,14 @@ venues, and see enough authored variety that a second shift does not feel like a
### 0.8.1 First-shift tutorial
- [ ] Write the tutorial beat sheet: menu → movement/look → focus and target cycling → action
- [x] Write the tutorial beat sheet: menu → movement/look → focus and target cycling → action
wheel → access inspection → polite direction → formal warning/body camera → radio →
shift review. Keep it to 6–8 beats and one safe, compliant NPC.
- [ ] Add a tutorial state machine independent of the normal shift FSM. It must be skippable,
restartable and deterministic, and must not change the outcome of the live shift.
shift review. Eight beats and a safe compliant visitor are specified in
[`docs/tutorial.md`](docs/tutorial.md).
- [x] Add a tutorial state machine independent of the normal shift FSM. `Tutorial` in
`src/game/tutorial.gd` owns the eight beats, accepts only the current beat's action,
supports skip/restart and has deterministic headless tests. GameManager/UI wiring starts
with the contextual-prompt task.
- [ ] Add contextual prompts that advance on the performed action, not on a timer. Prompts must
not block normal input or repeat after the player has learned the control.
- [ ] Stage a safe encounter with one compliant visitor; no emergency, threat or crowd panic in
+1 -1
View File
@@ -37,7 +37,7 @@ This document follows the structure of the arc42 architecture documentation temp
- **MINOR** — new venues, NPC archetypes, systems, or features that are backward compatible.
- **PATCH** — bug fixes and small polish that do not change behavior.
The single source of truth for the version is `config/version` in `project.godot` (currently `0.7.2`). Each release is tagged in git as `v<version>` (e.g. `v0.6.0`). The `0.x` series is pre-1.0, so MINOR bumps may introduce breaking changes while the prototype is still stabilizing. `tools/build_releases.sh` and `tools/publish_release.sh` read the version from `project.godot`, so bumping it there is the only required step before a release. The full bump checklist lives in [`RELEASING.md`](RELEASING.md).
The single source of truth for the version is `config/version` in `project.godot` (currently `0.7.3`). Each release is tagged in git as `v<version>` (e.g. `v0.6.0`). The `0.x` series is pre-1.0, so MINOR bumps may introduce breaking changes while the prototype is still stabilizing. `tools/build_releases.sh` and `tools/publish_release.sh` read the version from `project.godot`, so bumping it there is the only required step before a release. The full bump checklist lives in [`RELEASING.md`](RELEASING.md).
## 3. Context and Scope
+3 -2
View File
@@ -5,7 +5,7 @@ Implemented so far:
- Godot 4.3 project scaffold, input map, procedural world builder
- Venue-driven simulation core: `ShiftCoordinator`, `VenueScenario`, `EventScheduler`, `NPCController` + FSM, `AccessVerifier`, `EscalationManager`, `RadioSystem`, `IncidentRecorder`, `ReputationEvaluator`, `CrowdSystem`
- Three authored scenarios (Hollywood Boulevard building, The Joke Shop, Late Night TV Studio) with access policies, NPC archetypes, and event pools
- Headless unit tests: **231 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, input remaps, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material, player movement, NPC character layers/LOD/queue, input bindings)
- Headless unit tests: **237 passing** (access rules and venue-correct access loadout, escalation, radio cooldown/battery, NPC reaction determinism, door lock, flashlight perception, persistence round-trip, settings round-trip, input remaps, navmesh, step colliders, skyline alpha, scenario data, full-shift simulation, wand screening, responder dispatch, localization strings, focus cone, action wheel, session shell, PLAN verbs and incident recording, crowd presentation, emergency aftermath, body-camera timeline, NPC theater poses, audio, accessibility, authored scenario content, string formatting, `.tres` venue round-trip, surface materials and environment, focus ranking and outfit/gait helpers, generated prop models, web export preset, prop atlas material, player movement, NPC character layers/LOD/queue, input bindings)
- Simulation extras: protester archetype, CALL_SOMEONE reaction, door lock/secure mechanic (visual indicator), flashlight with night perception bonus, LATE shift phase reaching the violent-threat event
- First-person prototype with interaction HUD, access inspection, wand screening, radio report form (incl. emergency hold-to-confirm), dispatched responder avatars, aim-free targeting, venue cycling between all three scenarios, color-coded escalation feedback, procedural audio cues, navigation-mesh NPC paths, JSON-backed localized strings, persisted shift history, debug overlay, and shift review
- Control pass: focus cone on NPC heads with a billboarded blue circle in front of the face and name, action wheel (LMB/Space) with de-escalation-first slices, mouse-look freeze while a panel is open, acceleration-based walking, and named input actions (`action_menu`, `confirm`, `cancel`, `inspect`, `wand`)
@@ -40,7 +40,8 @@ Implemented so far:
- Visual alignment: start/venue menu, pause, settings, shift review, radio, wand, inspection, timeline, HUD target chip/escalation bar, and radial menu now use or match their AI references in `assets/images/ui/`. The runtime art loader (`src/game/ui/art.gd`) falls back cleanly when a file is missing.
- Realism adoption (post-v0.6.0): v2 per-phase skylines and surface tiles with normal maps, retuned sun/exposure/fog, wet asphalt, denser foliage, a visible additive flashlight beam, and refreshed staging captures. `tools/check_screenshots.sh` is the repeatable capture/compare gate; judge is clean of `diff` verdicts.
- Input/reliability fixes: selecting a venue now releases the start-menu input lock and recaptures the mouse; the body-camera overlay no longer covers the shift status.
- Input/reliability fixes: selecting a venue releases the start-menu input lock and recaptures the mouse; the body-camera overlay no longer covers the shift status. Mouse look now reads at `_input`, restores capture on window focus/click-back, and the HUD is mouse-transparent.
- Tutorial foundation (0.8.0 work): the eight-beat first-shift design and a deterministic, skippable/restartable `Tutorial` state machine exist; the in-game prompt/visitor wiring is the next step.
- Vision judge (v0.7.2): discovers the cheapest current OpenRouter image+JSON model, reports the model/fallbacks used, and clears the full screen-design judge (13/13 match, 0 diff).
- Desktop HQ (v0.7.1): optional Forward+ Windows/Linux zip with volumetric fog, SDFGI and a volumetric flashlight; the default Compatibility/Web builds and headless tests gate the effect off. A/B captures are generated locally under `docs/screens/realism/` (gitignored).
- Venue geometry (v0.7.0): the Joke Shop now builds as a narrow brick alley with a warm wall lantern, shutters, dumpster/AC clutter and no skyline; the TV Studio builds a stone facade, bulb canopy, glossy black car and barrier line. `WorldBuilder.VENUE_SPECS` drives facade/street/nav dimensions, and `GameManager` rebuilds only the world container when the venue changes.
+2
View File
@@ -16,6 +16,8 @@ A typical shift lasts 10–15 minutes. The player:
The game rewards calm resolutions rather than aggressive behavior. Emergency assistance and armed responders are rare last-resort systems with meaningful consequences.
The first shift starts with an in-game tutorial covering movement/look, focus cycling, the action wheel, access inspection, polite direction, formal warning/body camera, radio and shift review. Its beat sheet is in [`tutorial.md`](tutorial.md).
## Venues
### Hollywood Boulevard Building
+45
View File
@@ -0,0 +1,45 @@
# First-shift tutorial
Status: planned for v0.8.0. The tutorial teaches the real controls and the real
inspection/wheel/radio panels; it is not a fake UI or a separate combat mode.
## Rules
- One short, safe, compliant visitor. No emergency, threat, crowd panic or random
event can interrupt the tutorial.
- Prompts advance on the performed action, not on a timer. The player can keep
moving/looking while a prompt is visible.
- The tutorial is skippable at any point. Completion and skip are both persisted
separately; a skipped tutorial can be replayed from Help/Settings.
- The tutorial state machine is independent of the shift FSM. It observes the
same actions as normal play and must be deterministic for tests.
## Beat sheet
| # | Beat | Prompt | Advances when | Notes |
| --- | --- | --- | --- | --- |
| 1 | Arrive | "Walk forward and look around. The entrance is behind you." | The guard has moved at least 1 m and looked away/returned | Start at the normal guard post, facing the street. |
| 2 | Focus | "Look at the visitor. Press Tab or the mouse wheel to cycle targets." | A target is acquired and explicitly cycled | The visitor waits at the foot of the steps; no other guest is required. |
| 3 | Wheel | "Open the action wheel with Space or left click." | The radial wheel opens on the visitor | The wheel is the real de-escalation-first wheel. |
| 4 | Inspect | "Choose Inspect and read the access evidence." | The inspection panel is opened and the policy result is visible | Use a valid wristband so the tutorial can teach both evidence and a safe admit/deny decision. |
| 5 | Direction | "Close the panel and give a polite direction." | A Level 1 polite-direction action is applied | The visitor complies; there is no escalation requirement. |
| 6 | Warning | "Give a formal warning if the visitor persists. It arms the body camera." | A Level 2 warning is applied or the body camera is switched on | Show the REC overlay and the escalation cue. |
| 7 | Radio | "Open the assistance radio and submit a short report." | The assistance report is submitted successfully | A non-emergency assistance call; no responder is required to resolve the tutorial. |
| 8 | Review | "Finish the shift and review what happened." | The shift review opens | The tutorial ends; the real review remains available for inspection. |
## Visitor script
- Display name: `Visitor`.
- Intent: `enter` (approaches the door, then waits for interaction).
- Access: valid wristband evidence; the policy result should be unambiguous.
- Behavior: compliant at every escalation level; never becomes a threat, never
joins a crowd and never triggers an emergency.
- Failure handling: if the player denies admission incorrectly, the tutorial
explains the evidence but does not fail the run; the visitor remains safe and
the review records the decision.
## Persistence and replay
Settings gain `tutorial_completed` and `tutorial_skipped`. A first launch starts
the tutorial after venue selection. Skipping sets `tutorial_skipped`; Settings/
Help offers "Replay tutorial", which clears both flags for the next shift.
+1 -1
View File
@@ -12,7 +12,7 @@ config_version=5
config/name="Gig Line"
config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
config/version="0.7.2"
config/version="0.7.3"
run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.3", "GL Compatibility")
+28 -6
View File
@@ -115,14 +115,36 @@ static func _ensure_gamepad_bindings() -> void:
press.pressed = false
InputMap.action_add_event(action, press)
func _unhandled_input(event: InputEvent) -> void:
## Mouse look is read at the `_input` stage, before GUI/Control handling. A
## full-screen HUD or a focused control must never be able to swallow relative
## mouse motion while the game is in captured mode.
func _input(event: InputEvent) -> void:
if input_locked:
return
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
rotate_y(-event.relative.x * look_sensitivity)
var pitch_sign := 1.0 if invert_y else -1.0
head.rotate_x(pitch_sign * event.relative.y * look_sensitivity)
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0))
if event is InputEventMouseMotion:
if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
return
_apply_mouse_look(event.relative)
get_viewport().set_input_as_handled()
elif event is InputEventMouseButton and event.pressed and Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
# Recover capture if the window lost it (alt-tab, compositor focus,
# clicking back into the game) without opening the radial menu.
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
get_viewport().set_input_as_handled()
## Pure look update, kept separate so the rotation math is testable.
func _apply_mouse_look(relative: Vector2) -> void:
rotate_y(-relative.x * look_sensitivity)
var pitch_sign := 1.0 if invert_y else -1.0
head.rotate_x(pitch_sign * relative.y * look_sensitivity)
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0))
func _notification(what: int) -> void:
# A window/compositor focus change can silently release capture. Restore it
# when gameplay owns the input layer; menus set input_locked instead.
if what == NOTIFICATION_APPLICATION_FOCUS_IN or what == NOTIFICATION_WM_WINDOW_FOCUS_IN:
if is_inside_tree() and not input_locked:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
func _physics_process(_delta: float) -> void:
if focus_suppress_s > 0.0:
+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())
+11
View File
@@ -356,6 +356,17 @@ func _init() -> void:
debug_label.add_theme_font_size_override("font_size", 11)
debug_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
add_child(debug_label)
_ignore_mouse(self)
## HUD controls are presentation-only. Leaving the full-screen containers on the
## default MOUSE_FILTER_STOP can swallow mouse motion/buttons before the guard's
## captured-look input sees them. Make every non-gameplay HUD control inert to
## the mouse; modal menus are separate CanvasLayers and keep their own filters.
func _ignore_mouse(node: Node) -> void:
for child in node.get_children():
if child is Control:
(child as Control).mouse_filter = Control.MOUSE_FILTER_IGNORE
_ignore_mouse(child)
func set_shift_state(coordinator: Object, scenario: Resource) -> void:
top_label.text = scenario.display_name if scenario else "Gig Line"
+38
View File
@@ -0,0 +1,38 @@
extends RefCounted
## Regression tests for the first-person mouse-look path and non-blocking HUD.
const GameManager := preload("res://src/game/game_manager.gd")
const GuardController := preload("res://src/game/guard_controller.gd")
const Hud := preload("res://src/game/ui/hud.gd")
func test_mouse_look_rotates_yaw_and_pitch() -> bool:
var root := Node3D.new()
var gm: Node = GameManager.new()
var guard: GuardController = gm.call("_build_guard")
guard._ready()
var yaw0 := guard.rotation.y
var pitch0 := guard.head.rotation.x
guard._apply_mouse_look(Vector2(100, 50))
var a := TestUtils.check(guard.rotation.y < yaw0 - 0.1, "horizontal motion yaws the guard")
var b := TestUtils.check(guard.head.rotation.x < pitch0 - 0.1, "vertical motion pitches the head")
guard._apply_mouse_look(Vector2(0, 100000))
var c := TestUtils.check(absf(rad_to_deg(guard.head.rotation.x)) <= 80.01,
"pitch stays clamped")
guard.free()
gm.free()
root.free()
return a and b and c
func test_hud_does_not_capture_mouse_input() -> bool:
var hud: Hud = Hud.new()
var root := hud.get_child(0) as Control
var a := TestUtils.check(root != null and root.mouse_filter == Control.MOUSE_FILTER_IGNORE,
"full-screen HUD root ignores mouse input")
var labels := hud.find_children("*", "Label", true, false)
var blocking := 0
for node in labels:
if (node as Control).mouse_filter != Control.MOUSE_FILTER_IGNORE:
blocking += 1
var b := TestUtils.check(blocking == 0, "HUD labels do not swallow mouse motion")
hud.free()
return a and b
+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