class_name ScenarioFactory ## Builds all authored venue scenarios const Rules := preload("res://src/core/rules.gd") const VenueScenario := preload("res://src/core/venue_scenario.gd") const AccessPolicy := preload("res://src/core/access_policy.gd") const NpcProfile := preload("res://src/core/npc_profile.gd") ## Every venue derives from shared NPC norms and overrides venue rules. const PLAN_ACCESS_SUMMARY_BUILDING := "Private property. Entry requires recognition or a valid wristband." const PLAN_ACCESS_SUMMARY_JOKE_SHOP := "Government ID and a valid ticket or reservation required. Carry items are screened." const PLAN_ACCESS_SUMMARY_TV_STUDIO := "Entry requires a wristband, credential, ticket, or guest-list confirmation." static func shared_profiles() -> Array[NpcProfile]: var profiles: Array[NpcProfile] = [] # --- Small, low-risk crowd ------------------------------------------------- var tourist := NpcProfile.new() tourist.id = "tourist" tourist.display_name = "Tourist" tourist.npc_type = Rules.NpcType.TOURIST tourist.temperament = 0.1 tourist.base_compliance = 0.9 tourist.context_lines = { "refusal": ["I just want a photo, I'm not staying.", "I didn't know this entrance was restricted."], "deescalation": ["Okay, I'll move to the sidewalk.", "Sorry, I'll put the camera away."], "recognition": ["You remember me from the tour, right?"], } tourist.dialogue_lines = [ "Oh sure, sorry, just taking a picture.", "Is there a better spot to stand?", "Understood, moving.", ] profiles.append(tourist) var confused := NpcProfile.new() confused.id = "confused_visitor" confused.display_name = "Confused Visitor" confused.npc_type = Rules.NpcType.CONFUSED_VISITOR confused.temperament = 0.2 confused.base_compliance = 0.85 confused.context_lines = { "refusal": ["I'm sure the event is here. Let me check my phone."], "deescalation": ["Oh, I understand. I'll check with the box office."], } confused.dialogue_lines = [ "Wait, this isn't the entrance?", "Sorry about that, I'm a bit lost.", ] profiles.append(confused) var delivery := NpcProfile.new() delivery.id = "delivery" delivery.display_name = "Delivery Worker" delivery.npc_type = Rules.NpcType.DELIVERY delivery.temperament = 0.25 delivery.base_compliance = 0.8 delivery.dialogue_lines = ["Where should I leave this?"] profiles.append(delivery) var performer := NpcProfile.new() performer.id = "street_performer" performer.display_name = "Street Performer" performer.npc_type = Rules.NpcType.PERFORMER performer.temperament = 0.45 performer.base_compliance = 0.55 performer.reactions_by_level = { Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.COMPLY: 55, Rules.Reaction.IGNORE: 30, Rules.Reaction.ARGUE: 15, }, } profiles.append(performer) var influencer := NpcProfile.new() influencer.id = "influencer" influencer.display_name = "Influencer" influencer.npc_type = Rules.NpcType.INFLUENCER influencer.temperament = 0.5 influencer.base_compliance = 0.5 influencer.reactions_by_level = { Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.COMPLY: 40, Rules.Reaction.IGNORE: 30, Rules.Reaction.RECORD: 20, Rules.Reaction.ARGUE: 10, }, Rules.Level.FORMAL_WARNING: { Rules.Reaction.RECORD: 35, Rules.Reaction.ARGUE: 20, Rules.Reaction.CALL_SOMEONE: 15, Rules.Reaction.COMPLY: 30, }, Rules.Level.BLOCKING: { Rules.Reaction.RECORD: 30, Rules.Reaction.ARGUE: 20, Rules.Reaction.CALL_SOMEONE: 25, Rules.Reaction.LEAVE: 15, Rules.Reaction.COMPLY: 10, }, } influencer.dialogue_lines = [ "This is public sidewalk, you can't stop me from filming.", ] profiles.append(influencer) # --- Higher-risk crowd ------------------------------------------------------ var intoxicated := NpcProfile.new() intoxicated.id = "intoxicated" intoxicated.display_name = "Intoxicated Pedestrian" intoxicated.npc_type = Rules.NpcType.INTOXICATED intoxicated.temperament = 0.6 intoxicated.base_compliance = 0.4 intoxicated.reaction_delay = 1.8 intoxicated.reactions_by_level = { Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.COMPLY: 30, Rules.Reaction.IGNORE: 50, Rules.Reaction.ARGUE: 20, }, Rules.Level.FORMAL_WARNING: { Rules.Reaction.COMPLY: 40, Rules.Reaction.ARGUE: 35, Rules.Reaction.RECORD: 10, Rules.Reaction.IGNORE: 15, }, } profiles.append(intoxicated) var trespasser := NpcProfile.new() trespasser.id = "trespasser" trespasser.display_name = "Trespasser" trespasser.npc_type = Rules.NpcType.TRESPASSER trespasser.temperament = 0.55 trespasser.base_compliance = 0.35 trespasser.return_probability = 0.6 trespasser.reactions_by_level = { Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.IGNORE: 45, Rules.Reaction.ARGUE: 25, Rules.Reaction.COMPLY: 30, }, Rules.Level.FORMAL_WARNING: { Rules.Reaction.ARGUE: 35, Rules.Reaction.RETURN_LATER: 25, Rules.Reaction.COMPLY: 40, }, Rules.Level.BLOCKING: { Rules.Reaction.LEAVE: 35, Rules.Reaction.ARGUE: 25, Rules.Reaction.RETURN_LATER: 15, Rules.Reaction.CALL_SOMEONE: 15, Rules.Reaction.FORCE_ENTRY: 10, }, Rules.Level.RADIO_ASSIST: { Rules.Reaction.LEAVE: 70, Rules.Reaction.FORCE_ENTRY: 30, }, Rules.Level.EMERGENCY: { Rules.Reaction.FORCE_ENTRY: 20, Rules.Reaction.THREATEN: 50, Rules.Reaction.LEAVE: 30, }, } trespasser.dialogue_lines = [ "I'm just here to see a friend, come on.", "I'll be inside for ten seconds.", ] profiles.append(trespasser) var mental_crisis := NpcProfile.new() mental_crisis.id = "mental_crisis" mental_crisis.display_name = "Person in Crisis" mental_crisis.npc_type = Rules.NpcType.MENTAL_CRISIS mental_crisis.temperament = 0.7 mental_crisis.base_compliance = 0.3 mental_crisis.return_probability = 0.4 mental_crisis.reaction_delay = 2.4 mental_crisis.reactions_by_level = { Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.IGNORE: 55, Rules.Reaction.COMPLY: 25, Rules.Reaction.ARGUE: 20, }, Rules.Level.FORMAL_WARNING: { Rules.Reaction.IGNORE: 30, Rules.Reaction.ARGUE: 35, Rules.Reaction.COMPLY: 25, Rules.Reaction.THREATEN: 10, }, Rules.Level.BLOCKING: { Rules.Reaction.THREATEN: 30, Rules.Reaction.ARGUE: 30, Rules.Reaction.COMPLY: 25, Rules.Reaction.LEAVE: 15, }, Rules.Level.RADIO_ASSIST: { Rules.Reaction.LEAVE: 40, Rules.Reaction.THREATEN: 35, Rules.Reaction.FORCE_ENTRY: 25, }, Rules.Level.EMERGENCY: { Rules.Reaction.FORCE_ENTRY: 15, Rules.Reaction.THREATEN: 60, Rules.Reaction.LEAVE: 25, }, } profiles.append(mental_crisis) var violent_threat := NpcProfile.new() violent_threat.id = "violent_threat" violent_threat.display_name = "Violent Threat" violent_threat.npc_type = Rules.NpcType.VIOLENT_THREAT violent_threat.temperament = 0.95 violent_threat.base_compliance = 0.05 violent_threat.reactions_by_level = { Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.THREATEN: 30, Rules.Reaction.IGNORE: 45, Rules.Reaction.ARGUE: 25, }, Rules.Level.FORMAL_WARNING: { Rules.Reaction.THREATEN: 45, Rules.Reaction.FORCE_ENTRY: 25, Rules.Reaction.ARGUE: 30, }, Rules.Level.BLOCKING: { Rules.Reaction.FORCE_ENTRY: 35, Rules.Reaction.THREATEN: 45, Rules.Reaction.LEAVE: 20, }, Rules.Level.RADIO_ASSIST: { Rules.Reaction.FORCE_ENTRY: 55, Rules.Reaction.THREATEN: 30, Rules.Reaction.LEAVE: 15, }, Rules.Level.EMERGENCY: { Rules.Reaction.FORCE_ENTRY: 30, Rules.Reaction.THREATEN: 40, Rules.Reaction.LEAVE: 30, }, } profiles.append(violent_threat) var protester := NpcProfile.new() protester.id = "protester" protester.display_name = "Protester" protester.npc_type = Rules.NpcType.PROTESTER protester.temperament = 0.5 protester.base_compliance = 0.4 protester.return_probability = 0.25 protester.reactions_by_level = { Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.ARGUE: 35, Rules.Reaction.IGNORE: 25, Rules.Reaction.RECORD: 15, Rules.Reaction.COMPLY: 25, }, Rules.Level.FORMAL_WARNING: { Rules.Reaction.ARGUE: 40, Rules.Reaction.RECORD: 25, Rules.Reaction.IGNORE: 15, Rules.Reaction.CALL_SOMEONE: 5, Rules.Reaction.COMPLY: 15, }, Rules.Level.BLOCKING: { Rules.Reaction.ARGUE: 25, Rules.Reaction.RECORD: 25, Rules.Reaction.CALL_SOMEONE: 15, Rules.Reaction.LEAVE: 25, Rules.Reaction.COMPLY: 10, }, Rules.Level.RADIO_ASSIST: { Rules.Reaction.LEAVE: 60, Rules.Reaction.ARGUE: 20, Rules.Reaction.FORCE_ENTRY: 20, }, } protester.dialogue_lines = [ "I have every right to be on public sidewalk.", "You can't touch me, this is a lawful protest.", ] profiles.append(protester) # --- Authorized crowd --------------------------------------------------------- var staff := NpcProfile.new() staff.id = "authorized_staff" staff.display_name = "Staff Member" staff.npc_type = Rules.NpcType.AUTHORIZED_STAFF staff.temperament = 0.15 staff.base_compliance = 1.0 staff.default_reactions = { Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.COMPLY: 100 } } profiles.append(staff) var guest := NpcProfile.new() guest.id = "authorized_guest" guest.display_name = "Authorized Guest" guest.npc_type = Rules.NpcType.AUTHORIZED_GUEST guest.temperament = 0.2 guest.base_compliance = 0.95 guest.default_reactions = { Rules.Level.SIMPLE_DETERRENCE: { Rules.Reaction.COMPLY: 92, Rules.Reaction.ARGUE: 8 }, Rules.Level.FORMAL_WARNING: { Rules.Reaction.COMPLY: 88, Rules.Reaction.ARGUE: 12 }, } profiles.append(guest) return profiles # --- Venue builders -------------------------------------------------------------- static func hollywood_building() -> VenueScenario: var scenario := VenueScenario.new() scenario.id = "hollywood_building" scenario.display_name = "Hollywood Boulevard Building" scenario.description = "Steps and entrance of a private building off Hollywood Boulevard." scenario.shift_duration_s = 900.0 var policy := AccessPolicy.new() policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true } policy.policy_summary = PLAN_ACCESS_SUMMARY_BUILDING scenario.access_policy = policy scenario.equipment = ["wristband_policy", "flashlight", "radio"] scenario.locations = { "guard_post": Vector3(0, 0, -3), "entrance": Vector3(0, 0, 1.0), "steps_end": Vector3(0, 0, 3), "sidewalk": Vector3(0, 0, 9), "queue_area": Vector3(0, 0, 6), } scenario.shift_phases = { Rules.Phase.EARLY: {"start_s": 0.0}, Rules.Phase.MIDDAY: {"start_s": 300.0}, Rules.Phase.EVENING: {"start_s": 600.0}, Rules.Phase.LATE: {"start_s": 700.0}, } scenario.npc_profiles = _filter_profiles([ "tourist", "confused_visitor", "delivery", "authorized_staff", "authorized_guest", "street_performer", "influencer", "intoxicated", "trespasser", "mental_crisis", "violent_threat", "protester", ]) scenario.event_pool = [ _building_routine("tourist_photo", Rules.NpcType.TOURIST, "photo", Vector2(0, 900), 0.42), _building_routine("protest_gather", Rules.NpcType.PROTESTER, "protest", Vector2(300, 900), 0.18), _building_routine("tourist_sit", Rules.NpcType.TOURIST, "sit", Vector2(0, 900), 0.26), _building_routine("confused_enter", Rules.NpcType.CONFUSED_VISITOR, "enter", Vector2(0, 900), 0.25), _building_routine("delivery_drop", Rules.NpcType.DELIVERY, "deliver", Vector2(0, 600), 0.30), _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), { "id": "crisis_event", "phase": Rules.Phase.EVENING, "kind": "scripted", "npc_type": Rules.NpcType.MENTAL_CRISIS, "intent": "crisis", "time_range_s": Vector2(640, 740), "probability": 0.5, "base_weight": 1.0, }, { "id": "violent_emergency_event", "phase": Rules.Phase.LATE, "kind": "scripted", "npc_type": Rules.NpcType.VIOLENT_THREAT, "intent": "threat", "time_range_s": Vector2(760, 880), "probability": 0.28, "base_weight": 1.0, "requires_emergency": true, }, { "id": "building_apology_return", "phase": Rules.Phase.EVENING, "kind": "recovery", "npc_type": Rules.NpcType.AUTHORIZED_GUEST, "intent": "enter", "time_range_s": Vector2(620, 800), "probability": 0.22, "base_weight": 1.0, }, ] return scenario static func joke_shop() -> VenueScenario: var scenario := VenueScenario.new() scenario.id = "joke_shop" scenario.display_name = "The Joke Shop" scenario.description = "Comedy club door: IDs, reservations, and wand screening." scenario.shift_duration_s = 900.0 var policy := AccessPolicy.new() policy.required_artifacts = { Rules.AccessKind.ID: true, Rules.AccessKind.TICKET: true, } policy.minimum_age = 21 policy.require_id = true policy.wand_screening_required = true policy.policy_summary = PLAN_ACCESS_SUMMARY_JOKE_SHOP scenario.access_policy = policy scenario.equipment = ["id_check", "guest_list", "wand", "radio"] scenario.locations = { "guard_post": Vector3(0, 0, -3), "entrance": Vector3(0, 0, 1.0), "door_area": Vector3(0, 0, 3), "steps_end": Vector3(0, 0, 3), "sidewalk": Vector3(0, 0, 9), "queue_area": Vector3(0, 0, 9), } scenario.door_staff_lines = { "id_check": [ "May I see your ID, please?", "Could you show me some identification?", ], "ticket_check": [ "Your ticket or reservation, please.", "Do you have a ticket for tonight's show?", ], "wand_check": [ "Please step forward for the wand check.", "Arms out, please — just a quick screening.", ], "refusal": [ "I'm sorry, I can't let you in without that.", "Without a valid ID, I can't admit you tonight.", ], "prohibited_item": [ "I can't allow that item inside. Please leave it outside.", "That item isn't permitted in the venue.", ], } scenario.shift_phases = { Rules.Phase.EARLY: {"start_s": 0.0}, Rules.Phase.MIDDAY: {"start_s": 300.0}, Rules.Phase.EVENING: {"start_s": 600.0}, Rules.Phase.LATE: {"start_s": 700.0}, } scenario.npc_profiles = _filter_profiles([ "tourist", "confused_visitor", "authorized_guest", "authorized_staff", "street_performer", "influencer", "intoxicated", "trespasser", "violent_threat", ]) scenario.event_pool = [ _joke_guest("guest_valid", true, true, Vector2(0, 900), 0.30), _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), { "id": "joke_disruptive", "phase": Rules.Phase.EVENING, "kind": "scripted", "npc_type": Rules.NpcType.INTOXICATED, "intent": "force_entry", "time_range_s": Vector2(650, 830), "probability": 0.5, "base_weight": 1.0, }, { "id": "joke_denied_guest_return", "phase": Rules.Phase.EVENING, "kind": "recovery", "npc_type": Rules.NpcType.AUTHORIZED_GUEST, "intent": "enter", "time_range_s": Vector2(650, 830), "probability": 0.2, "base_weight": 1.0, "override_access": {"has_id": true, "has_ticket": true}, }, ] return scenario static func tv_studio() -> VenueScenario: var scenario := VenueScenario.new() scenario.id = "tv_studio" scenario.display_name = "Hollywood Blvd Late Night TV Studio" scenario.description = "Crowd, celebrities, and staff around a late-night studio." scenario.shift_duration_s = 900.0 var policy := AccessPolicy.new() # OR-style admission: credential, wristband, guest list, or audience ticket. policy.required_artifacts = { Rules.AccessKind.CREDENTIAL: true, Rules.AccessKind.WRISTBAND: true, Rules.AccessKind.TICKET: true, Rules.AccessKind.GUEST_LIST: true, } policy.grant_if_any_required = true policy.policy_summary = PLAN_ACCESS_SUMMARY_TV_STUDIO scenario.access_policy = policy scenario.equipment = ["wristband_policy", "guest_list", "radio", "flashlight"] scenario.locations = { "guard_post": Vector3(0, 0, -3), "entrance": Vector3(0, 0, 1.0), "stage_door": Vector3(6, 0, -2), "steps_end": Vector3(0, 0, 3), "sidewalk": Vector3(0, 0, 9), "queue_area": Vector3(0, 0, 9), } scenario.shift_phases = { Rules.Phase.EARLY: {"start_s": 0.0}, Rules.Phase.MIDDAY: {"start_s": 300.0}, Rules.Phase.EVENING: {"start_s": 600.0}, Rules.Phase.LATE: {"start_s": 700.0}, } scenario.npc_profiles = _filter_profiles([ "tourist", "confused_visitor", "delivery", "authorized_staff", "authorized_guest", "street_performer", "influencer", "intoxicated", "trespasser", "mental_crisis", "violent_threat", "protester", ]) scenario.event_pool = [ _studio_guest("audience_ticket", {"has_ticket": true}, Vector2(0, 900), 0.35), _studio_routine("protest_outside", Rules.NpcType.PROTESTER, "protest", Vector2(300, 900), 0.18), _studio_guest("celebrity_arrival", {"has_guest_list": true}, Vector2(300, 700), 0.20, "arrive"), _studio_routine("crew_shift", Rules.NpcType.AUTHORIZED_STAFF, "enter", Vector2(0, 700), 0.30), _studio_routine("tourist_photo", Rules.NpcType.TOURIST, "photo", Vector2(0, 900), 0.35), _studio_routine("confused_enter", Rules.NpcType.CONFUSED_VISITOR, "enter", Vector2(0, 900), 0.25), _studio_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(0, 900), 0.28), _studio_routine("trespass_blend", Rules.NpcType.TRESPASSER, "blend_in", Vector2(300, 900), 0.15), { "id": "studio_counterfeit", "phase": Rules.Phase.EVENING, "kind": "scripted", "npc_type": Rules.NpcType.TRESPASSER, "intent": "counterfeit_credential", "time_range_s": Vector2(620, 780), "probability": 0.55, "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, "kind": "scripted", "npc_type": Rules.NpcType.TRESPASSER, "intent": "follow_authorized", "override_access": {"has_ticket": false, "has_wristband": false, "has_credential": false, "has_guest_list": false}, "time_range_s": Vector2(700, 860), "probability": 0.45, "base_weight": 1.0, }, { "id": "studio_manager_check", "phase": Rules.Phase.EVENING, "kind": "recovery", "npc_type": Rules.NpcType.AUTHORIZED_STAFF, "intent": "enter", "time_range_s": Vector2(620, 820), "probability": 0.2, "base_weight": 1.0, }, ] return scenario # --- Profile helpers ------------------------------------------------------------- static func _filter_profiles(ids: Array) -> Array[NpcProfile]: var all := shared_profiles() var out: Array[NpcProfile] = [] for profile in all: if ids.has(profile.id): out.append(profile) return out static func profile_dictionary() -> Dictionary: ## id -> profile for tests and UI lookups. var out := {} for p in shared_profiles(): out[p.id] = p return out # --- Event builders ---------------------------------------------------------------- static func _building_routine(id: String, npc_type: int, intent: String, time_range: Vector2, prob: float) -> Dictionary: return { "id": id, "phase": _phase_for_range(time_range, 900.0), "kind": "routine", "npc_type": npc_type, "intent": intent, "time_range_s": time_range, "probability": prob, "base_weight": 1.0, } static func _joke_guest(id: String, has_ticket: bool, has_id: bool, time_range: Vector2, prob: float, extra: Dictionary = {}) -> Dictionary: var override := {"has_ticket": has_ticket, "has_id": has_id} for key in extra.keys(): override[key] = extra[key] return { "id": id, "phase": _phase_for_range(time_range, 900.0), "kind": "routine", "npc_type": Rules.NpcType.AUTHORIZED_GUEST, "intent": "enter", "time_range_s": time_range, "probability": prob, "base_weight": 1.0, "override_access": override, } static func _studio_guest(id: String, override_access: Dictionary, time_range: Vector2, prob: float, intent: String = "enter") -> Dictionary: return { "id": id, "phase": _phase_for_range(time_range, 900.0), "kind": "routine", "npc_type": Rules.NpcType.AUTHORIZED_GUEST, "intent": intent, "time_range_s": time_range, "probability": prob, "base_weight": 1.0, "override_access": override_access, } static func _joke_staff_entry(time_range: Vector2, prob: float) -> Dictionary: return { "id": "joke_staff", "phase": _phase_for_range(time_range, 900.0), "kind": "routine", "npc_type": Rules.NpcType.AUTHORIZED_STAFF, "intent": "enter", "time_range_s": time_range, "probability": prob, "base_weight": 1.0, } static func _joke_routine(id: String, npc_type: int, intent: String, time_range: Vector2, prob: float) -> Dictionary: return _building_routine(id, npc_type, intent, time_range, prob) 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: return Rules.Phase.EARLY if start < 0.67: return Rules.Phase.MIDDAY return Rules.Phase.EVENING