Files
security-game/src/game/game_manager.gd
T
zwitschi 031503e161
Full build / Import + headless tests (push) Successful in 16s
Full build / Full desktop build + optional release (push) Skipped
Detailed graphics pass: authored textures, humanoid NPCs, HUD feedback, asset pipeline
- tools/generate_textures.py (deterministic PIL) produces all authored PNGs:
  brick facade, concrete, asphalt, awning, skyline, vignette, HUD icons,
  access-card art (all <=1024px, power-of-two)
- WorldBuilder: textured facade/glass door/sidewalk/asphalt, striped awning
  with 'STEP LINE', generated skyline backdrop planes
- GameManager: soft sun shadows (4-split parallel); guard in 'guard' group
- NPC avatars: humanoid rig (torso/head/limbs) with archetype props (camera,
  delivery box, performer hat, protest sign, tourist backpack), procedural
  walk bob, head-turns-toward-guard attention cue
- HUD: inspection artifact cards, mode icons (flashlight/door/wand),
  per-escalation vignette, body-camera overlay (corner brackets + REC + clock)
- assets/ import convention documented; test_asset_textures_present_and_pow2
  guards the pipeline (57 tests pass)
- README tech stack updated for Gitea Actions + asset pipeline; screenshot
  gallery regenerated (day + night)
2026-10-31 13:33:43 +01:00

752 lines
28 KiB
GDScript

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"
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 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)
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 = ""
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_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
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 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]))