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 Strings := preload("res://src/core/strings.gd") const AccessArtifact := preload("res://src/core/access_artifact.gd") const AccessVerifier := preload("res://src/core/access_verifier.gd") const DoorState := preload("res://src/core/door_state.gd") const WandScreener := preload("res://src/core/wand_screener.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 NpcProfile := preload("res://src/core/npc_profile.gd") const AudioFeedback := preload("res://src/game/audio_feedback.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 world_builder: WorldBuilder = null var door := DoorState.new() var flashlight_on := false var coordinator: ShiftCoordinator = null var guard: GuardController = null var _sun: DirectionalLight3D = null var _env_node: WorldEnvironment = null var _sky: ProceduralSkyMaterial = null var _last_phase: int = -1 ## Per-phase lighting looks for the sun, sky, and ambient (PLAN.md: shift ## structure — phase-based lighting). Night phases switch street lamps on. const PHASE_LIGHTING := { Rules.Phase.EARLY: { "sun_energy": 1.2, "sun_angle": -50.0, "sun_color": Color(1.0, 0.98, 0.92), "sky_top": Color(0.32, 0.55, 0.95), "sky_horizon": Color(0.82, 0.88, 0.95), "sky_ground": Color(0.45, 0.5, 0.55), "ambient": Color(0.75, 0.78, 0.85), "night": false, }, Rules.Phase.MIDDAY: { "sun_energy": 1.5, "sun_angle": -62.0, "sun_color": Color(1.0, 1.0, 0.98), "sky_top": Color(0.3, 0.52, 0.95), "sky_horizon": Color(0.85, 0.9, 0.96), "sky_ground": Color(0.5, 0.54, 0.58), "ambient": Color(0.85, 0.88, 0.95), "night": false, }, Rules.Phase.EVENING: { "sun_energy": 0.7, "sun_angle": -16.0, "sun_color": Color(1.0, 0.72, 0.5), "sky_top": Color(0.62, 0.32, 0.35), "sky_horizon": Color(1.0, 0.68, 0.42), "sky_ground": Color(0.3, 0.24, 0.3), "ambient": Color(0.55, 0.45, 0.5), "night": true, }, Rules.Phase.LATE: { "sun_energy": 0.05, "sun_angle": -8.0, "sun_color": Color(0.3, 0.35, 0.5), "sky_top": Color(0.02, 0.03, 0.08), "sky_horizon": Color(0.1, 0.12, 0.2), "sky_ground": Color(0.05, 0.05, 0.08), "ambient": Color(0.08, 0.09, 0.16), "night": true, }, } var hud: Hud = null var radio_panel: RadioPanel = null var shift_review_panel: ShiftReview = null var audio: AudioFeedback = null var verifier := AccessVerifier.new() var wand_screener := WandScreener.new() var active_scenario_id := "hollywood_building" ## Shift length mirrors the scenario default (900s / 10-15 min per PLAN.md) unless ## overridden for a test or debug build. var scenario_duration_s: float = 900.0 var _wand_cooldown := 0.0 const WAND_COOLDOWN_S := 0.8 var scene_root: Node3D = null var npcs: Array = [] var npc_counter := 0 var targeted_npc: Object = null var inspection_open := false var wand_open := false var current_incident_npc_id := "" var current_incident_active := false var current_npc_truth := {"weapons_seen": false, "danger_level": 0} ## Venue cycling (V): next venue in the scenario list. const VENUE_ORDER := ["hollywood_building", "joke_shop", "tv_studio"] var _venue_index := 0 ## RNG for venue door-staff lines so repeated interactions vary. var _door_rng := RandomNumberGenerator.new() ## Aim-free targeting: cycle through nearby NPCs with Tab (accessibility). const NEARBY_RADIUS := 12.0 var _nearby_index := -1 func _ready() -> void: _door_rng.randomize() scene_root = Node3D.new() scene_root.name = "SceneRoot" add_child(scene_root) world_builder = WorldBuilder.new(scene_root) world_builder.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) scenario_duration_s = selected_scenario.shift_duration_s 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) coordinator.radio.responder_arrived.connect(_on_responder_arrived) _build_ui() coordinator.start() _last_phase = -1 _apply_phase_lighting() 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 flashlight := SpotLight3D.new() flashlight.name = "Flashlight" flashlight.visible = false flashlight.position = Vector3(0, -0.15, -0.4) flashlight.rotation_degrees = Vector3(-10, 0, 0) flashlight.spot_range = 18.0 flashlight.spot_angle = 28.0 flashlight.light_energy = 2.5 head.add_child(flashlight) 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) g.add_to_group("guard") 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 = "" scenario_duration_s = selected_scenario.shift_duration_s coordinator.setup(selected_scenario, scenario_duration_s, 4242) coordinator.start() _last_phase = -1 _apply_phase_lighting() var inspector_current_events: Array = [] func _build_ui() -> void: hud = Hud.new() add_child(hud) audio = AudioFeedback.new() add_child(audio) 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: _sun = DirectionalLight3D.new() _sun.name = "Sun" _sun.rotation_degrees = Vector3(-50, -30, 0) _sun.light_energy = 1.2 _sun.shadow_enabled = true _sun.light_angular_distance = 1.2 _sun.directional_shadow_mode = DirectionalLight3D.SHADOW_PARALLEL_4_SPLITS _sun.directional_shadow_max_distance = 60.0 scene_root.add_child(_sun) _env_node = WorldEnvironment.new() var env := Environment.new() env.background_mode = Environment.BG_SKY _sky = ProceduralSkyMaterial.new() var sky := Sky.new() sky.sky_material = _sky env.sky = sky env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY env.ambient_light_energy = 0.5 env.reflected_light_source = Environment.REFLECTION_SOURCE_SKY _env_node.environment = env scene_root.add_child(_env_node) ## Apply the lighting look for the current shift phase (sun, sky, ambient, ## street lamps). Runs on phase change and once at shift start. func _apply_phase_lighting() -> void: var phase: int = coordinator.current_phase if coordinator else Rules.Phase.EARLY var cfg: Dictionary = PHASE_LIGHTING.get(phase, PHASE_LIGHTING[Rules.Phase.EARLY]) if _sun: _sun.light_energy = float(cfg.get("sun_energy", 1.0)) _sun.light_color = cfg.get("sun_color", Color.WHITE) _sun.rotation_degrees = Vector3(float(cfg.get("sun_angle", -50.0)), -30, 0) if _sky: _sky.sky_top_color = cfg.get("sky_top", Color.BLUE) _sky.sky_horizon_color = cfg.get("sky_horizon", Color.WHITE) _sky.ground_bottom_color = cfg.get("sky_ground", Color(0.5, 0.5, 0.5)) _sky.ground_horizon_color = cfg.get("sky_horizon", Color.WHITE) if world_builder: world_builder.set_night_mode(cfg.get("night", false)) 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)) # Per-intent waypoint (photo spots, queue, stage door, sidewalk exit) instead # of every NPC walking to the entrance (TODO: Core Simulation — NPC Perception). var target_pos: Vector3 = selected_scenario.waypoint_for_intent(intent) var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2)) avatar.setup(profile, intent, npc_counter * 991 + 17, spawn_pos, target_pos, door_pos) scene_root.add_child(avatar) npcs.append(avatar) ## Spawn a dispatched responder avatar when a radio call's arrival delay elapses ## (TODO: Escalation and Radio — Responder Dispatch). The responder walks to the ## entrance and resolves the incident with command/restraint/evacuation/arrest. func _on_responder_arrived(call: Dictionary) -> void: var outcome: String = RadioSystem.responder_outcome(call) var responder_profile := _responder_profile() npc_counter += 1 var avatar := NpcAvatar.new() avatar.name = "Responder_%d" % npc_counter # Responder approaches from the street, toward the entrance. var spawn_pos := Vector3(0.0, 0.5, 14.0) var door_pos: Vector3 = selected_scenario._location_or_default("entrance", Vector3(0, 0.5, 1.2)) avatar.setup(responder_profile, "respond", npc_counter * 991 + 17, spawn_pos, door_pos, door_pos) scene_root.add_child(avatar) npcs.append(avatar) hud.log(Strings.fmt("responder.arrived", [outcome])) coordinator.recorder.add_event(call.get("report", {}).get("npc_id", ""), { "type": "responder_action", "outcome": outcome, "call_type": call.get("type", "assist"), }) coordinator.crowd.apply_event("emergency_responder" if call.get("type", "") == "emergency" else "calm_professionalism") func _responder_profile() -> NpcProfile: var p := NpcProfile.new() p.id = "responder" p.display_name = "Responder" p.npc_type = Rules.NpcType.RESPONDER p.temperament = 0.1 p.base_compliance = 1.0 return p func _on_shift_ended(review: Dictionary) -> void: # Persist the finished shift locally (Save autoload, user:// JSON). if Save: Save.save_shift_result(review) shift_review_panel.show_review(review, selected_scenario, Save.load_shift_results() if Save else []) 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, "polite direction") KEY_W: if targeted_npc != null and not inspection_open: _toggle_wand_screening() KEY_TAB: _cycle_nearby_target() KEY_F: hud.toggle_debug() KEY_G: _toggle_flashlight() KEY_L: _toggle_door_lock() KEY_V: _cycle_venue() _: _handle_action_key(keycode) func _handle_action_key(keycode: Key) -> void: match keycode: KEY_1: _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "raise hand") KEY_2: _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "point") KEY_3: _apply_interaction(Rules.Level.SIMPLE_DETERRENCE, "head shake") KEY_4: _apply_interaction(Rules.Level.FORMAL_WARNING, "formal warning") KEY_5: _apply_interaction(Rules.Level.BLOCKING, "blocking") KEY_6: _open_radio(false) KEY_R: _open_radio(true) KEY_A: if inspection_open: decide_access(true) elif wand_open: decide_wand(true) KEY_D: if inspection_open: decide_access(false) elif wand_open: decide_wand(false) _: pass ## Toggle the guard's flashlight. In evening/late phases it widens NPC ## perception radius (PLAN.md: Level 3 — flashlight at night). func _toggle_flashlight() -> void: flashlight_on = not flashlight_on var lamp: SpotLight3D = guard.get_node_or_null("Head/Flashlight") as SpotLight3D if lamp: lamp.visible = flashlight_on hud.set_flashlight(flashlight_on) hud.log(Strings.lookup("flashlight.on") if flashlight_on else Strings.lookup("flashlight.off")) ## Lock or unlock the restricted door (PLAN.md: Level 3). Locked doors thwart ## FORCE_ENTRY attempts during the reaction flow. func _toggle_door_lock() -> void: door.toggle() if world_builder: world_builder.set_door_locked(door.locked) hud.set_door(door.locked) hud.log(Strings.lookup("door.locked") if door.locked else Strings.lookup("door.unlocked")) ## Cycle to the next venue (Hollywood Blvd → The Joke Shop → TV Studio). ## The coordinator is rebuilt with the new scenario and the shift restarts. func _cycle_venue() -> void: _venue_index = (_venue_index + 1) % VENUE_ORDER.size() switch_scenario(VENUE_ORDER[_venue_index]) var venue_name: String = selected_scenario.display_name if selected_scenario else "?" hud.log(Strings.fmt("hud.venue_switched", [venue_name])) hud.set_wand_icon(false) hud.set_shift_state(coordinator, selected_scenario) ## Aim-free targeting: cycle through NPCs near the guard (Tab). Lets players ## interact without precise aiming (accessibility). func _cycle_nearby_target() -> void: if guard == null: return var nearby: Array = [] for avatar in npcs: if not is_instance_valid(avatar): continue if (avatar.global_position - guard.global_position).length() <= NEARBY_RADIUS: nearby.append(avatar) if nearby.is_empty(): hud.log(Strings.lookup("hud.no_one_nearby")) return _nearby_index = (_nearby_index + 1) % nearby.size() var next_target: Node3D = nearby[_nearby_index] targeted_npc = next_target _begin_incident(next_target) hud.log(Strings.fmt("hud.targeting", [next_target.name])) func _apply_interaction(level: int, action_name: String) -> void: if targeted_npc == null: hud.log(Strings.lookup("hud.aim_first")) return var controller: Object = _targeted_controller() if controller == null or coordinator == null: return var outcome: Dictionary = controller.apply_player_action(level) var npc_id: String = controller.npc_id coordinator.recorder.add_event(npc_id, { "type": "player_action", "action": action_name, "level": level, }) 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]) hud.set_escalation(level) if audio: audio.play_escalation(level) _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(Strings.fmt("react.comply", [name])) coordinator.crowd.apply_event("de_escalation") coordinator.reputation.apply("peaceful", 2.0, "resolved peacefully") _close_incident("peaceful") Rules.Reaction.LEAVE: hud.log(Strings.fmt("react.leave", [name])) coordinator.crowd.apply_event("calm_professionalism") _close_incident("left") Rules.Reaction.ARGUE: hud.log(Strings.fmt("react.argue", [name])) coordinator.crowd.apply_event("aggressive_behavior") Rules.Reaction.RECORD: hud.log(Strings.fmt("react.record", [name])) coordinator.reputation.apply("complaint", -3.0, "filmed interaction") coordinator.crowd.apply_event("aggressive_behavior") Rules.Reaction.IGNORE: hud.log(Strings.fmt("react.ignore", [name])) Rules.Reaction.THREATEN: hud.log(Strings.fmt("react.threaten", [name])) coordinator.crowd.apply_event("aggressive_behavior") Rules.Reaction.FORCE_ENTRY: var force_result: Dictionary = door.attempt_force() if force_result.get("succeeded", false): hud.log(Strings.fmt("react.force_entry", [name])) coordinator.crowd.apply_event("emergency_responder") coordinator.reputation.apply("security_failure", -5.0, "forced entry attempt") else: hud.log(Strings.fmt("door.thwarted", [name])) coordinator.crowd.apply_event("aggressive_behavior") coordinator.reputation.apply("prop", -2.0, "forcing attempt (locked door)") Rules.Reaction.RETURN_LATER: hud.log(Strings.fmt("react.return_later", [name])) Rules.Reaction.CALL_SOMEONE: hud.log(Strings.fmt("react.call_someone", [name])) coordinator.crowd.apply_event("aggressive_behavior") _: hud.log(Strings.lookup("react.none")) func _open_radio(emergency: bool) -> void: if targeted_npc == null: hud.log(Strings.lookup("radio.address_first")) 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(Strings.fmt("radio.blocked", [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(Strings.fmt("radio.inaccurate", [4 * hits])) else: hud.log(Strings.lookup("radio.received")) if emergency: coordinator.crowd.apply_event("emergency_responder") hud.log(Strings.lookup("radio.emergency_dispatched")) else: coordinator.crowd.apply_event("calm_professionalism") if audio: audio.play_radio_response() _maybe_radio_easter_egg() ## Rare hidden radio transmission: a stray voice asks "¿Dónde está la mota?" ## over the radio. Fires on a small random chance after a successful call. func _maybe_radio_easter_egg() -> void: if _door_rng.randf() < 0.025: hud.log(Strings.lookup("radio.easter_egg")) # ----------------------------------------------------------------- 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) # Venue door-staff flavor: an applicable check line (PLAN.md: door-staff dialogue). var line := _door_context_line() if line != "": hud.log(line) # ------------------------------------------------------------- wand screening -- ## Handheld wand inspection (The Joke Shop). Only available when the venue ## carries a wand. The wand reports an alert; the player interprets it. func _toggle_wand_screening() -> void: if _wand_cooldown > 0.0: hud.log(Strings.lookup("wand.recharge")) return if wand_open: hud.hide_wand() hud.set_wand_icon(false) wand_open = false return if not selected_scenario.equipment.has("wand"): hud.log(Strings.lookup("hud.no_wand")) hud.set_wand_icon(false) return hud.set_wand_icon(true) var controller: Object = _targeted_controller() if controller == null: return var profile: Object = controller.profile var items: Array = profile.carried_items if profile else [] var result: Dictionary = wand_screener.screen(items) wand_open = true hud.show_wand(controller, result) var wand_line := selected_scenario.door_staff_line("wand_check", _door_rng) if wand_line != "": hud.log(wand_line) ## Resolve a wand screening: the player decides whether to admit the guest. func decide_wand(allowed: bool) -> void: var controller: Object = _targeted_controller() if controller == null: hud.hide_wand() wand_open = false return var profile: Object = controller.profile var items: Array = profile.carried_items if profile else [] var result: Dictionary = wand_screener.screen(items) var decision: Dictionary = wand_screener.decide(result, allowed) coordinator.recorder.add_access_evidence(controller.npc_id, { "type": "wand_screening", "decided": allowed, "prohibited": decision.get("prohibited", false), "false_alarm": decision.get("false_alarm", false), "item": decision.get("item", ""), "correct": decision.get("correct", false), }) var name: String = (profile.display_name if profile else "Person") if decision.get("prohibited", false) and allowed: hud.log(Strings.fmt("wand.admitted_prohibited", [name])) coordinator.reputation.apply("security_failure", -5.0, "admitted prohibited item") elif decision.get("prohibited", false) and not allowed: var ban_line := selected_scenario.door_staff_line("prohibited_item", _door_rng) if ban_line != "": hud.log(ban_line) coordinator.reputation.apply("access_judgment", 1.0, "correct prohibited-item refusal") elif decision.get("false_alarm", false) and not allowed: hud.log(Strings.fmt("wand.denied_false_alarm", [name])) coordinator.reputation.apply("complaint", -2.0, "false-alarm refusal") elif allowed: hud.log(Strings.fmt("wand.cleared", [name])) coordinator.reputation.apply("access_judgment", 1.0, "correct wand admission") else: hud.log(Strings.fmt("wand.refused", [name])) coordinator.reputation.apply("access_judgment", 1.0, "correct wand refusal") # Mark the NPC as screened so the screening-before-entry flow is satisfied. controller.screened = true hud.hide_wand() wand_open = false # Short scan cooldown: the wand must “recharge” between guests (PLAN.md: feel). _wand_cooldown = WAND_COOLDOWN_S func decide_access(allowed: bool) -> void: var controller: Object = _targeted_controller() if controller == null: hud.hide_inspection() inspection_open = false return # Screening-before-entry: at wand venues, a guest must be screened first. if allowed and selected_scenario.access_policy.wand_screening_required and not controller.screened: hud.log(Strings.lookup("hud.screen_first")) 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(Strings.fmt("access.granted", ["; ".join(result.get("reasons", [])) if result.get("reasons", []) else Strings.lookup("access.policy_satisfied")])) coordinator.reputation.apply("access_judgment", 2.0, "correct admission") if audio: audio.play_confirm() else: hud.log(Strings.fmt("access.denied", ["; ".join(result.get("issues", []))])) var refusal_line := selected_scenario.door_staff_line("refusal", _door_rng) if refusal_line != "": hud.log(refusal_line) coordinator.reputation.apply("access_judgment", 1.0, "correct refusal") if audio: audio.play_deny() else: if allowed: hud.log(Strings.lookup("access.wrong_grant")) else: hud.log(Strings.lookup("access.wrong_deny")) coordinator.reputation.apply("access_judgment", -6.0, "wrong access decision") if audio: audio.play_deny() hud.hide_inspection() inspection_open = false ## Venue-specific opening line for the access inspection (e.g. The Joke Shop's ## ID / ticket check lines). Returns "" when the venue has no matching lines. func _door_context_line() -> String: if selected_scenario == null: return "" if selected_scenario.access_policy.require_id: return selected_scenario.door_staff_line("id_check", _door_rng) if selected_scenario.access_policy.requires(Rules.AccessKind.TICKET): return selected_scenario.door_staff_line("ticket_check", _door_rng) return "" 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 _wand_cooldown > 0.0: _wand_cooldown = maxf(0.0, _wand_cooldown - delta) if coordinator == null or not coordinator.running: return coordinator.tick(delta) if coordinator.current_phase != _last_phase: _last_phase = coordinator.current_phase _apply_phase_lighting() hud.set_shift_state(coordinator, selected_scenario) _perception_pass() ## Perception pass: NPCs notice the guard's proximity (PLAN.md: Level 0 ## Observation). Feeds each NPC's distance to the guard into its controller ## and logs a one-time reaction when an NPC first notices the guard. func _perception_pass() -> void: if guard == null: return var guard_pos: Vector3 = guard.global_position var lit_bonus := 0.0 if flashlight_on and coordinator.current_phase >= Rules.Phase.EVENING: lit_bonus = 4.0 for avatar in npcs: if not is_instance_valid(avatar): continue var controller: Object = avatar.get_npc_controller() if avatar.has_method("get_npc_controller") else null if controller == null: continue var distance: float = (avatar.global_position - guard_pos).length() var already_noticed: bool = controller.guard_noticed var perception: Dictionary = controller.perceive_guard(distance, lit_bonus) if perception.get("noticed", false) and not already_noticed: # First-time notice: log a subtle reaction. var name: String = (controller.profile.display_name if controller.profile else "Person") if perception.get("band", "") == "near": hud.log(Strings.fmt("perception.near", [name])) else: hud.log(Strings.fmt("perception.aware", [name]))