Implement prototype: venue-driven simulation core, scenarios, tests, and playable entrance scene
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:
zwitschi committed 2026-10-31 11:37:54 +01:00
1 parent e23f284ad4
commit 439e0a4281
45 files changed
+4368 -7

No files matched your search

+509
View File
@@ -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