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.
771 lines
28 KiB
GDScript
771 lines
28 KiB
GDScript
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.customer_capacity < -1 or definition.service_attribute not in [&"", &"appetite", &"relaxation", &"sexual", &"gambling"]:
|
|
_add_error(&"value_out_of_range", facility_id, requested_path, &"customer_capacity", "Customer capacity or service attribute is invalid.")
|
|
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.faith_per_service < 0:
|
|
_add_error(&"invalid_service_reward", facility_id, requested_path, &"faith_per_service", "Service Faith must be non-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"])
|