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,39 @@
|
||||
extends Node
|
||||
## Autoload for local persistence (JSON in user://). Wrapped in an autoload so UI
|
||||
## nodes never touch the filesystem directly.
|
||||
|
||||
const SHIFT_RESULTS_FILE := "shift_results.json"
|
||||
const SETTINGS_FILE := "settings.json"
|
||||
|
||||
func _load_json(file_name: String) -> Variant:
|
||||
var path := "user://" + file_name
|
||||
if not FileAccess.file_exists(path):
|
||||
return null
|
||||
var text := FileAccess.get_file_as_string(path)
|
||||
if text.is_empty():
|
||||
return null
|
||||
var parsed = JSON.parse_string(text)
|
||||
return parsed
|
||||
|
||||
func _save_json(file_name: String, data: Variant) -> Error:
|
||||
var path := "user://" + file_name
|
||||
var text := JSON.stringify(data, "\t")
|
||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||
if f == null:
|
||||
return FileAccess.get_open_error()
|
||||
f.store_string(text)
|
||||
f.close()
|
||||
return OK
|
||||
|
||||
## Load previous shift results. Returns Array of Dictionaries or [].
|
||||
func load_shift_results() -> Array:
|
||||
var data = _load_json(SHIFT_RESULTS_FILE)
|
||||
if data is Array:
|
||||
return data
|
||||
return []
|
||||
|
||||
## Append one shift result and save. Returns Error.
|
||||
func save_shift_result(result: Dictionary) -> Error:
|
||||
var results := load_shift_results()
|
||||
results.append(result)
|
||||
return _save_json(SHIFT_RESULTS_FILE, results)
|
||||
@@ -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
|
||||
@@ -0,0 +1,22 @@
|
||||
class_name AccessArtifact
|
||||
extends Resource
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
## An item or piece of evidence used to verify entry (PLAN.md: Authorized Access).
|
||||
|
||||
@export var kind: int = Rules.AccessKind.NONE
|
||||
@export var status: int = Rules.ArtifactStatus.MISSING
|
||||
|
||||
## Human-readable attributes the player can inspect, e.g. color, pattern, date,
|
||||
## event marking, DOB, name, ticket section. Populated by scenario content.
|
||||
@export var attributes: Dictionary = {}
|
||||
|
||||
func _init(p_kind: int = Rules.AccessKind.NONE, p_status: int = Rules.ArtifactStatus.MISSING, p_attributes: Dictionary = {}) -> void:
|
||||
kind = p_kind
|
||||
status = p_status
|
||||
attributes = p_attributes
|
||||
|
||||
func summary() -> String:
|
||||
var kind_name: String = Rules.ACCESS_KIND_NAMES.get(kind, "None")
|
||||
var status_name: String = Rules.ARTIFACT_STATUS_NAMES.get(status, "Unknown")
|
||||
return "%s (%s)" % [kind_name, status_name]
|
||||
@@ -0,0 +1,32 @@
|
||||
class_name AccessPolicy
|
||||
extends Resource
|
||||
## Venue admission rules (PLAN.md: access rules per venue).
|
||||
|
||||
## Minimum required artifacts. For the Hollywood Blvd building this may be
|
||||
## { WRISTBAND: true }. The Joke Shop may require { ID: true, TICKET: true }.
|
||||
@export var required_artifacts: Dictionary = {}
|
||||
|
||||
## OR-admission: when true, entry is granted as soon as ANY required artifact
|
||||
## kind is valid (e.g. TV studio). When false, every kind must be satisfied.
|
||||
@export var grant_if_any_required: bool = false
|
||||
|
||||
## Artifact kinds accepted as OR-equivalent: e.g. { GUEST_LIST: true } when a
|
||||
## reservation list can replace a ticket. If true, the verifier grants when any
|
||||
## listed kind is valid.
|
||||
@export_range(1, 8) var minimum_age: int = 0
|
||||
|
||||
## Wether identification is required or only checked when present.
|
||||
@export var require_id: bool = false
|
||||
|
||||
@export var wand_screening_required: bool = false
|
||||
|
||||
## Displayed rules summary used by dialogue/UI.
|
||||
@export var policy_summary: String = ""
|
||||
|
||||
func requires(kind: int) -> bool:
|
||||
return required_artifacts.get(kind, false)
|
||||
|
||||
func description() -> String:
|
||||
if not policy_summary.is_empty():
|
||||
return policy_summary
|
||||
return str(required_artifacts)
|
||||
@@ -0,0 +1,123 @@
|
||||
class_name AccessVerifier
|
||||
extends RefCounted
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const AccessPolicy := preload("res://src/core/access_policy.gd")
|
||||
const AccessArtifact := preload("res://src/core/access_artifact.gd")
|
||||
## Evaluates access artifacts against a venue AccessPolicy.
|
||||
## Pure logic: no UI, no Node dependency, fully headless-testable.
|
||||
|
||||
## Result dictionary contract:
|
||||
## { granted: bool, reasons: Array[String], issues: Array[String],
|
||||
## decisive_artifacts: Array[String], checked_artifacts: Array[String] }
|
||||
|
||||
func _init() -> void:
|
||||
pass
|
||||
|
||||
## Verify a single person's set of artifacts (person_artifacts: Array[AccessArtifact]).
|
||||
func verify(person_artifacts: Array, policy: AccessPolicy) -> Dictionary:
|
||||
var reasons: Array[String] = []
|
||||
var issues: Array[String] = []
|
||||
var checked: Array[String] = []
|
||||
|
||||
for artifact in person_artifacts:
|
||||
if not (artifact is AccessArtifact):
|
||||
continue
|
||||
checked.append(artifact.summary())
|
||||
if artifact.kind == Rules.AccessKind.RECOGNITION and artifact.status == Rules.ArtifactStatus.VALID:
|
||||
reasons.append("Recognized by guard")
|
||||
elif artifact.status == Rules.ArtifactStatus.VALID:
|
||||
reasons.append("Valid %s" % Rules.ACCESS_KIND_NAMES.get(artifact.kind, "artifact"))
|
||||
elif artifact.status == Rules.ArtifactStatus.EXPIRED:
|
||||
issues.append("Expired %s" % Rules.ACCESS_KIND_NAMES.get(artifact.kind, "artifact"))
|
||||
elif artifact.status == Rules.ArtifactStatus.COUNTERFEIT:
|
||||
issues.append("Suspected counterfeit %s" % Rules.ACCESS_KIND_NAMES.get(artifact.kind, "artifact"))
|
||||
elif artifact.status == Rules.ArtifactStatus.MISMATCHED:
|
||||
issues.append("%s does not match person" % Rules.ACCESS_KIND_NAMES.get(artifact.kind, "artifact"))
|
||||
elif artifact.status == Rules.ArtifactStatus.AMBIGUOUS:
|
||||
issues.append("Ambiguous %s" % Rules.ACCESS_KIND_NAMES.get(artifact.kind, "artifact"))
|
||||
elif artifact.status == Rules.ArtifactStatus.MISSING:
|
||||
issues.append("Missing %s" % Rules.ACCESS_KIND_NAMES.get(artifact.kind, "artifact"))
|
||||
|
||||
# Age check.
|
||||
if policy.minimum_age > 0 and require_age_info(person_artifacts, policy):
|
||||
var age := person_age(person_artifacts)
|
||||
if age < policy.minimum_age:
|
||||
issues.append("Underage (%d < %d)" % [age, policy.minimum_age])
|
||||
return _result(false, reasons, issues, checked)
|
||||
|
||||
# Wand screening is processed by the interaction layer (false alarms), but a
|
||||
# policy can hard-require it; a refused screen blocks entry.
|
||||
if policy.wand_screening_required and not person_screened(person_artifacts):
|
||||
issues.append("Screening required")
|
||||
return _result(false, reasons, issues, checked)
|
||||
|
||||
# Recognition short-circuits if present and valid.
|
||||
var recognized := false
|
||||
for artifact in person_artifacts:
|
||||
if artifact is AccessArtifact:
|
||||
if artifact.kind == Rules.AccessKind.RECOGNITION and artifact.status == Rules.ArtifactStatus.VALID:
|
||||
recognized = true
|
||||
if recognized:
|
||||
return _result(true, reasons, issues, checked)
|
||||
|
||||
# Every required artifact kind must have a valid match (AND admission),
|
||||
# unless the policy grants on any single valid artifact (OR admission).
|
||||
var any_satisfied := false
|
||||
for kind in policy.required_artifacts.keys():
|
||||
var satisfied := false
|
||||
for artifact in person_artifacts:
|
||||
if artifact is AccessArtifact and artifact.kind == kind:
|
||||
if artifact.status == Rules.ArtifactStatus.VALID:
|
||||
satisfied = true
|
||||
any_satisfied = any_satisfied or satisfied
|
||||
if not satisfied:
|
||||
issues.append("Missing valid %s" % Rules.ACCESS_KIND_NAMES.get(kind, "credential"))
|
||||
if not policy.grant_if_any_required:
|
||||
return _result(false, reasons, issues, checked)
|
||||
if policy.grant_if_any_required and not any_satisfied:
|
||||
return _result(false, reasons, issues, checked)
|
||||
|
||||
if policy.require_id:
|
||||
var id_ok := false
|
||||
for artifact in person_artifacts:
|
||||
if artifact is AccessArtifact and artifact.kind == Rules.AccessKind.ID and artifact.status == Rules.ArtifactStatus.VALID:
|
||||
id_ok = true
|
||||
if not id_ok:
|
||||
issues.append("Valid ID required")
|
||||
return _result(false, reasons, issues, checked)
|
||||
|
||||
return _result(true, reasons, issues, checked)
|
||||
|
||||
func _result(granted: bool, reasons: Array, issues: Array, checked: Array) -> Dictionary:
|
||||
return {
|
||||
"granted": granted,
|
||||
"reasons": reasons,
|
||||
"issues": issues,
|
||||
"decisive_artifacts": reasons.duplicate(),
|
||||
"checked_artifacts": checked,
|
||||
}
|
||||
|
||||
func require_age_info(person_artifacts: Array, policy: AccessPolicy) -> bool:
|
||||
if policy.minimum_age <= 0:
|
||||
return false
|
||||
var id_present := false
|
||||
for artifact in person_artifacts:
|
||||
if artifact is AccessArtifact and artifact.kind == Rules.AccessKind.ID:
|
||||
id_present = true
|
||||
return id_present
|
||||
|
||||
func person_age(person_artifacts: Array) -> int:
|
||||
for artifact in person_artifacts:
|
||||
if artifact is AccessArtifact and artifact.kind == Rules.AccessKind.ID:
|
||||
var age: Variant = artifact.attributes.get("age", -1)
|
||||
if age is int and age > 0:
|
||||
return age
|
||||
return -1
|
||||
|
||||
func person_screened(person_artifacts: Array) -> bool:
|
||||
for artifact in person_artifacts:
|
||||
if artifact is AccessArtifact:
|
||||
if artifact.kind == Rules.AccessKind.CREDENTIAL and artifact.attributes.get("screened", false) == true:
|
||||
return true
|
||||
return false
|
||||
@@ -0,0 +1,53 @@
|
||||
class_name CrowdSystem
|
||||
extends RefCounted
|
||||
## Models crowd mood and public reaction (PLAN.md: Public Crowd Reaction).
|
||||
|
||||
const EventBus := preload("res://src/core/event_bus.gd")
|
||||
## mood: 0.0 (calm) .. 1.0 (panic). attention: how many people are watching.
|
||||
|
||||
signal changed(payload: Dictionary)
|
||||
|
||||
var event_bus: EventBus = null
|
||||
var mood: float = 0.05
|
||||
var attention: float = 0.0
|
||||
var crowd_panic := false
|
||||
|
||||
func set_event_bus(bus: EventBus) -> void:
|
||||
event_bus = bus
|
||||
|
||||
## Apply a crowd event. type: "calm_professionalism", "aggressive_behavior",
|
||||
## "visible_weapon", "emergency_responder", "de_escalation".
|
||||
func apply_event(event_type: String) -> void:
|
||||
match event_type:
|
||||
"calm_professionalism":
|
||||
attention = maxf(0.0, attention - 0.05)
|
||||
mood = maxf(0.0, mood - 0.02)
|
||||
"de_escalation":
|
||||
mood = maxf(0.0, mood - 0.06)
|
||||
attention = clampf(attention + 0.05, 0.0, 1.0)
|
||||
"aggressive_behavior":
|
||||
attention = clampf(attention + 0.20, 0.0, 1.0)
|
||||
mood = clampf(mood + 0.12, 0.0, 1.0)
|
||||
"visible_weapon":
|
||||
attention = 1.0
|
||||
mood = clampf(mood + 0.35, 0.0, 1.0)
|
||||
"emergency_responder":
|
||||
attention = 1.0
|
||||
mood = clampf(mood + 0.25, 0.0, 1.0)
|
||||
_:
|
||||
pass
|
||||
crowd_panic = mood >= 0.75
|
||||
if event_bus:
|
||||
event_bus.crowd_changed.emit({
|
||||
"mood": mood,
|
||||
"attention": attention,
|
||||
"panic": crowd_panic,
|
||||
"event": event_type,
|
||||
})
|
||||
|
||||
func summary() -> Dictionary:
|
||||
return {
|
||||
"mood": mood,
|
||||
"attention": attention,
|
||||
"panic": crowd_panic,
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
class_name EscalationManager
|
||||
extends RefCounted
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
## Tracks the current player escalation level for an interaction and which
|
||||
## actions are allowed. Rules derive from VenueScenario + NpcProfile.
|
||||
|
||||
var current_level: int = Rules.Level.OBSERVATION
|
||||
var max_allowed_level: int = Rules.Level.EMERGENCY
|
||||
var _history: Array[Dictionary] = []
|
||||
|
||||
## Reasons must be short rule strings, e.g. "refused_polite_direction".
|
||||
func _init(p_max_level: int = Rules.Level.EMERGENCY) -> void:
|
||||
max_allowed_level = p_max_level
|
||||
|
||||
func can_act(level: int) -> bool:
|
||||
return level >= 0 and level <= max_allowed_level
|
||||
|
||||
## Move to a target level if it is allowed. Actions can jump directly
|
||||
## (e.g. skipping straight to a warning) but never above max_allowed_level.
|
||||
func advance_to(level: int, reason: String) -> bool:
|
||||
if not can_act(level):
|
||||
return false
|
||||
if level < current_level:
|
||||
# De-escalation is allowed (level can drop if the NPC complies).
|
||||
current_level = level
|
||||
else:
|
||||
current_level = level
|
||||
_history.append({
|
||||
"level": level,
|
||||
"reason": reason,
|
||||
"at": Time.get_ticks_msec(),
|
||||
})
|
||||
return true
|
||||
|
||||
func history() -> Array[Dictionary]:
|
||||
return _history
|
||||
@@ -0,0 +1,17 @@
|
||||
class_name EventBus
|
||||
extends RefCounted
|
||||
## Instance-based event bus (ADR-0005 spirit). Owned by ShiftCoordinator and
|
||||
## passed to subsystems that need to notify observers. Subsystems emit only if
|
||||
## a bus is assigned, so headless tests never depend on global state.
|
||||
|
||||
signal incident_recorded(incident: Dictionary)
|
||||
signal player_action(action: Dictionary)
|
||||
signal escalation_changed(payload: Dictionary)
|
||||
signal radio_call(call: Dictionary)
|
||||
signal responder_arrived(responder: Dictionary)
|
||||
signal npc_state_changed(payload: Dictionary)
|
||||
signal shift_state_changed(payload: Dictionary)
|
||||
signal reputation_changed(payload: Dictionary)
|
||||
signal crowd_changed(payload: Dictionary)
|
||||
signal access_decided(payload: Dictionary)
|
||||
signal shift_review_ready(review: Dictionary)
|
||||
@@ -0,0 +1,68 @@
|
||||
class_name EventScheduler
|
||||
extends RefCounted
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const VenueScenario := preload("res://src/core/venue_scenario.gd")
|
||||
## Schedules routine traffic and scripted events for a shift according to the
|
||||
## scenario's event pool (ADR-0001, Shift Structure).
|
||||
|
||||
signal event_ready(event_data: Dictionary)
|
||||
|
||||
var rng := RandomNumberGenerator.new()
|
||||
var _scenario: VenueScenario = null
|
||||
var _weighted_pool: Array[Dictionary] = []
|
||||
|
||||
func _init() -> void:
|
||||
pass
|
||||
|
||||
func setup(scenario: VenueScenario, seed_value: int = 0) -> void:
|
||||
_scenario = scenario
|
||||
if seed_value != 0:
|
||||
rng.seed = seed_value
|
||||
_weighted_pool = []
|
||||
for event in scenario.event_pool:
|
||||
if event.get("enabled", true):
|
||||
_weighted_pool.append(event)
|
||||
|
||||
## Pull events due for the current elapsed time and phase.
|
||||
## Returns Array[Dictionary] of event data to spawn.
|
||||
func pull_due(elapsed_s: float, phase: int) -> Array[Dictionary]:
|
||||
var due: Array[Dictionary] = []
|
||||
if _scenario == null:
|
||||
return due
|
||||
for event in _weighted_pool:
|
||||
var time_range: Vector2 = event.get("time_range_s", Vector2(0, _scenario.shift_duration_s))
|
||||
if elapsed_s < time_range.x or elapsed_s > time_range.y:
|
||||
continue
|
||||
if event.get("phase", -1) != phase:
|
||||
continue
|
||||
var prob: float = event.get("probability", 0.0)
|
||||
var base: float = event.get("base_weight", 1.0)
|
||||
var spawn_rate: float = event.get("spawn_rate", 0.0)
|
||||
if spawn_rate > 0.0:
|
||||
# Rate-based spawning: emit at most one per second window.
|
||||
if rng.randf() < spawn_rate:
|
||||
if rng.randf() < prob:
|
||||
due.append(event.duplicate())
|
||||
else:
|
||||
if rng.randf() < prob * base:
|
||||
due.append(event.duplicate())
|
||||
return due
|
||||
|
||||
func fallback_routine_traffic(phase: int) -> Array[Dictionary]:
|
||||
## Cheap routine traffic so the street never feels empty.
|
||||
var out: Array[Dictionary] = []
|
||||
var pool := _routine_pool(phase)
|
||||
if pool.is_empty():
|
||||
return out
|
||||
for i in 3:
|
||||
if rng.randf() < 0.35:
|
||||
out.append(pool[rng.randi_range(0, pool.size() - 1)].duplicate())
|
||||
return out
|
||||
|
||||
func _routine_pool(phase: int) -> Array:
|
||||
var out: Array = []
|
||||
for event in _weighted_pool:
|
||||
if event.get("kind", "routine") == "routine" and event.get("phase", -1) == phase:
|
||||
out.append(event)
|
||||
return out
|
||||
@@ -0,0 +1,47 @@
|
||||
class_name FSM
|
||||
extends RefCounted
|
||||
## Minimal generic finite state machine used by NPCController and ShiftCoordinator.
|
||||
## Deterministic and headless-testable.
|
||||
|
||||
signal state_changed(node: Object, from_state: String, to_state: String)
|
||||
|
||||
var current_state: String = ""
|
||||
var _transitions: Dictionary = {} # state_name -> Array[{to, guard: Callable}]
|
||||
var _owner_node: Object = null
|
||||
|
||||
func _init(owner_node: Object = null) -> void:
|
||||
_owner_node = owner_node
|
||||
|
||||
## Register a transition. guard is an optional Callable(node) -> bool.
|
||||
func add_transition(from_state: String, to_state: String, guard: Callable = Callable()) -> void:
|
||||
if not _transitions.has(from_state):
|
||||
_transitions[from_state] = []
|
||||
_transitions[from_state].append({"to": to_state, "guard": guard})
|
||||
|
||||
func start(initial_state: String) -> void:
|
||||
current_state = initial_state
|
||||
state_changed.emit(_owner_node, "", initial_state)
|
||||
|
||||
## Attempt to move to `to_state`. Returns true when executed.
|
||||
func transition_to(to_state: String) -> bool:
|
||||
if current_state == to_state:
|
||||
return false
|
||||
var enabled: Array = _transitions.get(current_state, [])
|
||||
for t in enabled:
|
||||
if t["to"] != to_state:
|
||||
continue
|
||||
var guard: Callable = t["guard"]
|
||||
if not guard.is_valid() or guard.call(_owner_node):
|
||||
var from := current_state
|
||||
current_state = to_state
|
||||
state_changed.emit(_owner_node, from, to_state)
|
||||
return true
|
||||
return false
|
||||
|
||||
func can_transition_to(to_state: String) -> bool:
|
||||
for t in _transitions.get(current_state, []):
|
||||
if t["to"] == to_state:
|
||||
var guard: Callable = t["guard"]
|
||||
if not guard.is_valid() or guard.call(_owner_node):
|
||||
return true
|
||||
return false
|
||||
@@ -0,0 +1,85 @@
|
||||
class_name IncidentRecorder
|
||||
extends RefCounted
|
||||
|
||||
const EventBus := preload("res://src/core/event_bus.gd")
|
||||
|
||||
var event_bus: EventBus = null
|
||||
|
||||
func set_event_bus(bus: EventBus) -> void:
|
||||
event_bus = bus
|
||||
## Structured incident history (ADR-0005). Records observations, player actions,
|
||||
## access evidence, escalation levels, responses, and outcomes for evidence,
|
||||
## reputation, crowd reactions, and shift review.
|
||||
|
||||
var incidents: Array[Dictionary] = []
|
||||
var _open_incidents: Dictionary = {} # npc_id -> incident dict
|
||||
|
||||
## Begin an incident for an NPC. Returns incident_id.
|
||||
func start_incident(npc_id: String, trigger: String) -> String:
|
||||
var incident := {
|
||||
"id": "inc_%s_%d" % [npc_id, Time.get_ticks_usec()],
|
||||
"npc_id": npc_id,
|
||||
"trigger": trigger,
|
||||
"opened_at_ms": Time.get_ticks_msec(),
|
||||
"events": [],
|
||||
"access_evidence": [],
|
||||
"escalation_history": [],
|
||||
"outcome": null,
|
||||
}
|
||||
_open_incidents[npc_id] = incident
|
||||
return incident["id"]
|
||||
|
||||
func add_event(npc_id: String, event: Dictionary) -> void:
|
||||
if not _open_incidents.has(npc_id):
|
||||
return
|
||||
var incident: Dictionary = _open_incidents[npc_id]
|
||||
event["at_ms"] = Time.get_ticks_msec()
|
||||
incident["events"].append(event)
|
||||
if event_bus:
|
||||
event_bus.incident_recorded.emit(incident.duplicate(true))
|
||||
|
||||
func add_access_evidence(npc_id: String, evidence: Dictionary) -> void:
|
||||
if not _open_incidents.has(npc_id):
|
||||
return
|
||||
var incident: Dictionary = _open_incidents[npc_id]
|
||||
incident["access_evidence"].append(evidence)
|
||||
if event_bus:
|
||||
event_bus.access_decided.emit(evidence.duplicate(true))
|
||||
incident["events"].append({
|
||||
"type": "access_check",
|
||||
"at_ms": Time.get_ticks_msec(),
|
||||
"evidence": evidence,
|
||||
})
|
||||
|
||||
func add_escalation(npc_id: String, level: int, reason: String) -> void:
|
||||
if not _open_incidents.has(npc_id):
|
||||
return
|
||||
var incident: Dictionary = _open_incidents[npc_id]
|
||||
incident["escalation_history"].append({
|
||||
"level": level,
|
||||
"reason": reason,
|
||||
"at_ms": Time.get_ticks_msec(),
|
||||
})
|
||||
|
||||
## Close an incident. outcome: e.g. "peaceful", "escort_off_property",
|
||||
## "assisted", "responded", "custody". Returns the closed incident.
|
||||
func close_incident(npc_id: String, outcome: String) -> Dictionary:
|
||||
if not _open_incidents.has(npc_id):
|
||||
return {}
|
||||
var incident: Dictionary = _open_incidents[npc_id]
|
||||
incident["outcome"] = outcome
|
||||
incident["closed_at_ms"] = Time.get_ticks_msec()
|
||||
incidents.append(incident)
|
||||
_open_incidents.erase(npc_id)
|
||||
if event_bus:
|
||||
event_bus.incident_recorded.emit(incident.duplicate(true))
|
||||
return incident
|
||||
|
||||
func open_incidents_count() -> int:
|
||||
return _open_incidents.size()
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"incidents": incidents.duplicate(true),
|
||||
"open_count": _open_incidents.size(),
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
class_name NPCController
|
||||
extends RefCounted
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||
const FSM := preload("res://src/core/fsm.gd")
|
||||
## Simulation-side NPC (PLAN.md: NPC Behavior Types). Owns intent, an FSM, and
|
||||
## reaction logic. The presentation layer adds a visual avatar and navigation.
|
||||
## RefCounted on purpose: headless tests and CI do not need a scene tree.
|
||||
|
||||
signal interacted(level: int, tone: int)
|
||||
|
||||
var npc_id: String = ""
|
||||
var profile: NpcProfile = null
|
||||
var intent: String = "roam" # enter | photo | sit | roam | deliver | film | trespass
|
||||
var fsm: FSM = null
|
||||
var rng := RandomNumberGenerator.new()
|
||||
|
||||
var highest_escalation_received: int = Rules.Level.OBSERVATION
|
||||
var leave_requested := false
|
||||
var reaction: int = Rules.Reaction.COMPLY
|
||||
var reaction_set := false
|
||||
|
||||
func _init() -> void:
|
||||
fsm = FSM.new(self)
|
||||
|
||||
## initial_state: APPROACH, AT_ENTRANCE, LOITERING, QUEUE, ARGUING
|
||||
func setup(p_id: String, p_profile: NpcProfile, p_intent: String = "roam", seed_value: int = 0, initial_state: String = "APPROACH") -> void:
|
||||
npc_id = p_id
|
||||
profile = p_profile
|
||||
intent = p_intent
|
||||
rng.seed = seed_value if seed_value != 0 else hash(npc_id)
|
||||
_build_fsm(initial_state)
|
||||
|
||||
func _build_fsm(initial_state: String) -> void:
|
||||
fsm.add_transition("APPROACH", "AT_ENTRANCE")
|
||||
fsm.add_transition("AT_ENTRANCE", "ARGUING")
|
||||
fsm.add_transition("AT_ENTRANCE", "LEAVING")
|
||||
fsm.add_transition("AT_ENTRANCE", "THREATENING")
|
||||
fsm.add_transition("AT_ENTRANCE", "FORCING_ENTRY")
|
||||
fsm.add_transition("APPROACH", "LEAVING")
|
||||
fsm.add_transition("ARGUING", "LEAVING")
|
||||
fsm.add_transition("ARGUING", "THREATENING")
|
||||
fsm.add_transition("ARGUING", "FORCING_ENTRY")
|
||||
fsm.add_transition("LEAVING", "LOITERING_DISTANT") # potential return later
|
||||
fsm.add_transition("LOITERING_DISTANT", "APPROACH")
|
||||
fsm.add_transition("THREATENING", "FORCING_ENTRY")
|
||||
fsm.start(initial_state)
|
||||
|
||||
func roll_reaction(level: int, tone: int) -> int:
|
||||
## Weighted pick from profile's reaction table for `level`, adjusted for tone.
|
||||
highest_escalation_received = maxi(highest_escalation_received, level)
|
||||
var weights: Dictionary = profile.pickup_reactions_per_level(level).duplicate()
|
||||
var tone_mod: float = profile.tone_modifiers.get(tone, 0.0)
|
||||
if tone_mod > 0.0 and weights.has(Rules.Reaction.COMPLY):
|
||||
# Shift weight mass toward compliance proportionally.
|
||||
var transferable: float = float(weights[Rules.Reaction.COMPLY]) * tone_mod
|
||||
weights[Rules.Reaction.COMPLY] += transferable * 0.5
|
||||
var roll_value := rng.randf()
|
||||
var total := 0.0
|
||||
for key in weights.keys():
|
||||
total += weights[key]
|
||||
if total <= 0.0:
|
||||
return Rules.Reaction.ARGUE
|
||||
var cursor := 0.0
|
||||
for key in weights.keys():
|
||||
cursor += float(weights[key])
|
||||
if roll_value < cursor / total:
|
||||
return key
|
||||
return Rules.Reaction.LEAVE
|
||||
|
||||
func apply_player_action(level: int, tone: int) -> Dictionary:
|
||||
## Returns reaction to the player's action and updates FSM when needed.
|
||||
var performed_reaction := roll_reaction(level, tone)
|
||||
reaction = performed_reaction
|
||||
reaction_set = true
|
||||
_update_state_for_reaction(performed_reaction)
|
||||
return {
|
||||
"npc_id": npc_id,
|
||||
"level_used": level,
|
||||
"tone": tone,
|
||||
"reaction": performed_reaction,
|
||||
"reaction_name": Rules.Reaction.find_key(performed_reaction),
|
||||
"new_state": fsm.current_state,
|
||||
}
|
||||
|
||||
func _update_state_for_reaction(r: int) -> void:
|
||||
match r:
|
||||
Rules.Reaction.COMPLY, Rules.Reaction.LEAVE:
|
||||
leave_requested = true
|
||||
fsm.transition_to("LEAVING")
|
||||
Rules.Reaction.ARGUE, Rules.Reaction.RECORD:
|
||||
fsm.transition_to("ARGUING")
|
||||
Rules.Reaction.THREATEN:
|
||||
fsm.transition_to("THREATENING")
|
||||
Rules.Reaction.FORCE_ENTRY:
|
||||
fsm.transition_to("FORCING_ENTRY")
|
||||
Rules.Reaction.RETURN_LATER:
|
||||
fsm.transition_to("LOITERING_DISTANT")
|
||||
_:
|
||||
pass
|
||||
|
||||
func decide_leave() -> bool:
|
||||
return leave_requested
|
||||
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"id": npc_id,
|
||||
"name": profile.display_name if profile else "",
|
||||
"type": profile.npc_type if profile else -1,
|
||||
"intent": intent,
|
||||
"state": fsm.current_state,
|
||||
"highest_escalation": highest_escalation_received,
|
||||
"reaction": reaction,
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
class_name NpcProfile
|
||||
extends Resource
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
## Authorable behavior profile for one kind of NPC (PLAN.md: NPC Behavior Types).
|
||||
|
||||
@export var id: String = ""
|
||||
@export var display_name: String = ""
|
||||
@export var npc_type: int = Rules.NpcType.TOURIST
|
||||
|
||||
## How difficult temperament is (0 = very calm, 1 = very hot-headed).
|
||||
## Affects how likely the NPC is to argue, point, or threaten at each level.
|
||||
@export_range(0.0, 1.0) var temperament: float = 0.25
|
||||
|
||||
## Base compliance: probability the NPC obeys a level-1 polite direction.
|
||||
## Venue/scenario recalculates effective compliance from profile + tone + level.
|
||||
@export_range(0.0, 1.0) var base_compliance: float = 0.8
|
||||
|
||||
## Probability weighting of the NP C's reaction per player escalation level.
|
||||
## keys: escalation level int -> { ReactionEnum: weight }
|
||||
## Missing entries fall back to the profile's `default_reactions`.
|
||||
@export var reactions_by_level: Dictionary = {}
|
||||
|
||||
@export var default_reactions: Dictionary = {
|
||||
Rules.Level.SIMPLE_DETERRENCE: {
|
||||
Rules.Reaction.COMPLY: 80,
|
||||
Rules.Reaction.IGNORE: 12,
|
||||
Rules.Reaction.ARGUE: 8,
|
||||
},
|
||||
Rules.Level.FORMAL_WARNING: {
|
||||
Rules.Reaction.COMPLY: 70,
|
||||
Rules.Reaction.LEAVE: 10,
|
||||
Rules.Reaction.ARGUE: 12,
|
||||
Rules.Reaction.RECORD: 8,
|
||||
},
|
||||
Rules.Level.BLOCKING: {
|
||||
Rules.Reaction.COMPLY: 55,
|
||||
Rules.Reaction.LEAVE: 15,
|
||||
Rules.Reaction.ARGUE: 20,
|
||||
Rules.Reaction.RECORD: 10,
|
||||
},
|
||||
Rules.Level.RADIO_ASSIST: {
|
||||
Rules.Reaction.LEAVE: 65,
|
||||
Rules.Reaction.ARGUE: 15,
|
||||
Rules.Reaction.FORCE_ENTRY: 20,
|
||||
},
|
||||
Rules.Level.EMERGENCY: {
|
||||
Rules.Reaction.FORCE_ENTRY: 30,
|
||||
Rules.Reaction.THREATEN: 40,
|
||||
Rules.Reaction.LEAVE: 30,
|
||||
},
|
||||
}
|
||||
|
||||
@export var tone_modifiers: Dictionary = {
|
||||
Rules.Tone.FRIENDLY: 0.10, # + compliance
|
||||
Rules.Tone.PROFESSIONAL: 0.00,
|
||||
Rules.Tone.FIRM: -0.05,
|
||||
Rules.Tone.SUSPICIOUS: -0.15,
|
||||
}
|
||||
|
||||
## Delay the NPC waits before reacting to the player (frames/seconds scaled).
|
||||
@export_range(0.1, 5.0) var reaction_delay: float = 0.6
|
||||
|
||||
## Dialogue lines per tone and intent. Falls back to generic lines when empty.
|
||||
@export var dialogue_lines: Dictionary = {}
|
||||
|
||||
func pickup_reactions_per_level(level: int) -> Dictionary:
|
||||
if reactions_by_level.has(level):
|
||||
return reactions_by_level[level]
|
||||
return default_reactions.get(level, {})
|
||||
@@ -0,0 +1,106 @@
|
||||
class_name RadioSystem
|
||||
extends RefCounted
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const EventBus := preload("res://src/core/event_bus.gd")
|
||||
## Radio call handling (PLAN.md: Level 4 radio assist, Level 5 emergency).
|
||||
## - Level 4: security assistance with a report form, arrival delay.
|
||||
## - Level 5: emergency radio, hold-to-confirm enforced by calling layer,
|
||||
## cooldown and battery limits to prevent spam.
|
||||
## - Incorrect/exaggerated reports damage credibility (recorded via reporter).
|
||||
|
||||
signal call_made(call_data: Dictionary)
|
||||
signal responder_arrived(call_data: Dictionary)
|
||||
|
||||
var battery: float = 100.0
|
||||
var max_battery: float = 100.0
|
||||
var emergency_cooldown_remaining: float = 0.0
|
||||
var emergency_cooldown_seconds: float = 120.0
|
||||
var battery_cost_emergency: float = 30.0
|
||||
var battery_cost_assist: float = 10.0
|
||||
|
||||
var accepting_calls := true
|
||||
var event_bus: EventBus = null
|
||||
|
||||
func set_event_bus(bus: EventBus) -> void:
|
||||
event_bus = bus
|
||||
|
||||
## report: { npc_id, location, description, behavior, weapons_seen, danger_level: 0..3 }
|
||||
func request_assistance(report: Dictionary) -> Dictionary:
|
||||
var result := {
|
||||
"ok": false,
|
||||
"level": Rules.Level.RADIO_ASSIST,
|
||||
"reason": "",
|
||||
"call": {},
|
||||
}
|
||||
if not accepting_calls:
|
||||
result.reason = "Radio not accepting calls"
|
||||
return result
|
||||
if battery < battery_cost_assist:
|
||||
result.reason = "battery depleted"
|
||||
return result
|
||||
battery -= battery_cost_assist
|
||||
var call := {
|
||||
"type": "assist",
|
||||
"report": report.duplicate(),
|
||||
"placed_at_ms": Time.get_ticks_msec(),
|
||||
"arrival_delay_s": 8.0,
|
||||
"responder": "security",
|
||||
}
|
||||
result.ok = true
|
||||
result.call = call
|
||||
call_made.emit(call)
|
||||
if event_bus:
|
||||
event_bus.radio_call.emit(call)
|
||||
return result
|
||||
|
||||
## Emergency call. The caller (UI) must handle hold-to-confirm; this applies
|
||||
## cooldown and battery limits.
|
||||
func request_emergency(report: Dictionary) -> Dictionary:
|
||||
var result := {
|
||||
"ok": false,
|
||||
"level": Rules.Level.EMERGENCY,
|
||||
"reason": "",
|
||||
"call": {},
|
||||
}
|
||||
if not accepting_calls:
|
||||
result.reason = "Radio not accepting calls"
|
||||
return result
|
||||
if emergency_cooldown_remaining > 0.0:
|
||||
result.reason = "Cooldown active (%.1fs)" % emergency_cooldown_remaining
|
||||
return result
|
||||
if battery < battery_cost_emergency:
|
||||
result.reason = "battery depleted"
|
||||
return result
|
||||
battery -= battery_cost_emergency
|
||||
emergency_cooldown_remaining = emergency_cooldown_seconds
|
||||
var call := {
|
||||
"type": "emergency",
|
||||
"report": report.duplicate(),
|
||||
"placed_at_ms": Time.get_ticks_msec(),
|
||||
"arrival_delay_s": 15.0,
|
||||
"responder": "armed_responder",
|
||||
}
|
||||
result.ok = true
|
||||
result.call = call
|
||||
call_made.emit(call)
|
||||
if event_bus:
|
||||
event_bus.radio_call.emit(call)
|
||||
return result
|
||||
|
||||
## Report inaccuracies deserve a reputation hit. Caller uses this helper.
|
||||
static func credibility_hits(report: Dictionary, truth: Dictionary) -> int:
|
||||
var hits := 0
|
||||
if report.get("weapons_seen", false) != truth.get("weapons_seen", false):
|
||||
hits += 1
|
||||
var danger: int = report.get("danger_level", 0)
|
||||
var actual_danger: int = truth.get("danger_level", 0)
|
||||
if danger != actual_danger:
|
||||
hits += 1
|
||||
return hits
|
||||
|
||||
## Advance cooldown; called by the simulation tick.
|
||||
func tick(delta: float) -> void:
|
||||
if emergency_cooldown_remaining > 0.0:
|
||||
emergency_cooldown_remaining = maxf(0.0, emergency_cooldown_remaining - delta)
|
||||
# Battery could slowly recharge over a shift; keep it static for now.
|
||||
@@ -0,0 +1,54 @@
|
||||
class_name ReputationEvaluator
|
||||
extends RefCounted
|
||||
## Evaluates player performance (PLAN.md: Reputation Meter).
|
||||
## Scoring weights are data so they can be tuned per venue.
|
||||
|
||||
const EventBus := preload("res://src/core/event_bus.gd")
|
||||
|
||||
## rating: float starting 50 (out of 100). Errors subtract, though marks add.
|
||||
var rating: float = 70.0
|
||||
var _modifiers: Array[Dictionary] = []
|
||||
var event_bus: EventBus = null
|
||||
|
||||
func set_event_bus(bus: EventBus) -> void:
|
||||
event_bus = bus
|
||||
|
||||
## Apply a reputation change.
|
||||
## category: "peaceful", "force", "false_report", "accuracy", "complaint",
|
||||
## "security_failure", "property_damage", "access_judgment", "crowd"
|
||||
func apply(_category: String, delta: float, reason: String) -> void:
|
||||
# Clamp range: 0..100.
|
||||
var clamped := clampf(rating + delta, 0.0, 100.0)
|
||||
var applied: float = clamped - rating
|
||||
rating = clamped
|
||||
_modifiers.append({
|
||||
"category": _category,
|
||||
"delta": applied,
|
||||
"reason": reason,
|
||||
"at_ms": Time.get_ticks_msec(),
|
||||
})
|
||||
if event_bus:
|
||||
event_bus.reputation_changed.emit({
|
||||
"rating": rating,
|
||||
"delta": applied,
|
||||
"reason": reason,
|
||||
"category": _category,
|
||||
})
|
||||
|
||||
static func grade_for(rating_value: float) -> String:
|
||||
if rating_value >= 90.0:
|
||||
return "A"
|
||||
if rating_value >= 75.0:
|
||||
return "B"
|
||||
if rating_value >= 60.0:
|
||||
return "C"
|
||||
if rating_value >= 40.0:
|
||||
return "D"
|
||||
return "F"
|
||||
|
||||
func summary() -> Dictionary:
|
||||
return {
|
||||
"rating": rating,
|
||||
"grade": grade_for(rating),
|
||||
"modifiers": _modifiers.duplicate(true),
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
class_name Rules
|
||||
## Shared constants and helpers for the simulation rules.
|
||||
|
||||
func _init() -> void:
|
||||
push_error("Rules is a static utility class and must not be instantiated.")
|
||||
|
||||
## Escalation levels (0..5). Names must stay in sync with PLAN.md.
|
||||
enum Level {
|
||||
OBSERVATION = 0,
|
||||
SIMPLE_DETERRENCE = 1,
|
||||
FORMAL_WARNING = 2,
|
||||
BLOCKING = 3,
|
||||
RADIO_ASSIST = 4,
|
||||
EMERGENCY = 5,
|
||||
}
|
||||
|
||||
const LEVEL_NAMES := {
|
||||
Level.OBSERVATION: "Observation",
|
||||
Level.SIMPLE_DETERRENCE: "Simple Deterrence",
|
||||
Level.FORMAL_WARNING: "Formal Warning",
|
||||
Level.BLOCKING: "Blocking",
|
||||
Level.RADIO_ASSIST: "Radio Assist",
|
||||
Level.EMERGENCY: "Emergency",
|
||||
}
|
||||
|
||||
## NPC behavioral archetypes from PLAN.md.
|
||||
enum NpcType {
|
||||
TOURIST,
|
||||
PERFORMER,
|
||||
INFLUENCER,
|
||||
CONFUSED_VISITOR,
|
||||
INTOXICATED,
|
||||
TRESPASSER,
|
||||
VIOLENT_THREAT,
|
||||
MENTAL_CRISIS, # "51/50" style mental-health crisis character
|
||||
AUTHORIZED_STAFF,
|
||||
AUTHORIZED_GUEST,
|
||||
DELIVERY,
|
||||
RESPONDER,
|
||||
}
|
||||
|
||||
const NPC_TYPE_NAMES := {
|
||||
NpcType.TOURIST: "Tourist",
|
||||
NpcType.PERFORMER: "Street Performer",
|
||||
NpcType.INFLUENCER: "Influencer",
|
||||
NpcType.CONFUSED_VISITOR: "Confused Visitor",
|
||||
NpcType.INTOXICATED: "Intoxicated Pedestrian",
|
||||
NpcType.TRESPASSER: "Trespasser",
|
||||
NpcType.VIOLENT_THREAT: "Violent Threat",
|
||||
NpcType.MENTAL_CRISIS: "Mental Health Crisis",
|
||||
NpcType.AUTHORIZED_STAFF: "Authorized Staff",
|
||||
NpcType.AUTHORIZED_GUEST: "Authorized Guest",
|
||||
NpcType.DELIVERY: "Delivery",
|
||||
NpcType.RESPONDER: "Responder",
|
||||
}
|
||||
|
||||
## Access artifact kinds used by AccessVerifier.
|
||||
enum AccessKind {
|
||||
NONE = -1,
|
||||
RECOGNITION = 0,
|
||||
WRISTBAND = 1,
|
||||
ID = 2,
|
||||
TICKET = 3,
|
||||
CREDENTIAL = 4,
|
||||
GUEST_LIST = 5,
|
||||
}
|
||||
|
||||
const ACCESS_KIND_NAMES := {
|
||||
AccessKind.RECOGNITION: "Recognition",
|
||||
AccessKind.WRISTBAND: "Wristband",
|
||||
AccessKind.ID: "ID",
|
||||
AccessKind.TICKET: "Ticket",
|
||||
AccessKind.CREDENTIAL: "Credential",
|
||||
AccessKind.GUEST_LIST: "Guest List",
|
||||
}
|
||||
|
||||
## Artifact validity states.
|
||||
enum ArtifactStatus {
|
||||
VALID = 0,
|
||||
EXPIRED = 1,
|
||||
COUNTERFEIT = 2,
|
||||
MISMATCHED = 3,
|
||||
MISSING = 4,
|
||||
AMBIGUOUS = 5,
|
||||
}
|
||||
|
||||
const ARTIFACT_STATUS_NAMES := {
|
||||
ArtifactStatus.VALID: "Valid",
|
||||
ArtifactStatus.EXPIRED: "Expired",
|
||||
ArtifactStatus.COUNTERFEIT: "Counterfeit",
|
||||
ArtifactStatus.MISMATCHED: "Mismatched",
|
||||
ArtifactStatus.MISSING: "Missing",
|
||||
ArtifactStatus.AMBIGUOUS: "Ambiguous",
|
||||
}
|
||||
|
||||
## NPC reaction outcomes after player actions.
|
||||
enum Reaction {
|
||||
COMPLY = 0,
|
||||
LEAVE = 1,
|
||||
ARGUE = 2,
|
||||
IGNORE = 3,
|
||||
RECORD = 4,
|
||||
THREATEN = 5,
|
||||
RETURN_LATER = 6,
|
||||
FORCE_ENTRY = 7,
|
||||
}
|
||||
|
||||
## Player dialogue tones.
|
||||
enum Tone {
|
||||
PROFESSIONAL = 0,
|
||||
FRIENDLY = 1,
|
||||
FIRM = 2,
|
||||
SUSPICIOUS = 3,
|
||||
}
|
||||
|
||||
const TONE_NAMES := {
|
||||
Tone.PROFESSIONAL: "Professional",
|
||||
Tone.FRIENDLY: "Friendly",
|
||||
Tone.FIRM: "Firm",
|
||||
Tone.SUSPICIOUS: "Suspicious",
|
||||
}
|
||||
|
||||
## Shift phases by time of day.
|
||||
enum Phase {
|
||||
EARLY = 0,
|
||||
MIDDAY = 1,
|
||||
EVENING = 2,
|
||||
LATE = 3,
|
||||
}
|
||||
|
||||
const PHASE_NAMES := {
|
||||
Phase.EARLY: "Early",
|
||||
Phase.MIDDAY: "Midday",
|
||||
Phase.EVENING: "Evening",
|
||||
Phase.LATE: "Late",
|
||||
}
|
||||
|
||||
static func level_name(level: int) -> String:
|
||||
return LEVEL_NAMES.get(level, "Unknown")
|
||||
@@ -0,0 +1,135 @@
|
||||
class_name ShiftCoordinator
|
||||
extends Node
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const EventBus := preload("res://src/core/event_bus.gd")
|
||||
const VenueScenario := preload("res://src/core/venue_scenario.gd")
|
||||
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||
const IncidentRecorder := preload("res://src/core/incident_recorder.gd")
|
||||
const ReputationEvaluator := preload("res://src/core/reputation_evaluator.gd")
|
||||
const RadioSystem := preload("res://src/core/radio_system.gd")
|
||||
const CrowdSystem := preload("res://src/core/crowd_system.gd")
|
||||
const EventScheduler := preload("res://src/core/event_scheduler.gd")
|
||||
## Drives one shift: advances time, schedules events, owns the shared systems
|
||||
## (recorder, reputation, radio, crowd), and produces the shift review.
|
||||
|
||||
signal npc_spawn_requested(profile: NpcProfile, intent: String, npc_id: String)
|
||||
signal shift_started(payload: Dictionary)
|
||||
signal shift_ended(review: Dictionary)
|
||||
signal phase_changed(phase: int)
|
||||
|
||||
var scenario: VenueScenario = null
|
||||
var duration_s: float = 900.0
|
||||
var elapsed_s: float = 0.0
|
||||
var current_phase: int = Rules.Phase.EARLY
|
||||
var running := false
|
||||
|
||||
var event_bus := EventBus.new()
|
||||
var recorder := IncidentRecorder.new()
|
||||
var reputation := ReputationEvaluator.new()
|
||||
var radio := RadioSystem.new()
|
||||
var crowd := CrowdSystem.new()
|
||||
var scheduler := EventScheduler.new()
|
||||
|
||||
var spawned_count := 0
|
||||
var spawn_seed := 0
|
||||
var _shift_finished := false
|
||||
|
||||
func setup(p_scenario: VenueScenario, p_duration_s: float = 900.0, p_seed: int = 0) -> void:
|
||||
scenario = p_scenario
|
||||
duration_s = p_duration_s
|
||||
spawn_seed = p_seed
|
||||
scheduler.setup(scenario, p_seed)
|
||||
event_bus = EventBus.new()
|
||||
radio = RadioSystem.new()
|
||||
reputation = ReputationEvaluator.new()
|
||||
crowd = CrowdSystem.new()
|
||||
recorder.set_event_bus(event_bus)
|
||||
radio.set_event_bus(event_bus)
|
||||
reputation.set_event_bus(event_bus)
|
||||
crowd.set_event_bus(event_bus)
|
||||
|
||||
func start() -> void:
|
||||
if scenario == null:
|
||||
push_error("ShiftCoordinator.start() requires a scenario.")
|
||||
return
|
||||
running = true
|
||||
_shift_finished = false
|
||||
elapsed_s = 0.0
|
||||
current_phase = _phase_for_time(0.0)
|
||||
event_bus.shift_state_changed.emit({"running": true, "elapsed_s": elapsed_s, "phase": current_phase})
|
||||
shift_started.emit({"scenario_id": scenario.id, "duration_s": duration_s})
|
||||
|
||||
func stop() -> void:
|
||||
running = false
|
||||
_finish_shift()
|
||||
|
||||
func tick(delta: float) -> void:
|
||||
if not running:
|
||||
return
|
||||
elapsed_s += delta
|
||||
radio.tick(delta)
|
||||
var new_phase := _phase_for_time(elapsed_s)
|
||||
if new_phase != current_phase:
|
||||
current_phase = new_phase
|
||||
phase_changed.emit(current_phase)
|
||||
event_bus.shift_state_changed.emit({"running": true, "elapsed_s": elapsed_s, "phase": current_phase})
|
||||
if elapsed_s >= duration_s:
|
||||
_finish_shift()
|
||||
return
|
||||
_spawn_due(delta)
|
||||
|
||||
func _phase_for_time(t: float) -> int:
|
||||
if scenario == null or scenario.shift_phases.is_empty():
|
||||
return Rules.Phase.EARLY
|
||||
var last_phase := Rules.Phase.EARLY
|
||||
var keys := scenario.shift_phases.keys()
|
||||
keys.sort()
|
||||
for phase in keys:
|
||||
var start_s: float = scenario.shift_phases[phase].get("start_s", 0.0)
|
||||
if t >= start_s:
|
||||
last_phase = phase
|
||||
return last_phase
|
||||
|
||||
func _spawn_due(delta: float) -> void:
|
||||
var due := scheduler.pull_due(elapsed_s, current_phase)
|
||||
var fallback := scheduler.fallback_routine_traffic(current_phase)
|
||||
var all := due
|
||||
all.append_array(fallback)
|
||||
for event in all:
|
||||
spawned_count += 1
|
||||
var npc_id := "%s_%d" % [scenario.id, spawned_count]
|
||||
var profile := scenario.profile_for(event.get("npc_type", Rules.NpcType.TOURIST))
|
||||
if profile == null:
|
||||
continue
|
||||
npc_spawn_requested.emit(profile, event.get("intent", "roam"), npc_id)
|
||||
|
||||
func _finish_shift() -> void:
|
||||
if _shift_finished:
|
||||
return
|
||||
_shift_finished = true
|
||||
running = false
|
||||
var review := build_review()
|
||||
event_bus.shift_state_changed.emit({"running": false, "review": review})
|
||||
shift_ended.emit(review)
|
||||
event_bus.shift_review_ready.emit(review)
|
||||
|
||||
func build_review() -> Dictionary:
|
||||
return {
|
||||
"scenario_id": scenario.id if scenario else "",
|
||||
"duration_s": duration_s,
|
||||
"elapsed_s": elapsed_s,
|
||||
"phase_at_end": current_phase,
|
||||
"spawned_count": spawned_count,
|
||||
"incidents": recorder.to_dict(),
|
||||
"reputation": reputation.summary(),
|
||||
"crowd": crowd.summary(),
|
||||
"radio": {
|
||||
"battery": radio.battery,
|
||||
"emergency_cooldown_s": radio.emergency_cooldown_remaining,
|
||||
},
|
||||
"completed_at_ms": Time.get_ticks_msec(),
|
||||
}
|
||||
|
||||
func spawn_context_dict(profile: NpcProfile, intent: String) -> Dictionary:
|
||||
return {"profile": profile, "intent": intent}
|
||||
@@ -0,0 +1,21 @@
|
||||
class_name SignalBus
|
||||
extends RefCounted
|
||||
## Static signal bus (ADR-0005 spirit: decoupled, deterministic).
|
||||
## Unlike an autoload, `SignalBus` resolves at parse time through preloads,
|
||||
## so it works identically under `--script`, in headless CI, and in the game.
|
||||
|
||||
static signal incident_recorded(incident: Dictionary)
|
||||
static signal player_action(action: Dictionary)
|
||||
static signal escalation_changed(payload: Dictionary)
|
||||
static signal radio_call(call: Dictionary)
|
||||
static signal responder_arrived(responder: Dictionary)
|
||||
static signal npc_state_changed(payload: Dictionary)
|
||||
static signal shift_state_changed(payload: Dictionary)
|
||||
static signal reputation_changed(payload: Dictionary)
|
||||
static signal crowd_changed(payload: Dictionary)
|
||||
static signal access_decided(payload: Dictionary)
|
||||
static signal shift_review_ready(review: Dictionary)
|
||||
static signal anything_happened(value: String)
|
||||
|
||||
func _init() -> void:
|
||||
push_error("SignalBus is a static utility class; use SignalBus.<signal> directly.")
|
||||
@@ -0,0 +1,49 @@
|
||||
class_name VenueScenario
|
||||
extends Resource
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const AccessPolicy := preload("res://src/core/access_policy.gd")
|
||||
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||
## Data-driven venue definition (ADR-0001). Provides locations, rules,
|
||||
## NPC pools, event pools, equipment, and shift phases.
|
||||
|
||||
@export var id: String = ""
|
||||
@export var display_name: String = ""
|
||||
@export_multiline var description: String = ""
|
||||
|
||||
@export var access_policy: AccessPolicy = AccessPolicy.new()
|
||||
|
||||
## Equipment available at this venue.
|
||||
@export var equipment: Array[String] = [] # e.g. "wristband_policy", "id_check", "wand", "flashlight"
|
||||
|
||||
## Named spots: "guard_post", "entrance", "steps", "sidewalk", "queue_area".
|
||||
@export var locations: Dictionary = {}
|
||||
|
||||
## NPC archetype definitions that appear in this scenario.
|
||||
@export var npc_profiles: Array[NpcProfile] = []
|
||||
|
||||
## Event pool. Each event: { id, phase: Rules.Phase, kind: "routine"|"scripted",
|
||||
## npc_type: int, intent: String, weight: float, probability: float,
|
||||
## time_range_s: Vector2, requires_emergency: bool }
|
||||
@export var event_pool: Array[Dictionary] = []
|
||||
|
||||
## Shift setup: phases map Rules.Phase -> { start_s, end_s, spawn_rate }.
|
||||
@export var shift_phases: Dictionary = {}
|
||||
|
||||
## Default duration of one shift in seconds (PLAN.md: 10-15 minutes).
|
||||
@export var shift_duration_s: float = 900.0
|
||||
|
||||
@export var policy_summary: String = ""
|
||||
|
||||
func profile_for(npc_type: int) -> NpcProfile:
|
||||
for p in npc_profiles:
|
||||
if p.npc_type == npc_type:
|
||||
return p
|
||||
return null
|
||||
|
||||
func events_in_phase(phase: int) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for event in event_pool:
|
||||
if event.get("phase", -1) == phase:
|
||||
result.append(event)
|
||||
return result
|
||||
@@ -0,0 +1,402 @@
|
||||
class_name GameManager
|
||||
extends Node
|
||||
## Wireframe: connects a scenario to a ShiftCoordinator, spawns NPC avatars,
|
||||
## and routes player input into the interaction and access flows.
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const AccessArtifact := preload("res://src/core/access_artifact.gd")
|
||||
const AccessVerifier := preload("res://src/core/access_verifier.gd")
|
||||
const RadioSystem := preload("res://src/core/radio_system.gd")
|
||||
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
|
||||
const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd")
|
||||
const VenueScenario := preload("res://src/core/venue_scenario.gd")
|
||||
const WorldBuilder := preload("res://src/game/world_builder.gd")
|
||||
const GuardController := preload("res://src/game/guard_controller.gd")
|
||||
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
|
||||
const Hud := preload("res://src/game/ui/hud.gd")
|
||||
const RadioPanel := preload("res://src/game/ui/radio_panel.gd")
|
||||
const ShiftReview := preload("res://src/game/ui/shift_review.gd")
|
||||
|
||||
var coordinator: ShiftCoordinator = null
|
||||
var guard: GuardController = null
|
||||
var hud: Hud = null
|
||||
var radio_panel: RadioPanel = null
|
||||
var shift_review_panel: ShiftReview = null
|
||||
var verifier := AccessVerifier.new()
|
||||
|
||||
var active_scenario_id := "hollywood_building"
|
||||
var scenario_duration_s: float = 300.0 # shortened prototype duration for testing
|
||||
var scene_root: Node3D = null
|
||||
var npcs: Array = []
|
||||
var npc_counter := 0
|
||||
var targeted_npc: Object = null
|
||||
var inspection_open := false
|
||||
var current_incident_npc_id := ""
|
||||
var current_incident_active := false
|
||||
var current_npc_truth := {"weapons_seen": false, "danger_level": 0}
|
||||
|
||||
func _ready() -> void:
|
||||
scene_root = Node3D.new()
|
||||
scene_root.name = "SceneRoot"
|
||||
add_child(scene_root)
|
||||
|
||||
WorldBuilder.new(scene_root).build()
|
||||
_ensure_lighting()
|
||||
|
||||
guard = _build_guard()
|
||||
scene_root.add_child(guard)
|
||||
guard.global_position = Vector3(0, 1.6, -2.5)
|
||||
guard.picked_target.connect(_on_guard_target)
|
||||
guard.cleared_target.connect(_on_guard_clear)
|
||||
|
||||
selected_scenario = _build_scenario(active_scenario_id)
|
||||
coordinator = ShiftCoordinator.new()
|
||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||
coordinator.npc_spawn_requested.connect(_spawn_npc)
|
||||
coordinator.shift_ended.connect(_on_shift_ended)
|
||||
|
||||
_build_ui()
|
||||
coordinator.start()
|
||||
|
||||
var selected_scenario: VenueScenario = null
|
||||
|
||||
func _build_guard() -> GuardController:
|
||||
var g := GuardController.new()
|
||||
g.name = "Guard"
|
||||
var head := Node3D.new()
|
||||
head.name = "Head"
|
||||
g.add_child(head)
|
||||
var camera := Camera3D.new()
|
||||
camera.name = "Camera3D"
|
||||
camera.current = true
|
||||
head.add_child(camera)
|
||||
var collider := CollisionShape3D.new()
|
||||
var shape := CapsuleShape3D.new()
|
||||
shape.radius = 0.4
|
||||
shape.height = 1.7
|
||||
collider.shape = shape
|
||||
collider.position = Vector3(0, 0.85, 0)
|
||||
g.add_child(collider)
|
||||
return g
|
||||
|
||||
func _build_scenario(id: String) -> VenueScenario:
|
||||
match id:
|
||||
"joke_shop":
|
||||
return ScenarioFactory.joke_shop()
|
||||
"tv_studio":
|
||||
return ScenarioFactory.tv_studio()
|
||||
_:
|
||||
return ScenarioFactory.hollywood_building()
|
||||
|
||||
func switch_scenario(id: String) -> void:
|
||||
active_scenario_id = id
|
||||
selected_scenario = _build_scenario(id)
|
||||
if coordinator:
|
||||
restart_shift()
|
||||
|
||||
func restart_shift() -> void:
|
||||
shift_review_panel.hide()
|
||||
for n in npcs:
|
||||
n.queue_free()
|
||||
npcs.clear()
|
||||
npc_counter = 0
|
||||
inspector_current_events = []
|
||||
current_incident_active = false
|
||||
current_incident_npc_id = ""
|
||||
coordinator.setup(selected_scenario, scenario_duration_s, 4242)
|
||||
coordinator.start()
|
||||
|
||||
var inspector_current_events: Array = []
|
||||
|
||||
func _build_ui() -> void:
|
||||
hud = Hud.new()
|
||||
add_child(hud)
|
||||
radio_panel = RadioPanel.new()
|
||||
add_child(radio_panel)
|
||||
radio_panel.hide()
|
||||
radio_panel.submitted.connect(_on_radio_submitted)
|
||||
radio_panel.cancelled.connect(func():
|
||||
radio_panel.hide()
|
||||
hud.set_action_hint(""))
|
||||
shift_review_panel = ShiftReview.new()
|
||||
add_child(shift_review_panel)
|
||||
shift_review_panel.hide()
|
||||
shift_review_panel.restart_requested.connect(restart_shift)
|
||||
|
||||
func _ensure_lighting() -> void:
|
||||
var sun := DirectionalLight3D.new()
|
||||
sun.rotation_degrees = Vector3(-50, -30, 0)
|
||||
sun.light_energy = 1.2
|
||||
scene_root.add_child(sun)
|
||||
var env_node := WorldEnvironment.new()
|
||||
var env := Environment.new()
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
env.ambient_light_color = Color(0.7, 0.7, 0.75)
|
||||
env.ambient_light_energy = 0.5
|
||||
env_node.environment = env
|
||||
scene_root.add_child(env_node)
|
||||
|
||||
func _spawn_npc(profile: NpcProfile, intent: String, _npc_id: String) -> void:
|
||||
npc_counter += 1
|
||||
var avatar := NpcAvatar.new()
|
||||
avatar.name = "Npc_%d" % npc_counter
|
||||
var spawn_side: float = 1.0 if npc_counter % 2 == 0 else -1.0
|
||||
var spawn_x := spawn_side * (4.0 + float((npc_counter * 13) % 18))
|
||||
var spawn_pos := Vector3(spawn_x, 0.5, 6.0 + float((npc_counter * 7) % 16))
|
||||
var target_pos := Vector3(0.0, 0.5, 1.2)
|
||||
avatar.setup(profile, intent, npc_counter * 991 + 17, spawn_pos, target_pos)
|
||||
scene_root.add_child(avatar)
|
||||
npcs.append(avatar)
|
||||
|
||||
func _on_shift_ended(review: Dictionary) -> void:
|
||||
shift_review_panel.show_review(review, selected_scenario)
|
||||
|
||||
func _on_guard_target(target: Node3D) -> void:
|
||||
targeted_npc = target
|
||||
_begin_incident(target)
|
||||
|
||||
func _on_guard_clear() -> void:
|
||||
targeted_npc = null
|
||||
hud.set_target_label("")
|
||||
hud.set_action_hint("")
|
||||
|
||||
func _begin_incident(avatar: Node3D) -> void:
|
||||
var controller: Object = avatar.get_npc_controller() if avatar.has_method("get_npc_controller") else null
|
||||
if controller == null:
|
||||
return
|
||||
current_incident_npc_id = controller.npc_id
|
||||
current_incident_active = true
|
||||
current_npc_truth = _read_npc_truth(controller)
|
||||
var profile: Object = controller.profile
|
||||
var label := "[%s] %s — %s" % [
|
||||
Rules.NPC_TYPE_NAMES.get((profile.npc_type if profile else -1), "?"),
|
||||
profile.display_name if profile else "?",
|
||||
controller.intent,
|
||||
]
|
||||
hud.set_target_label(label)
|
||||
var hint := ""
|
||||
if profile and profile.npc_type == Rules.NpcType.VIOLENT_THREAT:
|
||||
hint = "High risk: threaten/force-entry likely. Emergency radio (R) is justified."
|
||||
elif profile and profile.npc_type == Rules.NpcType.MENTAL_CRISIS:
|
||||
hint = "Requires patience. Avoid aggressive confrontation."
|
||||
elif profile and profile.npc_type == Rules.NpcType.AUTHORIZED_GUEST:
|
||||
hint = "Likely authorized. Press E to verify access."
|
||||
hud.set_action_hint(hint)
|
||||
|
||||
# ------------------------------------------------------------------ input ---
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventKey and event.pressed and not event.echo:
|
||||
_handle_key(event.keycode)
|
||||
|
||||
func _handle_key(keycode: Key) -> void:
|
||||
match keycode:
|
||||
KEY_E:
|
||||
if targeted_npc != null and not inspection_open:
|
||||
_toggle_inspection()
|
||||
KEY_Q:
|
||||
if targeted_npc != null and not inspection_open:
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "polite direction")
|
||||
KEY_F:
|
||||
hud.toggle_debug()
|
||||
_:
|
||||
_handle_action_key(keycode)
|
||||
|
||||
func _handle_action_key(keycode: Key) -> void:
|
||||
match keycode:
|
||||
KEY_1:
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "raise hand")
|
||||
KEY_2:
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "point")
|
||||
KEY_3:
|
||||
_apply_interaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL, "head shake")
|
||||
KEY_4:
|
||||
_apply_interaction(Rules.Level.FORMAL_WARNING, Rules.Tone.PROFESSIONAL, "formal warning")
|
||||
KEY_5:
|
||||
_apply_interaction(Rules.Level.BLOCKING, Rules.Tone.PROFESSIONAL, "blocking")
|
||||
KEY_6:
|
||||
_open_radio(false)
|
||||
KEY_R:
|
||||
_open_radio(true)
|
||||
KEY_A:
|
||||
if inspection_open:
|
||||
decide_access(true)
|
||||
KEY_D:
|
||||
if inspection_open:
|
||||
decide_access(false)
|
||||
_:
|
||||
pass
|
||||
|
||||
func _apply_interaction(level: int, tone: int, action_name: String) -> void:
|
||||
if targeted_npc == null:
|
||||
hud.log("Aim at someone first.")
|
||||
return
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller == null or coordinator == null:
|
||||
return
|
||||
var outcome: Dictionary = controller.apply_player_action(level, tone)
|
||||
var npc_id: String = controller.npc_id
|
||||
coordinator.recorder.add_event(npc_id, {
|
||||
"type": "player_action",
|
||||
"action": action_name,
|
||||
"level": level,
|
||||
"tone": Rules.TONE_NAMES.get(tone, "?"),
|
||||
})
|
||||
coordinator.recorder.add_escalation(npc_id, level, "%s on %s" % [action_name, npc_id])
|
||||
if level >= Rules.Level.FORMAL_WARNING:
|
||||
coordinator.reputation.apply("prop", -1.5, "escalation L%d %s" % [level, action_name])
|
||||
_react_to_outcome(controller, outcome)
|
||||
|
||||
func _react_to_outcome(controller: Object, outcome: Dictionary) -> void:
|
||||
var reaction: int = outcome.get("reaction", -1)
|
||||
var name: String = (controller.profile.display_name if controller.profile else "Person")
|
||||
match reaction:
|
||||
Rules.Reaction.COMPLY:
|
||||
hud.log("%s complies and steps back." % name)
|
||||
coordinator.crowd.apply_event("de_escalation")
|
||||
coordinator.reputation.apply("peaceful", 2.0, "resolved peacefully")
|
||||
_close_incident("peaceful")
|
||||
Rules.Reaction.LEAVE:
|
||||
hud.log("%s turns and leaves." % name)
|
||||
coordinator.crowd.apply_event("calm_professionalism")
|
||||
_close_incident("left")
|
||||
Rules.Reaction.ARGUE:
|
||||
hud.log("%s argues with you." % name)
|
||||
coordinator.crowd.apply_event("aggressive_behavior")
|
||||
Rules.Reaction.RECORD:
|
||||
hud.log("%s starts filming you." % name)
|
||||
coordinator.reputation.apply("complaint", -3.0, "filmed interaction")
|
||||
coordinator.crowd.apply_event("aggressive_behavior")
|
||||
Rules.Reaction.IGNORE:
|
||||
hud.log("%s ignores you." % name)
|
||||
Rules.Reaction.THREATEN:
|
||||
hud.log("%s is threatening you. Emergency radio (R) is justified now." % name)
|
||||
coordinator.crowd.apply_event("aggressive_behavior")
|
||||
Rules.Reaction.FORCE_ENTRY:
|
||||
hud.log("%s is forcing the door!" % name)
|
||||
coordinator.crowd.apply_event("emergency_responder")
|
||||
coordinator.reputation.apply("security_failure", -5.0, "forced entry attempt")
|
||||
Rules.Reaction.RETURN_LATER:
|
||||
hud.log("%s leaves but may come back." % name)
|
||||
_:
|
||||
hud.log("No reaction (yet).")
|
||||
|
||||
func _open_radio(emergency: bool) -> void:
|
||||
if targeted_npc == null:
|
||||
hud.log("Address someone first before calling.")
|
||||
return
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller == null:
|
||||
return
|
||||
radio_panel.open(controller, emergency, current_npc_truth)
|
||||
hud.set_action_hint("Fill in the radio report on screen.")
|
||||
|
||||
func _on_radio_submitted(report: Dictionary, emergency: bool) -> void:
|
||||
hud.set_action_hint("")
|
||||
var result: Dictionary = coordinator.radio.request_emergency(report) if emergency else coordinator.radio.request_assistance(report)
|
||||
if not result.get("ok", false):
|
||||
hud.log("Radio blocked: %s" % result.get("reason", "unknown"))
|
||||
return
|
||||
var hits: int = coordinator.radio.credibility_hits(report, current_npc_truth)
|
||||
if hits > 0:
|
||||
coordinator.reputation.apply("false_report", -4.0 * hits, "inaccurate radio report")
|
||||
hud.log("Dispatch flags inaccuracies in your report (-%d)." % (4 * hits))
|
||||
else:
|
||||
hud.log("Dispatch received your report. Assistance en route.")
|
||||
if emergency:
|
||||
coordinator.crowd.apply_event("emergency_responder")
|
||||
hud.log("Emergency responder dispatched.")
|
||||
else:
|
||||
coordinator.crowd.apply_event("calm_professionalism")
|
||||
|
||||
# ----------------------------------------------------------------- access --
|
||||
|
||||
func _toggle_inspection() -> void:
|
||||
if inspection_open:
|
||||
hud.hide_inspection()
|
||||
inspection_open = false
|
||||
return
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller == null:
|
||||
return
|
||||
inspection_open = true
|
||||
var artifacts := _artifacts_for(controller)
|
||||
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy)
|
||||
hud.show_inspection(controller, artifacts, result)
|
||||
|
||||
func decide_access(allowed: bool) -> void:
|
||||
var controller: Object = _targeted_controller()
|
||||
if controller == null:
|
||||
hud.hide_inspection()
|
||||
inspection_open = false
|
||||
return
|
||||
var artifacts := _artifacts_for(controller)
|
||||
var result: Dictionary = verifier.verify(artifacts, selected_scenario.access_policy)
|
||||
var granted: bool = result.get("granted", false)
|
||||
coordinator.recorder.add_access_evidence(controller.npc_id, {
|
||||
"decided": allowed,
|
||||
"verified_granted": granted,
|
||||
"issues": result.get("issues", []),
|
||||
"checked": result.get("checked_artifacts", []),
|
||||
})
|
||||
if allowed == granted:
|
||||
if allowed:
|
||||
hud.log("Entry granted: %s" % ("; ".join(result.get("reasons", [])) if result.get("reasons", []) else "policy satisfied"))
|
||||
coordinator.reputation.apply("access_judgment", 2.0, "correct admission")
|
||||
else:
|
||||
hud.log("Entry denied: %s" % "; ".join(result.get("issues", [])))
|
||||
coordinator.reputation.apply("access_judgment", 1.0, "correct refusal")
|
||||
else:
|
||||
if allowed:
|
||||
hud.log("You granted entry despite verification failure!")
|
||||
else:
|
||||
hud.log("You denied entry despite valid verification!")
|
||||
coordinator.reputation.apply("access_judgment", -6.0, "wrong access decision")
|
||||
hud.hide_inspection()
|
||||
inspection_open = false
|
||||
|
||||
func _artifacts_for(controller: Object) -> Array:
|
||||
var profile: Object = controller.profile
|
||||
var npc_type: int = profile.npc_type if profile else -1
|
||||
var out: Array = []
|
||||
match npc_type:
|
||||
Rules.NpcType.AUTHORIZED_GUEST, Rules.NpcType.AUTHORIZED_STAFF:
|
||||
out.append(AccessArtifact.new(Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.VALID, {"known": true}))
|
||||
Rules.NpcType.DELIVERY:
|
||||
out.append(AccessArtifact.new(Rules.AccessKind.CREDENTIAL, Rules.ArtifactStatus.VALID, {"role": "delivery"}))
|
||||
Rules.NpcType.TRESPASSER:
|
||||
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, {"color": "red", "date": "2024-01-01"}))
|
||||
Rules.NpcType.TOURIST:
|
||||
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {}))
|
||||
Rules.NpcType.CONFUSED_VISITOR:
|
||||
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.AMBIGUOUS, {"color": "gold"}))
|
||||
_:
|
||||
out.append(AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.MISSING, {}))
|
||||
return out
|
||||
|
||||
func _read_npc_truth(controller: Object) -> Dictionary:
|
||||
var profile: Object = controller.profile
|
||||
var npc_type: int = profile.npc_type if profile else -1
|
||||
return {
|
||||
"weapons_seen": npc_type == Rules.NpcType.VIOLENT_THREAT,
|
||||
"danger_level": 3 if npc_type == Rules.NpcType.VIOLENT_THREAT else 1,
|
||||
}
|
||||
|
||||
func _targeted_controller() -> Object:
|
||||
if targeted_npc == null:
|
||||
return null
|
||||
if targeted_npc.has_method("get_npc_controller"):
|
||||
return targeted_npc.get_npc_controller()
|
||||
return null
|
||||
|
||||
func _close_incident(outcome: String) -> void:
|
||||
if current_incident_active and not current_incident_npc_id.is_empty():
|
||||
coordinator.recorder.close_incident(current_incident_npc_id, outcome)
|
||||
current_incident_active = false
|
||||
current_incident_npc_id = ""
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if coordinator == null or not coordinator.running:
|
||||
return
|
||||
coordinator.tick(delta)
|
||||
hud.set_shift_state(coordinator, selected_scenario)
|
||||
@@ -0,0 +1,83 @@
|
||||
class_name GuardController
|
||||
extends CharacterBody3D
|
||||
## First-person guard controller: WASD movement, mouse look, interact raycast.
|
||||
|
||||
signal picked_target(target: Node)
|
||||
signal cleared_target()
|
||||
|
||||
var move_speed: float = 3.2
|
||||
var look_sensitivity: float = 0.0022
|
||||
var is_ready := false
|
||||
var targeted_npc: Node3D = null
|
||||
|
||||
@onready var camera: Camera3D = $Head/Camera3D
|
||||
@onready var head: Node3D = $Head
|
||||
|
||||
func _ready() -> void:
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||
is_ready = true
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
|
||||
rotate_y(-event.relative.x * look_sensitivity)
|
||||
head.rotate_x(-event.relative.y * look_sensitivity)
|
||||
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0))
|
||||
elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
|
||||
if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
|
||||
else:
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
# Movement relative to the head's yaw only.
|
||||
var forward := -global_transform.basis.z
|
||||
forward.y = 0.0
|
||||
forward = forward.normalized()
|
||||
var right := global_transform.basis.x
|
||||
right.y = 0.0
|
||||
right = right.normalized()
|
||||
|
||||
var input_dir := Vector2(
|
||||
Input.get_axis("move_left", "move_right"),
|
||||
Input.get_axis("move_forward", "move_back")
|
||||
)
|
||||
var direction := (forward * input_dir.y + right * input_dir.x).normalized()
|
||||
velocity.x = direction.x * move_speed
|
||||
velocity.z = direction.z * move_speed
|
||||
velocity.y -= 9.8 * _delta
|
||||
move_and_slide()
|
||||
_update_target()
|
||||
|
||||
func _update_target() -> void:
|
||||
var space := get_world_3d().direct_space_state
|
||||
var from := camera.global_position
|
||||
var to := from + -camera.global_transform.basis.z * 8.0
|
||||
var query := PhysicsRayQueryParameters3D.create(from, to)
|
||||
query.collide_with_areas = true
|
||||
query.collide_with_bodies = true
|
||||
query.exclude = [get_rid()]
|
||||
var hit := space.intersect_ray(query)
|
||||
var new_target: Node3D = null
|
||||
if hit:
|
||||
var obj: Object = hit.get("collider")
|
||||
var parent_avatar: Node3D = _find_avatar(obj)
|
||||
if parent_avatar != null:
|
||||
new_target = parent_avatar
|
||||
if new_target != targeted_npc:
|
||||
targeted_npc = new_target
|
||||
if new_target != null:
|
||||
picked_target.emit(new_target)
|
||||
else:
|
||||
cleared_target.emit()
|
||||
|
||||
func _find_avatar(obj: Object) -> Node3D:
|
||||
# Avatars are CharacterBody3D with a collision shape; guard against self and scene junk.
|
||||
var n := obj as Node
|
||||
if n == null:
|
||||
return null
|
||||
var current: Node = n
|
||||
while current != null:
|
||||
if current.has_method("get_npc_controller"):
|
||||
return current as Node3D
|
||||
current = current.get_parent()
|
||||
return null
|
||||
@@ -0,0 +1,11 @@
|
||||
extends Node
|
||||
## Top-level game entry point. Instantiates the GameManager which owns the world,
|
||||
## guard, simulation coordinator, and UI.
|
||||
|
||||
const GameManager := preload("res://src/game/game_manager.gd")
|
||||
|
||||
func _ready() -> void:
|
||||
var manager := GameManager.new()
|
||||
manager.name = "GameManager"
|
||||
add_child(manager)
|
||||
print("[Step Line] game started")
|
||||
@@ -0,0 +1,118 @@
|
||||
class_name NpcAvatar
|
||||
extends CharacterBody3D
|
||||
## Visual + navigation wrapper around a logic NPCController (ADR-0001).
|
||||
## Gives the controller a body, color-coded by archetype, and simple movement.
|
||||
|
||||
const NPCController := preload("res://src/core/npc_controller.gd")
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
|
||||
var npc: NPCController = null
|
||||
var spawn_position: Vector3 = Vector3.ZERO
|
||||
var target_position: Vector3 = Vector3.ZERO
|
||||
var awaiting_interaction := false
|
||||
var arrived := false
|
||||
|
||||
const TYPE_COLORS := {
|
||||
Rules.NpcType.TOURIST: Color(0.90, 0.75, 0.55),
|
||||
Rules.NpcType.PERFORMER: Color(0.80, 0.55, 0.90),
|
||||
Rules.NpcType.INFLUENCER: Color(0.95, 0.55, 0.55),
|
||||
Rules.NpcType.CONFUSED_VISITOR: Color(0.95, 0.85, 0.50),
|
||||
Rules.NpcType.INTOXICATED: Color(0.60, 0.75, 0.50),
|
||||
Rules.NpcType.TRESPASSER: Color(0.55, 0.55, 0.75),
|
||||
Rules.NpcType.VIOLENT_THREAT: Color(0.85, 0.25, 0.25),
|
||||
Rules.NpcType.MENTAL_CRISIS: Color(0.85, 0.65, 0.45),
|
||||
Rules.NpcType.AUTHORIZED_STAFF: Color(0.45, 0.70, 0.90),
|
||||
Rules.NpcType.AUTHORIZED_GUEST: Color(0.60, 0.85, 0.65),
|
||||
Rules.NpcType.DELIVERY: Color(0.80, 0.70, 0.40),
|
||||
Rules.NpcType.RESPONDER: Color(0.25, 0.30, 0.85),
|
||||
}
|
||||
|
||||
func _init() -> void:
|
||||
npc = NPCController.new()
|
||||
|
||||
func get_npc_controller() -> NPCController:
|
||||
return npc
|
||||
|
||||
func setup(profile: NpcProfile, intent: String, seed_value: int, p_spawn: Vector3, p_target: Vector3) -> void:
|
||||
npc.setup("npc_%d" % seed_value, profile, intent, seed_value)
|
||||
spawn_position = p_spawn
|
||||
target_position = p_target
|
||||
position = p_spawn
|
||||
npc.fsm.state_changed.connect(_on_state_changed)
|
||||
_build_body(profile.npc_type)
|
||||
|
||||
# Normalize intent targets: approach the entrance, stop near the door.
|
||||
var collider := CollisionShape3D.new()
|
||||
var shape := CapsuleShape3D.new()
|
||||
shape.radius = 0.35
|
||||
shape.height = 1.6
|
||||
collider.shape = shape
|
||||
collider.position = Vector3(0, 0.8, 0)
|
||||
add_child(collider)
|
||||
|
||||
func _build_body(npc_type: int) -> void:
|
||||
var body := MeshInstance3D.new()
|
||||
var capsule := CapsuleMesh.new()
|
||||
capsule.radius = 0.35
|
||||
capsule.height = 1.6
|
||||
body.mesh = capsule
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = TYPE_COLORS.get(npc_type, Color(0.7, 0.7, 0.7))
|
||||
body.material_override = mat
|
||||
add_child(body)
|
||||
var id_tag := MeshInstance3D.new()
|
||||
var sphere := SphereMesh.new()
|
||||
sphere.radius = 0.12
|
||||
sphere.height = 0.24
|
||||
var tag_mat := StandardMaterial3D.new()
|
||||
tag_mat.albedo_color = Color(1, 1, 1)
|
||||
id_tag.mesh = sphere
|
||||
id_tag.material_override = tag_mat
|
||||
id_tag.position = Vector3(0, 1.05, 0)
|
||||
id_tag.scale = Vector3(1, 0.4, 1)
|
||||
add_child(id_tag)
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if npc == null or npc.fsm == null:
|
||||
return
|
||||
_advance_fsm()
|
||||
_move_toward_target(delta)
|
||||
|
||||
func _advance_fsm() -> void:
|
||||
if npc.leave_requested:
|
||||
var away := global_position + (global_position - spawn_position).normalized() * 40.0
|
||||
away.y = 0.5
|
||||
target_position = away
|
||||
return
|
||||
if arrived and not awaiting_interaction:
|
||||
awaiting_interaction = true
|
||||
|
||||
func _move_toward_target(delta: float) -> void:
|
||||
var to_target := target_position - global_position
|
||||
to_target.y = 0.0
|
||||
var dist := to_target.length()
|
||||
if dist < 0.7:
|
||||
_arrive()
|
||||
return
|
||||
var dir := to_target.normalized()
|
||||
velocity.x = dir.x * 1.3
|
||||
velocity.z = dir.z * 1.3
|
||||
velocity.y -= 9.8 * delta
|
||||
look_at(global_position + Vector3(dir.x, 0, dir.z), Vector3.UP)
|
||||
move_and_slide()
|
||||
|
||||
func _arrive() -> void:
|
||||
arrived = true
|
||||
velocity = Vector3.ZERO
|
||||
if npc.intent == "enter" or npc.intent == "trespass" or npc.intent == "deliver" or npc.intent == "arrive":
|
||||
target_position = Vector3(0, 0.5, 1.2)
|
||||
if (global_position - Vector3(0, 0.5, 1.2)).length() < 0.8:
|
||||
awaiting_interaction = true
|
||||
|
||||
func _on_state_changed(_node: Object, _from: String, to_state: String) -> void:
|
||||
match to_state:
|
||||
"LEAVING":
|
||||
npc.leave_requested = true
|
||||
"THREATENING", "FORCING_ENTRY":
|
||||
# Advance toward the door posture; keep it simple for the prototype.
|
||||
pass
|
||||
@@ -0,0 +1,168 @@
|
||||
class_name Hud
|
||||
extends CanvasLayer
|
||||
## Minimal on-screen HUD: top status bar, target info, action hints,
|
||||
## event log, access inspection panel, and a debug overlay.
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
|
||||
var top_label: Label
|
||||
var time_label: Label
|
||||
var target_label: Label
|
||||
var hint_label: Label
|
||||
var log_label: Label
|
||||
var debug_label: Label
|
||||
var inspect_panel: PanelContainer
|
||||
var inspect_text: Label
|
||||
var inspect_meta: Label
|
||||
|
||||
var _debug_enabled := false
|
||||
var _log_lines: Array[String] = []
|
||||
|
||||
func _init() -> void:
|
||||
name = "HUD"
|
||||
layer = 10
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
margin.add_theme_constant_override("margin_left", 16)
|
||||
margin.add_theme_constant_override("margin_top", 12)
|
||||
margin.add_theme_constant_override("margin_right", 16)
|
||||
margin.add_theme_constant_override("margin_bottom", 12)
|
||||
add_child(margin)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
margin.add_child(vbox)
|
||||
|
||||
var top := HBoxContainer.new()
|
||||
vbox.add_child(top)
|
||||
|
||||
top_label = Label.new()
|
||||
top_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
top.add_child(top_label)
|
||||
|
||||
time_label = Label.new()
|
||||
top.add_child(time_label)
|
||||
|
||||
var spacer := Control.new()
|
||||
spacer.custom_minimum_size = Vector2(0, 6)
|
||||
vbox.add_child(spacer)
|
||||
|
||||
target_label = Label.new()
|
||||
target_label.add_theme_font_size_override("font_size", 16)
|
||||
vbox.add_child(target_label)
|
||||
|
||||
hint_label = Label.new()
|
||||
hint_label.modulate = Color(1, 0.9, 0.6)
|
||||
hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
hint_label.custom_minimum_size = Vector2(0, 40)
|
||||
vbox.add_child(hint_label)
|
||||
|
||||
var spacer2 := Control.new()
|
||||
spacer2.custom_minimum_size = Vector2(0, 4)
|
||||
vbox.add_child(spacer2)
|
||||
|
||||
log_label = Label.new()
|
||||
log_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
log_label.custom_minimum_size = Vector2(0, 120)
|
||||
log_label.add_theme_font_size_override("font_size", 13)
|
||||
vbox.add_child(log_label)
|
||||
|
||||
var bottom_spacer := Control.new()
|
||||
bottom_spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
vbox.add_child(bottom_spacer)
|
||||
|
||||
var controls := Label.new()
|
||||
controls.text = "Q polite direction · 1-3 simple gestures · 4 warning · 5 block · 6 assist · R emergency · E inspect / close · A allow · D deny · F debug"
|
||||
controls.add_theme_font_size_override("font_size", 12)
|
||||
controls.modulate = Color(0.8, 0.8, 0.85)
|
||||
vbox.add_child(controls)
|
||||
|
||||
# --- inspect panel (popup center) ---
|
||||
inspect_panel = PanelContainer.new()
|
||||
inspect_panel.visible = false
|
||||
inspect_panel.set_anchors_preset(Control.PRESET_CENTER)
|
||||
inspect_panel.modulate.a = 1.0
|
||||
var ivbox := VBoxContainer.new()
|
||||
inspect_panel.add_child(ivbox)
|
||||
inspect_text = Label.new()
|
||||
inspect_text.add_theme_font_size_override("font_size", 15)
|
||||
ivbox.add_child(inspect_text)
|
||||
inspect_meta = Label.new()
|
||||
inspect_meta.add_theme_font_size_override("font_size", 12)
|
||||
inspect_meta.modulate = Color(0.8, 0.85, 1.0)
|
||||
ivbox.add_child(inspect_meta)
|
||||
var insp_hint := Label.new()
|
||||
insp_hint.text = "A = allow entry D = deny entry"
|
||||
insp_hint.add_theme_font_size_override("font_size", 12)
|
||||
ivbox.add_child(insp_hint)
|
||||
add_child(inspect_panel)
|
||||
|
||||
# --- debug overlay ---
|
||||
debug_label = Label.new()
|
||||
debug_label.visible = false
|
||||
debug_label.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
|
||||
debug_label.position = Vector2(16, 0)
|
||||
debug_label.add_theme_font_size_override("font_size", 11)
|
||||
debug_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
add_child(debug_label)
|
||||
|
||||
func set_shift_state(coordinator: Object, scenario: Resource) -> void:
|
||||
top_label.text = scenario.display_name if scenario else "Step Line"
|
||||
var mins := int(coordinator.elapsed_s) / 60
|
||||
var secs := int(coordinator.elapsed_s) % 60
|
||||
time_label.text = "shift %02d:%02d phase %s\nrep %.0f crowd %.0f%%" % [
|
||||
mins, secs,
|
||||
Rules.PHASE_NAMES.get(coordinator.current_phase, "Early"),
|
||||
coordinator.reputation.rating,
|
||||
coordinator.crowd.mood * 100.0,
|
||||
]
|
||||
|
||||
func set_target_label(text: String) -> void:
|
||||
target_label.text = text
|
||||
|
||||
func set_action_hint(text: String) -> void:
|
||||
hint_label.text = text
|
||||
|
||||
func log(text: String) -> void:
|
||||
_log_lines.append(text)
|
||||
if _log_lines.size() > 8:
|
||||
_log_lines.pop_front()
|
||||
log_label.text = "\n".join(_log_lines)
|
||||
|
||||
func toggle_debug() -> void:
|
||||
_debug_enabled = not _debug_enabled
|
||||
debug_label.visible = _debug_enabled
|
||||
|
||||
func refresh_debug(coordinator: Object, npcs: Array) -> void:
|
||||
if not _debug_enabled:
|
||||
return
|
||||
var lines: Array[String] = ["--- DEBUG ---", "shift %.1fs / %.1fs rating %.1f" % [
|
||||
coordinator.elapsed_s, coordinator.duration_s, coordinator.reputation.rating]]
|
||||
for npc in npcs:
|
||||
var c: Object = npc.get_npc_controller()
|
||||
if c == null:
|
||||
continue
|
||||
var profile: Object = c.profile
|
||||
lines.append(" [%s] %s intent=%s state=%s esc=%d" % [
|
||||
Rules.NPC_TYPE_NAMES.get(profile.npc_type if profile else -1, "?"),
|
||||
npc.name, c.intent, c.fsm.current_state, c.highest_escalation_received,
|
||||
])
|
||||
debug_label.text = "\n".join(lines)
|
||||
|
||||
func show_inspection(controller: Object, artifacts: Array, result: Dictionary) -> void:
|
||||
var profile: Object = controller.profile
|
||||
var npc_display: String = (profile.display_name if profile else "Person")
|
||||
var granted: bool = result.get("granted", false)
|
||||
inspect_text.text = "Access check — %s\nGranted: %s" % [npc_display, "YES" if granted else "NO"]
|
||||
var lines: Array[String] = []
|
||||
for artifact in artifacts:
|
||||
lines.append(" • " + artifact.summary())
|
||||
if result.get("reasons", []).size() > 0:
|
||||
lines.append("Reasons: " + ", ".join(result.get("reasons", [])))
|
||||
if result.get("issues", []).size() > 0:
|
||||
lines.append("Issues: " + "; ".join(result.get("issues", [])))
|
||||
inspect_meta.text = "\n".join(lines)
|
||||
inspect_panel.visible = true
|
||||
|
||||
func hide_inspection() -> void:
|
||||
inspect_panel.visible = false
|
||||
@@ -0,0 +1,159 @@
|
||||
class_name RadioPanel
|
||||
extends CanvasLayer
|
||||
## Radio report form (PLAN.md Level 4 assist / Level 5 emergency).
|
||||
## Emergency calls use hold-to-activate (press and hold 1.6s) to prevent
|
||||
## accidental activation.
|
||||
|
||||
signal submitted(report: Dictionary, emergency: bool)
|
||||
signal cancelled()
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
|
||||
var panel: PanelContainer
|
||||
var npc_name_label: Label
|
||||
var location_edit: LineEdit
|
||||
var desc_edit: LineEdit
|
||||
var weapons_check: CheckBox
|
||||
var danger_option: OptionButton
|
||||
var action_button: Button
|
||||
var hold_hint: Label
|
||||
var status_label: Label
|
||||
|
||||
var _emergency := false
|
||||
var _npc_id := ""
|
||||
var _holding := false
|
||||
var _hold_time := 0.0
|
||||
const HOLD_NEEDED := 1.6
|
||||
var _truth := {"weapons_seen": false, "danger_level": 0}
|
||||
|
||||
func _init() -> void:
|
||||
name = "RadioPanel"
|
||||
layer = 20
|
||||
var dim := ColorRect.new()
|
||||
dim.color = Color(0, 0, 0, 0.55)
|
||||
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(dim)
|
||||
|
||||
panel = PanelContainer.new()
|
||||
panel.set_anchors_preset(Control.PRESET_CENTER)
|
||||
add_child(panel)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
panel.add_child(vbox)
|
||||
vbox.add_theme_constant_override("separation", 8)
|
||||
|
||||
var title := Label.new()
|
||||
title.name = "RadioTitle"
|
||||
title.add_theme_font_size_override("font_size", 18)
|
||||
vbox.add_child(title)
|
||||
npc_name_label = Label.new()
|
||||
vbox.add_child(npc_name_label)
|
||||
|
||||
location_edit = LineEdit.new()
|
||||
location_edit.placeholder_text = "Location (e.g. front steps)"
|
||||
location_edit.text = "front entrance"
|
||||
vbox.add_child(location_edit)
|
||||
|
||||
desc_edit = LineEdit.new()
|
||||
desc_edit.placeholder_text = "Describe the person and behavior"
|
||||
vbox.add_child(desc_edit)
|
||||
|
||||
weapons_check = CheckBox.new()
|
||||
weapons_check.text = "Weapon seen"
|
||||
vbox.add_child(weapons_check)
|
||||
|
||||
var danger_row := HBoxContainer.new()
|
||||
vbox.add_child(danger_row)
|
||||
var danger_label := Label.new()
|
||||
danger_label.text = "Danger level: "
|
||||
danger_row.add_child(danger_label)
|
||||
danger_option = OptionButton.new()
|
||||
for i in 4:
|
||||
danger_option.add_item("level %d — %s" % [i, _danger_name(i)], i)
|
||||
danger_option.select(1)
|
||||
danger_row.add_child(danger_option)
|
||||
|
||||
hold_hint = Label.new()
|
||||
hold_hint.text = ""
|
||||
hold_hint.modulate = Color(1, 0.85, 0.55)
|
||||
vbox.add_child(hold_hint)
|
||||
|
||||
status_label = Label.new()
|
||||
status_label.text = ""
|
||||
vbox.add_child(status_label)
|
||||
|
||||
var buttons := HBoxContainer.new()
|
||||
vbox.add_child(buttons)
|
||||
action_button = Button.new()
|
||||
action_button.text = "Send request"
|
||||
action_button.button_down.connect(func(): _start_hold())
|
||||
action_button.button_up.connect(func(): _stop_hold(false))
|
||||
buttons.add_child(action_button)
|
||||
var cancel_btn := Button.new()
|
||||
cancel_btn.text = "Cancel"
|
||||
cancel_btn.pressed.connect(func(): cancelled.emit())
|
||||
buttons.add_child(cancel_btn)
|
||||
|
||||
var _title_label: Label
|
||||
|
||||
func open(controller: Object, emergency: bool, truth: Dictionary) -> void:
|
||||
_emergency = emergency
|
||||
_truth = truth
|
||||
_npc_id = controller.npc_id if controller else ""
|
||||
npc_name_label.text = "Target: %s" % (controller.profile.display_name if controller else "?")
|
||||
var title: Label = panel.find_child("RadioTitle", true, false)
|
||||
title.text = "EMERGENCY CALL" if emergency else "Security Assistance"
|
||||
weapons_check.button_pressed = truth.get("weapons_seen", false)
|
||||
danger_option.select(truth.get("danger_level", 1))
|
||||
hold_hint.text = "Hold the button for %.1fs" % HOLD_NEEDED if emergency else ""
|
||||
status_label.text = ""
|
||||
action_button.text = "SEND EMERGENCY" if emergency else "Send request"
|
||||
_holding = false
|
||||
_hold_time = 0.0
|
||||
visible = true
|
||||
|
||||
func _start_hold() -> void:
|
||||
_holding = _emergency
|
||||
_hold_time = 0.0
|
||||
|
||||
func _stop_hold(cancelled_early: bool) -> void:
|
||||
if _holding:
|
||||
_holding = false
|
||||
if not cancelled_early and _hold_time >= HOLD_NEEDED:
|
||||
_submit()
|
||||
else:
|
||||
status_label.text = "Call cancelled - must hold the button."
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not visible:
|
||||
return
|
||||
if _holding:
|
||||
_hold_time += delta
|
||||
status_label.text = "Holding... %.0f%%" % (minf(1.0, _hold_time / HOLD_NEEDED) * 100.0)
|
||||
if _hold_time >= HOLD_NEEDED:
|
||||
var was := _holding
|
||||
_holding = false
|
||||
if was:
|
||||
_submit()
|
||||
|
||||
func _submit() -> void:
|
||||
if not visible:
|
||||
return
|
||||
var report := {
|
||||
"npc_id": _npc_id,
|
||||
"location": location_edit.text,
|
||||
"description": desc_edit.text,
|
||||
"behavior": desc_edit.text,
|
||||
"weapons_seen": weapons_check.button_pressed,
|
||||
"danger_level": danger_option.get_selected_id(),
|
||||
}
|
||||
visible = false
|
||||
submitted.emit(report, _emergency)
|
||||
|
||||
func _danger_name(level: int) -> String:
|
||||
match level:
|
||||
0: return "none"
|
||||
1: return "low"
|
||||
2: return "elevated"
|
||||
3: return "immediate threat"
|
||||
_: return "unknown"
|
||||
@@ -0,0 +1,77 @@
|
||||
class_name ShiftReview
|
||||
extends CanvasLayer
|
||||
## End-of-shift review screen (PLAN.md: Evidence and Body Camera, Shift Review).
|
||||
|
||||
signal restart_requested()
|
||||
|
||||
var report_label: Label
|
||||
|
||||
func _init() -> void:
|
||||
name = "ShiftReview"
|
||||
layer = 30
|
||||
var dim := ColorRect.new()
|
||||
dim.color = Color(0.04, 0.05, 0.08, 0.92)
|
||||
dim.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(dim)
|
||||
|
||||
var center := VBoxContainer.new()
|
||||
center.set_anchors_preset(Control.PRESET_CENTER)
|
||||
center.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
center.add_theme_constant_override("separation", 12)
|
||||
add_child(center)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "Shift Review"
|
||||
title.add_theme_font_size_override("font_size", 28)
|
||||
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
center.add_child(title)
|
||||
|
||||
report_label = Label.new()
|
||||
report_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
report_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
report_label.custom_minimum_size = Vector2(720, 0)
|
||||
center.add_child(report_label)
|
||||
|
||||
var restart := Button.new()
|
||||
restart.text = "Run another shift"
|
||||
restart.pressed.connect(func(): restart_requested.emit())
|
||||
restart.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||
center.add_child(restart)
|
||||
|
||||
var quit_btn := Button.new()
|
||||
quit_btn.text = "Quit"
|
||||
quit_btn.pressed.connect(func(): get_tree().quit())
|
||||
quit_btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||
center.add_child(quit_btn)
|
||||
|
||||
func show_review(review: Dictionary, scenario: Resource) -> void:
|
||||
var reputation: Dictionary = review.get("reputation", {})
|
||||
var incidents: Dictionary = review.get("incidents", {})
|
||||
var crowd: Dictionary = review.get("crowd", {})
|
||||
var radio: Dictionary = review.get("radio", {})
|
||||
var incident_list: Array = incidents.get("incidents", [])
|
||||
var peaceful := 0
|
||||
for inc in incident_list:
|
||||
if inc.get("outcome", "") in ["peaceful", "left"]:
|
||||
peaceful += 1
|
||||
|
||||
var lines: Array[String] = [
|
||||
"Venue: %s" % (scenario.display_name if scenario else review.get("scenario_id", "")),
|
||||
"Grade: %s Score: %.0f/100" % [reputation.get("grade", "?"), reputation.get("rating", 0.0)],
|
||||
"Incidents handled: %d (peaceful %d)" % [incident_list.size(), peaceful],
|
||||
"NPCs encountered: %d" % review.get("spawned_count", 0),
|
||||
"Radio battery: %.0f%% emergency cooldown: %.0fs" % [radio.get("battery", 0.0), radio.get("emergency_cooldown_s", 0.0)],
|
||||
"Crowd mood: %.0f%% (panic: %s)" % [crowd.get("mood", 0.0) * 100.0, str(crowd.get("panic", false))],
|
||||
"",
|
||||
"Highlights:",
|
||||
]
|
||||
var mods: Array = reputation.get("modifiers", [])
|
||||
var shown := 0
|
||||
for mod in mods:
|
||||
var delta: float = float(mod.get("delta", 0.0))
|
||||
lines.append(" %s (%+.0f) %s" % [mod.get("category", ""), delta, mod.get("reason", "")])
|
||||
shown += 1
|
||||
if shown >= 12:
|
||||
break
|
||||
report_label.text = "\n".join(lines)
|
||||
visible = true
|
||||
@@ -0,0 +1,110 @@
|
||||
class_name WorldBuilder
|
||||
## Procedurally assembles a minimal Hollywood Boulevard entrance prototype:
|
||||
## steps, building facade, restricted door, sidewalk, pavement, and street edge.
|
||||
## No art assets required; all geometry is BoxMesh with flat materials.
|
||||
|
||||
var root: Node3D = null
|
||||
|
||||
func _init(root_node: Node3D) -> void:
|
||||
root = root_node
|
||||
|
||||
func build() -> void:
|
||||
_build_ground()
|
||||
_build_building()
|
||||
_build_steps()
|
||||
_build_sidewalk()
|
||||
_build_street()
|
||||
_build_guard_spawn()
|
||||
|
||||
func _add_box(parent: Node3D, size: Vector3, pos: Vector3, color: Color, cast_shadow: bool = false) -> MeshInstance3D:
|
||||
var mesh := BoxMesh.new()
|
||||
mesh.size = size
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = color
|
||||
mesh.material = mat
|
||||
var mi := MeshInstance3D.new()
|
||||
mi.mesh = mesh
|
||||
mi.position = pos
|
||||
mi.cast_shadow = (1 if cast_shadow else 0) as GeometryInstance3D.ShadowCastingSetting
|
||||
parent.add_child(mi)
|
||||
return mi
|
||||
|
||||
func _build_ground() -> void:
|
||||
var ground := StaticBody3D.new()
|
||||
ground.name = "Ground"
|
||||
_add_box(root, Vector3(160, 0.5, 160), Vector3(0, -0.25, 20), Color(0.22, 0.24, 0.26))
|
||||
var col := BoxShape3D.new()
|
||||
col.size = Vector3(160, 1, 160)
|
||||
var shape_owner := ground.create_shape_owner(ground)
|
||||
ground.shape_owner_add_shape(shape_owner, col)
|
||||
root.add_child(ground)
|
||||
|
||||
func _build_building() -> void:
|
||||
var building := StaticBody3D.new()
|
||||
building.name = "Building"
|
||||
# Main facade
|
||||
_add_box(building, Vector3(40, 18, 4), Vector3(0, 9, -2), Color(0.35, 0.32, 0.30))
|
||||
# Entrance recess + door recess frame
|
||||
_add_box(building, Vector3(5.4, 6.4, 0.2), Vector3(0, 3.2, -4.0), Color(0.10, 0.10, 0.10))
|
||||
# Door
|
||||
_add_box(building, Vector3(3.0, 5.2, 0.3), Vector3(0, 2.9, -4.15), Color(0.16, 0.15, 0.13))
|
||||
# Door window
|
||||
_add_box(building, Vector3(1.8, 1.4, 0.34), Vector3(0, 3.8, -4.0), Color(0.5, 0.7, 0.9, 1.0))
|
||||
# Awning sign bar
|
||||
_add_box(building, Vector3(8, 0.5, 0.6), Vector3(0, 7.0, -3.6), Color(0.45, 0.44, 0.44))
|
||||
var col := BoxShape3D.new()
|
||||
col.size = Vector3(40, 18, 4)
|
||||
var so := building.create_shape_owner(building)
|
||||
building.shape_owner_add_shape(so, col)
|
||||
building.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 9, -2)))
|
||||
root.add_child(building)
|
||||
|
||||
func _build_steps() -> void:
|
||||
var steps := StaticBody3D.new()
|
||||
steps.name = "Steps"
|
||||
var step_w := 8.0
|
||||
var tread := 0.6
|
||||
var height := 0.16
|
||||
for i in 4:
|
||||
var y: float = i * height
|
||||
var z: float = i * tread
|
||||
_add_box(steps, Vector3(step_w - i * 0.35, height, tread), Vector3(0, y + height / 2.0, z), Color(0.55, 0.52, 0.5))
|
||||
# Landing at the top
|
||||
_add_box(steps, Vector3(9, 0.2, 3), Vector3(0, 4 * height + 0.1, -1.5), Color(0.5, 0.47, 0.45))
|
||||
var col := BoxShape3D.new()
|
||||
col.size = Vector3(step_w, 0.65, tread * 4 + 3)
|
||||
var so := steps.create_shape_owner(steps)
|
||||
steps.shape_owner_add_shape(so, col)
|
||||
steps.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0.32, 0.8)))
|
||||
root.add_child(steps)
|
||||
|
||||
func _build_sidewalk() -> void:
|
||||
var sidewalk := StaticBody3D.new()
|
||||
sidewalk.name = "Sidewalk"
|
||||
_add_box(sidewalk, Vector3(30, 0.18, 8), Vector3(0, 0.09, 8), Color(0.60, 0.58, 0.56))
|
||||
var col := BoxShape3D.new()
|
||||
col.size = Vector3(30, 0.36, 8)
|
||||
var so := sidewalk.create_shape_owner(sidewalk)
|
||||
sidewalk.shape_owner_add_shape(so, col)
|
||||
sidewalk.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, 8)))
|
||||
root.add_child(sidewalk)
|
||||
|
||||
func _build_street() -> void:
|
||||
var street := StaticBody3D.new()
|
||||
street.name = "Street"
|
||||
_add_box(street, Vector3(120, 0.1, 26), Vector3(0, 0.05, 22), Color(0.13, 0.13, 0.14))
|
||||
# Center dashes
|
||||
for i in range(-40, 41, 4):
|
||||
_add_box(street, Vector3(0.2, 0.02, 1.2), Vector3(i * 0.0, 0.11, 22), Color(0.85, 0.85, 0.30))
|
||||
var col := BoxShape3D.new()
|
||||
col.size = Vector3(120, 0.2, 26)
|
||||
var so := street.create_shape_owner(street)
|
||||
street.shape_owner_add_shape(so, col)
|
||||
street.shape_owner_set_transform(so, Transform3D(Basis(), Vector3(0, 0, 22)))
|
||||
root.add_child(street)
|
||||
|
||||
func _build_guard_spawn() -> void:
|
||||
var marker := Marker3D.new()
|
||||
marker.name = "GuardSpawn"
|
||||
marker.position = Vector3(0, 1.6, -2.5)
|
||||
root.add_child(marker)
|
||||
@@ -0,0 +1,39 @@
|
||||
extends SceneTree
|
||||
## Dev diagnostic: preload every project script to surface parse errors one by one.
|
||||
|
||||
const FILES := [
|
||||
"res://src/core/event_bus.gd",
|
||||
"res://src/core/rules.gd",
|
||||
"res://src/core/access_artifact.gd",
|
||||
"res://src/core/access_policy.gd",
|
||||
"res://src/core/access_verifier.gd",
|
||||
"res://src/core/npc_profile.gd",
|
||||
"res://src/core/npc_controller.gd",
|
||||
"res://src/core/fsm.gd",
|
||||
"res://src/core/escalation_manager.gd",
|
||||
"res://src/core/incident_recorder.gd",
|
||||
"res://src/core/reputation_evaluator.gd",
|
||||
"res://src/core/radio_system.gd",
|
||||
"res://src/core/crowd_system.gd",
|
||||
"res://src/core/event_scheduler.gd",
|
||||
"res://src/core/venue_scenario.gd",
|
||||
"res://src/core/shift_coordinator.gd",
|
||||
"res://src/content/scenario_factory.gd",
|
||||
"res://src/game/main.gd",
|
||||
"res://src/test/test_utils.gd",
|
||||
]
|
||||
|
||||
func _initialize() -> void:
|
||||
var failures := 0
|
||||
for f in FILES:
|
||||
var res: Variant = load(f)
|
||||
if res == null or not (res is GDScript):
|
||||
push_error("FAILED to load: %s" % f)
|
||||
failures += 1
|
||||
elif not (res as GDScript).can_instantiate():
|
||||
push_error("PARSE/COMPILE FAILURE (cannot instantiate): %s" % f)
|
||||
failures += 1
|
||||
else:
|
||||
print("OK: %s" % f)
|
||||
print("load failures: %d" % failures)
|
||||
quit(1 if failures > 0 else 0)
|
||||
@@ -0,0 +1,25 @@
|
||||
extends SceneTree
|
||||
## Dev helper: run the game windowed for N frames, save a screenshot, quit.
|
||||
## Usage: godot --path . -s res://src/test/take_screenshot.gd (windowed)
|
||||
|
||||
var frames := 0
|
||||
const TARGET_FRAMES := 300
|
||||
|
||||
func _initialize() -> void:
|
||||
# Launch the main scene.
|
||||
var packed: PackedScene = load("res://scenes/main.tscn")
|
||||
var inst: Node = packed.instantiate()
|
||||
root.add_child(inst)
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
frames += 1
|
||||
if frames == 60:
|
||||
var img: Image = root.get_texture().get_image()
|
||||
var err := img.save_png("user://screenshot.png")
|
||||
print("screenshot saved err=", err)
|
||||
quit(0)
|
||||
return true
|
||||
if frames > TARGET_FRAMES:
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
@@ -0,0 +1,73 @@
|
||||
extends SceneTree
|
||||
## Minimal headless test runner for CI (used in place of an editor-based addon
|
||||
## until a Godot editor workflow is introduced).
|
||||
##
|
||||
## Usage:
|
||||
## godot --headless --path . --script res://src/test/test_runner.gd
|
||||
## Exit code 0 = all passed, 1 = failures.
|
||||
|
||||
const TestUtils := preload("res://src/test/test_utils.gd")
|
||||
const TEST_DIR := "res://src/test/tests"
|
||||
|
||||
func _initialize() -> void:
|
||||
var test_files := _list_gd_files(TEST_DIR)
|
||||
var total_passed := 0
|
||||
var total_failed := 0
|
||||
var failures: Array[String] = []
|
||||
|
||||
for path in test_files:
|
||||
var script: GDScript = load(path)
|
||||
if script == null:
|
||||
failures.append("Could not load %s" % path)
|
||||
total_failed += 1
|
||||
continue
|
||||
var instance: Object = script.new()
|
||||
TestUtils.reset()
|
||||
var suite_passed := 0
|
||||
var suite_failed := 0
|
||||
for method_info in instance.get_method_list():
|
||||
var method: StringName = method_info["name"]
|
||||
if not String(method).begins_with("test_"):
|
||||
continue
|
||||
if method_info["flags"] & METHOD_FLAG_STATIC:
|
||||
continue
|
||||
TestUtils.reset()
|
||||
var result: Variant = instance.call(method)
|
||||
var errored: bool = TestUtils._errors.size() > 0
|
||||
if result == true and not errored:
|
||||
suite_passed += 1
|
||||
else:
|
||||
suite_failed += 1
|
||||
var detail := ""
|
||||
if errored:
|
||||
detail = " :: " + TestUtils.report()
|
||||
failures.append("%s.%s%s" % [path.get_file(), method, detail])
|
||||
total_passed += suite_passed
|
||||
total_failed += suite_failed
|
||||
print("Suite %-42s pass=%d fail=%d" % [path.get_file(), suite_passed, suite_failed])
|
||||
|
||||
print("")
|
||||
print("==================== TEST SUMMARY ====================")
|
||||
print("Passed: %d Failed: %d" % [total_passed, total_failed])
|
||||
if failures.size() > 0:
|
||||
print("--------------------------------------------------")
|
||||
for f in failures:
|
||||
print("FAIL: %s" % f)
|
||||
print("======================================================")
|
||||
quit(0 if total_failed == 0 else 1)
|
||||
|
||||
func _list_gd_files(dir_path: String) -> Array[String]:
|
||||
var out: Array[String] = []
|
||||
var dir := DirAccess.open(dir_path)
|
||||
if dir == null:
|
||||
push_error("Cannot open test dir %s" % dir_path)
|
||||
return out
|
||||
dir.list_dir_begin()
|
||||
var file_name := dir.get_next()
|
||||
while file_name != "":
|
||||
if not dir.current_is_dir() and file_name.ends_with(".gd"):
|
||||
out.append(dir_path.path_join(file_name))
|
||||
file_name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
out.sort()
|
||||
return out
|
||||
@@ -0,0 +1,32 @@
|
||||
class_name TestUtils
|
||||
## Tiny assertion helpers shared by headless test suites.
|
||||
|
||||
static var _errors: Array[String] = []
|
||||
|
||||
static func reset() -> void:
|
||||
_errors.clear()
|
||||
|
||||
## Returns true and records the failure message when condition is false.
|
||||
static func check(condition: bool, message: String) -> bool:
|
||||
if not condition:
|
||||
_errors.append(message)
|
||||
return condition
|
||||
|
||||
static func check_eq(actual: Variant, expected: Variant, message: String) -> bool:
|
||||
if actual == expected:
|
||||
return true
|
||||
_errors.append("%s (expected %s, got %s)" % [message, str(expected), str(actual)])
|
||||
return false
|
||||
|
||||
static func check_dict_has(data: Dictionary, key: String, message: String) -> bool:
|
||||
if data.has(key):
|
||||
return true
|
||||
_errors.append("%s (missing key %s)" % [message, key])
|
||||
return false
|
||||
|
||||
static func fail(message: String) -> bool:
|
||||
_errors.append(message)
|
||||
return false
|
||||
|
||||
static func report() -> String:
|
||||
return "\n".join(_errors)
|
||||
@@ -0,0 +1,120 @@
|
||||
extends RefCounted
|
||||
## Headless tests for AccessVerifier (ADR-0003, Access Policy).
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const AccessVerifier := preload("res://src/core/access_verifier.gd")
|
||||
const AccessPolicy := preload("res://src/core/access_policy.gd")
|
||||
const AccessArtifact := preload("res://src/core/access_artifact.gd")
|
||||
|
||||
var verifier := AccessVerifier.new()
|
||||
|
||||
func test_wristband_valid_grants() -> bool:
|
||||
var policy := AccessPolicy.new()
|
||||
policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true }
|
||||
var artifacts: Array[AccessArtifact] = [
|
||||
AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.VALID, {"color": "red", "date": "2026-02-13", "event": "gala"}),
|
||||
]
|
||||
var result := verifier.verify(artifacts, policy)
|
||||
return TestUtils.check_eq(result["granted"], true, "valid wristband should grant access")
|
||||
|
||||
func test_wristband_expired_denies() -> bool:
|
||||
var policy := AccessPolicy.new()
|
||||
policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true }
|
||||
var artifacts: Array[AccessArtifact] = [
|
||||
AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.EXPIRED, {}),
|
||||
]
|
||||
var result := verifier.verify(artifacts, policy)
|
||||
return TestUtils.check_eq(result["granted"], false, "expired wristband must deny")
|
||||
|
||||
func test_wristband_counterfeit_denies() -> bool:
|
||||
var policy := AccessPolicy.new()
|
||||
policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true }
|
||||
var artifacts: Array[AccessArtifact] = [
|
||||
AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, {}),
|
||||
]
|
||||
var result := verifier.verify(artifacts, policy)
|
||||
return TestUtils.check_eq(result["granted"], false, "counterfeit wristband must deny")
|
||||
|
||||
func test_missing_wristband_denies() -> bool:
|
||||
var policy := AccessPolicy.new()
|
||||
policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true }
|
||||
var result := verifier.verify([], policy)
|
||||
return TestUtils.check_eq(result["granted"], false, "missing wristband must deny")
|
||||
|
||||
func test_recognition_grants_without_wristband() -> bool:
|
||||
## PLAN.md: "The guard already knows and recognizes them."
|
||||
var policy := AccessPolicy.new()
|
||||
policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true }
|
||||
var artifacts: Array[AccessArtifact] = [
|
||||
AccessArtifact.new(Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.VALID, {}),
|
||||
]
|
||||
var result := verifier.verify(artifacts, policy)
|
||||
return TestUtils.check_eq(result["granted"], true, "recognition should grant access")
|
||||
|
||||
func test_recognition_expired_does_not_grant() -> bool:
|
||||
var policy := AccessPolicy.new()
|
||||
policy.required_artifacts = { Rules.AccessKind.WRISTBAND: true }
|
||||
var artifacts: Array[AccessArtifact] = [
|
||||
AccessArtifact.new(Rules.AccessKind.RECOGNITION, Rules.ArtifactStatus.MISSING, {}),
|
||||
]
|
||||
var result := verifier.verify(artifacts, policy)
|
||||
return TestUtils.check_eq(result["granted"], false, "missing recognition should not grant under band policy")
|
||||
|
||||
func test_joke_shop_id_and_ticket() -> bool:
|
||||
var policy := AccessPolicy.new()
|
||||
policy.required_artifacts = { Rules.AccessKind.ID: true, Rules.AccessKind.TICKET: true }
|
||||
policy.minimum_age = 21
|
||||
var ok: Array[AccessArtifact] = [
|
||||
AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.VALID, {"age": 24}),
|
||||
AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.VALID, {}),
|
||||
]
|
||||
var denied_underage: Array[AccessArtifact] = [
|
||||
AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.VALID, {"age": 19}),
|
||||
AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.VALID, {}),
|
||||
]
|
||||
var denied_no_ticket: Array[AccessArtifact] = [
|
||||
AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.VALID, {"age": 24}),
|
||||
]
|
||||
var ok_result := verifier.verify(ok, policy)
|
||||
var denied_underage_result := verifier.verify(denied_underage, policy)
|
||||
var denied_no_ticket_result := verifier.verify(denied_no_ticket, policy)
|
||||
var a := TestUtils.check_eq(ok_result["granted"], true, "valid ID + ticket grants")
|
||||
var b := TestUtils.check_eq(denied_underage_result["granted"], false, "underage denied")
|
||||
var c := TestUtils.check_eq(denied_no_ticket_result["granted"], false, "no ticket denied")
|
||||
return a and b and c
|
||||
|
||||
func test_joke_shop_requires_wand_screen() -> bool:
|
||||
var policy := AccessPolicy.new()
|
||||
policy.required_artifacts = { Rules.AccessKind.ID: true, Rules.AccessKind.TICKET: true }
|
||||
policy.wand_screening_required = true
|
||||
var artifacts: Array[AccessArtifact] = [
|
||||
AccessArtifact.new(Rules.AccessKind.ID, Rules.ArtifactStatus.VALID, {"age": 25}),
|
||||
AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.VALID, {}),
|
||||
]
|
||||
var result := verifier.verify(artifacts, policy)
|
||||
return TestUtils.check_eq(result["granted"], false, "unscreened guest denied when screening required")
|
||||
|
||||
func test_tv_studio_any_valid_grants() -> bool:
|
||||
## OR-admission: any single valid artifact among several kinds grants entry.
|
||||
var policy := AccessPolicy.new()
|
||||
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
|
||||
var with_wristband: Array[AccessArtifact] = [
|
||||
AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.VALID, {}),
|
||||
]
|
||||
var with_guest_list: Array[AccessArtifact] = [
|
||||
AccessArtifact.new(Rules.AccessKind.GUEST_LIST, Rules.ArtifactStatus.VALID, {}),
|
||||
]
|
||||
var all_fake: Array[AccessArtifact] = [
|
||||
AccessArtifact.new(Rules.AccessKind.WRISTBAND, Rules.ArtifactStatus.COUNTERFEIT, {}),
|
||||
AccessArtifact.new(Rules.AccessKind.TICKET, Rules.ArtifactStatus.EXPIRED, {}),
|
||||
]
|
||||
var a := TestUtils.check_eq(verifier.verify(with_wristband, policy)["granted"], true, "wristband grants")
|
||||
var b := TestUtils.check_eq(verifier.verify(with_guest_list, policy)["granted"], true, "guest list grants")
|
||||
var c := TestUtils.check_eq(verifier.verify(all_fake, policy)["granted"], false, "all invalid denied")
|
||||
return a and b and c
|
||||
@@ -0,0 +1,52 @@
|
||||
extends RefCounted
|
||||
## Headless tests for EscalationManager and RadioSystem.
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const EscalationManager := preload("res://src/core/escalation_manager.gd")
|
||||
const RadioSystem := preload("res://src/core/radio_system.gd")
|
||||
|
||||
|
||||
func test_level_advance_and_gate() -> bool:
|
||||
var mgr := EscalationManager.new(Rules.Level.EMERGENCY)
|
||||
var a := TestUtils.check_eq(mgr.current_level, Rules.Level.OBSERVATION, "starts at observation")
|
||||
var b := TestUtils.check(mgr.can_act(Rules.Level.FORMAL_WARNING), "warning is allowed")
|
||||
var c := TestUtils.check(mgr.advance_to(Rules.Level.FORMAL_WARNING, "refused_direction"), "advanced to warning")
|
||||
return a and b and c
|
||||
|
||||
func test_cannot_exceed_max_level() -> bool:
|
||||
var mgr := EscalationManager.new(Rules.Level.BLOCKING)
|
||||
var blocked := TestUtils.check(not mgr.advance_to(Rules.Level.EMERGENCY, "unavailable"), "emergency blocked by venue rules")
|
||||
var stayed := TestUtils.check_eq(mgr.current_level, Rules.Level.OBSERVATION, "stayed at observation")
|
||||
return blocked and stayed
|
||||
|
||||
func test_radio_assist_consumes_battery() -> bool:
|
||||
var radio := RadioSystem.new()
|
||||
var call: Dictionary = radio.request_assistance({
|
||||
"location": "front steps", "description": "person refusing to leave",
|
||||
"behavior": "refusing", "weapons_seen": false, "danger_level": 1,
|
||||
})
|
||||
var a := TestUtils.check(call["ok"], "assist call accepted")
|
||||
var b := TestUtils.check(radio.battery < radio.max_battery, "battery consumed")
|
||||
return a and b
|
||||
|
||||
func test_emergency_cooldown_blocks_spam() -> bool:
|
||||
var radio := RadioSystem.new()
|
||||
var first: Dictionary = radio.request_emergency({"danger_level": 3, "weapons_seen": true})
|
||||
var a := TestUtils.check(first["ok"], "first emergency accepted")
|
||||
var second: Dictionary = radio.request_emergency({"danger_level": 3, "weapons_seen": true})
|
||||
var b := TestUtils.check(not second["ok"], "second immediately rejected")
|
||||
var c := TestUtils.check(second["reason"].contains("Cooldown"), "cooldown reason given")
|
||||
return a and b and c
|
||||
|
||||
func test_emergency_after_cooldown_expires() -> bool:
|
||||
var radio := RadioSystem.new()
|
||||
radio.request_emergency({"danger_level": 3, "weapons_seen": true})
|
||||
radio.tick(radio.emergency_cooldown_seconds + 1.0)
|
||||
var again: Dictionary = radio.request_emergency({"danger_level": 3, "weapons_seen": true})
|
||||
return TestUtils.check(again["ok"], "emergency available after cooldown")
|
||||
|
||||
func test_dead_battery_blocks_calls() -> bool:
|
||||
var radio := RadioSystem.new()
|
||||
radio.battery = 0.0
|
||||
var call: Dictionary = radio.request_assistance({"danger_level": 1})
|
||||
return TestUtils.check(not call["ok"], "no battery means no call") and TestUtils.check(call["reason"].contains("battery"), "battery reason")
|
||||
@@ -0,0 +1,83 @@
|
||||
extends RefCounted
|
||||
## Headless tests for NPC reaction rolls (PLAN.md: NPC Behavior Types).
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||
const NPCController := preload("res://src/core/npc_controller.gd")
|
||||
|
||||
func _profile_with_defaults(id: String, npc_type: int, compliance: float) -> NpcProfile:
|
||||
var p := NpcProfile.new()
|
||||
p.id = id
|
||||
p.npc_type = npc_type
|
||||
p.base_compliance = compliance
|
||||
return p
|
||||
|
||||
func test_tourist_complies_at_level_1() -> bool:
|
||||
var p := _profile_with_defaults("tourist", Rules.NpcType.TOURIST, 0.9)
|
||||
var npc := NPCController.new()
|
||||
npc.setup("t1", p, "photo", 1234, "AT_ENTRANCE")
|
||||
var outcome := npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.FRIENDLY)
|
||||
var ok := TestUtils.check_eq(outcome["reaction"], Rules.Reaction.COMPLY, "base_compliance 0.9 tourist should comply at level 1")
|
||||
return ok and TestUtils.check_eq(npc.decide_leave(), true, "compliant NPC leaves")
|
||||
|
||||
func test_trespasser_frequent_non_compliance() -> bool:
|
||||
var p := _profile_with_defaults("trespasser", Rules.NpcType.TRESPASSER, 0.2)
|
||||
var npc := NPCController.new()
|
||||
npc.setup("t1", p, "trespass", 99, "AT_ENTRANCE")
|
||||
## Several rolls at formal warning; default table for this test profile is
|
||||
## from NpcProfile.default_reactions (mostly COMPLY there).
|
||||
var outcomes: Array = []
|
||||
for i in 5:
|
||||
outcomes.append(npc.roll_reaction(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.PROFESSIONAL))
|
||||
## With compliance 0.9/0.2 variation we simply assert we get SOME reaction.
|
||||
var ok := TestUtils.check(npc.highest_escalation_received >= Rules.Level.SIMPLE_DETERRENCE, "highest escalation tracked")
|
||||
return ok
|
||||
|
||||
func test_deterministic_with_seed() -> bool:
|
||||
## Same seed and inputs => same reaction sequence.
|
||||
var p1 := _profile_with_defaults("guest_a", Rules.NpcType.TRESPASSER, 0.4)
|
||||
var n1 := NPCController.new()
|
||||
n1.setup("a", p1, "trespass", 4242, "AT_ENTRANCE")
|
||||
|
||||
var p2 := _profile_with_defaults("guest_b", Rules.NpcType.TRESPASSER, 0.4)
|
||||
var n2 := NPCController.new()
|
||||
n2.setup("b", p2, "trespass", 4242, "AT_ENTRANCE")
|
||||
|
||||
var seq_a: Array = []
|
||||
var seq_b: Array = []
|
||||
for i in 4:
|
||||
seq_a.append(n1.roll_reaction(Rules.Level.FORMAL_WARNING, Rules.Tone.PROFESSIONAL))
|
||||
seq_b.append(n2.roll_reaction(Rules.Level.FORMAL_WARNING, Rules.Tone.PROFESSIONAL))
|
||||
return TestUtils.check_eq(seq_a, seq_b, "same seed produces identical reaction sequence")
|
||||
|
||||
func test_violent_threat_scales_to_threaten() -> bool:
|
||||
## Violent archetype with default reactions: from formal warning upward there
|
||||
## is a meaningful probability of THREATEN / FORCE_ENTRY. We sample enough
|
||||
## rolls to see at least one THREATEN with the violent profile rng.
|
||||
var p := NpcProfile.new()
|
||||
p.id = "violent"
|
||||
p.npc_type = Rules.NpcType.VIOLENT_THREAT
|
||||
p.base_compliance = 0.05
|
||||
p.reactions_by_level = {
|
||||
Rules.Level.EMERGENCY: {
|
||||
Rules.Reaction.THREATEN: 40,
|
||||
Rules.Reaction.FORCE_ENTRY: 30,
|
||||
Rules.Reaction.LEAVE: 30,
|
||||
},
|
||||
}
|
||||
var npc := NPCController.new()
|
||||
npc.setup("v1", p, "threat", 777, "AT_ENTRANCE")
|
||||
var saw_threat := false
|
||||
for i in 20:
|
||||
if npc.roll_reaction(Rules.Level.EMERGENCY, Rules.Tone.PROFESSIONAL) == Rules.Reaction.THREATEN:
|
||||
saw_threat = true
|
||||
return TestUtils.check(saw_threat, "violent profile can threaten at emergency level")
|
||||
|
||||
func test_fsm_transitions() -> bool:
|
||||
var p := _profile_with_defaults("guest", Rules.NpcType.TOURIST, 0.9)
|
||||
var npc := NPCController.new()
|
||||
npc.setup("g1", p, "enter", 5, "APPROACH")
|
||||
var started := TestUtils.check_eq(npc.fsm.current_state, "APPROACH", "starts approaching")
|
||||
npc.apply_player_action(Rules.Level.SIMPLE_DETERRENCE, Rules.Tone.FRIENDLY)
|
||||
var left := TestUtils.check_eq(npc.fsm.current_state, "LEAVING", "complies and leaves")
|
||||
return started and left
|
||||
@@ -0,0 +1,67 @@
|
||||
extends RefCounted
|
||||
## Headless tests for venue scenario data (Plan: Venues and Scenarios).
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const VenueScenario := preload("res://src/core/venue_scenario.gd")
|
||||
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
|
||||
const ShiftCoordinator := preload("res://src/core/shift_coordinator.gd")
|
||||
|
||||
|
||||
func test_hollywood_building_loads() -> bool:
|
||||
var scenario: VenueScenario = ScenarioFactory.hollywood_building()
|
||||
var a := TestUtils.check_eq(scenario.id, "hollywood_building", "scenario id")
|
||||
var b := TestUtils.check(scenario.npc_profiles.size() > 0, "has profiles")
|
||||
var c := TestUtils.check(scenario.access_policy.requires(Rules.AccessKind.WRISTBAND), "requires wristband")
|
||||
return a and b and c
|
||||
|
||||
func test_joke_shop_rules() -> bool:
|
||||
var scenario: VenueScenario = ScenarioFactory.joke_shop()
|
||||
var a := TestUtils.check_eq(scenario.access_policy.minimum_age, 21, "21+ club")
|
||||
var b := TestUtils.check(scenario.access_policy.require_id, "ID required")
|
||||
var c := TestUtils.check(scenario.access_policy.wand_screening_required, "wand screening required")
|
||||
var d := TestUtils.check(scenario.equipment.has("wand"), "wand in equipment")
|
||||
return a and b and c and d
|
||||
|
||||
func test_tv_studio_has_all_npc_types() -> bool:
|
||||
var scenario: VenueScenario = ScenarioFactory.tv_studio()
|
||||
var all_types := [Rules.NpcType.TOURIST, Rules.NpcType.PERFORMER, Rules.NpcType.INFLUENCER,
|
||||
Rules.NpcType.CONFUSED_VISITOR, Rules.NpcType.INTOXICATED, Rules.NpcType.TRESPASSER,
|
||||
Rules.NpcType.MENTAL_CRISIS, Rules.NpcType.VIOLENT_THREAT,
|
||||
Rules.NpcType.AUTHORIZED_GUEST, Rules.NpcType.AUTHORIZED_STAFF, Rules.NpcType.DELIVERY]
|
||||
var profile_types: Array = []
|
||||
for p in scenario.npc_profiles:
|
||||
profile_types.append(p.npc_type)
|
||||
for npc_type in all_types:
|
||||
if not profile_types.has(npc_type):
|
||||
return TestUtils.fail("TV studio missing npc type %s" % Rules.NPC_TYPE_NAMES[npc_type])
|
||||
return true
|
||||
|
||||
var _spawn_count := 0
|
||||
|
||||
func _on_spawn(_profile: NpcProfile, _intent: String, _id: String) -> void:
|
||||
_spawn_count += 1
|
||||
|
||||
func test_shift_coordinator_runs_a_shift() -> bool:
|
||||
var scenario: VenueScenario = ScenarioFactory.hollywood_building()
|
||||
var coordinator := ShiftCoordinator.new()
|
||||
coordinator.setup(scenario, 60.0, 1234)
|
||||
_spawn_count = 0
|
||||
coordinator.npc_spawn_requested.connect(_on_spawn)
|
||||
coordinator.start()
|
||||
for i in 60:
|
||||
coordinator.tick(1.0)
|
||||
if not coordinator.running:
|
||||
break
|
||||
var a := TestUtils.check(coordinator.elapsed_s > 0.0, "time advanced")
|
||||
var b := TestUtils.check(_spawn_count > 0, "NPCs spawned during shift")
|
||||
var review := coordinator.build_review()
|
||||
var c := TestUtils.check_dict_has(review, "reputation", "review has reputation")
|
||||
return a and b and c
|
||||
|
||||
func test_emergency_event_requires_emergency() -> bool:
|
||||
var scenario: VenueScenario = ScenarioFactory.hollywood_building()
|
||||
var found := false
|
||||
for event in scenario.event_pool:
|
||||
if event.get("requires_emergency", false):
|
||||
found = true
|
||||
return TestUtils.check(found, "scenario contains a scripted emergency event")
|
||||
Reference in new issue
Block a user