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.
158 lines
5.3 KiB
GDScript
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"
|