- Route the Reason verb through authored reason contexts and give every shared archetype an in-character answer. - Add Hollywood wristband and TV Studio ticket/credential door-staff lines. - Clarify radio cancellation, Ask terminology, start hints and campaign-reset copy. - Record the dialogue audit and length findings in docs/ui-copy.md; add regression tests.
817 lines
27 KiB
GDScript
817 lines
27 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 = {
|
|
"reason": ["Just getting a photo of the entrance.", "I'm meeting friends here."],
|
|
"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 = {
|
|
"reason": ["I thought this was the box office.", "I'm looking for the event entrance."],
|
|
"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.context_lines = {
|
|
"reason": ["Drop-off for the venue. Where should I leave it?"],
|
|
}
|
|
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.context_lines = {
|
|
"reason": ["Just doing my act; I'll keep the sidewalk clear."],
|
|
}
|
|
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.context_lines = {
|
|
"reason": ["Filming a quick clip for my channel."],
|
|
}
|
|
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.context_lines = {
|
|
"reason": ["Looking for my friends. I think they went inside."],
|
|
}
|
|
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.context_lines = {
|
|
"reason": ["Meeting a friend inside. I'll only be a minute.", "Just cutting through."],
|
|
}
|
|
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.context_lines = {
|
|
"reason": ["I just need a quiet minute.", "I'm waiting for someone."],
|
|
}
|
|
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.context_lines = {
|
|
"reason": ["Back off. This doesn't concern you."],
|
|
}
|
|
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.context_lines = {
|
|
"reason": ["We're demonstrating on the public sidewalk.", "This is a peaceful protest."],
|
|
}
|
|
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.context_lines = {
|
|
"reason": ["I'm on staff. I'm checking the entrance.", "Crew check-in; I'm working tonight."],
|
|
}
|
|
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.context_lines = {
|
|
"reason": ["I'm on the guest list. Here's my ID.", "A friend invited me; I have a ticket."],
|
|
}
|
|
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 = "El Capitan Entertainment Centre"
|
|
scenario.description = "The historic 1921 Masonic Temple at 6840 Hollywood Boulevard, now the El Capitan Entertainment Centre."
|
|
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.door_staff_lines = {
|
|
"wristband_check": [
|
|
"Wristband, please.",
|
|
"I need to see your event wristband.",
|
|
],
|
|
}
|
|
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),
|
|
_late_routine("late_tourist", Rules.NpcType.TOURIST, "photo", 0.22),
|
|
_late_routine("late_guest", Rules.NpcType.AUTHORIZED_GUEST, "enter", 0.20),
|
|
_late_routine("late_trespasser", Rules.NpcType.TRESPASSER, "trespass", 0.14),
|
|
{
|
|
"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.door_staff_lines = {
|
|
"ticket_check": [
|
|
"Ticket, credential, or guest-list confirmation, please.",
|
|
"I need your ticket or guest-list confirmation.",
|
|
],
|
|
}
|
|
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 ----------------------------------------------------------------
|
|
|
|
## Late-phase routine traffic. The range-based helper would classify a 700 s
|
|
## start as evening, so the phase is set explicitly.
|
|
static func _late_routine(id: String, npc_type: int, intent: String, prob: float) -> Dictionary:
|
|
var event := _building_routine(id, npc_type, intent, Vector2(700, 900), prob)
|
|
event["phase"] = Rules.Phase.LATE
|
|
return event
|
|
|
|
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
|