class_name DefinitionRegistry extends RefCounted ## Loads, validates, and indexes immutable game definitions. ## ## Invalid definitions are reported but never exposed through lookup/query APIs. ## All returned definition and issue lists use a deterministic ID-first order. const DEFAULT_FACILITY_DIRECTORY := "res://data/facilities" const FACILITY_FILE_EXTENSION := "tres" const SEVERITY_ERROR := &"error" const SEVERITY_WARNING := &"warning" var _facilities_by_id: Dictionary = {} var _ordered_facility_ids: Array[StringName] = [] var _errors: Array[Dictionary] = [] var _warnings: Array[Dictionary] = [] var _source_count := 0 ## Scans the default facility directory and rebuilds the registry. func rebuild(directory_path: String = DEFAULT_FACILITY_DIRECTORY) -> Dictionary: _reset() var paths := _discover_facility_paths(directory_path) if not _errors.is_empty(): _finish_rebuild() return get_report() _load_and_index(paths) _finish_rebuild() return get_report() ## Rebuilds from an explicit path list. Primarily useful for content packs and tests. func rebuild_from_paths(paths: Array[String]) -> Dictionary: _reset() var ordered_paths := paths.duplicate() ordered_paths.sort() _load_and_index(ordered_paths) _finish_rebuild() return get_report() func is_valid() -> bool: return _errors.is_empty() func has_fatal_errors() -> bool: return not _errors.is_empty() func facility_count() -> int: return _ordered_facility_ids.size() func source_count() -> int: return _source_count func has_facility(facility_id: StringName) -> bool: return _facilities_by_id.has(facility_id) func get_facility(facility_id: StringName) -> FacilityDefinition: return _facilities_by_id.get(facility_id) as FacilityDefinition func get_facility_ids() -> Array[StringName]: return _ordered_facility_ids.duplicate() func get_all_facilities() -> Array[FacilityDefinition]: var result: Array[FacilityDefinition] = [] for facility_id in _ordered_facility_ids: result.append(_facilities_by_id[facility_id] as FacilityDefinition) return result func query_facilities_by_tag(tag: StringName) -> Array[FacilityDefinition]: var result: Array[FacilityDefinition] = [] for facility_id in _ordered_facility_ids: var definition := _facilities_by_id[facility_id] as FacilityDefinition if tag in definition.tags: result.append(definition) return result func query_facilities_by_zone(zone: int) -> Array[FacilityDefinition]: var result: Array[FacilityDefinition] = [] for facility_id in _ordered_facility_ids: var definition := _facilities_by_id[facility_id] as FacilityDefinition if definition.zone == zone: result.append(definition) return result func query_facilities_by_layer(layer: int) -> Array[FacilityDefinition]: var result: Array[FacilityDefinition] = [] for facility_id in _ordered_facility_ids: var definition := _facilities_by_id[facility_id] as FacilityDefinition if definition.layer == layer: result.append(definition) return result func get_fatal_errors() -> Array[Dictionary]: return _errors.duplicate(true) func get_warnings() -> Array[Dictionary]: return _warnings.duplicate(true) func get_report() -> Dictionary: return { &"valid": is_valid(), &"source_count": _source_count, &"definition_count": facility_count(), &"error_count": _errors.size(), &"warning_count": _warnings.size(), &"errors": get_fatal_errors(), &"warnings": get_warnings(), } func _reset() -> void: _facilities_by_id.clear() _ordered_facility_ids.clear() _errors.clear() _warnings.clear() _source_count = 0 func _discover_facility_paths(directory_path: String) -> Array[String]: var result: Array[String] = [] var directory := DirAccess.open(directory_path) if directory == null: _add_error( &"directory_unreadable", &"", directory_path, &"resource_path", "Facility definition directory cannot be opened." ) return result directory.list_dir_begin() var file_name := directory.get_next() while not file_name.is_empty(): if not directory.current_is_dir() and file_name.get_extension().to_lower() == FACILITY_FILE_EXTENSION: result.append(directory_path.path_join(file_name)) file_name = directory.get_next() directory.list_dir_end() result.sort() if result.is_empty(): _add_warning( &"no_definitions_found", &"", directory_path, &"resource_path", "No facility definitions were found." ) return result func _load_and_index(paths: Array[String]) -> void: var candidates: Array[Dictionary] = [] var seen_paths: Dictionary = {} for path in paths: _source_count += 1 if seen_paths.has(path): _add_warning( &"duplicate_source_path", &"", path, &"resource_path", "The same source path was supplied more than once." ) seen_paths[path] = true if not _validate_requested_path(path): continue var resource := ResourceLoader.load(path) if resource == null: _add_error( &"resource_load_failed", &"", path, &"resource_path", "The resource could not be loaded." ) continue if not resource is FacilityDefinition: _add_error( &"wrong_resource_type", &"", path, &"resource", "Expected FacilityDefinition, got %s." % resource.get_class() ) continue var definition := resource as FacilityDefinition candidates.append({ &"definition": definition, &"path": path, &"valid": _validate_facility(definition, path), }) candidates.sort_custom(_candidate_less) var counts_by_id: Dictionary = {} var paths_by_id: Dictionary = {} for candidate in candidates: var definition := candidate[&"definition"] as FacilityDefinition if definition.id.is_empty(): continue counts_by_id[definition.id] = int(counts_by_id.get(definition.id, 0)) + 1 if not paths_by_id.has(definition.id): paths_by_id[definition.id] = [] var id_paths := paths_by_id[definition.id] as Array id_paths.append(String(candidate[&"path"])) for candidate in candidates: var definition := candidate[&"definition"] as FacilityDefinition var path := String(candidate[&"path"]) if definition.id.is_empty(): continue if int(counts_by_id[definition.id]) > 1: var duplicate_paths := paths_by_id[definition.id] as Array _add_error( &"duplicate_id", definition.id, path, &"id", "Facility ID is duplicated by: %s." % ", ".join(duplicate_paths) ) continue if not bool(candidate[&"valid"]): continue _facilities_by_id[definition.id] = definition _ordered_facility_ids.append(definition.id) func _validate_requested_path(path: String) -> bool: var valid := true if path.is_empty(): _add_error(&"empty_resource_path", &"", path, &"resource_path", "Resource path is empty.") return false if not path.begins_with("res://"): _add_error( &"non_project_resource_path", &"", path, &"resource_path", "Facility definitions must use a res:// project path." ) valid = false if path.get_extension().to_lower() != FACILITY_FILE_EXTENSION: _add_error( &"wrong_resource_extension", &"", path, &"resource_path", "Facility definitions must use the .tres extension." ) valid = false # Exported .tres resources are remapped to binary .res files. Physical # FileAccess checks incorrectly reject them when no source checkout exists. if not ResourceLoader.exists(path): _add_error( &"resource_not_found", &"", path, &"resource_path", "Facility definition file does not exist." ) valid = false return valid func _validate_facility(definition: FacilityDefinition, requested_path: String) -> bool: var valid := true var facility_id := definition.id var actual_path := definition.resource_path if facility_id.is_empty(): _add_error(&"missing_required_value", facility_id, requested_path, &"id", "Facility ID is required.") valid = false elif not _is_snake_case_id(String(facility_id)): _add_error( &"invalid_id_format", facility_id, requested_path, &"id", "Facility ID must be lowercase ASCII snake_case and begin with a letter." ) valid = false if definition.display_name.strip_edges().is_empty(): _add_error( &"missing_required_value", facility_id, requested_path, &"display_name", "Display name is required." ) valid = false if definition.description.strip_edges().is_empty(): _add_warning( &"empty_description", facility_id, requested_path, &"description", "Description is empty." ) if actual_path.is_empty(): _add_error( &"missing_resource_path", facility_id, requested_path, &"resource_path", "Loaded FacilityDefinition has no resource_path." ) valid = false elif actual_path != requested_path: _add_error( &"resource_path_mismatch", facility_id, requested_path, &"resource_path", "Loaded resource_path '%s' does not match its requested path." % actual_path ) valid = false if definition.zone < FacilityDefinition.MapZone.ANY \ or definition.zone > FacilityDefinition.MapZone.UNDERGROUND: _add_error(&"value_out_of_range", facility_id, requested_path, &"zone", "Zone enum value is out of range.") valid = false if definition.layer < FacilityDefinition.ViewLayer.FRONT \ or definition.layer > FacilityDefinition.ViewLayer.BACK: _add_error(&"value_out_of_range", facility_id, requested_path, &"layer", "Layer enum value is out of range.") valid = false if definition.footprint.x <= 0 or definition.footprint.y <= 0: _add_error( &"value_out_of_range", facility_id, requested_path, &"footprint", "Footprint dimensions must both be greater than zero." ) valid = false if definition.build_minutes <= 0: _add_error(&"value_out_of_range", facility_id, requested_path, &"build_minutes", "Build time must be greater than zero.") valid = false if definition.weight_per_cell not in [1, 2, 3]: _add_error(&"value_out_of_range", facility_id, requested_path, &"weight_per_cell", "Structural weight must be 1, 2, or 3.") valid = false if definition.allowed_rotations.is_empty(): _add_error(&"missing_required_value", facility_id, requested_path, &"allowed_rotations", "At least one rotation is required.") valid = false else: var seen_rotations := {} for rotation in definition.allowed_rotations: if rotation not in [0, 90, 180, 270] or seen_rotations.has(rotation): _add_error(&"invalid_rotation", facility_id, requested_path, &"allowed_rotations", "Rotations must be unique quarter turns.") valid = false seen_rotations[rotation] = true if definition.staff_min < 0 or definition.staff_max < definition.staff_min: _add_error(&"value_out_of_range", facility_id, requested_path, &"staff_min", "Staff limits are inconsistent.") valid = false if definition.capacity < 0 or definition.queue_capacity < 0: _add_error(&"value_out_of_range", facility_id, requested_path, &"capacity", "Service and queue capacities cannot be negative.") valid = false if definition.base_service_minutes <= 0 or definition.base_price < 0: _add_error(&"value_out_of_range", facility_id, requested_path, &"base_service_minutes", "Service duration must be positive and price cannot be negative.") valid = false for item_id: Variant in definition.inventory_inputs: if StringName(item_id).is_empty() or int(definition.inventory_inputs[item_id]) <= 0: _add_error(&"invalid_inventory_requirement", facility_id, requested_path, &"inventory_inputs", "Inventory input ids and quantities must be positive.") valid = false for item_id: Variant in definition.inventory_outputs: if StringName(item_id).is_empty() or int(definition.inventory_outputs[item_id]) <= 0: _add_error(&"invalid_inventory_output", facility_id, requested_path, &"inventory_outputs", "Inventory output ids and quantities must be positive.") valid = false if not _validate_construction_cost(definition.construction_cost, facility_id, requested_path): valid = false if definition.maintenance_mana < 0: _add_error( &"value_out_of_range", facility_id, requested_path, &"maintenance_mana", "Maintenance mana cannot be negative." ) valid = false if definition.staff_min < 0: _add_error(&"value_out_of_range", facility_id, requested_path, &"staff_min", "Minimum staff cannot be negative.") valid = false if definition.staff_max < 0: _add_error(&"value_out_of_range", facility_id, requested_path, &"staff_max", "Maximum staff cannot be negative.") valid = false if definition.staff_max < definition.staff_min: _add_error( &"invalid_value_relation", facility_id, requested_path, &"staff_max", "Maximum staff must be greater than or equal to minimum staff." ) valid = false if definition.capacity < 0: _add_error(&"value_out_of_range", facility_id, requested_path, &"capacity", "Capacity cannot be negative.") valid = false if not _validate_tags(definition.tags, facility_id, requested_path): valid = false if not _validate_detailed_profile(definition, requested_path): valid = false if not facility_id.is_empty() and requested_path.get_file().get_basename() != String(facility_id): _add_warning( &"id_filename_mismatch", facility_id, requested_path, &"id", "Facility ID does not match the resource filename." ) return valid func _validate_detailed_profile(definition: FacilityDefinition, path: String) -> bool: var facility_id := definition.id if definition.definition_schema_version == FacilityDefinition.LEGACY_SCHEMA_VERSION: return true if definition.definition_schema_version != FacilityDefinition.DETAILED_SCHEMA_VERSION: _add_error(&"unsupported_definition_schema", facility_id, path, &"definition_schema_version", "Facility definition schema is unsupported.") return false var valid := true if definition.category.is_empty() or not _is_snake_case_id(String(definition.category)): _add_error(&"invalid_category", facility_id, path, &"category", "Detailed facilities require one snake_case category.") valid = false if not _validate_service_profiles(definition, path): valid = false for field_row: Array in [ [&"service_types", definition.service_types, true], [&"required_connection_tags", definition.required_connection_tags, false], [&"preferred_adjacency_tags", definition.preferred_adjacency_tags, false], [&"forbidden_adjacency_tags", definition.forbidden_adjacency_tags, false], [&"allowed_guest_tags", definition.allowed_guest_tags, false], [&"forbidden_guest_tags", definition.forbidden_guest_tags, false], [&"research_requirements", definition.research_requirements, false], [&"statistics_keys", definition.statistics_keys, true], ]: if not _validate_name_list(field_row[1], bool(field_row[2]), facility_id, path, field_row[0]): valid = false if not _validate_access_offsets(definition, definition.guest_access_offsets, &"guest_access_offsets", path): valid = false if not _validate_access_offsets(definition, definition.staff_access_offsets, &"staff_access_offsets", path): valid = false if not definition.pass_through and definition.guest_access_offsets.is_empty() and definition.staff_access_offsets.is_empty(): _add_error(&"missing_access_point", facility_id, path, &"guest_access_offsets", "A non-route detailed facility requires a guest or staff access point.") valid = false if definition.max_level < 1 or definition.max_level > 5 or definition.upgrade_costs.size() != definition.max_level - 1: _add_error(&"invalid_upgrade_ladder", facility_id, path, &"upgrade_costs", "Upgrade costs must contain exactly max_level - 1 entries.") valid = false for index: int in definition.upgrade_costs.size(): if not _validate_construction_cost(definition.upgrade_costs[index], facility_id, path): valid = false if definition.demolition_refund_percent < 0 or definition.demolition_refund_percent > 100: _add_error(&"value_out_of_range", facility_id, path, &"demolition_refund_percent", "Demolition refund percent must be from 0 through 100.") valid = false if definition.unlock_rank < 1 or definition.unlock_rank > 7: _add_error(&"value_out_of_range", facility_id, path, &"unlock_rank", "Unlock rank must be from 1 through 7.") valid = false var expected_schedule_keys: Array[StringName] = [&"morning", &"day", &"night"] if definition.opening_schedule.size() != expected_schedule_keys.size() \ or not definition.opening_schedule.has(&"morning") \ or not definition.opening_schedule.has(&"day") \ or not definition.opening_schedule.has(&"night"): _add_error(&"invalid_opening_schedule", facility_id, path, &"opening_schedule", "Opening schedule must contain exactly morning, day, and night.") valid = false else: for time_band: StringName in expected_schedule_keys: if not definition.opening_schedule[time_band] is bool: _add_error(&"wrong_value_type", facility_id, path, &"opening_schedule", "Opening schedule values must be booleans.") valid = false if not _validate_nonnegative_int_map(definition.incident_weights, facility_id, path, &"incident_weights"): valid = false if not _validate_policy_schema(definition.policy_schema, facility_id, path): valid = false if definition.adult_content and (not definition.tags.has(&"adult_only") or definition.content_replacement_id.is_empty()): _add_error(&"unsafe_adult_content_profile", facility_id, path, &"adult_content", "Adult content requires adult_only and a safe content replacement ID.") valid = false if not definition.adult_content and not definition.content_replacement_id.is_empty(): _add_warning(&"unused_content_replacement", facility_id, path, &"content_replacement_id", "A non-adult facility does not use a content replacement.") return valid func _validate_service_profiles(definition: FacilityDefinition, path: String) -> bool: var valid := true if definition.default_service_type.is_empty() or not definition.service_types.has(definition.default_service_type): _add_error(&"invalid_default_service", definition.id, path, &"default_service_type", "Default service must reference one declared service type.") valid = false if definition.service_profiles.size() != definition.service_types.size(): _add_error(&"service_profile_mismatch", definition.id, path, &"service_profiles", "Every declared service type requires exactly one profile.") valid = false for service_type: StringName in definition.service_types: if not definition.service_profiles.has(service_type) or not definition.service_profiles[service_type] is Dictionary: _add_error(&"missing_service_profile", definition.id, path, &"service_profiles", "Service '%s' requires a Dictionary profile." % service_type) valid = false continue var profile := definition.service_profiles[service_type] as Dictionary for required_key: StringName in [&"duration_minutes", &"base_quality", &"base_price", &"capacity_units", &"need_effects"]: if not profile.has(required_key): _add_error(&"missing_service_profile_field", definition.id, path, &"service_profiles", "Service '%s' is missing '%s'." % [service_type, required_key]) valid = false if profile.has(&"duration_minutes") and (not profile[&"duration_minutes"] is int or int(profile[&"duration_minutes"]) <= 0): _add_error(&"invalid_service_profile_value", definition.id, path, &"service_profiles", "Service '%s' duration_minutes must be a positive integer." % service_type) valid = false if profile.has(&"base_quality") and (not profile[&"base_quality"] is int or int(profile[&"base_quality"]) < 0 or int(profile[&"base_quality"]) > 100): _add_error(&"invalid_service_profile_value", definition.id, path, &"service_profiles", "Service '%s' base_quality must be an integer from 0 through 100." % service_type) valid = false if profile.has(&"base_price") and (not profile[&"base_price"] is int or int(profile[&"base_price"]) < 0): _add_error(&"invalid_service_profile_value", definition.id, path, &"service_profiles", "Service '%s' base_price must be a non-negative integer." % service_type) valid = false if profile.has(&"capacity_units") and (not profile[&"capacity_units"] is int or int(profile[&"capacity_units"]) <= 0): _add_error(&"invalid_service_profile_value", definition.id, path, &"service_profiles", "Service '%s' capacity_units must be a positive integer." % service_type) valid = false if not profile.has(&"need_effects"): pass # The missing-field issue was emitted above. elif not profile[&"need_effects"] is Dictionary: _add_error(&"invalid_service_profile_value", definition.id, path, &"service_profiles", "Service '%s' need_effects must be a Dictionary." % service_type) valid = false elif not _validate_signed_int_map(profile[&"need_effects"], -100, 100, definition.id, path, &"service_profiles"): valid = false return valid func _validate_name_list(values: Array[StringName], required: bool, facility_id: StringName, path: String, field: StringName) -> bool: if required and values.is_empty(): _add_error(&"missing_required_value", facility_id, path, field, "%s requires at least one ID." % field) return false var valid := true var seen: Dictionary = {} for value: StringName in values: if value.is_empty() or not _is_snake_case_id(String(value)) or seen.has(value): _add_error(&"invalid_id_list", facility_id, path, field, "%s must contain unique snake_case IDs." % field) valid = false seen[value] = true return valid func _validate_access_offsets(definition: FacilityDefinition, offsets: Array[Vector2i], field: StringName, path: String) -> bool: var valid := true var seen: Dictionary = {} for offset: Vector2i in offsets: if offset.x < 0 or offset.y < 0 or offset.x >= definition.footprint.x or offset.y >= definition.footprint.y or seen.has(offset): _add_error(&"invalid_access_offset", definition.id, path, field, "Access points must be unique cells inside the unrotated footprint.") valid = false seen[offset] = true return valid func _validate_nonnegative_int_map(values: Dictionary, facility_id: StringName, path: String, field: StringName) -> bool: var valid := true for raw_key: Variant in values: var key := StringName(raw_key) if key.is_empty() or not _is_snake_case_id(String(key)) or not values[raw_key] is int or int(values[raw_key]) < 0: _add_error(&"invalid_weight_map", facility_id, path, field, "%s requires snake_case IDs and non-negative integer weights." % field) valid = false return valid func _validate_signed_int_map(values: Dictionary, minimum: int, maximum: int, facility_id: StringName, path: String, field: StringName) -> bool: var valid := true for raw_key: Variant in values: var key := StringName(raw_key) if key.is_empty() or not _is_snake_case_id(String(key)) or not values[raw_key] is int or int(values[raw_key]) < minimum or int(values[raw_key]) > maximum: _add_error(&"invalid_signed_value_map", facility_id, path, field, "%s requires snake_case IDs and integer values from %d through %d." % [field, minimum, maximum]) valid = false return valid func _validate_policy_schema(values: Dictionary, facility_id: StringName, path: String) -> bool: var valid := true for raw_key: Variant in values: var key := StringName(raw_key) var row: Variant = values[raw_key] if key.is_empty() or not _is_snake_case_id(String(key)) or not row is Dictionary: _add_error(&"invalid_policy_schema", facility_id, path, &"policy_schema", "Policy entries require snake_case IDs and Dictionary schemas.") valid = false continue var schema := row as Dictionary if not schema.has(&"type") or not schema.has(&"default") or StringName(schema[&"type"]) not in [&"bool", &"enum", &"int"]: _add_error(&"invalid_policy_schema", facility_id, path, &"policy_schema", "Every policy requires type and default; type must be bool, enum, or int.") valid = false return valid func _validate_construction_cost(cost: Dictionary, facility_id: StringName, path: String) -> bool: var valid := true for required_key in [&"gold", &"materials"]: if not cost.has(required_key): _add_error( &"missing_required_key", facility_id, path, &"construction_cost", "Construction cost is missing '%s'." % required_key ) valid = false continue var value: Variant = cost[required_key] if not value is int: _add_error( &"wrong_value_type", facility_id, path, &"construction_cost", "Construction cost '%s' must be an integer." % required_key ) valid = false elif int(value) < 0: _add_error( &"value_out_of_range", facility_id, path, &"construction_cost", "Construction cost '%s' cannot be negative." % required_key ) valid = false var cost_keys := cost.keys() cost_keys.sort() for key in cost_keys: if key != &"gold" and key != &"materials": _add_warning( &"unknown_cost_key", facility_id, path, &"construction_cost", "Unknown construction cost key '%s' is ignored by the base game." % key ) return valid func _validate_tags(tags: Array[StringName], facility_id: StringName, path: String) -> bool: var valid := true var seen: Dictionary = {} for tag in tags: if tag.is_empty() or not _is_snake_case_id(String(tag)): _add_error( &"invalid_tag_format", facility_id, path, &"tags", "Tags must be non-empty lowercase ASCII snake_case identifiers." ) valid = false if seen.has(tag): _add_warning(&"duplicate_tag", facility_id, path, &"tags", "Duplicate tag '%s'." % tag) seen[tag] = true return valid func _is_snake_case_id(value: String) -> bool: if value.is_empty(): return false var first := value.unicode_at(0) if first < 97 or first > 122: return false var previous_was_underscore := false for index in value.length(): var character := value.unicode_at(index) var is_letter := character >= 97 and character <= 122 var is_number := character >= 48 and character <= 57 var is_underscore := character == 95 if not is_letter and not is_number and not is_underscore: return false if is_underscore and previous_was_underscore: return false previous_was_underscore = is_underscore return not previous_was_underscore func _finish_rebuild() -> void: _ordered_facility_ids.sort_custom(_id_less) _errors.sort_custom(_issue_less) _warnings.sort_custom(_issue_less) func _add_error( code: StringName, definition_id: StringName, resource_path: String, field: StringName, message: String ) -> void: _errors.append(_make_issue(SEVERITY_ERROR, code, definition_id, resource_path, field, message)) func _add_warning( code: StringName, definition_id: StringName, resource_path: String, field: StringName, message: String ) -> void: _warnings.append(_make_issue(SEVERITY_WARNING, code, definition_id, resource_path, field, message)) func _make_issue( severity: StringName, code: StringName, definition_id: StringName, resource_path: String, field: StringName, message: String ) -> Dictionary: return { &"severity": severity, &"code": code, &"definition_id": definition_id, &"resource_path": resource_path, &"field": field, &"message": message, } func _candidate_less(left: Dictionary, right: Dictionary) -> bool: var left_definition := left[&"definition"] as FacilityDefinition var right_definition := right[&"definition"] as FacilityDefinition var left_id := String(left_definition.id) var right_id := String(right_definition.id) if left_id != right_id: return left_id < right_id return String(left[&"path"]) < String(right[&"path"]) func _id_less(left: StringName, right: StringName) -> bool: return String(left) < String(right) func _issue_less(left: Dictionary, right: Dictionary) -> bool: var left_id := String(left[&"definition_id"]) var right_id := String(right[&"definition_id"]) if left_id != right_id: return left_id < right_id var left_path := String(left[&"resource_path"]) var right_path := String(right[&"resource_path"]) if left_path != right_path: return left_path < right_path var left_field := String(left[&"field"]) var right_field := String(right[&"field"]) if left_field != right_field: return left_field < right_field var left_code := String(left[&"code"]) var right_code := String(right[&"code"]) if left_code != right_code: return left_code < right_code return String(left[&"message"]) < String(right[&"message"])