class_name TimeAnchorChartTool extends RefCounted ## Chart-side tooling for time anchors (design doc 3.4): ## generate explicit periodic anchors, validate anchor/mask configuration, ## and simulate the runtime periodic scheduler for a constant streak. const ChartEventScript := preload("res://resources/chart_event.gd") const VALID_TIME_PHASES: Array[StringName] = [&"past", &"future"] const VALID_TIME_PHASE_MASKS: Array[StringName] = [&"", &"both", &"past", &"future"] static func validate_chart(chart: Resource, beat_time := 0.5, bad_window := 0.2, judgement_scale := 1.0) -> Array[String]: var errors: Array[String] = [] if chart == null: errors.append("chart is null") return errors if bad_window * judgement_scale > beat_time * 0.5: errors.append("bad window %.3f exceeds half a beat %.3f; overlapping anchor windows at the 1-beat tier" % [bad_window * judgement_scale, beat_time * 0.5]) var initial = chart.get("initial_time_phase") if initial != null and not VALID_TIME_PHASES.has(StringName(str(initial))): errors.append("initial_time_phase must be past/future, got %s" % str(initial)) var seen_anchor_beats: Dictionary = {} for event: Resource in chart.call("all_events"): var event_key := str(event.call("key")) var mask = event.get("time_phase_mask") if mask != null and not VALID_TIME_PHASE_MASKS.has(StringName(str(mask))): errors.append("%s: invalid time_phase_mask %s" % [event_key, str(mask)]) var event_type := StringName(str(event.get("event_type"))) if event_type == &"time_anchor": if int(event.get("subdivision")) != 0: errors.append("%s: time_anchor must sit on an integer beat" % event_key) var beat := int(event.get("beat_index")) if seen_anchor_beats.has(beat): errors.append("%s: duplicate time_anchor on beat %d" % [event_key, beat]) seen_anchor_beats[beat] = true elif event_type == &"force_time_phase": var payload = event.get("payload") var target := StringName(str(payload.get("target_time_phase", ""))) if payload is Dictionary else &"" if not VALID_TIME_PHASES.has(target): errors.append("%s: force_time_phase needs payload.target_time_phase past/future" % event_key) return errors static func validate_time_phase_profile(profile: Resource) -> Array[String]: var errors: Array[String] = [] if profile == null: return errors for property_name: String in ["past_effects", "future_effects"]: var definitions = profile.get(property_name) if not definitions is Array: continue for definition: Variant in definitions: if definition is Resource and StringName(str((definition as Resource).get("duration_type"))) != &"infinite": errors.append("%s: effect %s must be duration_type infinite (lifecycle owned by TimePhaseAdapter)" % [property_name, str((definition as Resource).get("id"))]) return errors static func generate_periodic_time_anchor_events(interval_beats: int, total_beats: int, lead_beats := 2.0) -> Array[Resource]: var events: Array[Resource] = [] var safe_interval := maxi(1, interval_beats) var beat := safe_interval while beat < total_beats: var event: Resource = ChartEventScript.new() event.set("event_id", StringName("time_anchor_%d" % beat)) event.set("beat_index", beat) event.set("event_type", &"time_anchor") event.set("lead_beats", lead_beats) events.append(event) beat += safe_interval return events static func simulate_periodic_schedule(frequency_profile: Resource, streak: int, total_beats: int, explicit_beats: Array[int] = []) -> Array[int]: var window_beats := 4 if frequency_profile != null and frequency_profile.get("window_beats") != null: window_beats = maxi(1, int(frequency_profile.get("window_beats"))) var offsets: Array[int] = [window_beats] if frequency_profile != null and frequency_profile.has_method("anchor_offsets_for_streak"): var profile_offsets = frequency_profile.call("anchor_offsets_for_streak", streak) if profile_offsets is Array and not profile_offsets.is_empty(): offsets = [] for offset: Variant in profile_offsets: offsets.append(clampi(int(offset), 1, window_beats)) var scheduled: Array[int] = [] var origin := 0 while origin < total_beats: var explicit_hit := false for explicit_beat: int in explicit_beats: if explicit_beat > origin and explicit_beat <= origin + window_beats: origin = explicit_beat explicit_hit = true break if explicit_hit: continue for offset: int in offsets: var beat := origin + offset if beat < total_beats: scheduled.append(beat) origin += window_beats return scheduled