From a2a0ded527b589e900b042124a068dda5f8d93ff Mon Sep 17 00:00:00 2001 From: Zwitschi Date: Sat, 31 Oct 2026 21:55:24 +0100 Subject: [PATCH] Author the missing scenario content from the PLAN lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Joke Shop - Reservation list without ID (guest list valid, ID missing) — denied. - Borrowed/mismatched ID (ID valid but mismatched) — denied. - Guest who refuses the wand screen: new `refuses_screening` event flag, copied onto NPCController, handled in _toggle_wand_screening (logs wand.refused, records a wand_refused event, small prop hit, panel stays closed). - Late arrival during the performance (ID + ticket, "arrive" intent). Studio - Filming-boundary event and a later repeat, both using the new `film_boundary` intent which maps to `steps_end` so the influencer stands past the posted line. - Celebrity arrival now pulls in a small crowd via a new `extra_spawns` event field (two tourists + an influencer), spawned through the normal path. Building - Filming-boundary event whose hint points at the body camera and request-reason verbs, plus an unclear-intent event that hints at asking for a reason. Engine support - Events may carry `hint` (string key logged on spawn), `extra_spawns`, and `refuses_screening`; 9 new localized hint strings. Tests/docs - Headless suite 139 -> 149 (test_scenario_content.gd) and a new smoke_scenario_content.gd that spawns the refusal and celebrity events through the real signal path. - README, TODO section moved to DONE, MEMORY. Version 0.2.13. --- DONE.md | 12 ++ MEMORY.md | 10 +- README.md | 11 +- TODO.md | 13 +-- content/strings.en.json | 9 ++ project.godot | 2 +- src/content/scenario_factory.gd | 126 +++++++++++++++++++++ src/core/npc_controller.gd | 2 + src/core/venue_scenario.gd | 4 +- src/game/game_manager.gd | 21 ++++ src/test/smoke_scenario_content.gd | 91 +++++++++++++++ src/test/tests/test_scenario_content.gd | 143 ++++++++++++++++++++++++ 12 files changed, 426 insertions(+), 18 deletions(-) create mode 100644 src/test/smoke_scenario_content.gd create mode 100644 src/test/tests/test_scenario_content.gd diff --git a/DONE.md b/DONE.md index bd295b8..bb95ac0 100644 --- a/DONE.md +++ b/DONE.md @@ -514,3 +514,15 @@ Completed 2026-10-31 (working tree). Headless **139/139**; verified visually at - [x] Escalation colors that still read in grayscale (shape or label, not hue alone). - [x] HUD font scale in settings. + +## Scenario content still missing vs PLAN lists + +Completed 2026-10-31 (working tree). Headless **149/149**; `src/test/smoke_scenario_content.gd` spawns the refusal event and the celebrity arrival through the real signal path. + + +- [x] Joke Shop: guest on the reservation list without ID; borrowed/mismatched ID; guest + refuses wand screening; late arrival during a performance. +- [x] Studio: filming-boundary repeat (influencer ignores a posted line); celebrity-arrival + crowd spike (more rubberneckers, not only one `arrive` spawn). +- [x] Building: "activate body camera" and "request reason" once those verbs exist, used in + at least one authored event. diff --git a/MEMORY.md b/MEMORY.md index 583579f..0078c73 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -21,8 +21,8 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o Windowed control smoke: `godot --path . --script res://src/test/smoke_focus_radial.gd` → exit 0. - `main` is in sync with origin. **v0.2.2** tags the session-shell + world-follow-up commit; CI builds the portable binaries, NSIS installer, and AppImage on the tag push. -- Next work: `TODO.md` scenario content gaps (Joke Shop / Studio / Building events), then the - remaining polish and tooling items. +- Next work: the remaining TODO sections — distribution host setting (`REQUIRE_SIGNIN_VIEW`), + open polish (`Strings.fmt` placeholders), the vision-model screenshot judge, and editor tooling. - Do not re-open `override_access`. Do not bind wand to `W`. Do not remove the radio easter egg. ## Current State @@ -95,6 +95,12 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o `_begin_incident()` now starts one per focused NPC (`has_open_incident()` guard), verbs call `_ensure_incident()`, and `ShiftCoordinator._finish_shift()` closes leftovers as `unresolved` before building the review. +- **Authored scenario content (2026-10-31):** events can now carry `hint` (a string key logged + when the avatar spawns), `extra_spawns` (celebrity arrivals pull in tourists/influencers via + `_spawn_npc` with `kind: routine`), and `refuses_screening` (copied to + `NPCController.refuses_screening`; `_toggle_wand_screening` logs `wand.refused`, records a + `wand_refused` event, applies a small prop hit, and does not open the panel). `film_boundary` + is a new intent that maps to `steps_end` so the influencer stands past the posted line. - **Accessibility (2026-10-31):** escalation text is `" [###--] L3/5"` via `Hud.escalation_meter(level)` (pure, tested) so the cue survives grayscale. HUD font scale uses `add_theme_font_size_override` on the core labels (`set_font_scale`), **not** `Control.scale`, diff --git a/README.md b/README.md index 7070da6..ad17a67 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,9 @@ The central design goal is simple: **All releases:** -> **Latest: v0.2.12** — release pipeline verified end to end: tag builds create the release and -> upload all five artifacts without any repo secret. +> **Latest: v0.2.13** — authored scenario content: Joke Shop access edge cases, Studio filming +> boundaries and celebrity crowd spikes, and Building events that use the body-camera and +> request-reason verbs. > 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 @@ -256,6 +257,9 @@ godot --headless --path . --script res://src/test/smoke_timeline.gd # Audio smoke: crowd bed follows mood, radio open/close crackle godot --headless --path . --script res://src/test/smoke_audio.gd + +# Scenario-content smoke: wand refusal, celebrity crowd spike +godot --headless --path . --script res://src/test/smoke_scenario_content.gd ``` The unit test runner is the project's own lightweight harness (no editor plugin required) so CI can run it without the editor. @@ -308,7 +312,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: **139 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, 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) +- Headless unit tests: **149 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, 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) - 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`) @@ -325,6 +329,7 @@ Implemented so far: - NPC theater: seated guests sit on the steps with bent legs, influencers and tourists raise a camera prop while photographing, performers sway their sign, and filming visitors pan slowly — all once they reach their intent's waypoint - Audio: a looping procedural crowd bed (filtered murmur) whose volume follows `CrowdSystem.mood`, plus radio open/close crackle. All generated at runtime, no audio assets, and safe with a dummy audio server - Accessibility: the escalation cue shows a shape meter plus the numeric level (`Escalation: Blocking [###--] L3/5`) so it reads in grayscale, and the HUD font scale from settings is applied at boot by resizing fonts (not Control.scale, which broke the right-aligned layout) +- Authored scenario content: Joke Shop reservation-without-ID, borrowed/mismatched ID, a guest who refuses the wand screen, and a late arrival during a performance; Studio filming-boundary repeats and a celebrity arrival that draws a small crowd; Building events that point the player at the body camera and request-reason verbs - Visual polish pass (v0.2.0): phase-based lighting (sun/sky/ambient per shift phase), street lamps with night-mode bulbs and omni lights, glowing marquee signage, crosswalk, planters and low-poly street trees, NPC height/brightness variety, denser routine traffic — day and late-night screenshot sets - Assets pass: authored textures in `assets/textures/` (brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons, access-card art) generated by `tools/generate_textures.py`, soft sun shadows, humanoid NPC rigs with archetype props + procedural walk bob and attention head-turn, inspection artifact cards, escalation vignette, mode icons, and a body-camera overlay (REC + shift clock) diff --git a/TODO.md b/TODO.md index 4f738fa..0c852bb 100644 --- a/TODO.md +++ b/TODO.md @@ -1,19 +1,10 @@ # TODO -Godot 4.3 prototype. Release **v0.2.9** is tagged. Headless suite **139/139**, plus the session, -PLAN-verb, crowd, emergency, timeline, audio, and windowed focus/radial smokes. +Godot 4.3 prototype. Release **v0.2.13** is tagged. Headless suite **149/149**, plus the session, +PLAN-verb, crowd, emergency, timeline, audio, scenario-content, and windowed focus/radial smokes. Host `REQUIRE_SIGNIN_VIEW` stays deferred. -## Scenario content still missing vs PLAN lists - -- [ ] Joke Shop: guest on the reservation list without ID; borrowed/mismatched ID; guest - refuses wand screening; late arrival during a performance. -- [ ] Studio: filming-boundary repeat (influencer ignores a posted line); celebrity-arrival - crowd spike (more rubberneckers, not only one `arrive` spawn). -- [ ] Building: "activate body camera" and "request reason" once those verbs exist, used in - at least one authored event. - ## Distribution follow-up (host config, deferred) Verified via API+host credentials (2026-10-31): diff --git a/content/strings.en.json b/content/strings.en.json index 56e5720..01391d3 100644 --- a/content/strings.en.json +++ b/content/strings.en.json @@ -56,6 +56,15 @@ "reason.ask": "You ask %s why they are here.", "reason.reply": "%s: \"%s\"", "reason.silent": "%s says nothing and looks away.", + "hint.reservation_no_id": "On the reservation list but no ID — ask for ID, then check the list.", + "hint.borrowed_id": "The ID photo does not match the person. Verify before admitting.", + "hint.refuses_wand": "This guest refuses the wand screen. No screen, no entry.", + "hint.late_arrival": "Late arrival during the performance — check ID and ticket before opening the door.", + "hint.film_boundary": "Filming past the posted line. Ask their reason and point them back to the sidewalk.", + "hint.film_boundary_repeat": "Same crew again, still past the line. A formal warning is now reasonable.", + "hint.celebrity_arrival": "Celebrity arrival: the crowd is swelling. Keep the entrance clear.", + "hint.body_camera_reason": "They are filming the entrance. Ask their reason (Reason) and consider the body camera (B).", + "hint.ask_reason": "Unclear intent. Ask why they are here (Reason) before deciding anything else.", "station.blocking": "You step between %s and the door.", "perception.near": "%s notices you standing close.", "perception.aware": "%s notices you watching.", diff --git a/project.godot b/project.godot index 21d4d53..620930a 100644 --- a/project.godot +++ b/project.godot @@ -8,7 +8,7 @@ config_version=5 config/name="Step Line" config/description="Workplace security simulation about observation, access verification, and proportionate escalation." -config/version="0.2.12" +config/version="0.2.13" run/main_scene="res://scenes/main.tscn" config/features=PackedStringArray("4.3", "GL Compatibility") diff --git a/src/content/scenario_factory.gd b/src/content/scenario_factory.gd index 804197f..c86c6f7 100644 --- a/src/content/scenario_factory.gd +++ b/src/content/scenario_factory.gd @@ -343,6 +343,28 @@ static func hollywood_building() -> VenueScenario: _building_routine("staff_entry", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 600), 0.35), _building_routine("guest_entry", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.25), _building_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.24), + { + "id": "building_filming_boundary", + "phase": Rules.Phase.MIDDAY, + "kind": "scripted", + "npc_type": Rules.NpcType.INFLUENCER, + "intent": "film", + "time_range_s": Vector2(300, 600), + "probability": 0.4, + "base_weight": 1.0, + "hint": "hint.body_camera_reason", + }, + { + "id": "building_unclear_intent", + "phase": Rules.Phase.EVENING, + "kind": "scripted", + "npc_type": Rules.NpcType.CONFUSED_VISITOR, + "intent": "enter", + "time_range_s": Vector2(600, 780), + "probability": 0.4, + "base_weight": 1.0, + "hint": "hint.ask_reason", + }, _building_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.26), _building_routine("intoxicated_wander", Rules.NpcType.INTOXICATED, "wander", Vector2(600, 900), 0.15), _building_routine("trespass_test", Rules.NpcType.TRESPASSER, "trespass", Vector2(300, 900), 0.16), @@ -437,6 +459,67 @@ static func joke_shop() -> VenueScenario: _joke_guest("guest_no_ticket", false, true, Vector2(0, 900), 0.18), _joke_guest("guest_expired_id", true, true, Vector2(0, 900), 0.12, {"id_status": Rules.ArtifactStatus.EXPIRED}), _joke_guest("guest_no_id", true, false, Vector2(0, 900), 0.12), + { + "id": "joke_guest_reservation_no_id", + "phase": Rules.Phase.EVENING, + "kind": "scripted", + "npc_type": Rules.NpcType.AUTHORIZED_GUEST, + "intent": "enter", + "time_range_s": Vector2(620, 860), + "probability": 0.45, + "base_weight": 1.0, + "override_access": { + "has_guest_list": true, "guest_list_status": Rules.ArtifactStatus.VALID, + "has_id": false, "has_ticket": false, + }, + "hint": "hint.reservation_no_id", + }, + { + "id": "joke_borrowed_id", + "phase": Rules.Phase.MIDDAY, + "kind": "scripted", + "npc_type": Rules.NpcType.AUTHORIZED_GUEST, + "intent": "enter", + "time_range_s": Vector2(320, 700), + "probability": 0.4, + "base_weight": 1.0, + "override_access": { + "has_id": true, "id_status": Rules.ArtifactStatus.MISMATCHED, + "has_ticket": true, "ticket_status": Rules.ArtifactStatus.VALID, + }, + "hint": "hint.borrowed_id", + }, + { + "id": "joke_refuses_wand", + "phase": Rules.Phase.EVENING, + "kind": "scripted", + "npc_type": Rules.NpcType.AUTHORIZED_GUEST, + "intent": "enter", + "time_range_s": Vector2(650, 880), + "probability": 0.4, + "base_weight": 1.0, + "refuses_screening": true, + "override_access": { + "has_id": true, "id_status": Rules.ArtifactStatus.VALID, + "has_ticket": true, "ticket_status": Rules.ArtifactStatus.VALID, + }, + "hint": "hint.refuses_wand", + }, + { + "id": "joke_late_arrival", + "phase": Rules.Phase.EVENING, + "kind": "scripted", + "npc_type": Rules.NpcType.AUTHORIZED_GUEST, + "intent": "arrive", + "time_range_s": Vector2(700, 860), + "probability": 0.35, + "base_weight": 1.0, + "override_access": { + "has_id": true, "id_status": Rules.ArtifactStatus.VALID, + "has_ticket": true, "ticket_status": Rules.ArtifactStatus.VALID, + }, + "hint": "hint.late_arrival", + }, _joke_staff_entry(Vector2(0, 900), 0.25), _joke_routine("performer_show", Rules.NpcType.PERFORMER, "perform", Vector2(300, 800), 0.15), _joke_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.15), @@ -515,6 +598,45 @@ static func tv_studio() -> VenueScenario: "base_weight": 1.0, "override_access": {"wristband_status": Rules.ArtifactStatus.COUNTERFEIT, "credential_status": Rules.ArtifactStatus.COUNTERFEIT}, }, + { + "id": "studio_film_boundary", + "phase": Rules.Phase.MIDDAY, + "kind": "scripted", + "npc_type": Rules.NpcType.INFLUENCER, + "intent": "film_boundary", + "time_range_s": Vector2(330, 560), + "probability": 0.5, + "base_weight": 1.0, + "hint": "hint.film_boundary", + }, + { + "id": "studio_film_boundary_repeat", + "phase": Rules.Phase.EVENING, + "kind": "scripted", + "npc_type": Rules.NpcType.INFLUENCER, + "intent": "film_boundary", + "time_range_s": Vector2(640, 830), + "probability": 0.45, + "base_weight": 1.0, + "hint": "hint.film_boundary_repeat", + }, + { + "id": "studio_celebrity_arrival", + "phase": Rules.Phase.EVENING, + "kind": "scripted", + "npc_type": Rules.NpcType.AUTHORIZED_GUEST, + "intent": "arrive", + "time_range_s": Vector2(620, 760), + "probability": 0.5, + "base_weight": 1.0, + "override_access": {"has_guest_list": true, "guest_list_status": Rules.ArtifactStatus.VALID}, + "extra_spawns": [ + {"npc_type": Rules.NpcType.TOURIST, "intent": "photo"}, + {"npc_type": Rules.NpcType.TOURIST, "intent": "photo"}, + {"npc_type": Rules.NpcType.INFLUENCER, "intent": "film"}, + ], + "hint": "hint.celebrity_arrival", + }, { "id": "studio_follow_through", "phase": Rules.Phase.EVENING, @@ -587,6 +709,10 @@ static func _joke_routine(id: String, npc_type: int, intent: String, time_range: static func _studio_routine(id: String, npc_type: int, intent: String, time_range: Vector2, prob: float) -> Dictionary: return _building_routine(id, npc_type, intent, time_range, prob) +## Scripted film-boundary intent: the influencer stands past the posted line. +static func _film_boundary_spot() -> String: + return "steps_end" + static func _phase_for_range(time_range: Vector2, duration: float) -> int: var start := time_range.x / duration if start < 0.34: diff --git a/src/core/npc_controller.gd b/src/core/npc_controller.gd index f207e66..6d47633 100644 --- a/src/core/npc_controller.gd +++ b/src/core/npc_controller.gd @@ -34,6 +34,8 @@ var guard_noticed := false ## Whether this NPC has passed the handheld wand screening (The Joke Shop). var screened := false +## Authored event flag: this person refuses the wand screen (Joke Shop). +var refuses_screening := false ## Whether the guard has declared they recognize this person (a player decision ## recorded as evidence, PLAN.md: Authorized Access). var player_recognized := false diff --git a/src/core/venue_scenario.gd b/src/core/venue_scenario.gd index 177c1d5..ff32d3a 100644 --- a/src/core/venue_scenario.gd +++ b/src/core/venue_scenario.gd @@ -63,7 +63,9 @@ func _spot_for_intent(intent: String) -> String: return "entrance" "photo", "film", "perform", "wander", "roam": return "sidewalk" - "sit": + "sit", "film_boundary": + # film_boundary: an influencer ignoring the posted filming line, so they + # stand at the steps instead of the permitted sidewalk spot. return "steps_end" _: return "sidewalk" diff --git a/src/game/game_manager.gd b/src/game/game_manager.gd index a759663..f74442c 100644 --- a/src/game/game_manager.gd +++ b/src/game/game_manager.gd @@ -479,8 +479,19 @@ func _spawn_npc(profile: NpcProfile, intent: String, npc_id: String, event: Dict # Authored events stay for the whole shift; routine traffic cycles out. avatar.ephemeral = event.get("kind", "routine") == "routine" avatar.lifetime_s = 45.0 + float((npc_counter * 17) % 60) + avatar.get_npc_controller().refuses_screening = bool(event.get("refuses_screening", false)) scene_root.add_child(avatar) npcs.append(avatar) + # Authored events can point the player at the right verb (PLAN.md verbs). + if event.has("hint") and hud: + hud.log(Strings.lookup(str(event["hint"]))) + # Celebrity arrivals draw extra bystanders, not just one avatar. + for extra in event.get("extra_spawns", []): + var extra_profile := selected_scenario.profile_for(extra.get("npc_type", Rules.NpcType.TOURIST)) + if extra_profile == null or npcs.size() >= MAX_LIVE_NPCS: + continue + _spawn_npc(extra_profile, str(extra.get("intent", "photo")), + "%s_w%d" % [npc_id, npcs.size()], {"kind": "routine"}) ## Drop references to freed avatars so the cap and Tab cycling stay accurate. func _purge_npcs() -> void: @@ -1238,6 +1249,16 @@ func _toggle_wand_screening() -> void: var controller: Object = _targeted_controller() if controller == null: return + if controller.refuses_screening: + # Authored event: this guest refuses the screen (Joke Shop content). + hud.set_wand_icon(false) + hud.log(Strings.fmt("wand.refused", [controller.profile.display_name if controller.profile else "The guest"])) + coordinator.recorder.add_event(controller.npc_id, { + "type": "wand_refused", + "outcome": "refused", + }) + coordinator.reputation.apply("prop", -1.0, "guest refused screening") + return var profile: Object = controller.profile var items: Array = profile.carried_items if profile else [] var result: Dictionary = wand_screener.screen(items) diff --git a/src/test/smoke_scenario_content.gd b/src/test/smoke_scenario_content.gd new file mode 100644 index 0000000..7ea7ea5 --- /dev/null +++ b/src/test/smoke_scenario_content.gd @@ -0,0 +1,91 @@ +extends SceneTree +## Scenario-content smoke: forces the authored Joke Shop refusal event and the +## Studio celebrity arrival through the real spawn path, then checks the extra +## bystanders, the hint log, and that a screening refusal is handled. +## +## Usage: +## godot --headless --path . --script res://src/test/smoke_scenario_content.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", "joke_shop") + started = true + return false + if frame > MAX_FRAMES: + _fail("timeout") + return true + if frame < 120: + return false + match step: + 0: + # Force the refusal event through the normal spawn signal. + var event := _find_event(gm.selected_scenario.event_pool, "joke_refuses_wand") + _check(not event.is_empty(), "the refusal event exists in the Joke Shop pool") + var guest: Object = gm.selected_scenario.profile_for(int(event.get("npc_type", 0))) + gm.coordinator.npc_spawn_requested.emit(guest, "enter", "smoke_refuser", event) + _check(gm.npcs.size() > 0, "the refusing guest spawned") + gm.call("_on_guard_target", gm.npcs[gm.npcs.size() - 1]) + var controller: Object = gm.npcs[gm.npcs.size() - 1].get_npc_controller() + _check(controller.refuses_screening, "the refusal flag reached the controller") + gm.call("_toggle_wand_screening") + _check(not gm.wand_open, "screening does not open for someone who refuses") + step = 1 + 1: + # Studio: celebrity arrival should bring extra bystanders. + gm.call("_on_venue_selected", "tv_studio") + step = 2 + 2: + if frame % 20 != 0: + return false + var event := _find_event(gm.selected_scenario.event_pool, "studio_celebrity_arrival") + var before: int = gm.npcs.size() + var guest: Object = gm.selected_scenario.profile_for(int(event.get("npc_type", 0))) + gm.coordinator.npc_spawn_requested.emit(guest, "arrive", "smoke_celeb", event) + var added: int = gm.npcs.size() - before + _check(added >= 4, "celebrity arrival spawns the guest plus bystanders (%d)" % added) + step = 3 + 3: + _finish() + return true + return false + +func _find_event(pool: Array, id: String) -> Dictionary: + for event in pool: + if str((event as Dictionary).get("id", "")) == id: + return event + return {} + +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: authored scenario content") + quit(0) + else: + for message in failures: + printerr("SMOKE FAIL: " + message) + quit(1) diff --git a/src/test/tests/test_scenario_content.gd b/src/test/tests/test_scenario_content.gd new file mode 100644 index 0000000..cc02971 --- /dev/null +++ b/src/test/tests/test_scenario_content.gd @@ -0,0 +1,143 @@ +extends RefCounted +## Authored scenario content added to match the PLAN lists: Joke Shop access +## edge cases, Studio filming boundaries and celebrity crowd spikes, and Building +## events that point at the body-camera / request-reason verbs. + +const Factory := preload("res://src/content/scenario_factory.gd") +const Rules := preload("res://src/core/rules.gd") +const Strings := preload("res://src/core/strings.gd") +const AccessLoadout := preload("res://src/core/access_loadout.gd") +const AccessVerifier := preload("res://src/core/access_verifier.gd") +const NpcProfile := preload("res://src/core/npc_profile.gd") + +func _events(scenario_id: String) -> Array: + var scenario: Resource + match scenario_id: + "joke_shop": + scenario = Factory.joke_shop() + "tv_studio": + scenario = Factory.tv_studio() + _: + scenario = Factory.hollywood_building() + return scenario.event_pool + +func _event(scenario_id: String, id: String) -> Dictionary: + for event in _events(scenario_id): + if str(event.get("id", "")) == id: + return event + return {} + +func test_joke_shop_has_the_missing_edge_cases() -> bool: + var a := TestUtils.check(not _event("joke_shop", "joke_guest_reservation_no_id").is_empty(), + "reservation list without ID exists") + var b := TestUtils.check(not _event("joke_shop", "joke_borrowed_id").is_empty(), + "borrowed/mismatched ID exists") + var c := TestUtils.check(not _event("joke_shop", "joke_refuses_wand").is_empty(), + "guest who refuses the wand exists") + var d := TestUtils.check(not _event("joke_shop", "joke_late_arrival").is_empty(), + "late arrival during the performance exists") + return a and b and c and d + +func test_reservation_without_id_is_denied() -> bool: + var event := _event("joke_shop", "joke_guest_reservation_no_id") + var profile := _guest_profile() + var artifacts := AccessLoadout.build(profile, Factory.joke_shop().access_policy, event.get("override_access", {})) + var verifier := AccessVerifier.new() + var result: Dictionary = verifier.verify(artifacts, Factory.joke_shop().access_policy, false) + var a := TestUtils.check(not result.get("granted", true), "a reservation without ID is not admitted") + var b := TestUtils.check(not (result.get("issues", []) as Array).is_empty(), "the denial names the missing artifact") + return a and b + +func test_borrowed_id_is_mismatched() -> bool: + var event := _event("joke_shop", "joke_borrowed_id") + var profile := _guest_profile() + var artifacts := AccessLoadout.build(profile, Factory.joke_shop().access_policy, event.get("override_access", {})) + var found_mismatch := false + for artifact in artifacts: + if artifact.kind == Rules.AccessKind.ID and artifact.status == Rules.ArtifactStatus.MISMATCHED: + found_mismatch = true + var verifier := AccessVerifier.new() + var result: Dictionary = verifier.verify(artifacts, Factory.joke_shop().access_policy, false) + var a := TestUtils.check(found_mismatch, "the ID is flagged mismatched") + var b := TestUtils.check(not result.get("granted", true), "a mismatched ID is not admitted") + return a and b + +func test_refuses_wand_event_sets_the_flag() -> bool: + var event := _event("joke_shop", "joke_refuses_wand") + var ok := TestUtils.check(bool(event.get("refuses_screening", false)), "the event marks the refusal") + var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd") + var wired := TestUtils.check(src.contains("refuses_screening = bool(event.get("), + "the spawn copies the flag onto the controller") + var handled := TestUtils.check(src.contains("if controller.refuses_screening:"), + "the wand screen handles a refusal") + return ok and wired and handled + +func test_studio_filming_boundary_repeats() -> bool: + var first := _event("tv_studio", "studio_film_boundary") + var repeat := _event("tv_studio", "studio_film_boundary_repeat") + var a := TestUtils.check(not first.is_empty(), "first boundary event exists") + var b := TestUtils.check(not repeat.is_empty(), "repeat boundary event exists") + var c := TestUtils.check_eq(str(first.get("intent", "")), "film_boundary", "first uses the boundary intent") + var d := TestUtils.check_eq(str(repeat.get("intent", "")), "film_boundary", "repeat uses the boundary intent") + var e := TestUtils.check(int(repeat.get("time_range_s", Vector2.ZERO).x) > int(first.get("time_range_s", Vector2.ZERO).x), + "the repeat happens later in the shift") + return a and b and c and d and e + +func test_film_boundary_stands_at_the_steps() -> bool: + var scenario := Factory.tv_studio() + var spot: Vector3 = scenario.waypoint_for_intent("film_boundary") + var sidewalk: Vector3 = scenario.waypoint_for_intent("film") + var a := TestUtils.check(spot != sidewalk, "the boundary spot differs from the permitted sidewalk spot") + var b := TestUtils.check_eq(spot, scenario._location_or_default("steps_end", Vector3.ZERO), + "the influencer stands at the steps, past the line") + return a and b + +func test_celebrity_arrival_spawns_a_crowd() -> bool: + var event := _event("tv_studio", "studio_celebrity_arrival") + var extras: Array = event.get("extra_spawns", []) + var a := TestUtils.check(extras.size() >= 3, "celebrity arrival adds several bystanders") + var types: Array = [] + for extra in extras: + types.append(int((extra as Dictionary).get("npc_type", -1))) + var b := TestUtils.check(types.has(Rules.NpcType.TOURIST), "tourists gather") + var c := TestUtils.check(types.has(Rules.NpcType.INFLUENCER), "an influencer films it") + var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd") + var d := TestUtils.check(src.contains('event.get("extra_spawns", [])'), "the spawner honours extra_spawns") + return a and b and c and d + +func test_building_events_point_at_the_new_verbs() -> bool: + var camera_event := _event("hollywood_building", "building_filming_boundary") + var reason_event := _event("hollywood_building", "building_unclear_intent") + var a := TestUtils.check(str(camera_event.get("hint", "")).contains("body"), "an event hints at the body camera") + var b := TestUtils.check(str(reason_event.get("hint", "")).contains("reason"), "an event hints at requesting a reason") + var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd") + var c := TestUtils.check(src.contains('if event.has("hint")'), "spawning logs the authored hint") + return a and b and c + +func test_every_authored_hint_resolves() -> bool: + var missing: Array[String] = [] + for scenario in [Factory.hollywood_building(), Factory.joke_shop(), Factory.tv_studio()]: + for event in scenario.event_pool: + var key := str((event as Dictionary).get("hint", "")) + if key != "" and Strings.lookup(key) == key: + missing.append(key) + return TestUtils.check(missing.is_empty(), "all authored hints have strings (missing: %s)" % str(missing)) + +func test_new_events_have_unique_ids() -> bool: + # Event ids only need to be unique within a venue. + var problems: Array[String] = [] + for scenario in [Factory.hollywood_building(), Factory.joke_shop(), Factory.tv_studio()]: + var seen := {} + for event in scenario.event_pool: + var id := str((event as Dictionary).get("id", "")) + if seen.has(id): + problems.append("%s:%s" % [scenario.id, id]) + seen[id] = true + return TestUtils.check(problems.is_empty(), "event ids are unique per venue (dupes: %s)" % str(problems)) + +func _guest_profile() -> NpcProfile: + var p := NpcProfile.new() + p.id = "guest" + p.display_name = "Guest" + p.npc_type = Rules.NpcType.AUTHORIZED_GUEST + return p