Files
TheTree_mock/scripts/data/mvp_content_catalog.gd
Takeshi 5dde0c5ec2 Record original-alignment implementation and traceability checkpoint
Capture current source, content data, tests, asset licenses, and original-specification traceability. Original implementation remains incomplete. The accepted matching UI generation passed 154 suites and 12,253 checks, with eight rendered staff-contract screens reviewed. Latest native Windows and full-original acceptance remain outstanding.
2026-09-30 23:52:24 +09:00

1071 lines
41 KiB
GDScript

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