Files
security-game/src/core/radio_system.gd
T
zwitschi aa51ee331e
Full build / Import + headless tests (push) Successful in 41s
Full build / Full desktop build + optional release (push) Successful in 4m18s
Ship the 0.8.0 first-hour and campaign milestone
Add the campaign shell, pause-safe checkpoint resume, post-shift campaign
continue, confirmed campaign reset, difficulty presets, searchable Help,
content-matrix validation, recovery events, contextual dialogue, policy/debrief
context and eligible-event previews. Extend persistence with atomic separate
files and add campaign/checkpoint/tutorial smokes. Headless 247/247, Python
94/94, tutorial/campaign/session/scenario smokes green.
2026-11-01 21:21:43 +01:00

158 lines
5.3 KiB
GDScript

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"