Implement prototype: venue-driven simulation core, scenarios, tests, and playable entrance scene
CI / headless-tests (push) Canceled after 49s
CI / headless-tests (push) Canceled after 49s
- Godot 4.3 project scaffold with first-person guard, procedural world, interaction HUD, access inspection, radio forms, debug overlay, shift review - Simulation core: ShiftCoordinator, VenueScenario, EventScheduler, NPCController/FSM, AccessVerifier, EscalationManager, RadioSystem, IncidentRecorder, ReputationEvaluator, CrowdSystem, EventBus - Three authored scenarios (Hollywood Blvd building, The Joke Shop, TV studio) - Custom headless test runner (25 tests) replacing editor-dependent GUT, plus GitHub Actions CI - Documentation workflow live: README, PLAN (tech stack), TODO/DONE, MEMORY
This commit is contained in:
1 parent
e23f284ad4
commit
439e0a4281
45 files changed
+4368
-7
No files matched your search
@@ -0,0 +1,509 @@
|
||||
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.dialogue_lines = {
|
||||
Rules.Tone.FRIENDLY: [
|
||||
"Oh sure, sorry, just taking a picture.",
|
||||
"Is there a better spot to stand?",
|
||||
],
|
||||
Rules.Tone.PROFESSIONAL: ["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.dialogue_lines = {
|
||||
Rules.Tone.FRIENDLY: [
|
||||
"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 = {
|
||||
Rules.Tone.PROFESSIONAL: ["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: 45,
|
||||
Rules.Reaction.ARGUE: 25,
|
||||
Rules.Reaction.COMPLY: 30,
|
||||
},
|
||||
}
|
||||
influencer.dialogue_lines = {
|
||||
Rules.Tone.SUSPICIOUS: [
|
||||
"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.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: 40,
|
||||
Rules.Reaction.ARGUE: 30,
|
||||
Rules.Reaction.RETURN_LATER: 20,
|
||||
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 = {
|
||||
Rules.Tone.SUSPICIOUS: [
|
||||
"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.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)
|
||||
|
||||
# --- 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, 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},
|
||||
}
|
||||
|
||||
scenario.npc_profiles = _filter_profiles([
|
||||
"tourist", "confused_visitor", "delivery", "authorized_staff", "authorized_guest",
|
||||
"street_performer", "influencer", "intoxicated", "trespasser", "mental_crisis", "violent_threat",
|
||||
])
|
||||
|
||||
scenario.event_pool = [
|
||||
_building_routine("tourist_photo", Rules.NpcType.TOURIST, "photo", Vector2(0, 900), 0.35),
|
||||
_building_routine("tourist_sit", Rules.NpcType.TOURIST, "sit", Vector2(0, 900), 0.20),
|
||||
_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.18),
|
||||
_building_routine("influencer_film", Rules.NpcType.INFLUENCER, "film", Vector2(300, 900), 0.20),
|
||||
_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.12),
|
||||
{
|
||||
"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,
|
||||
},
|
||||
]
|
||||
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, 0),
|
||||
"door_area": Vector3(0, 0, 3),
|
||||
"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},
|
||||
}
|
||||
|
||||
scenario.npc_profiles = _filter_profiles([
|
||||
"tourist", "confused_visitor", "authorized_guest", "authorized_staff",
|
||||
"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),
|
||||
_joke_guest("guest_no_id", true, false, Vector2(0, 900), 0.12),
|
||||
_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,
|
||||
},
|
||||
]
|
||||
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, 0),
|
||||
"stage_door": Vector3(6, 0, -2),
|
||||
"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},
|
||||
}
|
||||
|
||||
scenario.npc_profiles = _filter_profiles([
|
||||
"tourist", "confused_visitor", "delivery", "authorized_staff", "authorized_guest",
|
||||
"street_performer", "influencer", "intoxicated", "trespasser", "mental_crisis", "violent_threat",
|
||||
])
|
||||
|
||||
scenario.event_pool = [
|
||||
_studio_routine("audience_ticket", Rules.NpcType.AUTHORIZED_GUEST, "enter", Vector2(0, 900), 0.35),
|
||||
_studio_routine("celebrity_arrival", Rules.NpcType.AUTHORIZED_GUEST, "arrive", Vector2(300, 700), 0.20),
|
||||
_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,
|
||||
},
|
||||
{
|
||||
"id": "studio_follow_through",
|
||||
"phase": Rules.Phase.EVENING,
|
||||
"kind": "scripted",
|
||||
"npc_type": Rules.NpcType.TRESPASSER,
|
||||
"intent": "follow_authorized",
|
||||
"time_range_s": Vector2(700, 860),
|
||||
"probability": 0.45,
|
||||
"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) -> Dictionary:
|
||||
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": {"has_ticket": has_ticket, "has_id": has_id},
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
Reference in new issue
Block a user