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