365 lines
13 KiB
GDScript
365 lines
13 KiB
GDScript
extends RefCounted
|
|
|
|
const CATALOG_PATH := "res://p10_0/catalog.json"
|
|
const SCHEMA_VERSION := 1
|
|
const SOURCE_PATH := "prototype/site/data/catalog.json"
|
|
const SOURCE_SHA256 := "fb99bcd75ca6308091fa56b45f8da551276a496a1f96acbb44225efe491fdfef"
|
|
const SOURCE_REVISION := "phase8-p8-3r2-contact-rear-actions-replacement"
|
|
|
|
const FAMILY_COUNTS := {
|
|
"professions": 36,
|
|
"traits": 36,
|
|
"equipment": 40,
|
|
"artifacts": 8,
|
|
"enemies": 12,
|
|
"bosses": 2,
|
|
"contracts": 2,
|
|
}
|
|
|
|
const MECHANIC_FIELDS := {
|
|
"professions": ["rules", "cadence", "strategy"],
|
|
"traits": ["benefit", "cost", "prohibited"],
|
|
"equipment": ["effect", "tradeoff", "fit"],
|
|
"artifacts": ["rule", "tradeoff"],
|
|
"enemies": ["rule", "responses"],
|
|
"bosses": ["rule", "responses"],
|
|
"region": ["pressure", "progression"],
|
|
"contracts": ["region_id", "round_count", "boss_id", "clear_fact", "first_clear_renown"],
|
|
}
|
|
|
|
const FILLER_TERMS := ["todo", "tbd", "placeholder", "same as", "copy of", "generic effect"]
|
|
|
|
|
|
static func snapshot(path: String = CATALOG_PATH) -> Dictionary:
|
|
var text := FileAccess.get_file_as_string(path)
|
|
if text.is_empty():
|
|
return {"load_error": "catalog_unreadable:%s" % path}
|
|
var parsed = JSON.parse_string(text)
|
|
if not parsed is Dictionary:
|
|
return {"load_error": "catalog_not_dictionary"}
|
|
var content: Dictionary = parsed.duplicate(true)
|
|
_enrich_rows(content)
|
|
content["catalog_hash"] = _hash(content)
|
|
return content
|
|
|
|
|
|
static func validate(content: Dictionary) -> Dictionary:
|
|
var errors: Array[String] = []
|
|
if content.has("load_error"):
|
|
errors.append(String(content.load_error))
|
|
return {"ok": false, "errors": errors}
|
|
if int(content.get("schema", -1)) != SCHEMA_VERSION:
|
|
errors.append("schema")
|
|
_validate_source(content.get("source", {}), errors)
|
|
var names = content.get("names_zh", {})
|
|
var execution_tags = content.get("execution_tags", {})
|
|
if not names is Dictionary:
|
|
errors.append("names_zh_type")
|
|
names = {}
|
|
if not execution_tags is Dictionary:
|
|
errors.append("execution_tags_type")
|
|
execution_tags = {}
|
|
|
|
var all_ids := {}
|
|
var rows_by_family := {}
|
|
for family in FAMILY_COUNTS:
|
|
var rows = content.get(family, [])
|
|
if not rows is Array:
|
|
errors.append("family_type:%s" % family)
|
|
continue
|
|
if rows.size() != int(FAMILY_COUNTS[family]):
|
|
errors.append("family_count:%s" % family)
|
|
var by_id := {}
|
|
for row in rows:
|
|
_validate_row(row, String(family), names, execution_tags, all_ids, by_id, errors)
|
|
rows_by_family[family] = by_id
|
|
|
|
var region = content.get("region", {})
|
|
_validate_row(region, "region", names, execution_tags, all_ids, {}, errors)
|
|
_validate_contiguous_ids(rows_by_family, errors)
|
|
_validate_profession_tree(rows_by_family.get("professions", {}), errors)
|
|
_validate_references(rows_by_family, region, errors)
|
|
_validate_authority_maps(all_ids, names, execution_tags, errors)
|
|
_validate_rule_signatures(content, errors)
|
|
_validate_catalog_hash(content, errors)
|
|
return {"ok": errors.is_empty(), "errors": errors}
|
|
|
|
|
|
static func rows_by_id(content: Dictionary, family: String) -> Dictionary:
|
|
var result := {}
|
|
for row in content.get(family, []):
|
|
if row is Dictionary:
|
|
result[String(row.get("id", ""))] = row
|
|
return result
|
|
|
|
|
|
static func localized_name(content: Dictionary, row_id: String, locale: String) -> String:
|
|
if locale == "zh_CN":
|
|
return String(content.get("names_zh", {}).get(row_id, ""))
|
|
for family in FAMILY_COUNTS:
|
|
for row in content.get(family, []):
|
|
if String(row.get("id", "")) == row_id:
|
|
return String(row.get("name", ""))
|
|
var region = content.get("region", {})
|
|
return String(region.get("name", "")) if String(region.get("id", "")) == row_id else ""
|
|
|
|
|
|
static func _enrich_rows(content: Dictionary) -> void:
|
|
var names: Dictionary = content.get("names_zh", {})
|
|
var execution_tags: Dictionary = content.get("execution_tags", {})
|
|
for family in FAMILY_COUNTS:
|
|
for row in content.get(family, []):
|
|
if not row is Dictionary:
|
|
continue
|
|
_enrich_row(row, String(family), names, execution_tags)
|
|
var region = content.get("region", {})
|
|
if region is Dictionary:
|
|
_enrich_row(region, "region", names, execution_tags)
|
|
|
|
|
|
static func _enrich_row(row: Dictionary, family: String, names: Dictionary, execution_tags: Dictionary) -> void:
|
|
var row_id := String(row.get("id", ""))
|
|
row["name_en"] = String(row.get("name", ""))
|
|
row["name_zh"] = String(names.get(row_id, ""))
|
|
row["execution_key"] = "execute.%s" % row_id.to_lower().replace("-", "_")
|
|
row["semantic_tags"] = Array(execution_tags.get(row_id, [])).duplicate()
|
|
row["preview_rule"] = _preview_rule(row, family)
|
|
row["rule_signature"] = _rule_signature(row, family)
|
|
if family == "professions":
|
|
row["parent_profession_id"] = _profession_parent(row)
|
|
|
|
|
|
static func _validate_source(source, errors: Array[String]) -> void:
|
|
if not source is Dictionary:
|
|
errors.append("source_type")
|
|
return
|
|
if String(source.get("path", "")) != SOURCE_PATH:
|
|
errors.append("source_path")
|
|
if String(source.get("sha256", "")) != SOURCE_SHA256:
|
|
errors.append("source_sha256")
|
|
if String(source.get("authority_revision", "")) != SOURCE_REVISION:
|
|
errors.append("source_revision")
|
|
|
|
|
|
static func _validate_row(row, family: String, names: Dictionary, execution_tags: Dictionary, all_ids: Dictionary, by_id: Dictionary, errors: Array[String]) -> void:
|
|
if not row is Dictionary:
|
|
errors.append("row_type:%s" % family)
|
|
return
|
|
var row_id := String(row.get("id", ""))
|
|
if row_id.is_empty():
|
|
errors.append("row_id:%s" % family)
|
|
return
|
|
if by_id.has(row_id) or all_ids.has(row_id):
|
|
errors.append("duplicate_id:%s" % row_id)
|
|
by_id[row_id] = row
|
|
all_ids[row_id] = family
|
|
var name_en := String(row.get("name_en", row.get("name", "")))
|
|
var name_zh := String(row.get("name_zh", names.get(row_id, "")))
|
|
if name_en.strip_edges().is_empty():
|
|
errors.append("name_en:%s" % row_id)
|
|
if name_zh.strip_edges().is_empty() or name_zh == name_en:
|
|
errors.append("name_zh:%s" % row_id)
|
|
var tags = row.get("semantic_tags", execution_tags.get(row_id, []))
|
|
if not tags is Array or tags.size() < 3:
|
|
errors.append("execution_tags:%s" % row_id)
|
|
else:
|
|
var seen_tags := {}
|
|
for tag in tags:
|
|
var value := String(tag)
|
|
if not _is_semantic_tag(value) or seen_tags.has(value):
|
|
errors.append("execution_tag:%s:%s" % [row_id, value])
|
|
seen_tags[value] = true
|
|
if String(row.get("execution_key", "")).is_empty():
|
|
errors.append("execution_key:%s" % row_id)
|
|
if String(row.get("preview_rule", "")).strip_edges().is_empty():
|
|
errors.append("preview_rule:%s" % row_id)
|
|
if not _is_lower_hex(String(row.get("rule_signature", "")), 64):
|
|
errors.append("rule_signature:%s" % row_id)
|
|
|
|
|
|
static func _validate_contiguous_ids(rows: Dictionary, errors: Array[String]) -> void:
|
|
var expected := {
|
|
"professions": _id_range("PF-B", 12) + _id_range("PF-A", 24),
|
|
"traits": _id_range("TR-", 36, 3),
|
|
"equipment": _id_range("EQ-", 40, 3),
|
|
"artifacts": _id_range("AR-", 8, 3),
|
|
"enemies": _id_range("EN-R1-", 12),
|
|
"bosses": _id_range("BO-R1-", 2),
|
|
"contracts": _id_range("CTR-R1-", 2),
|
|
}
|
|
for family in expected:
|
|
var actual: Array = rows.get(family, {}).keys()
|
|
actual.sort()
|
|
var wanted: Array = expected[family]
|
|
wanted.sort()
|
|
if actual != wanted:
|
|
errors.append("contiguous_ids:%s" % family)
|
|
|
|
|
|
static func _validate_profession_tree(professions: Dictionary, errors: Array[String]) -> void:
|
|
var descendants := {}
|
|
for base_id in _id_range("PF-B", 12):
|
|
descendants[base_id] = []
|
|
for row_id in professions:
|
|
var row: Dictionary = professions[row_id]
|
|
var family := String(row.get("family", ""))
|
|
var parent := String(row.get("parent_profession_id", _profession_parent(row)))
|
|
if row_id.begins_with("PF-B"):
|
|
if family != "base" or not parent.is_empty():
|
|
errors.append("profession_base:%s" % row_id)
|
|
elif row_id.begins_with("PF-A"):
|
|
if family != "advanced" or not descendants.has(parent):
|
|
errors.append("profession_parent:%s" % row_id)
|
|
else:
|
|
descendants[parent].append(row_id)
|
|
for base_id in descendants:
|
|
if descendants[base_id].size() != 2:
|
|
errors.append("profession_descendants:%s" % base_id)
|
|
|
|
|
|
static func _validate_references(rows: Dictionary, region, errors: Array[String]) -> void:
|
|
if not region is Dictionary or String(region.get("id", "")) != "RG-01":
|
|
errors.append("region_id")
|
|
return
|
|
var bosses: Dictionary = rows.get("bosses", {})
|
|
for contract_id in rows.get("contracts", {}):
|
|
var contract: Dictionary = rows.contracts[contract_id]
|
|
if String(contract.get("region_id", "")) != "RG-01":
|
|
errors.append("contract_region:%s" % contract_id)
|
|
if not bosses.has(String(contract.get("boss_id", ""))):
|
|
errors.append("contract_boss:%s" % contract_id)
|
|
if int(contract.get("round_count", 0)) != 12:
|
|
errors.append("contract_rounds:%s" % contract_id)
|
|
if String(rows.contracts.get("CTR-R1-01", {}).get("boss_id", "")) != "BO-R1-01":
|
|
errors.append("contract_order:CTR-R1-01")
|
|
if String(rows.contracts.get("CTR-R1-02", {}).get("boss_id", "")) != "BO-R1-02":
|
|
errors.append("contract_order:CTR-R1-02")
|
|
|
|
|
|
static func _validate_authority_maps(all_ids: Dictionary, names: Dictionary, execution_tags: Dictionary, errors: Array[String]) -> void:
|
|
var expected: Array = all_ids.keys()
|
|
var name_ids: Array = names.keys()
|
|
var execution_ids: Array = execution_tags.keys()
|
|
expected.sort()
|
|
name_ids.sort()
|
|
execution_ids.sort()
|
|
if name_ids != expected:
|
|
errors.append("names_zh_ids")
|
|
if execution_ids != expected:
|
|
errors.append("execution_tag_ids")
|
|
|
|
|
|
static func _validate_rule_signatures(content: Dictionary, errors: Array[String]) -> void:
|
|
var seen := {}
|
|
for family in FAMILY_COUNTS:
|
|
for row in content.get(family, []):
|
|
_validate_rule_signature(row, String(family), seen, errors)
|
|
_validate_rule_signature(content.get("region", {}), "region", seen, errors)
|
|
|
|
|
|
static func _validate_rule_signature(row, family: String, seen: Dictionary, errors: Array[String]) -> void:
|
|
if not row is Dictionary:
|
|
return
|
|
var row_id := String(row.get("id", ""))
|
|
var signature := _rule_signature(row, family)
|
|
if String(row.get("rule_signature", "")) != signature:
|
|
errors.append("rule_signature_drift:%s" % row_id)
|
|
if seen.has(signature):
|
|
errors.append("filler_equivalent:%s:%s" % [seen[signature], row_id])
|
|
seen[signature] = row_id
|
|
var prose := _mechanic_text(row, family).to_lower()
|
|
for term in FILLER_TERMS:
|
|
if prose.contains(term):
|
|
errors.append("filler_term:%s:%s" % [row_id, term])
|
|
|
|
|
|
static func _validate_catalog_hash(content: Dictionary, errors: Array[String]) -> void:
|
|
var candidate := content.duplicate(true)
|
|
var stored := String(candidate.get("catalog_hash", ""))
|
|
candidate.erase("catalog_hash")
|
|
if not _is_lower_hex(stored, 64) or stored != _hash(candidate):
|
|
errors.append("catalog_hash")
|
|
|
|
|
|
static func _preview_rule(row: Dictionary, family: String) -> String:
|
|
match family:
|
|
"professions":
|
|
return String(row.get("rules", ""))
|
|
"traits":
|
|
return "%s Cost: %s" % [row.get("benefit", ""), row.get("cost", "")]
|
|
"equipment":
|
|
return "%s Tradeoff: %s" % [row.get("effect", ""), row.get("tradeoff", "")]
|
|
"artifacts":
|
|
return "%s Tradeoff: %s" % [row.get("rule", ""), row.get("tradeoff", "")]
|
|
"enemies", "bosses":
|
|
return "%s Response: %s" % [row.get("rule", ""), row.get("responses", "")]
|
|
"region":
|
|
return String(row.get("pressure", ""))
|
|
"contracts":
|
|
return "%d rounds, then %s; first clear %s." % [int(row.get("round_count", 0)), row.get("boss_id", ""), row.get("clear_fact", "")]
|
|
return ""
|
|
|
|
|
|
static func _rule_signature(row: Dictionary, family: String) -> String:
|
|
return _hash({"family": family, "mechanic": _mechanic_text(row, family), "tags": row.get("semantic_tags", [])})
|
|
|
|
|
|
static func _mechanic_text(row: Dictionary, family: String) -> String:
|
|
var values: Array[String] = []
|
|
for field in MECHANIC_FIELDS.get(family, []):
|
|
values.append(str(row.get(field, "")).strip_edges())
|
|
return " | ".join(values)
|
|
|
|
|
|
static func _profession_parent(row: Dictionary) -> String:
|
|
if String(row.get("family", "")) != "advanced":
|
|
return ""
|
|
var role := String(row.get("role", ""))
|
|
return role.get_slice(";", 0).strip_edges()
|
|
|
|
|
|
static func _id_range(prefix: String, count: int, width: int = 2) -> Array[String]:
|
|
var ids: Array[String] = []
|
|
for value in range(1, count + 1):
|
|
var suffix := "%02d" % value if width == 2 else "%03d" % value
|
|
ids.append("%s%s" % [prefix, suffix])
|
|
return ids
|
|
|
|
|
|
static func _is_semantic_tag(value: String) -> bool:
|
|
if value.is_empty() or value != value.to_lower():
|
|
return false
|
|
for index in range(value.length()):
|
|
if value.substr(index, 1) not in "abcdefghijklmnopqrstuvwxyz0123456789_":
|
|
return false
|
|
return true
|
|
|
|
|
|
static func _is_lower_hex(value: String, expected_length: int) -> bool:
|
|
if value.length() != expected_length:
|
|
return false
|
|
for index in range(value.length()):
|
|
if value.substr(index, 1) not in "0123456789abcdef":
|
|
return false
|
|
return true
|
|
|
|
|
|
static func _hash(value: Variant) -> String:
|
|
return JSON.stringify(_canonical(value)).sha256_text()
|
|
|
|
|
|
static func _canonical(value: Variant) -> Variant:
|
|
if value is Dictionary:
|
|
var result := {}
|
|
var keys: Array = value.keys()
|
|
keys.sort_custom(func(left, right): return String(left) < String(right))
|
|
for key in keys:
|
|
result[key] = _canonical(value[key])
|
|
return result
|
|
if value is Array:
|
|
var result: Array = []
|
|
for item in value:
|
|
result.append(_canonical(item))
|
|
return result
|
|
return value
|