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.
111 lines
3.9 KiB
GDScript
111 lines
3.9 KiB
GDScript
class_name EventScheduler
|
|
extends RefCounted
|
|
|
|
const Rules := preload("res://src/core/rules.gd")
|
|
const VenueScenario := preload("res://src/core/venue_scenario.gd")
|
|
## Schedules routine traffic and scripted events for a shift according to the
|
|
## scenario's event pool (ADR-0001, Shift Structure).
|
|
|
|
signal event_ready(event_data: Dictionary)
|
|
|
|
var rng := RandomNumberGenerator.new()
|
|
var _scenario: VenueScenario = null
|
|
var _weighted_pool: Array[Dictionary] = []
|
|
## Difficulty/pacing knob: <1 is calmer, >1 sends more events and routine traffic.
|
|
var spawn_rate_multiplier := 1.0
|
|
var _recent_scripted: Array[String] = []
|
|
|
|
func _init() -> void:
|
|
pass
|
|
|
|
func setup(scenario: VenueScenario, seed_value: int = 0) -> void:
|
|
_scenario = scenario
|
|
if seed_value != 0:
|
|
rng.seed = seed_value
|
|
_weighted_pool = []
|
|
for event in scenario.event_pool:
|
|
if event.get("enabled", true):
|
|
_weighted_pool.append(event)
|
|
|
|
## Pull events due for the current elapsed time and phase.
|
|
## Returns Array[Dictionary] of event data to spawn.
|
|
func pull_due(elapsed_s: float, phase: int) -> Array[Dictionary]:
|
|
var due: Array[Dictionary] = []
|
|
if _scenario == null:
|
|
return due
|
|
for event in _weighted_pool:
|
|
var time_range: Vector2 = event.get("time_range_s", Vector2(0, _scenario.shift_duration_s))
|
|
if elapsed_s < time_range.x or elapsed_s > time_range.y:
|
|
continue
|
|
if event.get("phase", -1) != phase:
|
|
continue
|
|
var prob: float = event.get("probability", 0.0)
|
|
var base: float = event.get("base_weight", 1.0)
|
|
var event_id := str(event.get("id", ""))
|
|
if str(event.get("kind", "routine")) == "scripted" and event_id in _recent_scripted:
|
|
continue
|
|
var spawn_rate: float = event.get("spawn_rate", 0.0)
|
|
var due_event := false
|
|
if spawn_rate > 0.0:
|
|
# Rate-based spawning: emit at most one per second window.
|
|
if rng.randf() < clampf(spawn_rate * spawn_rate_multiplier, 0.0, 1.0):
|
|
if rng.randf() < prob:
|
|
due_event = true
|
|
else:
|
|
if rng.randf() < clampf(prob * base * spawn_rate_multiplier, 0.0, 1.0):
|
|
due_event = true
|
|
if due_event:
|
|
due.append(event.duplicate())
|
|
if str(event.get("kind", "routine")) == "scripted" and event_id != "":
|
|
_recent_scripted.append(event_id)
|
|
if _recent_scripted.size() > 4:
|
|
_recent_scripted.pop_front()
|
|
return due
|
|
|
|
## Debug/content preview: events that could be selected now, without consuming
|
|
## RNG or marking anything as recently played.
|
|
func preview(elapsed_s: float, phase: int) -> Array[String]:
|
|
var out: Array[String] = []
|
|
if _scenario == null:
|
|
return out
|
|
for event in _weighted_pool:
|
|
var time_range: Vector2 = event.get("time_range_s", Vector2(0, _scenario.shift_duration_s))
|
|
if elapsed_s < time_range.x or elapsed_s > time_range.y:
|
|
continue
|
|
if int(event.get("phase", -1)) != phase:
|
|
continue
|
|
var event_id := str(event.get("id", ""))
|
|
if str(event.get("kind", "routine")) == "scripted" and event_id in _recent_scripted:
|
|
continue
|
|
out.append("%s (%s)" % [event_id, str(event.get("kind", "routine"))])
|
|
if out.size() >= 8:
|
|
break
|
|
return out
|
|
|
|
func fallback_routine_traffic(phase: int) -> Array[Dictionary]:
|
|
## Cheap routine traffic so the street never feels empty.
|
|
var out: Array[Dictionary] = []
|
|
var pool := _routine_pool(phase)
|
|
if pool.is_empty():
|
|
return out
|
|
for i in 3:
|
|
if rng.randf() < clampf(0.35 * spawn_rate_multiplier, 0.0, 1.0):
|
|
out.append(pool[rng.randi_range(0, pool.size() - 1)].duplicate())
|
|
return out
|
|
|
|
func snapshot() -> Dictionary:
|
|
return {"rng_state": rng.state, "recent_scripted": _recent_scripted.duplicate()}
|
|
|
|
func restore(data: Dictionary) -> void:
|
|
rng.state = int(data.get("rng_state", rng.state))
|
|
_recent_scripted.clear()
|
|
for event_id in data.get("recent_scripted", []):
|
|
_recent_scripted.append(str(event_id))
|
|
|
|
func _routine_pool(phase: int) -> Array:
|
|
var out: Array = []
|
|
for event in _weighted_pool:
|
|
if event.get("kind", "routine") == "routine" and event.get("phase", -1) == phase:
|
|
out.append(event)
|
|
return out
|