Files
security-game/src/content/scenario_factory.gd
T
zwitschi aa51ee331e
Full build / Import + headless tests (push) Successful in 41s
Full build / Full desktop build + optional release (push) Successful in 4m18s
Ship the 0.8.0 first-hour and campaign milestone
Add the campaign shell, pause-safe checkpoint resume, post-shift campaign
continue, confirmed campaign reset, difficulty presets, searchable Help,
content-matrix validation, recovery events, contextual dialogue, policy/debrief
context and eligible-event previews. Extend persistence with atomic separate
files and add campaign/checkpoint/tutorial smokes. Headless 247/247, Python
94/94, tutorial/campaign/session/scenario smokes green.
2026-11-01 21:21:43 +01:00

763 lines
25 KiB
GDScript

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