class_name FacilityUpkeepAccrual extends RefCounted ## Exact upkeep accumulation in gold-minute units. The caller supplies the ## applicable daily rates for [current_minute, target_minute); this component ## deliberately does not decide whether closed/suspended rooms are billable. ## No money is rounded, debited, or forgiven here. Settlement owns those rules. const SNAPSHOT_VERSION := 1 const MINUTES_PER_DAY := 1440 const MAX_DAILY_RATE := 1000000000 const MAX_FACILITIES := 20000 const MAX_PERIODS := 10000 const MAX_MINUTE := 1000000000 var _started_at := -1 var _current_minute := -1 var _period_start := -1 var _pending_units: Dictionary = {} var _closed_periods: Array[Dictionary] = [] func start_at(minute: int) -> Dictionary: if _started_at >= 0: return _failure(&"ALREADY_STARTED") if minute < 0 or minute > MAX_MINUTE: return _failure(&"INVALID_MINUTE") _started_at = minute _current_minute = minute _period_start = minute return {&"ok": true} func get_current_minute() -> int: return _current_minute func advance_to(minute: int, daily_rates: Dictionary) -> Dictionary: if _started_at < 0: return _failure(&"NOT_STARTED") if minute < _current_minute or minute > MAX_MINUTE: return _failure(&"INVALID_MINUTE") # A bounded batch also supports migration/testing without an unbounded loop. if minute - _current_minute > MINUTES_PER_DAY: return _failure(&"INTERVAL_TOO_LARGE") var parsed := _parse_units(daily_rates, MAX_DAILY_RATE) if not parsed[&"ok"]: return _failure(&"INVALID_RATES") var rates: Dictionary = parsed[&"value"] var touched := _pending_units.duplicate() for id: String in rates: if int(rates[id]) > 0: touched[id] = 0 if touched.size() > MAX_FACILITIES: return _failure(&"TOO_MANY_FACILITIES") var closures := minute / MINUTES_PER_DAY - _current_minute / MINUTES_PER_DAY if _closed_periods.size() + closures > MAX_PERIODS: return _failure(&"TOO_MANY_PERIODS") if minute == _current_minute: return {&"ok": true, &"advanced_minutes": 0, &"closed_periods": []} var before := _current_minute var newly_closed: Array[Dictionary] = [] while _current_minute < minute: var midnight := (_current_minute / MINUTES_PER_DAY + 1) * MINUTES_PER_DAY var until := mini(midnight, minute) var elapsed := until - _current_minute for id: String in rates: var rate := int(rates[id]) if rate > 0: _pending_units[id] = int(_pending_units.get(id, 0)) + rate * elapsed _current_minute = until if until == midnight: var closed := { &"period_start": _period_start, &"period_end": until, &"facility_units": _canonical_units(_pending_units), } _closed_periods.append(closed) newly_closed.append(closed.duplicate(true)) _pending_units = {} _period_start = until return {&"ok": true, &"advanced_minutes": minute - before, &"closed_periods": newly_closed} func get_unsettled_units(facility_id: StringName, settled_periods: int) -> int: var id := String(facility_id) var units := int(_pending_units.get(id, 0)) for index in range(clampi(settled_periods, 0, _closed_periods.size()), _closed_periods.size()): units += int(_closed_periods[index][&"facility_units"].get(id, 0)) return units func get_closed_periods_from(index: int) -> Array: return _closed_periods.slice(clampi(index, 0, _closed_periods.size())).duplicate(true) func get_report() -> Dictionary: return { &"current_minute": _current_minute, &"period_start": _period_start, &"gold_denominator": MINUTES_PER_DAY, &"pending_facility_units": _canonical_units(_pending_units), &"closed_periods": _closed_periods.duplicate(true), } func create_snapshot() -> Dictionary: return { &"version": SNAPSHOT_VERSION, &"started_at": _started_at, &"current_minute": _current_minute, &"period_start": _period_start, &"pending_units": _canonical_units(_pending_units), &"closed_periods": _closed_periods.duplicate(true), } func restore_snapshot(source: Dictionary) -> bool: # Parse and cross-check the complete candidate before mutating live state. if not _exact_keys(source, ["version", "started_at", "current_minute", "period_start", "pending_units", "closed_periods"]): return false for key: String in ["version", "started_at", "current_minute", "period_start"]: if typeof(source[key]) != TYPE_INT: return false if source["version"] != SNAPSHOT_VERSION or not source["closed_periods"] is Array: return false var started := int(source["started_at"]) var current := int(source["current_minute"]) var period_start := int(source["period_start"]) var raw_periods: Array = source["closed_periods"] if raw_periods.size() > MAX_PERIODS: return false if started == -1: if current != -1 or period_start != -1 or not raw_periods.is_empty() \ or not source["pending_units"] is Dictionary or not source["pending_units"].is_empty(): return false elif started < 0 or current < started or current > MAX_MINUTE \ or period_start < started or period_start > current \ or period_start / MINUTES_PER_DAY != current / MINUTES_PER_DAY: return false var periods: Array[Dictionary] = [] var expected_start := started for raw: Variant in raw_periods: if not raw is Dictionary or not _exact_keys(raw, ["period_start", "period_end", "facility_units"]): return false if typeof(raw["period_start"]) != TYPE_INT or typeof(raw["period_end"]) != TYPE_INT: return false var end := int(raw["period_end"]) if raw["period_start"] != expected_start or end > current \ or end != (expected_start / MINUTES_PER_DAY + 1) * MINUTES_PER_DAY: return false var units := _parse_units(raw["facility_units"], MAX_DAILY_RATE * (end - expected_start), false) if not units[&"ok"]: return false periods.append({&"period_start": expected_start, &"period_end": end, &"facility_units": units[&"value"]}) expected_start = end if expected_start != period_start: return false var pending := _parse_units(source["pending_units"], MAX_DAILY_RATE * maxi(current - period_start, 0), false) if not pending[&"ok"]: return false _started_at = started _current_minute = current _period_start = period_start _pending_units = pending[&"value"] _closed_periods = periods return true static func _parse_units(value: Variant, maximum: int, allow_zero := true) -> Dictionary: if not value is Dictionary or value.size() > MAX_FACILITIES: return {&"ok": false} var result: Dictionary = {} for raw_id: Variant in value: if typeof(raw_id) not in [TYPE_STRING, TYPE_STRING_NAME]: return {&"ok": false} var id := String(raw_id) if id.is_empty() or id.length() > 128 or result.has(id): return {&"ok": false} var amount: Variant = value[raw_id] if typeof(amount) != TYPE_INT or amount < (0 if allow_zero else 1) or amount > maximum: return {&"ok": false} result[id] = amount return {&"ok": true, &"value": _canonical_units(result)} static func _canonical_units(source: Dictionary) -> Dictionary: var keys := source.keys() keys.sort() var result: Dictionary = {} for key: Variant in keys: result[String(key)] = source[key] return result static func _exact_keys(value: Dictionary, expected: Array) -> bool: if value.size() != expected.size(): return false for key: Variant in value: if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] or String(key) not in expected: return false for key: String in expected: if not value.has(key): return false return true static func _failure(reason: StringName) -> Dictionary: return {&"ok": false, &"reason": reason}