Record original-alignment implementation and traceability checkpoint
Capture current source, content data, tests, asset licenses, and original-specification traceability. Original implementation remains incomplete. The accepted matching UI generation passed 154 suites and 12,253 checks, with eight rendered staff-contract screens reviewed. Latest native Windows and full-original acceptance remain outstanding.
This commit is contained in:
@@ -17,13 +17,13 @@ signal terminal_state_changed(old_state: StringName, new_state: StringName, reas
|
||||
|
||||
const CommandResultType = preload("res://scripts/core/command_result.gd")
|
||||
|
||||
const SNAPSHOT_VERSION := 3
|
||||
const SNAPSHOT_VERSION := 4
|
||||
const MIN_SUPPORTED_SNAPSHOT_VERSION := 1
|
||||
const MINUTES_PER_DAY := 1_440
|
||||
const DEMAND_INTERVAL_MINUTES := 60
|
||||
const ARRIVAL_INTERVAL_MINUTES := 30
|
||||
const MORNING_MINUTE_OF_DAY := 360
|
||||
const FIRST_DAILY_CLOSE_MINUTE := MORNING_MINUTE_OF_DAY + MINUTES_PER_DAY
|
||||
const FIRST_DAILY_CLOSE_MINUTE := MINUTES_PER_DAY
|
||||
const KEY_SEPARATOR := "\u001f"
|
||||
const MAX_START_MINUTE := 360
|
||||
const MAX_TRACKED_DAYS := 31
|
||||
@@ -98,6 +98,11 @@ var last_error := ""
|
||||
|
||||
var _origin_minute := 0
|
||||
|
||||
# Old saves finish their open 06:00 period once, then use midnight.
|
||||
# Historical insolvency evidence is retained without mixing it into new loss streaks.
|
||||
var _legacy_calendar_last_day := 0
|
||||
var _legacy_history_last_day := 0
|
||||
|
||||
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
|
||||
var _demand_profiles: Dictionary = {}
|
||||
var _arrival_carry: Dictionary = {}
|
||||
@@ -111,6 +116,7 @@ var _processed_arrival_minutes: Dictionary = {}
|
||||
|
||||
var _population_count := 0
|
||||
var _arrears_total := 0
|
||||
# Serialized field name retained for compatibility; new closes count net losses.
|
||||
var _consecutive_insolvency_count := 0
|
||||
var _terminal_state: StringName = TERMINAL_RUNNING
|
||||
var _next_arrival_plan_sequence := 1
|
||||
@@ -129,6 +135,7 @@ var _notification_queue: Array[Dictionary] = []
|
||||
var _notification_delivery_deferred := false
|
||||
var _initialization_valid := true
|
||||
|
||||
var _midnight_boundary := Callable()
|
||||
var _daily_close_boundary := Callable()
|
||||
var _arrival_boundary := Callable()
|
||||
var _morning_boundary := Callable()
|
||||
@@ -163,11 +170,13 @@ func _init(config: Dictionary = {}) -> void:
|
||||
func configure_callbacks(
|
||||
daily_close_boundary: Callable = Callable(),
|
||||
arrival_boundary: Callable = Callable(),
|
||||
morning_boundary: Callable = Callable()
|
||||
morning_boundary: Callable = Callable(),
|
||||
midnight_boundary: Callable = Callable()
|
||||
) -> OperationsCoordinator:
|
||||
_daily_close_boundary = daily_close_boundary
|
||||
_arrival_boundary = arrival_boundary
|
||||
_morning_boundary = morning_boundary
|
||||
_midnight_boundary = midnight_boundary
|
||||
return self
|
||||
|
||||
|
||||
@@ -548,6 +557,19 @@ func can_start_construction_or_investment() -> Dictionary:
|
||||
}
|
||||
|
||||
|
||||
## Compact, defensive projection for the continuously visible operations HUD.
|
||||
func get_dashboard_summary() -> Dictionary:
|
||||
var latest_day := -1
|
||||
for day: int in _day_summaries:
|
||||
latest_day = maxi(latest_day, day)
|
||||
var summaries: Array[Dictionary] = []
|
||||
if latest_day >= 0:
|
||||
var latest: Dictionary = _day_summaries[latest_day]
|
||||
summaries.append({&"day":latest[&"day"], &"net_profit":latest[&"net_profit"]})
|
||||
return {&"terminal_state":_terminal_state, &"consecutive_insolvency_count":_consecutive_insolvency_count,
|
||||
&"legacy_history_last_day":_legacy_history_last_day, &"day_summaries":summaries}
|
||||
|
||||
|
||||
func get_day_summary(day: int) -> Dictionary:
|
||||
if not _day_summaries.has(day):
|
||||
return {}
|
||||
@@ -598,7 +620,8 @@ func create_snapshot() -> Dictionary:
|
||||
if _notification_delivery_deferred or not _notification_queue.is_empty():
|
||||
last_error = "Deferred Operations notifications must be resolved before snapshotting."
|
||||
return {}
|
||||
var internal_report := _variant_tree_report({
|
||||
var field_cache: Dictionary = {}
|
||||
var internal_report := _snapshot_root_report({
|
||||
&"config": _config,
|
||||
&"demand_profiles": _demand_profiles,
|
||||
&"arrival_carry": _arrival_carry,
|
||||
@@ -608,7 +631,7 @@ func create_snapshot() -> Dictionary:
|
||||
&"morning_reports": _morning_reports,
|
||||
&"alerts": _alerts,
|
||||
&"quarantine_report": _quarantine_report,
|
||||
})
|
||||
}, field_cache, true)
|
||||
var shape_error := _internal_snapshot_shape_error()
|
||||
if not bool(internal_report[&"ok"]) or not shape_error.is_empty():
|
||||
last_error = "Operations state cannot be serialized: %s" % [
|
||||
@@ -636,20 +659,27 @@ func create_snapshot() -> Dictionary:
|
||||
|
||||
var saved_mornings: Array[Dictionary] = []
|
||||
for day: int in _sorted_int_keys(_morning_reports):
|
||||
saved_mornings.append((_morning_reports[day] as Dictionary).duplicate(true))
|
||||
saved_mornings.append(_morning_reports[day])
|
||||
var saved_days: Array[Dictionary] = []
|
||||
for day: int in _sorted_int_keys(_day_summaries):
|
||||
saved_days.append(_day_summaries[day])
|
||||
|
||||
# These borrowed containers never leave this synchronous method. The final
|
||||
# canonical conversion recursively creates owned dictionaries and arrays.
|
||||
var raw_snapshot := {
|
||||
&"schema_version": SNAPSHOT_VERSION,
|
||||
&"legacy_calendar_last_day": _legacy_calendar_last_day,
|
||||
&"legacy_history_last_day": _legacy_history_last_day,
|
||||
&"origin_minute": _origin_minute,
|
||||
&"current_minute": current_minute,
|
||||
&"config": _string_key_dictionary(_config),
|
||||
&"demand_profiles": saved_profiles,
|
||||
&"arrival_carry": saved_carry,
|
||||
&"demand_history": _demand_history.duplicate(true),
|
||||
&"arrival_reports": _arrival_reports.duplicate(true),
|
||||
&"day_summaries": get_day_summaries(),
|
||||
&"demand_history": _demand_history,
|
||||
&"arrival_reports": _arrival_reports,
|
||||
&"day_summaries": saved_days,
|
||||
&"morning_reports": saved_mornings,
|
||||
&"alerts": _alerts.duplicate(true),
|
||||
&"alerts": _alerts,
|
||||
&"processed_demand_minutes": _sorted_int_keys(_processed_demand_minutes),
|
||||
&"processed_arrival_minutes": _sorted_int_keys(_processed_arrival_minutes),
|
||||
&"population_count": _population_count,
|
||||
@@ -666,13 +696,13 @@ func create_snapshot() -> Dictionary:
|
||||
&"deferred_boundary_minute": _deferred_boundary_minute,
|
||||
&"pause_reason": String(_pause_reason),
|
||||
&"external_quarantined": _external_quarantined,
|
||||
&"quarantine_report": _quarantine_report.duplicate(true),
|
||||
&"quarantine_report": _quarantine_report,
|
||||
}
|
||||
var tree_report := _variant_tree_report(raw_snapshot)
|
||||
var tree_report := _snapshot_root_report(raw_snapshot, field_cache, false)
|
||||
if not bool(tree_report[&"ok"]):
|
||||
last_error = "Operations state cannot be serialized: %s" % tree_report[&"code"]
|
||||
return {}
|
||||
return _to_snapshot_variant(raw_snapshot)
|
||||
return tree_report[&"value"]
|
||||
|
||||
|
||||
func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
@@ -686,6 +716,8 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
return false
|
||||
var state: Dictionary = parsed[&"state"]
|
||||
# Atomic swap begins only after the entire candidate passed validation.
|
||||
_legacy_calendar_last_day = int(state[&"legacy_calendar_last_day"])
|
||||
_legacy_history_last_day = int(state[&"legacy_history_last_day"])
|
||||
_origin_minute = int(state[&"origin_minute"])
|
||||
current_minute = int(state[&"current_minute"])
|
||||
_config = (state[&"config"] as Dictionary).duplicate(true)
|
||||
@@ -840,9 +872,15 @@ func validate_invariants() -> Dictionary:
|
||||
|
||||
|
||||
func _process_minute_boundaries(minute: int, counts: Dictionary) -> CommandResult:
|
||||
# Fixed 06:00 boundary order: prior operating-day finance, morning
|
||||
# operations, demand, arrivals. The first 06:00 opens Day 1; the first close
|
||||
# occurs at Day 2 06:00 after a complete 24-hour operating period.
|
||||
# Migrated 06:00 financial periods retain their original payroll/history.
|
||||
# Calendar-midnight obligations still execute atomically at 00:00. Their
|
||||
# external receipt owns idempotency when a later boundary in this minute fails.
|
||||
if minute % MINUTES_PER_DAY == 0 and not _is_daily_close_boundary(minute) and _midnight_boundary.is_valid():
|
||||
var midnight := _run_boundary_transaction(_midnight_boundary, {&"boundary_minute": minute}, {&"ok": true})
|
||||
if not bool(midnight[&"ok"]):
|
||||
return _transaction_boundary_failure(midnight, &"MIDNIGHT_UPKEEP", minute, &"MIDNIGHT_UPKEEP_FAILED")
|
||||
# Finance precedes demand/arrivals at midnight. Morning work stays at 06:00.
|
||||
# During legacy migration, the final 06:00 close also precedes morning work.
|
||||
if _is_daily_close_boundary(minute):
|
||||
var close := _process_daily_close(minute)
|
||||
if not close.is_success():
|
||||
@@ -1086,7 +1124,7 @@ func _process_daily_close(boundary_minute: int) -> CommandResult:
|
||||
&"operation": &"DAILY_CLOSE",
|
||||
&"day": completed_day,
|
||||
&"boundary_minute": boundary_minute,
|
||||
&"period_start_minute": boundary_minute - MINUTES_PER_DAY,
|
||||
&"period_start_minute": get_financial_period_start(completed_day),
|
||||
&"period_end_minute_exclusive": boundary_minute,
|
||||
&"idempotency_key": "daily-close:%d" % completed_day,
|
||||
&"prior_arrears_total": _arrears_total,
|
||||
@@ -1097,7 +1135,7 @@ func _process_daily_close(boundary_minute: int) -> CommandResult:
|
||||
&"GENERATE_FACILITY_PROFIT_LOSS",
|
||||
&"DETECT_ANOMALIES",
|
||||
&"BUILD_DAY_SUMMARY",
|
||||
&"EVALUATE_ARREARS_AND_BANKRUPTCY",
|
||||
&"EVALUATE_LOSS_STREAK_AND_BANKRUPTCY",
|
||||
],
|
||||
}
|
||||
var transaction := _run_boundary_transaction(
|
||||
@@ -1141,14 +1179,15 @@ func _process_daily_close(boundary_minute: int) -> CommandResult:
|
||||
previous_profit = int((_day_summaries[completed_day - 1] as Dictionary)[&"net_profit"])
|
||||
var rolling := _rolling_profit_with(net_profit)
|
||||
var arrears := int((result[&"settlement"] as Dictionary)[&"arrears_total"])
|
||||
var consecutive := _consecutive_insolvency_count + 1 if arrears > 0 else 0
|
||||
var prior_streak := 0 if completed_day == _legacy_history_last_day + 1 else _consecutive_insolvency_count
|
||||
var consecutive := prior_streak + 1 if net_profit < 0 else 0
|
||||
var future_terminal := _terminal_state
|
||||
if consecutive >= int(_config[&"bankruptcy_close_count"]):
|
||||
future_terminal = TERMINAL_BANKRUPT
|
||||
var reconciliation_difference := int(result[&"reconciliation_difference"])
|
||||
var summary := {
|
||||
&"day": completed_day,
|
||||
&"period_start_minute": boundary_minute - MINUTES_PER_DAY,
|
||||
&"period_start_minute": get_financial_period_start(completed_day),
|
||||
&"period_end_minute_exclusive": boundary_minute,
|
||||
&"closed_at": boundary_minute,
|
||||
&"category_totals": category_totals,
|
||||
@@ -1181,6 +1220,9 @@ func _process_daily_close(boundary_minute: int) -> CommandResult:
|
||||
&"difference": reconciliation_difference,
|
||||
&"automatic_correction": false,
|
||||
})
|
||||
if net_profit < 0:
|
||||
_append_alert({&"code": &"LOSS_STREAK", &"minute": boundary_minute,
|
||||
&"day": completed_day, &"net_profit": net_profit, &"consecutive_count": consecutive})
|
||||
if arrears > 0:
|
||||
_append_alert({
|
||||
&"code": &"ARREARS_REMAIN",
|
||||
@@ -1201,7 +1243,7 @@ func _process_daily_close(boundary_minute: int) -> CommandResult:
|
||||
})
|
||||
_queue_notification(
|
||||
&"terminal",
|
||||
[old_state, _terminal_state, &"CONSECUTIVE_INSOLVENCY"]
|
||||
[old_state, _terminal_state, &"CONSECUTIVE_LOSSES"]
|
||||
)
|
||||
_queue_notification(&"daily", [summary.duplicate(true)])
|
||||
return _success(summary)
|
||||
@@ -1771,8 +1813,10 @@ func _current_snapshot_exact_shape_error(snapshot: Dictionary) -> String:
|
||||
&"deferred_boundary_minute", &"pause_reason", &"external_quarantined",
|
||||
&"quarantine_report",
|
||||
]
|
||||
if int(_value(snapshot, &"schema_version", 0)) >= 4:
|
||||
top_keys.append_array([&"legacy_calendar_last_day", &"legacy_history_last_day"])
|
||||
if not _dictionary_has_exact_string_keys(snapshot, top_keys):
|
||||
return "Operations v3 snapshot has missing or unknown root fields."
|
||||
return "Operations snapshot has missing or unknown root fields."
|
||||
|
||||
var profiles: Variant = _value(snapshot, &"demand_profiles", null)
|
||||
var carry: Variant = _value(snapshot, &"arrival_carry", null)
|
||||
@@ -1782,33 +1826,33 @@ func _current_snapshot_exact_shape_error(snapshot: Dictionary) -> String:
|
||||
var mornings: Variant = _value(snapshot, &"morning_reports", null)
|
||||
if not profiles is Array or not carry is Array or not demand_history is Array \
|
||||
or not arrivals is Array or not days is Array or not mornings is Array:
|
||||
return "Operations v3 snapshot history collections are malformed."
|
||||
return "Operations snapshot history collections are malformed."
|
||||
for raw_profile: Variant in profiles:
|
||||
if not raw_profile is Dictionary or not _dictionary_has_exact_string_keys(
|
||||
raw_profile,
|
||||
[&"facility_tag", &"guest_class", &"time_band", &"components",
|
||||
&"demand_index", &"last_evaluated_at"]
|
||||
):
|
||||
return "Operations v3 demand profile has missing or unknown fields."
|
||||
return "Operations demand profile has missing or unknown fields."
|
||||
for raw_carry: Variant in carry:
|
||||
if not raw_carry is Dictionary or not _dictionary_has_exact_string_keys(
|
||||
raw_carry, [&"facility_tag", &"guest_class", &"time_band", &"numerator"]
|
||||
):
|
||||
return "Operations v3 arrival carry row has missing or unknown fields."
|
||||
return "Operations arrival carry row has missing or unknown fields."
|
||||
for raw_report: Variant in demand_history:
|
||||
if not raw_report is Dictionary or not _dictionary_has_exact_string_keys(
|
||||
raw_report, [&"minute", &"values"]
|
||||
):
|
||||
return "Operations v3 demand history row has missing or unknown fields."
|
||||
return "Operations demand history row has missing or unknown fields."
|
||||
var values: Variant = (raw_report as Dictionary).get(&"values", null)
|
||||
if not values is Array:
|
||||
return "Operations v3 demand history values are malformed."
|
||||
return "Operations demand history values are malformed."
|
||||
for raw_value: Variant in values:
|
||||
if not raw_value is Dictionary or not _dictionary_has_exact_string_keys(
|
||||
raw_value,
|
||||
[&"facility_tag", &"guest_class", &"time_band", &"demand_index"]
|
||||
):
|
||||
return "Operations v3 demand history value has missing or unknown fields."
|
||||
return "Operations demand history value has missing or unknown fields."
|
||||
for raw_report: Variant in arrivals:
|
||||
if not raw_report is Dictionary or not _dictionary_has_exact_string_keys(
|
||||
raw_report,
|
||||
@@ -1816,18 +1860,18 @@ func _current_snapshot_exact_shape_error(snapshot: Dictionary) -> String:
|
||||
&"plans", &"total_count", &"population_after", &"capacity_remaining",
|
||||
&"carry_capped"]
|
||||
):
|
||||
return "Operations v3 arrival report has missing or unknown fields."
|
||||
return "Operations arrival report has missing or unknown fields."
|
||||
var plans: Variant = (raw_report as Dictionary).get(&"plans", null)
|
||||
var capped: Variant = (raw_report as Dictionary).get(&"carry_capped", null)
|
||||
if not plans is Array or not capped is Array:
|
||||
return "Operations v3 arrival report collections are malformed."
|
||||
return "Operations arrival report collections are malformed."
|
||||
for raw_plan: Variant in plans:
|
||||
if not raw_plan is Dictionary or not _dictionary_has_exact_string_keys(
|
||||
raw_plan,
|
||||
[&"plan_id", &"minute", &"facility_tag", &"guest_class", &"time_band",
|
||||
&"demand_index", &"count", &"guest_ids"]
|
||||
):
|
||||
return "Operations v3 arrival plan has missing or unknown fields."
|
||||
return "Operations arrival plan has missing or unknown fields."
|
||||
for raw_capped: Variant in capped:
|
||||
if not raw_capped is Dictionary or not _dictionary_has_exact_string_keys(
|
||||
raw_capped, [&"facility_tag", &"guest_class", &"discarded_numerator"]
|
||||
@@ -1918,7 +1962,7 @@ func _parse_snapshot(snapshot: Dictionary) -> Dictionary:
|
||||
var version := int(version_value)
|
||||
if version < MIN_SUPPORTED_SNAPSHOT_VERSION or version > SNAPSHOT_VERSION:
|
||||
return {&"ok": false, &"error": "Unsupported operations snapshot version."}
|
||||
if version == SNAPSHOT_VERSION:
|
||||
if version >= 3:
|
||||
var exact_shape_error := _current_snapshot_exact_shape_error(snapshot)
|
||||
if not exact_shape_error.is_empty():
|
||||
return {&"ok": false, &"error": exact_shape_error}
|
||||
@@ -2106,7 +2150,29 @@ func _parse_snapshot(snapshot: Dictionary) -> Dictionary:
|
||||
if typeof(quarantine_value) != TYPE_BOOL or not quarantine_report_value is Dictionary:
|
||||
return {&"ok": false, &"error": "External quarantine snapshot state is malformed."}
|
||||
|
||||
var legacy_history := 0
|
||||
var legacy_calendar := 0
|
||||
if version < 4:
|
||||
for day: int in histories[&"day_summaries"]:
|
||||
legacy_history = maxi(legacy_history, day)
|
||||
legacy_calendar = legacy_history + 1
|
||||
else:
|
||||
var calendar_value: Variant = _value(snapshot, &"legacy_calendar_last_day", null)
|
||||
var history_value: Variant = _value(snapshot, &"legacy_history_last_day", null)
|
||||
if not _is_integral_value(calendar_value) or not _is_integral_value(history_value):
|
||||
return {&"ok": false, &"error": "Invalid legacy calendar markers."}
|
||||
legacy_calendar = int(calendar_value)
|
||||
legacy_history = int(history_value)
|
||||
if legacy_history < 0 or legacy_calendar < 0 or legacy_calendar > MAX_TRACKED_DAYS + 1 \
|
||||
or (legacy_calendar != 0 and legacy_calendar != legacy_history + 1) \
|
||||
or (legacy_calendar == 0 and legacy_history != 0):
|
||||
return {&"ok": false, &"error": "Invalid legacy calendar transition."}
|
||||
for day in range(1, legacy_history + 1):
|
||||
if not (histories[&"day_summaries"] as Dictionary).has(day):
|
||||
return {&"ok": false, &"error": "Missing legacy financial history."}
|
||||
var state := {
|
||||
&"legacy_calendar_last_day": legacy_calendar,
|
||||
&"legacy_history_last_day": legacy_history,
|
||||
&"origin_minute": restored_origin,
|
||||
&"current_minute": restored_minute,
|
||||
&"config": normalized_config[&"config"],
|
||||
@@ -2138,6 +2204,8 @@ func _parse_snapshot(snapshot: Dictionary) -> Dictionary:
|
||||
# Validate the fully assembled state using a temporary instance without
|
||||
# copying any callback into save data.
|
||||
var candidate := OperationsCoordinator.new(state[&"config"])
|
||||
candidate._legacy_calendar_last_day = legacy_calendar
|
||||
candidate._legacy_history_last_day = legacy_history
|
||||
candidate._origin_minute = restored_origin
|
||||
candidate.current_minute = restored_minute
|
||||
candidate._config = (state[&"config"] as Dictionary).duplicate(true)
|
||||
@@ -2459,7 +2527,7 @@ func _validate_financial_history(errors: Array[Dictionary]) -> void:
|
||||
var expected_close := _daily_close_minute_for_day(day)
|
||||
if int(_value(summary, &"day", 0)) != day \
|
||||
or int(_value(summary, &"closed_at", -1)) != expected_close \
|
||||
or int(_value(summary, &"period_start_minute", -1)) != expected_close - MINUTES_PER_DAY \
|
||||
or int(_value(summary, &"period_start_minute", -1)) != get_financial_period_start(day) \
|
||||
or int(_value(summary, &"period_end_minute_exclusive", -1)) != expected_close \
|
||||
or expected_close <= _origin_minute \
|
||||
or expected_close > maximum_committed_minute:
|
||||
@@ -2520,7 +2588,10 @@ func _validate_financial_history(errors: Array[Dictionary]) -> void:
|
||||
or int(_value(settlement_value, &"arrears_total", -2)) != expected_arrears \
|
||||
or not _variant_tree_report(settlement_value)[&"ok"]:
|
||||
errors.append({&"code": &"SETTLEMENT_ARREARS_MISMATCH", &"day": day})
|
||||
expected_consecutive = expected_consecutive + 1 if expected_arrears > 0 else 0
|
||||
if day == _legacy_history_last_day + 1:
|
||||
expected_consecutive = 0
|
||||
var failed_close := expected_arrears > 0 if day <= _legacy_history_last_day else net < 0
|
||||
expected_consecutive = expected_consecutive + 1 if failed_close else 0
|
||||
if int(_value(summary, &"consecutive_insolvency_count", -1)) != expected_consecutive:
|
||||
errors.append({&"code": &"INSOLVENCY_COUNT_MISMATCH", &"day": day})
|
||||
if expected_consecutive >= int(_config[&"bankruptcy_close_count"]):
|
||||
@@ -2614,6 +2685,7 @@ func _validate_partial_boundary_prefix(minute: int, errors: Array[Dictionary]) -
|
||||
func _validate_alerts_and_pause(errors: Array[Dictionary]) -> void:
|
||||
var imbalance_by_day: Dictionary = {}
|
||||
var arrears_by_day: Dictionary = {}
|
||||
var losses_by_day: Dictionary = {}
|
||||
var bankrupt_count := 0
|
||||
var quarantine_count := 0
|
||||
for alert: Dictionary in _alerts:
|
||||
@@ -2625,6 +2697,16 @@ func _validate_alerts_and_pause(errors: Array[Dictionary]) -> void:
|
||||
&"LEDGER_IMBALANCE":
|
||||
var day := int(_value(alert, &"day", 0))
|
||||
imbalance_by_day[day] = int(imbalance_by_day.get(day, 0)) + 1
|
||||
&"LOSS_STREAK":
|
||||
var day := int(_value(alert, &"day", 0))
|
||||
losses_by_day[day] = int(losses_by_day.get(day, 0)) + 1
|
||||
var summary: Dictionary = _day_summaries.get(day, {})
|
||||
if summary.is_empty() or day <= _legacy_history_last_day \
|
||||
or int(_value(alert, &"net_profit", 0)) >= 0 \
|
||||
or _value(alert, &"minute", null) != summary.get(&"closed_at") \
|
||||
or _value(alert, &"net_profit", null) != summary.get(&"net_profit") \
|
||||
or _value(alert, &"consecutive_count", null) != summary.get(&"consecutive_insolvency_count"):
|
||||
errors.append({&"code": &"INVALID_LOSS_ALERT", &"day": day})
|
||||
&"ARREARS_REMAIN":
|
||||
var day := int(_value(alert, &"day", 0))
|
||||
arrears_by_day[day] = int(arrears_by_day.get(day, 0)) + 1
|
||||
@@ -2641,6 +2723,9 @@ func _validate_alerts_and_pause(errors: Array[Dictionary]) -> void:
|
||||
var imbalanced := int(_value(summary, &"reconciliation_difference", 0)) != 0
|
||||
if int(imbalance_by_day.get(day, 0)) != int(imbalanced):
|
||||
errors.append({&"code": &"IMBALANCE_ALERT_MISMATCH", &"day": day})
|
||||
var new_loss := day > _legacy_history_last_day and int(_value(summary, &"net_profit", 0)) < 0
|
||||
if int(losses_by_day.get(day, 0)) != int(new_loss):
|
||||
errors.append({&"code": &"LOSS_ALERT_MISMATCH", &"day": day})
|
||||
var has_arrears := int(_value(summary, &"arrears_total", 0)) > 0
|
||||
if int(arrears_by_day.get(day, 0)) != int(has_arrears):
|
||||
errors.append({&"code": &"ARREARS_ALERT_MISMATCH", &"day": day})
|
||||
@@ -2725,16 +2810,32 @@ func _minute_has_scheduled_boundary(minute: int) -> bool:
|
||||
|
||||
|
||||
func _is_daily_close_boundary(minute: int) -> bool:
|
||||
return minute >= FIRST_DAILY_CLOSE_MINUTE \
|
||||
and minute % MINUTES_PER_DAY == MORNING_MINUTE_OF_DAY
|
||||
var day := minute / MINUTES_PER_DAY
|
||||
return day > 0 and minute == get_daily_close_minute(day)
|
||||
|
||||
|
||||
func _financial_day_for_boundary(boundary_minute: int) -> int:
|
||||
return (boundary_minute - MORNING_MINUTE_OF_DAY) / MINUTES_PER_DAY
|
||||
return boundary_minute / MINUTES_PER_DAY
|
||||
|
||||
|
||||
func _daily_close_minute_for_day(day: int) -> int:
|
||||
return day * MINUTES_PER_DAY + MORNING_MINUTE_OF_DAY
|
||||
return get_daily_close_minute(day)
|
||||
|
||||
|
||||
func get_daily_close_minute(day: int) -> int:
|
||||
return day * MINUTES_PER_DAY + (MORNING_MINUTE_OF_DAY if day <= _legacy_calendar_last_day else 0)
|
||||
|
||||
|
||||
func is_legacy_financial_history(day: int) -> bool:
|
||||
return day <= _legacy_history_last_day
|
||||
|
||||
|
||||
func get_open_financial_day() -> int:
|
||||
return _day_summaries.size() + 1
|
||||
|
||||
|
||||
func get_financial_period_start(day: int) -> int:
|
||||
return get_daily_close_minute(day - 1) if day > 1 else (MORNING_MINUTE_OF_DAY if _legacy_calendar_last_day > 0 else 0)
|
||||
|
||||
|
||||
func _max_carry_for_config(config: Dictionary) -> int:
|
||||
@@ -2764,67 +2865,169 @@ static func _is_save_safe(value: Variant, depth := 0) -> bool:
|
||||
|
||||
|
||||
static func _variant_tree_report(value: Variant) -> Dictionary:
|
||||
var budget := {&"nodes": 0, &"string_length": 0}
|
||||
# Shared per-traversal counters: [visited nodes, total string length].
|
||||
var budget: Array[int] = [0, 0]
|
||||
var issue := _inspect_variant_tree(value, budget, 0)
|
||||
if not issue.is_empty():
|
||||
return {&"ok": false, &"code": issue}
|
||||
return {
|
||||
&"ok": true,
|
||||
&"code": &"OK",
|
||||
&"nodes": int(budget[&"nodes"]),
|
||||
&"string_length": int(budget[&"string_length"]),
|
||||
&"nodes": budget[0],
|
||||
&"string_length": budget[1],
|
||||
}
|
||||
|
||||
|
||||
static func _inspect_variant_tree(
|
||||
value: Variant,
|
||||
budget: Dictionary,
|
||||
budget: Array[int],
|
||||
depth: int
|
||||
) -> StringName:
|
||||
var issues: Array[StringName] = []
|
||||
_visit_snapshot_tree(value, budget, depth, issues, false)
|
||||
return issues[0] if not issues.is_empty() else &""
|
||||
|
||||
|
||||
static func _snapshot_tree_report(value: Variant) -> Dictionary:
|
||||
var budget: Array[int] = [0, 0]
|
||||
var issues: Array[StringName] = []
|
||||
var encoded: Variant = _visit_snapshot_tree(value, budget, 0, issues, true)
|
||||
if not issues.is_empty():
|
||||
return {&"ok": false, &"code": issues[0]}
|
||||
return {&"ok": true, &"code": &"OK", &"nodes": budget[0],
|
||||
&"string_length": budget[1], &"value": encoded}
|
||||
|
||||
|
||||
# Capture validated, canonical copies for unchanged root fields.
|
||||
# The cache never survives create_snapshot and only root-depth subtrees reuse it.
|
||||
# Use only for that synchronous internal-to-output conversion: these four fields
|
||||
# have identical source values and depths in both root dictionaries. No callbacks
|
||||
# or state mutation may occur between capture and reuse. Aggregate-limit failure
|
||||
# still uses the original visitor to retain its exact error precedence.
|
||||
static func _snapshot_root_report(source: Dictionary, cache: Dictionary, capture: bool) -> Dictionary:
|
||||
var budget: Array[int] = [1, 0]
|
||||
var issues: Array[StringName] = []
|
||||
var converted: Dictionary = {}
|
||||
if source.size() > MAX_SAFE_COLLECTION_SIZE:
|
||||
return {&"ok": false, &"code": &"COLLECTION_TOO_LARGE"}
|
||||
if not _canonical_key_collision(source).is_empty():
|
||||
return {&"ok": false, &"code": &"DUPLICATE_CANONICAL_KEY"}
|
||||
for key: Variant in source:
|
||||
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME, TYPE_INT]:
|
||||
return {&"ok": false, &"code": &"INVALID_DICTIONARY_KEY"}
|
||||
budget[0] += 1
|
||||
if budget[0] > MAX_VARIANT_NODES:
|
||||
return {&"ok": false, &"code": &"MAX_VARIANT_NODES"}
|
||||
var name := str(key)
|
||||
var length := name.length()
|
||||
if length > MAX_SINGLE_STRING_LENGTH:
|
||||
return {&"ok": false, &"code": &"STRING_TOO_LONG"}
|
||||
budget[1] += length
|
||||
if budget[1] > MAX_TOTAL_STRING_LENGTH:
|
||||
return {&"ok": false, &"code": &"TOTAL_STRING_BUDGET"}
|
||||
var node_start := budget[0]
|
||||
var string_start := budget[1]
|
||||
var reusable := name in ["demand_history", "arrival_reports", "alerts", "quarantine_report"]
|
||||
var value: Variant
|
||||
var cached: Dictionary = cache.get(name, {}) if not capture else {}
|
||||
if not cached.is_empty() and budget[0] + cached[&"nodes"] <= MAX_VARIANT_NODES and budget[1] + cached[&"strings"] <= MAX_TOTAL_STRING_LENGTH:
|
||||
budget[0] += cached[&"nodes"]
|
||||
budget[1] += cached[&"strings"]
|
||||
value = cached[&"value"]
|
||||
else:
|
||||
# Fall back to the original source-order visitor when an aggregate
|
||||
# limit can fail, preserving which budget error occurs first.
|
||||
value = _visit_snapshot_tree(source[key], budget, 1, issues, not capture or reusable)
|
||||
if not issues.is_empty():
|
||||
return {&"ok": false, &"code": issues[0]}
|
||||
if capture and reusable:
|
||||
cache[name] = {&"nodes": budget[0] - node_start, &"strings": budget[1] - string_start, &"value": value}
|
||||
elif not capture:
|
||||
converted[name] = value
|
||||
converted.sort()
|
||||
return {&"ok": true, &"code": &"OK", &"nodes": budget[0], &"string_length": budget[1], &"value": converted}
|
||||
|
||||
|
||||
static func _snapshot_tree_issue(issues: Array[StringName], code: StringName) -> Variant:
|
||||
issues.append(code)
|
||||
return null
|
||||
|
||||
|
||||
static func _visit_snapshot_tree(
|
||||
value: Variant,
|
||||
budget: Array[int],
|
||||
depth: int,
|
||||
issues: Array[StringName],
|
||||
encode: bool
|
||||
) -> Variant:
|
||||
# The validator and encoder share one traversal so their depth, key and
|
||||
# aggregate budgets cannot drift. Validation-only callers allocate no copies.
|
||||
if depth > MAX_VARIANT_DEPTH:
|
||||
return &"MAX_VARIANT_DEPTH"
|
||||
budget[&"nodes"] = int(budget[&"nodes"]) + 1
|
||||
if int(budget[&"nodes"]) > MAX_VARIANT_NODES:
|
||||
return &"MAX_VARIANT_NODES"
|
||||
return _snapshot_tree_issue(issues, &"MAX_VARIANT_DEPTH")
|
||||
budget[0] += 1
|
||||
if budget[0] > MAX_VARIANT_NODES:
|
||||
return _snapshot_tree_issue(issues, &"MAX_VARIANT_NODES")
|
||||
match typeof(value):
|
||||
TYPE_NIL, TYPE_BOOL, TYPE_INT:
|
||||
return &""
|
||||
return value
|
||||
TYPE_FLOAT:
|
||||
return &"" if is_finite(float(value)) else &"NON_FINITE_NUMBER"
|
||||
return value if is_finite(float(value)) else _snapshot_tree_issue(issues, &"NON_FINITE_NUMBER")
|
||||
TYPE_STRING, TYPE_STRING_NAME:
|
||||
var text := String(value)
|
||||
if text.length() > MAX_SINGLE_STRING_LENGTH:
|
||||
return &"STRING_TOO_LONG"
|
||||
budget[&"string_length"] = int(budget[&"string_length"]) + text.length()
|
||||
return &"" if int(budget[&"string_length"]) <= MAX_TOTAL_STRING_LENGTH \
|
||||
else &"TOTAL_STRING_BUDGET"
|
||||
return _snapshot_tree_issue(issues, &"STRING_TOO_LONG")
|
||||
budget[1] += text.length()
|
||||
if budget[1] > MAX_TOTAL_STRING_LENGTH:
|
||||
return _snapshot_tree_issue(issues, &"TOTAL_STRING_BUDGET")
|
||||
return text
|
||||
TYPE_ARRAY:
|
||||
var values: Array = value
|
||||
if values.size() > MAX_SAFE_COLLECTION_SIZE:
|
||||
return &"COLLECTION_TOO_LARGE"
|
||||
return _snapshot_tree_issue(issues, &"COLLECTION_TOO_LARGE")
|
||||
var converted: Variant = [] if encode else null
|
||||
for child: Variant in values:
|
||||
var issue := _inspect_variant_tree(child, budget, depth + 1)
|
||||
if not issue.is_empty():
|
||||
return issue
|
||||
return &""
|
||||
var encoded: Variant = _visit_snapshot_tree(child, budget, depth + 1, issues, encode)
|
||||
if not issues.is_empty():
|
||||
return null
|
||||
if encode:
|
||||
converted.append(encoded)
|
||||
return converted
|
||||
TYPE_DICTIONARY:
|
||||
var source: Dictionary = value
|
||||
if source.size() > MAX_SAFE_COLLECTION_SIZE:
|
||||
return &"COLLECTION_TOO_LARGE"
|
||||
return _snapshot_tree_issue(issues, &"COLLECTION_TOO_LARGE")
|
||||
if not _canonical_key_collision(source).is_empty():
|
||||
return &"DUPLICATE_CANONICAL_KEY"
|
||||
return _snapshot_tree_issue(issues, &"DUPLICATE_CANONICAL_KEY")
|
||||
var converted: Variant = {} if encode else null
|
||||
# Validate in source order, retaining the historical error precedence.
|
||||
for key: Variant in source:
|
||||
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME, TYPE_INT]:
|
||||
return &"INVALID_DICTIONARY_KEY"
|
||||
var key_issue := _inspect_variant_tree(str(key), budget, depth + 1)
|
||||
if not key_issue.is_empty():
|
||||
return key_issue
|
||||
var value_issue := _inspect_variant_tree(source[key], budget, depth + 1)
|
||||
if not value_issue.is_empty():
|
||||
return value_issue
|
||||
return &""
|
||||
return _snapshot_tree_issue(issues, &"INVALID_DICTIONARY_KEY")
|
||||
if depth + 1 > MAX_VARIANT_DEPTH:
|
||||
return _snapshot_tree_issue(issues, &"MAX_VARIANT_DEPTH")
|
||||
budget[0] += 1
|
||||
if budget[0] > MAX_VARIANT_NODES:
|
||||
return _snapshot_tree_issue(issues, &"MAX_VARIANT_NODES")
|
||||
var string_key := str(key)
|
||||
var key_length := string_key.length()
|
||||
if key_length > MAX_SINGLE_STRING_LENGTH:
|
||||
return _snapshot_tree_issue(issues, &"STRING_TOO_LONG")
|
||||
budget[1] += key_length
|
||||
if budget[1] > MAX_TOTAL_STRING_LENGTH:
|
||||
return _snapshot_tree_issue(issues, &"TOTAL_STRING_BUDGET")
|
||||
var encoded: Variant = _visit_snapshot_tree(source[key], budget, depth + 1, issues, encode)
|
||||
if not issues.is_empty():
|
||||
return null
|
||||
if encode:
|
||||
converted[string_key] = encoded
|
||||
if not encode:
|
||||
return null
|
||||
# Every key is now a unique String. Native string sorting preserves
|
||||
# canonical byte order without repeated Variant-to-string callbacks.
|
||||
converted.sort()
|
||||
return converted
|
||||
_:
|
||||
return &"UNSAVEABLE_VARIANT"
|
||||
return _snapshot_tree_issue(issues, &"UNSAVEABLE_VARIANT")
|
||||
|
||||
|
||||
static func _canonical_key_collision(source: Dictionary) -> Dictionary:
|
||||
|
||||
Reference in New Issue
Block a user