Author the missing scenario content from the PLAN lists
Full build / Import + headless tests (push) Successful in 35s
Full build / Full desktop build + optional release (push) Successful in 3m26s

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.
This commit is contained in:
zwitschi committed 2026-10-31 21:55:24 +01:00
1 parent 922a990c7a
commit a2a0ded527
12 files changed
+426 -18

No files matched your search

+91
View File
@@ -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)
+143
View File
@@ -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