class_name MvpContentCatalog extends RefCounted ## Validated, immutable content boundary for the 14-day MVP cast. ## ## Runtime snapshots keep stable IDs; balancing values remain in JSON and are ## rebuilt through this catalog. Invalid documents are never partially exposed. const GuestDefinitionType := preload("res://scripts/guest/guest_definition.gd") const SCHEMA_VERSION := 1 const MAX_ENTRIES_PER_DOCUMENT := 128 const MAX_DOCUMENT_BYTES := 1_048_576 const MAX_CONTAINER_FIELDS := 64 const MAX_ERRORS := 256 const MAX_ID_LENGTH := 64 const MAX_DISPLAY_NAME_LENGTH := 80 const MAX_PATH_LENGTH := 512 const MAX_JSON_DEPTH := 64 const MAX_JSON_KEY_LENGTH := 128 const MAX_JSON_TOKENS := 100_000 const MAX_AGE := 10_000 const MAX_GOLD_VALUE := 1_000_000_000_000 const MAX_MECHANICAL_EFFECT_VALUE := 1_000_000 const DEFAULT_PATHS := { &"guest_classes": "res://data/characters/mvp_guest_classes.json", &"species": "res://data/characters/mvp_species.json", &"staff_candidates": "res://data/characters/mvp_staff_candidates.json", &"unique_characters": "res://data/characters/mvp_unique_characters.json", } const REQUIRED_COUNTS := { &"guest_classes": 8, &"species": 4, &"staff_candidates": 21, &"unique_characters": 2, } const REQUIRED_IDS := { &"guest_classes": [ &"cleric", &"diplomat", &"mage", &"merchant", &"noble", &"outsider", &"rogue", &"warrior", ], &"species": [&"beastfolk", &"demonfolk", &"elf", &"human"], &"staff_candidates": [ &"staff_akane", &"staff_bardo", &"staff_chika", &"staff_doran", &"staff_eri", &"staff_fugo", &"staff_gina", &"staff_haru", &"staff_iris", &"staff_juno", &"staff_klaus", &"staff_lina", &"staff_marc", &"staff_nadia", &"staff_otto", &"staff_petra", &"staff_quinn", &"staff_rika", &"staff_sven", &"staff_tessa", &"staff_uma", ], &"unique_characters": [&"heroine_anri", &"rival_rai"], } const NEED_IDS: Array[StringName] = [ &"hunger", &"hygiene", &"intimacy", &"leisure", &"medical", &"sleep", ] const SKILL_IDS: Array[StringName] = [&"combat", &"operation", &"service", &"underworld"] const SHIFT_IDS: Array[StringName] = [&"any", &"day", &"night"] const SCHEDULE_PROFILE_IDS: Array[StringName] = [&"day", &"evening", &"night"] const UNIQUE_KINDS: Array[StringName] = [&"heroine", &"rival"] const MECHANICAL_EFFECT_IDS: Array[StringName] = [ &"assigned_floor_revenue_multiplier_bp", &"base_shield_attack", &"night_shield_attack_bonus", &"cleaning_work_rate_multiplier", ] const HEROINE_EFFECT_IDS: Array[StringName] = [ &"assigned_floor_revenue_multiplier_bp", ] const RIVAL_EFFECT_IDS: Array[StringName] = [ &"base_shield_attack", &"night_shield_attack_bonus", ] # Trusted per-person declarations. JSON cannot supply or broaden these policies. # The pair below preserves the current MVP JSON contract; it is not a new # relationship assignment inferred for the other characters in original 6.2. const UNIQUE_VALIDATION_POLICIES := { &"heroine_anri": { &"character_kind": &"heroine", &"mechanical_effect_ids": HEROINE_EFFECT_IDS, &"counterpart_id": &"rival_rai", }, &"rival_rai": { &"character_kind": &"rival", &"mechanical_effect_ids": RIVAL_EFFECT_IDS, &"counterpart_id": &"heroine_anri", }, } const GUEST_TAGS: Array[StringName] = [ &"combatant", &"diplomatic", &"faith_aligned", &"mana_sensitive", &"nocturnal", &"noise_sensitive", &"stealthy", &"trade_focused", &"underworld", &"vip", ] const SPECIES_TAGS: Array[StringName] = [ &"beastfolk", &"demonfolk", &"elf", &"human", &"mana_affinity", &"night_affinity", &"noise_sensitive", ] const PREFERENCE_IDS: Array[StringName] = [ &"adult", &"faith", &"food", &"luxury", &"mana", &"nightlife", &"quiet", &"security", &"service", &"tavern", &"trade", &"underground", &"vip", ] const ROLE_IDS: Array[StringName] = [ &"cleric", &"attendant", &"bartender", &"clerk", &"cleaner", &"cook", &"guard", &"influencer", &"innkeeper", &"maintenance", &"masseur", &"medic", &"porter", &"receptionist", ] const TRAIT_IDS: Array[StringName] = [ &"calm", &"careful", &"cleanly", &"diplomatic", &"friendly", &"merchant_minded", &"mentor", &"night_owl", &"short_tempered", &"underworld_savvy", &"unique", &"unsellable", ] const TASK_TAG_IDS: Array[StringName] = [ &"accounting", &"adult_service", &"bar", &"bath", &"cleaning", &"construction", &"cooking", &"defense", &"escort", &"logistics", &"maintenance", &"massage", &"medical", &"reception", &"repair", &"rescue", &"room_service", &"security", &"service", &"supply", &"trade", ] const DOCUMENT_FIELDS: Array[StringName] = [&"content_type", &"entries", &"schema_version"] const GUEST_CLASS_FIELDS: Array[StringName] = [ &"base_patience", &"credit_limit_gold", &"display_name", &"group_size_weights", &"id", &"legal_sensitivity", &"minimum_quality", &"need_rates", &"preferences", &"quality_sensitivity", &"risk_tolerance", &"schedule_profile", &"tags", &"wealth_max", &"wealth_min", ] const SPECIES_FIELDS: Array[StringName] = [ &"adult_age", &"display_name", &"id", &"need_rate_modifiers", &"patience_bonus", &"preference_modifiers", &"risk_bonus", &"tags", &"wealth_multiplier_bp", ] const STAFF_FIELDS: Array[StringName] = [ &"adult_verified", &"age", &"daily_wage", &"display_name", &"id", &"permitted_task_tags", &"preferred_shift", &"role_id", &"signing_cost", &"skills", &"traits", ] const UNIQUE_FIELDS: Array[StringName] = [ &"adult_verified", &"age", &"character_kind", &"counterpart_id", &"display_name", &"id", &"mechanical_effects", &"preferred_shift", &"role_id", &"skills", &"traits", ] # Transient revision of validated catalog content; never serialized. var _manifest_generation := 0 var _entries_by_type: Dictionary = {} var _ordered_ids_by_type: Dictionary = {} var _errors: Array[Dictionary] = [] var _errors_truncated := false var _json_scan_text := "" var _json_scan_index := 0 var _json_scan_depth := 0 var _json_scan_tokens := 0 var _json_scan_failure: StringName = &"" var _json_last_string := "" func rebuild(paths: Dictionary = DEFAULT_PATHS) -> Dictionary: _reset() if not _validate_path_set(paths): _errors.sort_custom(_issue_less) return get_report() var documents: Dictionary = {} for content_type: StringName in _ordered_content_types(): var path := String(_value(paths, content_type, "")) if path.is_empty(): documents[content_type] = null continue documents[content_type] = _read_json_document(path, content_type) var read_errors := _errors.duplicate(true) var report := rebuild_from_documents(documents) if not read_errors.is_empty(): _errors.append_array(read_errors) _errors.sort_custom(_issue_less) _entries_by_type.clear() _ordered_ids_by_type.clear() report = get_report() return report func rebuild_from_documents(documents: Dictionary) -> Dictionary: _reset() for content_type: StringName in _ordered_content_types(): var document: Variant = _value(documents, content_type, null) _validate_and_index_document(content_type, document) _validate_cross_references() _errors.sort_custom(_issue_less) if not _errors.is_empty(): _entries_by_type.clear() _ordered_ids_by_type.clear() return get_report() func get_manifest_generation() -> int: return _manifest_generation func is_valid() -> bool: return _errors.is_empty() and _entries_by_type.size() == DEFAULT_PATHS.size() func get_report() -> Dictionary: var counts: Dictionary = {} for content_type: StringName in _ordered_content_types(): counts[String(content_type)] = get_ids(content_type).size() return { &"valid": is_valid(), &"schema_version": SCHEMA_VERSION, &"counts": counts, &"error_count": _errors.size(), &"errors_truncated": _errors_truncated, &"errors": _errors.duplicate(true), } func get_ids(content_type: StringName) -> Array[StringName]: var source: Variant = _ordered_ids_by_type.get(content_type, []) var result: Array[StringName] = [] if source is Array: for raw_id: Variant in source: result.append(StringName(raw_id)) return result func get_entry(content_type: StringName, entry_id: StringName) -> Dictionary: var index: Dictionary = _entries_by_type.get(content_type, {}) var entry: Variant = index.get(entry_id, {}) return entry.duplicate(true) if entry is Dictionary else {} func get_guest_class_ids() -> Array[StringName]: return get_ids(&"guest_classes") func get_species_ids() -> Array[StringName]: return get_ids(&"species") func get_staff_candidate_ids() -> Array[StringName]: return get_ids(&"staff_candidates") func get_unique_character_ids() -> Array[StringName]: return get_ids(&"unique_characters") func build_guest_definition(class_id: StringName, species_id: StringName) -> GuestDefinition: if not is_valid(): return null var guest_class := get_entry(&"guest_classes", class_id) var species := get_entry(&"species", species_id) if guest_class.is_empty() or species.is_empty(): return null var preference_weights := _merged_integer_maps( guest_class[&"preferences"], species[&"preference_modifiers"] ) var need_rates := _merged_integer_maps( guest_class[&"need_rates"], species[&"need_rate_modifiers"] ) for need_id: StringName in NEED_IDS: need_rates[need_id] = maxi(int(need_rates.get(need_id, 0)), 0) var tags := _merged_name_arrays(guest_class[&"tags"], species[&"tags"]) var wealth_multiplier_bp := int(species[&"wealth_multiplier_bp"]) var combined_id := StringName("%s_%s" % [class_id, species_id]) return GuestDefinitionType.new().configure(combined_id, { &"adult_verified": true, &"base_patience": clampi( int(guest_class[&"base_patience"]) + int(species[&"patience_bonus"]), 0, 100 ), &"credit_limit_gold": int(guest_class[&"credit_limit_gold"]), &"group_size_weights": guest_class[&"group_size_weights"], &"legal_sensitivity": int(guest_class[&"legal_sensitivity"]), &"minimum_quality": int(guest_class[&"minimum_quality"]), &"need_rates": need_rates, &"preference_weights": preference_weights, &"quality_sensitivity": int(guest_class[&"quality_sensitivity"]), &"risk_tolerance": clampi( int(guest_class[&"risk_tolerance"]) + int(species[&"risk_bonus"]), 0, 100 ), &"schedule_profile": StringName(guest_class[&"schedule_profile"]), &"tags": tags, &"wealth_min": int(guest_class[&"wealth_min"]) * wealth_multiplier_bp / 10000, &"wealth_max": int(guest_class[&"wealth_max"]) * wealth_multiplier_bp / 10000, }) func build_all_guest_definitions() -> Array[GuestDefinition]: var result: Array[GuestDefinition] = [] if not is_valid(): return result for class_id: StringName in get_guest_class_ids(): for species_id: StringName in get_species_ids(): var definition := build_guest_definition(class_id, species_id) if definition != null: result.append(definition) return result ## General candidates remain a separate catalog category. Only approved unique ## recruitment is exposed here; rival joining conditions are not inferred. func get_recruitable_staff_ids() -> Array[StringName]: var ids := get_staff_candidate_ids() if not get_entry(&"unique_characters", &"heroine_anri").is_empty(): ids.append(&"heroine_anri") ids.sort_custom(_name_less) return ids func get_recruitment_entry(candidate_id: StringName) -> Dictionary: return get_entry(&"unique_characters", candidate_id) if candidate_id == &"heroine_anri" else get_entry(&"staff_candidates", candidate_id) func staff_hire_payload(candidate_id: StringName) -> Dictionary: var candidate := get_recruitment_entry(candidate_id) if candidate.is_empty(): return {} return { &"staff_id": candidate_id, &"role_id": StringName(candidate[&"role_id"]), &"daily_wage": int(candidate.get(&"daily_wage", 0)), &"signing_cost": int(candidate.get(&"signing_cost", 0)), &"preferred_shift": StringName(candidate[&"preferred_shift"]), &"adult_verified": bool(candidate[&"adult_verified"]), &"age": int(candidate[&"age"]), &"options": { &"daily_wage": int(candidate.get(&"daily_wage", 0)), &"employment_kind": &"UNIQUE" if candidate_id == &"heroine_anri" else &"FREE_HIRE", &"permitted_task_tags": _to_name_array(candidate.get(&"permitted_task_tags", [])), &"role_tags": [StringName(candidate[&"role_id"])], &"skills": _name_keyed_integer_map(candidate[&"skills"]), }, } func _reset() -> void: _manifest_generation += 1 _entries_by_type.clear() _ordered_ids_by_type.clear() _errors.clear() _errors_truncated = false func _validate_path_set(paths: Dictionary) -> bool: if paths.size() > MAX_CONTAINER_FIELDS: _add_error(&"catalog", &"", &"paths", &"field_limit_exceeded", "") return false var seen: Dictionary = {} for raw_key: Variant in paths: if not _is_string_variant(raw_key): _add_error(&"catalog", &"", &"paths", &"invalid_key_type", "") continue var content_type := StringName(raw_key) if seen.has(content_type): _add_error(&"catalog", &"", &"paths", &"canonical_key_collision", String(content_type)) continue seen[content_type] = true if not DEFAULT_PATHS.has(content_type): _add_error(&"catalog", &"", &"paths", &"unknown_content_type", String(content_type)) for content_type: StringName in _ordered_content_types(): if not seen.has(content_type): _add_error(&"catalog", &"", &"paths", &"missing_content_type", String(content_type)) return _errors.is_empty() func _read_json_document(path: String, content_type: StringName) -> Variant: if path.length() > MAX_PATH_LENGTH \ or not path.begins_with("res://") \ or path.get_extension().to_lower() != "json" \ or _contains_control_character(path) \ or _path_has_traversal(path): _add_error(content_type, &"", &"resource_path", &"invalid_resource_path", path) return null var file := FileAccess.open(path, FileAccess.READ) if file == null: _add_error(content_type, &"", &"resource_path", &"resource_unreadable", path) return null if file.get_length() > MAX_DOCUMENT_BYTES: _add_error(content_type, &"", &"document", &"document_too_large", path) return null var source_text := file.get_as_text() var structure_issue := _validate_json_key_structure(source_text) if not structure_issue.is_empty(): _add_error(content_type, &"", &"document", structure_issue, path) return null var parsed: Variant = JSON.parse_string(source_text) if not parsed is Dictionary: _add_error(content_type, &"", &"document", &"invalid_json_document", path) return null return parsed func _validate_json_key_structure(source_text: String) -> StringName: _json_scan_text = source_text _json_scan_index = 0 _json_scan_depth = 0 _json_scan_tokens = 0 _json_scan_failure = &"" _json_last_string = "" _json_skip_whitespace() if not _json_scan_value(): if _json_scan_failure.is_empty(): _json_scan_failure = &"invalid_json_document" else: _json_skip_whitespace() if _json_scan_index != _json_scan_text.length(): _json_scan_failure = &"invalid_json_document" var result := _json_scan_failure _json_scan_text = "" _json_last_string = "" return result func _json_scan_value() -> bool: _json_skip_whitespace() _json_scan_tokens += 1 if _json_scan_tokens > MAX_JSON_TOKENS or _json_scan_index >= _json_scan_text.length(): _json_scan_failure = &"json_structure_limit_exceeded" return false var character := _json_scan_text[_json_scan_index] if character == "{": return _json_scan_object() if character == "[": return _json_scan_array() if character == "\"": return _json_scan_string_token() while _json_scan_index < _json_scan_text.length(): character = _json_scan_text[_json_scan_index] if character in [",", "]", "}"] or character in [" ", "\t", "\r", "\n"]: break _json_scan_index += 1 return true func _json_scan_object() -> bool: _json_scan_depth += 1 if _json_scan_depth > MAX_JSON_DEPTH: _json_scan_failure = &"json_structure_limit_exceeded" return false _json_scan_index += 1 _json_skip_whitespace() if _json_try_consume("}"): _json_scan_depth -= 1 return true var seen_keys: Dictionary = {} while _json_scan_index < _json_scan_text.length(): if not _json_scan_string_token(): return false var key := _json_last_string if key.length() > MAX_JSON_KEY_LENGTH: _json_scan_failure = &"json_structure_limit_exceeded" return false if seen_keys.has(key): _json_scan_failure = &"duplicate_json_key" return false seen_keys[key] = true _json_skip_whitespace() if not _json_consume(":"): return false if not _json_scan_value(): return false _json_skip_whitespace() if _json_try_consume("}"): _json_scan_depth -= 1 return true if not _json_consume(","): return false _json_skip_whitespace() _json_scan_failure = &"invalid_json_document" return false func _json_scan_array() -> bool: _json_scan_depth += 1 if _json_scan_depth > MAX_JSON_DEPTH: _json_scan_failure = &"json_structure_limit_exceeded" return false _json_scan_index += 1 _json_skip_whitespace() if _json_try_consume("]"): _json_scan_depth -= 1 return true while _json_scan_index < _json_scan_text.length(): if not _json_scan_value(): return false _json_skip_whitespace() if _json_try_consume("]"): _json_scan_depth -= 1 return true if not _json_consume(","): return false _json_skip_whitespace() _json_scan_failure = &"invalid_json_document" return false func _json_scan_string_token() -> bool: if not _json_consume("\""): return false var token_start := _json_scan_index - 1 while _json_scan_index < _json_scan_text.length(): var character := _json_scan_text[_json_scan_index] if character == "\\": _json_scan_index += 2 continue _json_scan_index += 1 if character == "\"": var raw_token := _json_scan_text.substr(token_start, _json_scan_index - token_start) var decoded: Variant = JSON.parse_string(raw_token) if not decoded is String: _json_scan_failure = &"invalid_json_document" return false _json_last_string = decoded return true _json_scan_failure = &"invalid_json_document" return false func _json_skip_whitespace() -> void: while _json_scan_index < _json_scan_text.length() \ and _json_scan_text[_json_scan_index] in [" ", "\t", "\r", "\n"]: _json_scan_index += 1 func _json_consume(character: String) -> bool: if _json_scan_index >= _json_scan_text.length() \ or _json_scan_text[_json_scan_index] != character: _json_scan_failure = &"invalid_json_document" return false _json_scan_index += 1 return true func _json_try_consume(character: String) -> bool: if _json_scan_index >= _json_scan_text.length() \ or _json_scan_text[_json_scan_index] != character: return false _json_scan_index += 1 return true func _validate_and_index_document(content_type: StringName, raw_document: Variant) -> void: if not raw_document is Dictionary: _add_error(content_type, &"", &"document", &"missing_document", "") return var document := raw_document as Dictionary if document.size() > MAX_CONTAINER_FIELDS: _add_error(content_type, &"", &"document", &"field_limit_exceeded", "") return _reject_unknown_fields(content_type, &"", document, DOCUMENT_FIELDS) var schema_value: Variant = _value(document, &"schema_version", null) if not _is_integer_variant(schema_value) or int(schema_value) != SCHEMA_VERSION: _add_error(content_type, &"", &"schema_version", &"unsupported_schema", "") var declared_type: Variant = _value(document, &"content_type", null) if not _is_string_variant(declared_type) or StringName(declared_type) != content_type: _add_error(content_type, &"", &"content_type", &"content_type_mismatch", "") var raw_entries: Variant = _value(document, &"entries", null) if not raw_entries is Array: _add_error(content_type, &"", &"entries", &"entries_not_array", "") return var entries := raw_entries as Array if entries.size() > MAX_ENTRIES_PER_DOCUMENT: _add_error(content_type, &"", &"entries", &"entry_limit_exceeded", "") return if entries.size() != int(REQUIRED_COUNTS[content_type]): _add_error(content_type, &"", &"entries", &"mvp_count_mismatch", "") var index: Dictionary = {} var ordered_ids: Array[StringName] = [] for raw_entry: Variant in entries: if not raw_entry is Dictionary: _add_error(content_type, &"", &"entry", &"entry_not_object", "") continue var entry := raw_entry as Dictionary if entry.size() > MAX_CONTAINER_FIELDS: _add_error(content_type, &"", &"entry", &"field_limit_exceeded", "") continue var raw_entry_id: Variant = _value(entry, &"id", null) var entry_id := StringName(raw_entry_id) if _is_string_variant(raw_entry_id) else &"" if entry_id.is_empty() or not _is_snake_case_id(String(entry_id)): _add_error(content_type, entry_id, &"id", &"invalid_id", "") continue if index.has(entry_id): _add_error(content_type, entry_id, &"id", &"duplicate_id", "") continue var valid_before := _errors.size() _validate_entry(content_type, entry_id, entry) if _errors.size() == valid_before: index[entry_id] = _normalized_entry(entry) ordered_ids.append(entry_id) ordered_ids.sort_custom(_name_less) if ordered_ids != REQUIRED_IDS[content_type]: _add_error(content_type, &"", &"id", &"mvp_id_set_mismatch", "") _entries_by_type[content_type] = index _ordered_ids_by_type[content_type] = ordered_ids func _validate_entry(content_type: StringName, entry_id: StringName, entry: Dictionary) -> void: var display_name: Variant = _value(entry, &"display_name", null) if not _is_string_variant(display_name) or not _is_safe_display_name(String(display_name)): _add_error(content_type, entry_id, &"display_name", &"missing_display_name", "") match content_type: &"guest_classes": _validate_guest_class(entry_id, entry) &"species": _validate_species(entry_id, entry) &"staff_candidates": _validate_staff(entry_id, entry) &"unique_characters": _validate_unique(entry_id, entry) func _validate_guest_class(entry_id: StringName, entry: Dictionary) -> void: var content_type := &"guest_classes" _reject_unknown_fields(content_type, entry_id, entry, GUEST_CLASS_FIELDS) _validate_bounded_int_fields(content_type, entry_id, entry, [ &"base_patience", &"legal_sensitivity", &"minimum_quality", &"quality_sensitivity", &"risk_tolerance", ], 0, 100) var raw_wealth_min: Variant = _value(entry, &"wealth_min", null) var raw_wealth_max: Variant = _value(entry, &"wealth_max", null) var wealth_min := int(raw_wealth_min) if _is_integer_variant(raw_wealth_min) else -1 var wealth_max := int(raw_wealth_max) if _is_integer_variant(raw_wealth_max) else -1 if wealth_min < 0 or wealth_max < wealth_min or wealth_max > MAX_GOLD_VALUE: _add_error(content_type, entry_id, &"wealth_min", &"invalid_wealth_range", "") for field_id: StringName in [&"credit_limit_gold"]: var field_value: Variant = _value(entry, field_id, null) if not _is_integer_variant(field_value) or int(field_value) < 0 or int(field_value) > MAX_GOLD_VALUE: _add_error(content_type, entry_id, field_id, &"negative_value", "") var raw_schedule: Variant = _value(entry, &"schedule_profile", null) var schedule := StringName(raw_schedule) if _is_string_variant(raw_schedule) else &"" if schedule.is_empty() or not SCHEDULE_PROFILE_IDS.has(schedule): _add_error(content_type, entry_id, &"schedule_profile", &"unknown_shift", "") _validate_name_array(content_type, entry_id, &"tags", _value(entry, &"tags", null), GUEST_TAGS) _validate_integer_map(content_type, entry_id, &"preferences", _value(entry, &"preferences", null), PREFERENCE_IDS, -100, 100, false) _validate_integer_map(content_type, entry_id, &"need_rates", _value(entry, &"need_rates", null), NEED_IDS, 0, 100, true) _validate_group_weights(content_type, entry_id, _value(entry, &"group_size_weights", null)) func _validate_species(entry_id: StringName, entry: Dictionary) -> void: var content_type := &"species" _reject_unknown_fields(content_type, entry_id, entry, SPECIES_FIELDS) var adult_age: Variant = _value(entry, &"adult_age", null) if not _is_integer_variant(adult_age) or int(adult_age) < 18 or int(adult_age) > MAX_AGE: _add_error(content_type, entry_id, &"adult_age", &"adult_age_required", "") var raw_wealth_multiplier: Variant = _value(entry, &"wealth_multiplier_bp", null) var wealth_multiplier := int(raw_wealth_multiplier) if _is_integer_variant(raw_wealth_multiplier) else 0 if wealth_multiplier < 2500 or wealth_multiplier > 40000: _add_error(content_type, entry_id, &"wealth_multiplier_bp", &"multiplier_out_of_range", "") _validate_bounded_int_fields(content_type, entry_id, entry, [&"patience_bonus", &"risk_bonus"], -100, 100) _validate_name_array(content_type, entry_id, &"tags", _value(entry, &"tags", null), SPECIES_TAGS) var species_tags := _to_name_array(_value(entry, &"tags", [])) var identity_tag_count := 0 for identity_tag: StringName in [&"beastfolk", &"demonfolk", &"elf", &"human"]: identity_tag_count += int(species_tags.has(identity_tag)) if not species_tags.has(entry_id) or identity_tag_count != 1: _add_error(content_type, entry_id, &"tags", &"species_identity_mismatch", "") _validate_integer_map(content_type, entry_id, &"preference_modifiers", _value(entry, &"preference_modifiers", null), PREFERENCE_IDS, -100, 100, false) _validate_integer_map(content_type, entry_id, &"need_rate_modifiers", _value(entry, &"need_rate_modifiers", null), NEED_IDS, -100, 100, false) func _validate_staff(entry_id: StringName, entry: Dictionary) -> void: var content_type := &"staff_candidates" _reject_unknown_fields(content_type, entry_id, entry, STAFF_FIELDS) _validate_adult(content_type, entry_id, entry) _validate_employment_fields(content_type, entry_id, entry) _validate_name_array(content_type, entry_id, &"permitted_task_tags", _value(entry, &"permitted_task_tags", null), TASK_TAG_IDS) if not String(entry_id).begins_with("staff_"): _add_error(content_type, entry_id, &"id", &"invalid_namespace", "staff_") var traits := _to_name_array(_value(entry, &"traits", [])) if traits.has(&"unique") or traits.has(&"unsellable"): _add_error(content_type, entry_id, &"traits", &"reserved_trait", "") func _validate_unique(entry_id: StringName, entry: Dictionary) -> void: var content_type := &"unique_characters" _reject_unknown_fields(content_type, entry_id, entry, UNIQUE_FIELDS) _validate_adult(content_type, entry_id, entry) _validate_employment_fields(content_type, entry_id, entry, false) var raw_kind: Variant = _value(entry, &"character_kind", null) var kind := StringName(raw_kind) if _is_string_variant(raw_kind) else &"" if kind.is_empty() or not UNIQUE_KINDS.has(kind): _add_error(content_type, entry_id, &"character_kind", &"unknown_character_kind", "") elif not String(entry_id).begins_with("%s_" % String(kind)): _add_error(content_type, entry_id, &"id", &"invalid_namespace", "%s_" % String(kind)) var policy: Dictionary = _unique_validation_policy(entry_id) if policy.is_empty(): _add_error(content_type, entry_id, &"id", &"missing_unique_policy", "") elif kind != StringName(policy[&"character_kind"]): _add_error(content_type, entry_id, &"character_kind", &"character_policy_mismatch", "") if policy.has(&"counterpart_id"): var raw_counterpart_id: Variant = _value(entry, &"counterpart_id", null) var counterpart_id := StringName(raw_counterpart_id) if _is_string_variant(raw_counterpart_id) else &"" if counterpart_id.is_empty() or counterpart_id == entry_id: _add_error(content_type, entry_id, &"counterpart_id", &"invalid_counterpart", "") elif not policy.is_empty() and (entry.has(&"counterpart_id") or entry.has("counterpart_id")): # An unspecified relationship is represented by omission, not null, # an empty ID, a fabricated actor or an automatic reciprocal pair. _add_error(content_type, entry_id, &"counterpart_id", &"unexpected_counterpart", "") var traits := _to_name_array(_value(entry, &"traits", [])) if not traits.has(&"unique") or not traits.has(&"unsellable"): _add_error(content_type, entry_id, &"traits", &"unique_safety_traits_required", "") var effects: Variant = _value(entry, &"mechanical_effects", null) if not effects is Dictionary or effects.is_empty(): _add_error(content_type, entry_id, &"mechanical_effects", &"missing_mechanical_effect", "") elif effects.size() > 16: _add_error(content_type, entry_id, &"mechanical_effects", &"effect_limit_exceeded", "") else: var seen_effects: Dictionary = {} for raw_key: Variant in effects: var effect_id := StringName(raw_key) if _is_string_variant(raw_key) else &"" if effect_id.is_empty() or seen_effects.has(effect_id): _add_error(content_type, entry_id, &"mechanical_effects", &"canonical_key_collision", String(effect_id)) continue seen_effects[effect_id] = true var effect_value: Variant = effects[raw_key] if not MECHANICAL_EFFECT_IDS.has(effect_id) \ or not _mechanical_effect_value_is_valid(effect_id, effect_value): _add_error(content_type, entry_id, &"mechanical_effects", &"invalid_mechanical_effect", String(raw_key)) var expected_effects: Array[StringName] = [] if not policy.is_empty(): expected_effects.assign(policy[&"mechanical_effect_ids"]) for expected_effect_id: StringName in expected_effects: if not seen_effects.has(expected_effect_id): _add_error(content_type, entry_id, &"mechanical_effects", &"missing_mechanical_effect", String(expected_effect_id)) for effect_id: StringName in seen_effects: if not expected_effects.has(effect_id): _add_error(content_type, entry_id, &"mechanical_effects", &"effect_kind_mismatch", String(effect_id)) func _unique_validation_policy(entry_id: StringName) -> Dictionary: var policy: Dictionary = UNIQUE_VALIDATION_POLICIES.get(entry_id, {}) return policy.duplicate(true) func _validate_adult(content_type: StringName, entry_id: StringName, entry: Dictionary) -> void: var verified: Variant = _value(entry, &"adult_verified", null) if not verified is bool or not bool(verified): _add_error(content_type, entry_id, &"adult_verified", &"adult_verification_required", "") var age: Variant = _value(entry, &"age", null) if not _is_integer_variant(age) or int(age) < 18 or int(age) > MAX_AGE: _add_error(content_type, entry_id, &"age", &"adult_age_required", "") func _validate_employment_fields( content_type: StringName, entry_id: StringName, entry: Dictionary, require_costs: bool = true ) -> void: var raw_role_id: Variant = _value(entry, &"role_id", null) var role_id := StringName(raw_role_id) if _is_string_variant(raw_role_id) else &"" if role_id.is_empty() or not ROLE_IDS.has(role_id): _add_error(content_type, entry_id, &"role_id", &"unknown_role", "") var raw_shift_id: Variant = _value(entry, &"preferred_shift", null) var shift_id := StringName(raw_shift_id) if _is_string_variant(raw_shift_id) else &"" if shift_id.is_empty() or not SHIFT_IDS.has(shift_id): _add_error(content_type, entry_id, &"preferred_shift", &"unknown_shift", "") if require_costs: for field_id: StringName in [&"daily_wage", &"signing_cost"]: var cost: Variant = _value(entry, field_id, null) if not _is_integer_variant(cost) or int(cost) < 0 or int(cost) > MAX_GOLD_VALUE: _add_error(content_type, entry_id, field_id, &"negative_value", "") _validate_name_array(content_type, entry_id, &"traits", _value(entry, &"traits", null), TRAIT_IDS) _validate_integer_map(content_type, entry_id, &"skills", _value(entry, &"skills", null), SKILL_IDS, 0, 100, true) func _validate_cross_references() -> void: var unique_index: Dictionary = _entries_by_type.get(&"unique_characters", {}) for entry_id: StringName in get_unique_character_ids(): var entry: Dictionary = unique_index[entry_id] var policy: Dictionary = _unique_validation_policy(entry_id) if policy.is_empty(): _add_error(&"unique_characters", entry_id, &"id", &"missing_unique_policy", "") continue if not policy.has(&"counterpart_id"): continue var counterpart_id := StringName(entry[&"counterpart_id"]) if not unique_index.has(counterpart_id): _add_error(&"unique_characters", entry_id, &"counterpart_id", &"missing_counterpart", String(counterpart_id)) continue if counterpart_id != StringName(policy[&"counterpart_id"]): _add_error(&"unique_characters", entry_id, &"counterpart_id", &"counterpart_policy_mismatch", String(counterpart_id)) var counterpart: Dictionary = unique_index[counterpart_id] var counterpart_policy: Dictionary = _unique_validation_policy(counterpart_id) if StringName(_value(counterpart, &"counterpart_id", &"")) != entry_id \ or StringName(counterpart_policy.get(&"counterpart_id", &"")) != entry_id: _add_error(&"unique_characters", entry_id, &"counterpart_id", &"counterpart_not_reciprocal", String(counterpart_id)) if StringName(counterpart[&"character_kind"]) == StringName(entry[&"character_kind"]): _add_error(&"unique_characters", entry_id, &"character_kind", &"counterpart_kind_conflict", String(counterpart_id)) var staff_index: Dictionary = _entries_by_type.get(&"staff_candidates", {}) for unique_id: StringName in unique_index: if staff_index.has(unique_id): _add_error(&"unique_characters", unique_id, &"id", &"runtime_actor_id_collision", "") func _validate_bounded_int_fields( content_type: StringName, entry_id: StringName, entry: Dictionary, fields: Array, minimum: int, maximum: int ) -> void: for raw_field: Variant in fields: var field_id := StringName(raw_field) var value: Variant = _value(entry, field_id, null) if not _is_integer_variant(value) or int(value) < minimum or int(value) > maximum: _add_error(content_type, entry_id, field_id, &"integer_out_of_range", "") func _validate_name_array( content_type: StringName, entry_id: StringName, field_id: StringName, raw_values: Variant, allowed_values: Array[StringName] ) -> void: if not raw_values is Array or raw_values.is_empty() or raw_values.size() > 32: _add_error(content_type, entry_id, field_id, &"invalid_name_array", "") return var seen: Dictionary = {} for raw_value: Variant in raw_values: var value := StringName(raw_value) if _is_string_variant(raw_value) else &"" if value.is_empty() or not allowed_values.has(value): _add_error(content_type, entry_id, field_id, &"unknown_tag", String(value)) elif seen.has(value): _add_error(content_type, entry_id, field_id, &"duplicate_tag", String(value)) seen[value] = true func _validate_integer_map( content_type: StringName, entry_id: StringName, field_id: StringName, raw_map: Variant, allowed_keys: Array[StringName], minimum: int, maximum: int, require_all: bool ) -> void: if not raw_map is Dictionary or raw_map.size() > allowed_keys.size(): _add_error(content_type, entry_id, field_id, &"invalid_integer_map", "") return var seen: Dictionary = {} for raw_key: Variant in raw_map: var key := StringName(raw_key) if _is_string_variant(raw_key) else &"" if not allowed_keys.has(key): _add_error(content_type, entry_id, field_id, &"unknown_map_key", String(key)) continue if seen.has(key): _add_error(content_type, entry_id, field_id, &"canonical_key_collision", String(key)) seen[key] = true var value: Variant = raw_map[raw_key] if not _is_integer_variant(value) or int(value) < minimum or int(value) > maximum: _add_error(content_type, entry_id, field_id, &"map_value_out_of_range", String(key)) if require_all: for required_key: StringName in allowed_keys: if not seen.has(required_key): _add_error(content_type, entry_id, field_id, &"missing_map_key", String(required_key)) func _validate_group_weights(content_type: StringName, entry_id: StringName, raw_map: Variant) -> void: if not raw_map is Dictionary or raw_map.is_empty() or raw_map.size() > 4: _add_error(content_type, entry_id, &"group_size_weights", &"invalid_group_weights", "") return var total := 0 var seen: Dictionary = {} for raw_key: Variant in raw_map: var key_text := String(raw_key) if _is_string_variant(raw_key) else "" if key_text not in ["1", "2", "3", "4"] or seen.has(key_text): _add_error(content_type, entry_id, &"group_size_weights", &"invalid_group_size", key_text) continue seen[key_text] = true var value: Variant = raw_map[raw_key] if not _is_integer_variant(value) or int(value) <= 0: _add_error(content_type, entry_id, &"group_size_weights", &"invalid_group_weight", key_text) else: total += int(value) if total != 100: _add_error(content_type, entry_id, &"group_size_weights", &"group_weights_not_100", str(total)) func _reject_unknown_fields( content_type: StringName, entry_id: StringName, value: Dictionary, allowed_fields: Array[StringName] ) -> void: if value.size() > MAX_CONTAINER_FIELDS: _add_error(content_type, entry_id, &"fields", &"field_limit_exceeded", "") return var canonical: Dictionary = {} for raw_key: Variant in value: if not _is_string_variant(raw_key): _add_error(content_type, entry_id, &"fields", &"invalid_key_type", "") continue var key := StringName(raw_key) if canonical.has(key): _add_error(content_type, entry_id, key, &"canonical_key_collision", String(key)) canonical[key] = true if not allowed_fields.has(key): _add_error(content_type, entry_id, key, &"unknown_field", String(key)) func _normalized_entry(source: Dictionary) -> Dictionary: var result: Dictionary = {} var keys: Array[String] = [] for raw_key: Variant in source: keys.append(String(raw_key)) keys.sort() for key: String in keys: var value: Variant = source.get(key, source.get(StringName(key))) result[StringName(key)] = _deep_normalize(value) return result func _deep_normalize(value: Variant) -> Variant: if value is Dictionary: var result: Dictionary = {} var keys: Array[String] = [] for raw_key: Variant in value: keys.append(String(raw_key)) keys.sort() for key: String in keys: result[StringName(key)] = _deep_normalize(value.get(key, value.get(StringName(key)))) return result if value is Array: var result: Array = [] for item: Variant in value: result.append(_deep_normalize(item)) return result if value is float and value == floor(value): return int(value) return value func _merged_integer_maps(first: Variant, second: Variant) -> Dictionary: var result: Dictionary = {} for source: Variant in [first, second]: if not source is Dictionary: continue for raw_key: Variant in source: var key := StringName(raw_key) result[key] = int(result.get(key, 0)) + int(source[raw_key]) return result func _merged_name_arrays(first: Variant, second: Variant) -> Array[StringName]: var result: Array[StringName] = [] for source: Variant in [first, second]: if not source is Array: continue for raw_value: Variant in source: var value := StringName(raw_value) if not result.has(value): result.append(value) result.sort_custom(_name_less) return result func _to_name_array(source: Variant) -> Array[StringName]: var result: Array[StringName] = [] if source is Array: for raw_value: Variant in source: result.append(StringName(raw_value)) result.sort_custom(_name_less) return result func _name_keyed_integer_map(source: Variant) -> Dictionary: var result: Dictionary = {} if source is Dictionary: for raw_key: Variant in source: result[StringName(raw_key)] = int(source[raw_key]) return result func _add_error( content_type: StringName, entry_id: StringName, field_id: StringName, code: StringName, detail: String ) -> void: if _errors.size() >= MAX_ERRORS: _errors_truncated = true return _errors.append({ &"code": code, &"content_type": content_type, &"detail": detail.left(256), &"entry_id": entry_id, &"field": field_id, }) func _ordered_content_types() -> Array[StringName]: return [&"guest_classes", &"species", &"staff_candidates", &"unique_characters"] static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant: return source.get(key, source.get(String(key), fallback)) static func _is_integer_variant(value: Variant) -> bool: return value is int or (value is float and is_finite(value) and value == floor(value)) static func _is_string_variant(value: Variant) -> bool: return value is String or value is StringName static func _is_snake_case_id(value: String) -> bool: if value.is_empty() or value.length() > MAX_ID_LENGTH or value[0] < "a" or value[0] > "z": return false for index in value.length(): var character := value[index] if not ((character >= "a" and character <= "z") or character == "_" or (character >= "0" and character <= "9")): return false return not value.ends_with("_") and not "__" in value static func _is_safe_display_name(value: String) -> bool: if value.is_empty() or value.length() > MAX_DISPLAY_NAME_LENGTH or value != value.strip_edges(): return false return not _contains_control_character(value) static func _contains_control_character(value: String) -> bool: for index in value.length(): var code := value.unicode_at(index) if code < 32 or code == 127: return true return false static func _path_has_traversal(path: String) -> bool: for segment: String in path.trim_prefix("res://").split("/", false): if segment == "." or segment == "..": return true return false static func _mechanical_effect_value_is_valid(effect_id: StringName, raw_value: Variant) -> bool: if not _is_integer_variant(raw_value): return false var value := int(raw_value) if value < 0 or value > MAX_MECHANICAL_EFFECT_VALUE: return false if effect_id == &"assigned_floor_revenue_multiplier_bp": return value >= 2500 and value <= 30_000 if effect_id in [&"base_shield_attack", &"night_shield_attack_bonus"]: return value <= 100 if effect_id == &"cleaning_work_rate_multiplier": # Original 6.2 specifies exactly three. This validator does not register # Fine, assign an employment profile or activate any Runtime effect. return value == 3 return false static func _name_less(a: StringName, b: StringName) -> bool: return String(a) < String(b) static func _issue_less(a: Dictionary, b: Dictionary) -> bool: var left := "%s|%s|%s|%s|%s" % [a[&"content_type"], a[&"entry_id"], a[&"field"], a[&"code"], a[&"detail"]] var right := "%s|%s|%s|%s|%s" % [b[&"content_type"], b[&"entry_id"], b[&"field"], b[&"code"], b[&"detail"]] return left < right