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 ## Dispatched calls awaiting their responder's arrival. Each entry is the call ## dictionary with a mutable `arrival_delay_s` that `tick` decrements. var pending_calls: Array[Dictionary] = [] 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 pending_calls.append(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 pending_calls.append(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 and pending-call arrival timers; called by the simulation ## tick. When a call's arrival delay elapses, the responder arrives and the ## `responder_arrived` signal fires with the call data. 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. var arrived: Array[Dictionary] = [] for call in pending_calls: call["arrival_delay_s"] = call.get("arrival_delay_s", 0.0) - delta if call["arrival_delay_s"] <= 0.0: arrived.append(call) for call in arrived: pending_calls.erase(call) responder_arrived.emit(call) if event_bus: event_bus.responder_arrived.emit(call) func snapshot() -> Dictionary: return { "battery": battery, "emergency_cooldown_remaining": emergency_cooldown_remaining, "accepting_calls": accepting_calls, "pending_calls": pending_calls.duplicate(true), } func restore(data: Dictionary) -> void: battery = clampf(float(data.get("battery", 100.0)), 0.0, max_battery) emergency_cooldown_remaining = maxf(0.0, float(data.get("emergency_cooldown_remaining", 0.0))) accepting_calls = bool(data.get("accepting_calls", true)) pending_calls.clear() for call in data.get("pending_calls", []): if call is Dictionary: pending_calls.append((call as Dictionary).duplicate(true)) ## Determine the responder's resolution outcome for a call. The responder ## commands, restrains, evacuates, or arrests based on the reported danger ## level and whether a weapon was seen (PLAN.md: Level 5 Emergency Response). ## Returns a resolution string: "command", "restraint", "evacuation", "arrest". static func responder_outcome(call: Dictionary) -> String: var report: Dictionary = call.get("report", {}) var danger: int = report.get("danger_level", 0) var weapons: bool = report.get("weapons_seen", false) if weapons or danger >= 3: return "arrest" if danger == 2: return "restraint" if call.get("type", "") == "emergency": return "evacuation" return "command"