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.
344 lines
19 KiB
GDScript
344 lines
19 KiB
GDScript
extends SceneTree
|
|
|
|
const CatalogType := preload("res://scripts/data/mvp_content_catalog.gd")
|
|
|
|
var _checks := 0
|
|
var _failures := 0
|
|
|
|
|
|
func _initialize() -> void:
|
|
call_deferred("_run")
|
|
|
|
|
|
func _run() -> void:
|
|
_test_baseline_catalog()
|
|
_test_guest_combinations()
|
|
_test_six_need_schema()
|
|
_test_staff_hire_boundary()
|
|
_test_unique_pair_and_defensive_copies()
|
|
_test_invalid_content_is_fail_closed()
|
|
_test_type_coercion_is_rejected()
|
|
_test_fixed_ids_namespaces_and_resource_paths()
|
|
_test_unique_character_safety_contract()
|
|
_test_numeric_and_container_limits()
|
|
_test_json_key_collisions()
|
|
_test_error_order_is_deterministic()
|
|
if _failures == 0:
|
|
print("PASS: MvpContentCatalog (%d checks)" % _checks)
|
|
else:
|
|
push_error("FAIL: MvpContentCatalog (%d of %d checks failed)" % [_failures, _checks])
|
|
quit(_failures)
|
|
|
|
|
|
func _test_baseline_catalog() -> void:
|
|
var catalog := CatalogType.new()
|
|
var report: Dictionary = catalog.rebuild()
|
|
_expect(bool(report[&"valid"]), "MVP character catalog passes strict schema validation")
|
|
_expect(int(report[&"error_count"]) == 0, "baseline character data has no validation errors")
|
|
_expect(int(report[&"counts"]["guest_classes"]) == 8, "all eight guest classes are indexed")
|
|
_expect(int(report[&"counts"]["species"]) == 4, "all four independent species are indexed")
|
|
_expect(int(report[&"counts"]["staff_candidates"]) == 21, "twenty-one free-hire candidates are indexed")
|
|
_expect(int(report[&"counts"]["unique_characters"]) == 2, "one heroine and one rival are indexed")
|
|
_expect(catalog.get_guest_class_ids() == [
|
|
&"cleric", &"diplomat", &"mage", &"merchant", &"noble", &"outsider", &"rogue", &"warrior",
|
|
], "guest classes use stable lexical order independent of JSON order")
|
|
_expect(catalog.get_species_ids() == [
|
|
&"beastfolk", &"demonfolk", &"elf", &"human",
|
|
], "species use stable lexical order")
|
|
_expect(catalog.get_staff_candidate_ids().front() == &"staff_akane", "candidate IDs are stable and namespaced")
|
|
_expect(catalog.get_staff_candidate_ids().back() == &"staff_uma", "all candidate IDs participate in deterministic order")
|
|
|
|
|
|
func _test_guest_combinations() -> void:
|
|
var catalog := CatalogType.new()
|
|
catalog.rebuild()
|
|
var definitions := catalog.build_all_guest_definitions()
|
|
_expect(definitions.size() == 32, "eight classes and four species produce 32 independent archetypes")
|
|
var ids: Array[StringName] = []
|
|
for definition in definitions:
|
|
ids.append(definition.guest_type_id)
|
|
_expect(definition.is_valid(), "generated archetype %s satisfies GuestDefinition invariants" % definition.guest_type_id)
|
|
_expect(definition.adult_verified, "generated archetype %s is adult-content gated" % definition.guest_type_id)
|
|
var unique_ids: Dictionary = {}
|
|
for definition_id: StringName in ids:
|
|
unique_ids[definition_id] = true
|
|
_expect(unique_ids.size() == 32 and not unique_ids.has(&""), "all generated archetype IDs are non-empty and unique")
|
|
var warrior_elf = catalog.build_guest_definition(&"warrior", &"elf")
|
|
_expect(warrior_elf != null and warrior_elf.guest_type_id == &"warrior_elf", "class and species IDs form a reproducible archetype ID")
|
|
_expect(warrior_elf.wealth_min == 330 and warrior_elf.wealth_max == 990, "species wealth multiplier uses integer basis points")
|
|
_expect(warrior_elf.base_patience == 70 and warrior_elf.risk_tolerance == 70, "species modifiers compose with class values once")
|
|
_expect(&"combatant" in warrior_elf.tags and &"elf" in warrior_elf.tags, "class and species tags remain separate and both survive composition")
|
|
_expect(warrior_elf.preference_weights[&"quiet"] == 8, "species preferences extend class preferences")
|
|
_expect(warrior_elf.need_rates[&"sleep"] == 5, "species Need modifiers compose on the canonical Sleep axis")
|
|
_expect(catalog.build_guest_definition(&"missing", &"human") == null, "unknown class fails closed")
|
|
_expect(catalog.build_guest_definition(&"warrior", &"missing") == null, "unknown species fails closed")
|
|
|
|
|
|
func _test_six_need_schema() -> void:
|
|
var catalog := CatalogType.new()
|
|
catalog.rebuild()
|
|
var definition = catalog.build_guest_definition(&"diplomat", &"demonfolk")
|
|
var need_ids: Array = definition.need_rates.keys()
|
|
need_ids.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b))
|
|
_expect(need_ids == [&"hunger", &"hygiene", &"intimacy", &"leisure", &"medical", &"sleep"], "generated definitions expose exactly the canonical six Need IDs")
|
|
_expect(not definition.need_rates.has(&"safety") and not definition.need_rates.has(&"social"), "Safety and social context are not persisted as Needs")
|
|
_expect(definition.need_rates[&"leisure"] == 5, "species Leisure modifier composes without being misread as Medical")
|
|
|
|
var documents := _load_documents()
|
|
documents[&"guest_classes"]["entries"][0]["need_rates"].erase("hygiene")
|
|
documents[&"guest_classes"]["entries"][0]["need_rates"]["safety"] = 1
|
|
var report := catalog.rebuild_from_documents(documents)
|
|
_expect(&"unknown_map_key" in _issue_codes(report[&"errors"]), "legacy Need names are rejected by the current content schema")
|
|
documents = _load_documents()
|
|
documents[&"guest_classes"]["entries"][0]["need_rates"].erase("hygiene")
|
|
report = catalog.rebuild_from_documents(documents)
|
|
_expect(&"missing_map_key" in _issue_codes(report[&"errors"]), "all six class Need rates are required")
|
|
documents = _load_documents()
|
|
documents[&"species"]["entries"][0]["need_rate_modifiers"]["rest"] = -1
|
|
report = catalog.rebuild_from_documents(documents)
|
|
_expect(&"unknown_map_key" in _issue_codes(report[&"errors"]), "species modifiers cannot reintroduce a migration-only alias")
|
|
|
|
|
|
func _test_staff_hire_boundary() -> void:
|
|
var catalog := CatalogType.new()
|
|
catalog.rebuild()
|
|
for candidate_id: StringName in catalog.get_staff_candidate_ids():
|
|
var candidate := catalog.get_entry(&"staff_candidates", candidate_id)
|
|
_expect(bool(candidate[&"adult_verified"]) and int(candidate[&"age"]) >= 18, "%s carries explicit adult verification" % candidate_id)
|
|
_expect(int(candidate[&"daily_wage"]) >= 0 and int(candidate[&"signing_cost"]) >= 0, "%s has non-negative employment costs" % candidate_id)
|
|
_expect((candidate[&"skills"] as Dictionary).size() == 4, "%s defines all four dispatch skills" % candidate_id)
|
|
var payload := catalog.staff_hire_payload(&"staff_klaus")
|
|
_expect(payload[&"staff_id"] == &"staff_klaus" and payload[&"role_id"] == &"guard", "hire payload preserves candidate and role IDs")
|
|
_expect(payload[&"adult_verified"] and int(payload[&"age"]) == 36, "hire payload carries the adult gate across the runtime boundary")
|
|
_expect(payload[&"preferred_shift"] == &"day", "hire payload exposes the validated preferred shift to the scheduler adapter")
|
|
_expect(payload[&"options"][&"daily_wage"] == 155 and payload[&"options"][&"employment_kind"] == &"FREE_HIRE", "runtime options preserve wage liability and ordinary employment kind")
|
|
_expect(payload[&"options"][&"skills"][&"combat"] == 82, "hire payload maps static skills into StaffRuntime options")
|
|
_expect(&"security" in payload[&"options"][&"permitted_task_tags"], "hire payload preserves permitted task tags")
|
|
var runtime := StaffRuntime.new().configure(payload[&"staff_id"], payload[&"role_id"], payload[&"options"])
|
|
_expect(runtime.is_valid() and runtime.daily_wage == 155 and runtime.get_employment_kind_name() == &"FREE_HIRE", "hire payload constructs an exact payroll-bearing StaffRuntime")
|
|
_expect(catalog.staff_hire_payload(&"missing").is_empty(), "unknown candidate cannot produce a hire payload")
|
|
|
|
|
|
func _test_unique_pair_and_defensive_copies() -> void:
|
|
var catalog := CatalogType.new()
|
|
catalog.rebuild()
|
|
var heroine := catalog.get_entry(&"unique_characters", &"heroine_anri")
|
|
var rival := catalog.get_entry(&"unique_characters", &"rival_rai")
|
|
_expect(not heroine[&"mechanical_effects"].has(&"assigned_floor_patience_loss_multiplier_bp"), "unapproved patience substitute is absent from the original Anri definition")
|
|
_expect(heroine[&"character_kind"] == &"heroine" and rival[&"character_kind"] == &"rival", "unique characters have distinct mechanical kinds")
|
|
_expect(heroine[&"counterpart_id"] == &"rival_rai" and rival[&"counterpart_id"] == &"heroine_anri", "heroine and rival references are reciprocal")
|
|
_expect(heroine[&"adult_verified"] and rival[&"adult_verified"], "both relationship participants are explicitly adult-verified")
|
|
_expect(heroine[&"mechanical_effects"][&"assigned_floor_revenue_multiplier_bp"] == 12000, "original Anri floor revenue bonus is +20 percent in integer basis points")
|
|
heroine[&"display_name"] = "tampered"
|
|
heroine[&"mechanical_effects"][&"assigned_floor_revenue_multiplier_bp"] = 1
|
|
var pristine := catalog.get_entry(&"unique_characters", &"heroine_anri")
|
|
_expect(pristine[&"display_name"] == "アンリ", "returned definitions cannot mutate catalog identity")
|
|
_expect(pristine[&"mechanical_effects"][&"assigned_floor_revenue_multiplier_bp"] == 12000, "returned nested data is also a defensive copy")
|
|
|
|
|
|
func _test_invalid_content_is_fail_closed() -> void:
|
|
var documents := _load_documents()
|
|
var staff_entries: Array = documents[&"staff_candidates"]["entries"]
|
|
staff_entries[0]["adult_verified"] = false
|
|
staff_entries[1]["age"] = 17
|
|
staff_entries[2]["traits"].append("unknown_trait")
|
|
staff_entries[3]["typo_field"] = 1
|
|
var guest_entries: Array = documents[&"guest_classes"]["entries"]
|
|
guest_entries[1]["id"] = guest_entries[0]["id"]
|
|
var catalog := CatalogType.new()
|
|
var report: Dictionary = catalog.rebuild_from_documents(documents)
|
|
_expect(not bool(report[&"valid"]), "invalid character data rejects the whole catalog")
|
|
_expect(catalog.get_staff_candidate_ids().is_empty(), "fail-closed rebuild exposes no partially valid candidates")
|
|
_expect(catalog.build_all_guest_definitions().is_empty(), "fail-closed rebuild cannot generate partial archetypes")
|
|
var codes := _issue_codes(report[&"errors"])
|
|
_expect(&"adult_verification_required" in codes, "missing adult verification has a stable error code")
|
|
_expect(&"adult_age_required" in codes, "underage data has a stable error code")
|
|
_expect(&"unknown_tag" in codes, "unknown trait is rejected instead of silently ignored")
|
|
_expect(&"unknown_field" in codes, "misspelled data fields are rejected")
|
|
_expect(&"duplicate_id" in codes, "duplicate stable IDs are rejected")
|
|
|
|
|
|
func _test_error_order_is_deterministic() -> void:
|
|
var documents := _load_documents()
|
|
documents[&"species"]["schema_version"] = 99
|
|
documents[&"unique_characters"]["entries"][0]["counterpart_id"] = "missing_actor"
|
|
var catalog := CatalogType.new()
|
|
var first: Dictionary = catalog.rebuild_from_documents(documents)
|
|
var second: Dictionary = catalog.rebuild_from_documents(documents)
|
|
_expect(first[&"errors"] == second[&"errors"], "content error order is deterministic across rebuilds")
|
|
_expect(not bool(second[&"valid"]), "unsupported schema and orphan references remain fatal")
|
|
_expect(&"unsupported_schema" in _issue_codes(second[&"errors"]), "schema rejection has a stable code")
|
|
_expect(&"missing_counterpart" in _issue_codes(second[&"errors"]), "orphan counterpart has a stable code")
|
|
|
|
|
|
func _test_type_coercion_is_rejected() -> void:
|
|
var documents := _load_documents()
|
|
documents[&"species"]["schema_version"] = "1"
|
|
documents[&"guest_classes"]["entries"][0]["wealth_min"] = "300"
|
|
documents[&"staff_candidates"]["entries"][0]["adult_verified"] = "true"
|
|
documents[&"staff_candidates"]["entries"][1]["age"] = "38"
|
|
documents[&"staff_candidates"]["entries"][2]["daily_wage"] = "145"
|
|
documents[&"unique_characters"]["entries"][0]["character_kind"] = 1
|
|
var catalog := CatalogType.new()
|
|
var report: Dictionary = catalog.rebuild_from_documents(documents)
|
|
var codes := _issue_codes(report[&"errors"])
|
|
_expect(not bool(report[&"valid"]), "stringified booleans and numbers do not pass strict content validation")
|
|
_expect(&"unsupported_schema" in codes, "string schema version is not coerced")
|
|
_expect(&"invalid_wealth_range" in codes, "string wealth is not coerced")
|
|
_expect(&"adult_verification_required" in codes, "string adult flag is not truthy-coerced")
|
|
_expect(&"adult_age_required" in codes, "string age is not coerced")
|
|
_expect(&"negative_value" in codes, "string employment cost is not coerced")
|
|
_expect(&"unknown_character_kind" in codes, "numeric character kind is not coerced")
|
|
|
|
|
|
func _test_fixed_ids_namespaces_and_resource_paths() -> void:
|
|
var documents := _load_documents()
|
|
documents[&"guest_classes"]["entries"][0]["id"] = "replacement_class"
|
|
var catalog := CatalogType.new()
|
|
var report := catalog.rebuild_from_documents(documents)
|
|
_expect(&"mvp_id_set_mismatch" in _issue_codes(report[&"errors"]), "same-count replacement cannot silently change the fixed MVP ID set")
|
|
_expect(not bool(report[&"valid"]), "fixed-ID mismatch fails closed")
|
|
|
|
documents = _load_documents()
|
|
documents[&"staff_candidates"]["entries"][0]["id"] = "akane"
|
|
report = catalog.rebuild_from_documents(documents)
|
|
_expect(&"invalid_namespace" in _issue_codes(report[&"errors"]), "free-hire actor IDs must retain the staff namespace")
|
|
|
|
documents = _load_documents()
|
|
documents[&"species"]["entries"][0]["tags"] = ["elf"]
|
|
report = catalog.rebuild_from_documents(documents)
|
|
_expect(&"species_identity_mismatch" in _issue_codes(report[&"errors"]), "species identity cannot be changed by swapping its tag")
|
|
|
|
var paths := CatalogType.DEFAULT_PATHS.duplicate(true)
|
|
paths[&"species"] = "res://data/characters/../characters/mvp_species.json"
|
|
report = catalog.rebuild(paths)
|
|
_expect(&"invalid_resource_path" in _issue_codes(report[&"errors"]), "resource traversal segments are rejected before file parsing")
|
|
_expect(catalog.get_species_ids().is_empty(), "invalid resource paths expose no stale prior catalog")
|
|
|
|
paths = CatalogType.DEFAULT_PATHS.duplicate(true)
|
|
paths.erase(&"species")
|
|
report = catalog.rebuild(paths)
|
|
_expect(&"missing_content_type" in _issue_codes(report[&"errors"]), "custom path sets must name every required document")
|
|
|
|
|
|
func _test_unique_character_safety_contract() -> void:
|
|
var documents := _load_documents()
|
|
var heroine: Dictionary = documents[&"unique_characters"]["entries"][0]
|
|
heroine["traits"].erase("unsellable")
|
|
var catalog := CatalogType.new()
|
|
var report := catalog.rebuild_from_documents(documents)
|
|
_expect(&"unique_safety_traits_required" in _issue_codes(report[&"errors"]), "unique actors cannot lose the unsellable safety marker")
|
|
|
|
documents = _load_documents()
|
|
documents[&"staff_candidates"]["entries"][0]["traits"].append("unique")
|
|
report = catalog.rebuild_from_documents(documents)
|
|
_expect(&"reserved_trait" in _issue_codes(report[&"errors"]), "free-hire candidates cannot impersonate unique actors")
|
|
|
|
documents = _load_documents()
|
|
heroine = documents[&"unique_characters"]["entries"][0]
|
|
heroine["mechanical_effects"]["arbitrary_runtime_hook"] = 1
|
|
report = catalog.rebuild_from_documents(documents)
|
|
var codes := _issue_codes(report[&"errors"])
|
|
_expect(&"invalid_mechanical_effect" in codes, "unknown mechanical effect keys are rejected")
|
|
_expect(&"effect_kind_mismatch" in codes, "effects cannot cross the heroine/rival capability boundary")
|
|
|
|
documents = _load_documents()
|
|
documents[&"unique_characters"]["entries"][0]["mechanical_effects"]["assigned_floor_revenue_multiplier_bp"] = 2_000
|
|
report = catalog.rebuild_from_documents(documents)
|
|
_expect(&"invalid_mechanical_effect" in _issue_codes(report[&"errors"]), "basis-point effects below the global multiplier floor are rejected")
|
|
|
|
documents = _load_documents()
|
|
documents[&"unique_characters"]["entries"][1]["mechanical_effects"]["base_shield_attack"] = 101
|
|
report = catalog.rebuild_from_documents(documents)
|
|
_expect(&"invalid_mechanical_effect" in _issue_codes(report[&"errors"]), "relationship attack data cannot exceed its bounded runtime scale")
|
|
|
|
|
|
func _test_numeric_and_container_limits() -> void:
|
|
var documents := _load_documents()
|
|
documents[&"staff_candidates"]["entries"][0]["age"] = CatalogType.MAX_AGE + 1
|
|
var catalog := CatalogType.new()
|
|
var report := catalog.rebuild_from_documents(documents)
|
|
_expect(&"adult_age_required" in _issue_codes(report[&"errors"]), "implausibly large ages do not enter persistent actor data")
|
|
|
|
documents = _load_documents()
|
|
documents[&"guest_classes"]["entries"][0]["wealth_max"] = CatalogType.MAX_GOLD_VALUE + 1
|
|
report = catalog.rebuild_from_documents(documents)
|
|
_expect(&"invalid_wealth_range" in _issue_codes(report[&"errors"]), "money values are bounded before basis-point multiplication")
|
|
|
|
documents = _load_documents()
|
|
documents[&"staff_candidates"]["entries"][0]["daily_wage"] = INF
|
|
report = catalog.rebuild_from_documents(documents)
|
|
_expect(&"negative_value" in _issue_codes(report[&"errors"]), "non-finite numeric input is never accepted as an integer")
|
|
|
|
documents = _load_documents()
|
|
documents[&"staff_candidates"]["entries"][0]["display_name"] = "unsafe\nname"
|
|
report = catalog.rebuild_from_documents(documents)
|
|
_expect(&"missing_display_name" in _issue_codes(report[&"errors"]), "control characters are rejected from player-visible names")
|
|
|
|
documents = _load_documents()
|
|
for raw_document: Variant in documents.values():
|
|
for raw_entry: Variant in raw_document["entries"]:
|
|
for index in 40:
|
|
raw_entry["unexpected_%02d" % index] = index
|
|
report = catalog.rebuild_from_documents(documents)
|
|
_expect(int(report[&"error_count"]) == CatalogType.MAX_ERRORS, "adversarial documents cannot amplify validation reports beyond the error cap")
|
|
_expect(bool(report[&"errors_truncated"]), "capped validation reports explicitly disclose truncation")
|
|
_expect(not bool(report[&"valid"]), "error-report bounding never weakens fail-closed validation")
|
|
|
|
|
|
func _test_json_key_collisions() -> void:
|
|
var catalog := CatalogType.new()
|
|
_expect(
|
|
catalog._validate_json_key_structure("{\"id\":1,\"id\":2}") == &"duplicate_json_key",
|
|
"duplicate JSON object keys are rejected before the parser can overwrite one value"
|
|
)
|
|
_expect(
|
|
catalog._validate_json_key_structure("{\"id\":1,\"\\u0069d\":2}") == &"duplicate_json_key",
|
|
"escaped and literal forms of the same JSON key collide canonically"
|
|
)
|
|
_expect(
|
|
catalog._validate_json_key_structure("{\"outer\":{\"age\":1,\"age\":2}}") == &"duplicate_json_key",
|
|
"duplicate-key detection covers nested content maps"
|
|
)
|
|
_expect(
|
|
catalog._validate_json_key_structure("[".repeat(CatalogType.MAX_JSON_DEPTH + 1) + "0" + "]".repeat(CatalogType.MAX_JSON_DEPTH + 1)) == &"json_structure_limit_exceeded",
|
|
"deeply nested JSON is rejected before recursive validation can exhaust the stack"
|
|
)
|
|
for path: String in CatalogType.DEFAULT_PATHS.values():
|
|
var file := FileAccess.open(path, FileAccess.READ)
|
|
_expect(catalog._validate_json_key_structure(file.get_as_text()).is_empty(), "%s has a unique bounded JSON key structure" % path.get_file())
|
|
|
|
var documents := _load_documents()
|
|
documents[&"species"]["entries"][0][42] = "not_a_field_name"
|
|
var report := catalog.rebuild_from_documents(documents)
|
|
_expect(&"invalid_key_type" in _issue_codes(report[&"errors"]), "non-string object keys are rejected without coercion")
|
|
|
|
|
|
func _load_documents() -> Dictionary:
|
|
var result: Dictionary = {}
|
|
for content_type: StringName in [&"guest_classes", &"species", &"staff_candidates", &"unique_characters"]:
|
|
var path := String(CatalogType.DEFAULT_PATHS[content_type])
|
|
var file := FileAccess.open(path, FileAccess.READ)
|
|
result[content_type] = JSON.parse_string(file.get_as_text())
|
|
return result
|
|
|
|
|
|
func _issue_codes(raw_issues: Variant) -> Array[StringName]:
|
|
var result: Array[StringName] = []
|
|
if raw_issues is Array:
|
|
for raw_issue: Variant in raw_issues:
|
|
if raw_issue is Dictionary:
|
|
var code := StringName(raw_issue.get(&"code", raw_issue.get("code", "")))
|
|
if not result.has(code):
|
|
result.append(code)
|
|
return result
|
|
|
|
|
|
func _expect(condition: bool, label: String) -> void:
|
|
_checks += 1
|
|
if condition:
|
|
print(" ✓ %s" % label)
|
|
return
|
|
_failures += 1
|
|
push_error(" ✗ %s" % label)
|