270 lines
10 KiB
GDScript
270 lines
10 KiB
GDScript
class_name P101ContentRegistry
|
|
extends RefCounted
|
|
|
|
const CATALOG_PATH := "res://p10_1/catalog.json"
|
|
const SCHEMA_VERSION := 1
|
|
const CONTRACT_REVISION := "p10.1-siltwake-first-crossing-r1"
|
|
const SOURCE_AUTHORITY := {
|
|
"region_catalog_path": "docs/content/REGION_CATALOG.md",
|
|
"region_catalog_blob": "2ca533feb82bdedf6f141c6381319c3a56759ba3",
|
|
"enemy_catalog_path": "docs/content/ENEMY_CATALOG.md",
|
|
"enemy_catalog_blob": "cdf05128cfd1832835712e37c3eb200595983613",
|
|
"equipment_catalog_path": "docs/content/EQUIPMENT_CATALOG.md",
|
|
"equipment_catalog_blob": "3fd34ef951056b02749137f7bff2e66fb94b8375",
|
|
}
|
|
const EXPECTED_IDS := {
|
|
"equipment": ["EQ-041", "EQ-042", "EQ-043", "EQ-044", "EQ-045", "EQ-046", "EQ-047", "EQ-048"],
|
|
"enemies": ["EN-R2-01", "EN-R2-02", "EN-R2-03", "EN-R2-04", "EN-R2-05", "EN-R2-06", "EN-R2-07"],
|
|
"routes": ["RO-R2-01", "RO-R2-02", "RO-R2-03"],
|
|
}
|
|
const REQUIRED_FIELDS := {
|
|
"equipment": ["fit", "class", "effect", "tradeoff"],
|
|
"enemies": ["class", "rule", "responses"],
|
|
"routes": ["risk", "enemy_ids", "wave_order", "reward_coin", "recovery_coin", "pressure", "failure_cause"],
|
|
}
|
|
const FILLER_TERMS := ["todo", "tbd", "placeholder", "same as", "generic"]
|
|
|
|
|
|
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(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")
|
|
if String(content.get("contract_revision", "")) != CONTRACT_REVISION:
|
|
errors.append("contract_revision")
|
|
_validate_source(content.get("source", {}), errors)
|
|
var names = content.get("names_zh", {})
|
|
if not names is Dictionary:
|
|
errors.append("names_zh_type")
|
|
names = {}
|
|
|
|
var all_ids: Array[String] = []
|
|
_validate_singleton(content.get("region", {}), "RG-02", names, all_ids, errors)
|
|
_validate_singleton(content.get("contract", {}), "CTR-R2-01", names, all_ids, errors)
|
|
_validate_singleton(content.get("boss", {}), "BO-R2-01", names, all_ids, errors)
|
|
var rows_by_family := {}
|
|
for family in EXPECTED_IDS:
|
|
var rows = content.get(family, [])
|
|
if not rows is Array:
|
|
errors.append("family_type:%s" % family)
|
|
continue
|
|
var by_id := {}
|
|
for row in rows:
|
|
_validate_row(row, String(family), names, all_ids, by_id, errors)
|
|
rows_by_family[family] = by_id
|
|
var actual: Array = by_id.keys()
|
|
actual.sort()
|
|
var expected: Array = Array(EXPECTED_IDS[family]).duplicate()
|
|
expected.sort()
|
|
if actual != expected:
|
|
errors.append("family_ids:%s" % family)
|
|
|
|
var expected_names: Array = all_ids.duplicate()
|
|
expected_names.sort()
|
|
var actual_names: Array = names.keys()
|
|
actual_names.sort()
|
|
if actual_names != expected_names:
|
|
errors.append("names_zh_ids")
|
|
_validate_region_contract(content, rows_by_family, errors)
|
|
_validate_routes(rows_by_family, 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 key in ["region", "contract", "boss"]:
|
|
var singleton = content.get(key, {})
|
|
if singleton is Dictionary and String(singleton.get("id", "")) == row_id:
|
|
return String(singleton.get("name", ""))
|
|
for family in EXPECTED_IDS:
|
|
for row in content.get(family, []):
|
|
if row is Dictionary and String(row.get("id", "")) == row_id:
|
|
return String(row.get("name", ""))
|
|
return ""
|
|
|
|
|
|
static func _enrich(content: Dictionary) -> void:
|
|
var names: Dictionary = content.get("names_zh", {})
|
|
for key in ["region", "contract", "boss"]:
|
|
var singleton = content.get(key, {})
|
|
if singleton is Dictionary:
|
|
_enrich_row(singleton, names)
|
|
for family in EXPECTED_IDS:
|
|
for row in content.get(family, []):
|
|
if row is Dictionary:
|
|
_enrich_row(row, names)
|
|
|
|
|
|
static func _enrich_row(row: Dictionary, names: 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["mechanic_signature"] = _signature(row)
|
|
|
|
|
|
static func _validate_source(source, errors: Array[String]) -> void:
|
|
if not source is Dictionary:
|
|
errors.append("source_type")
|
|
return
|
|
for key in SOURCE_AUTHORITY:
|
|
if String(source.get(key, "")) != String(SOURCE_AUTHORITY[key]):
|
|
errors.append("source:%s" % key)
|
|
|
|
|
|
static func _validate_singleton(row, expected_id: String, names: Dictionary, all_ids: Array[String], errors: Array[String]) -> void:
|
|
if not row is Dictionary:
|
|
errors.append("singleton_type:%s" % expected_id)
|
|
return
|
|
_validate_common(row, names, all_ids, errors)
|
|
if String(row.get("id", "")) != expected_id:
|
|
errors.append("singleton_id:%s" % expected_id)
|
|
|
|
|
|
static func _validate_row(row, family: String, names: Dictionary, all_ids: Array[String], by_id: Dictionary, errors: Array[String]) -> void:
|
|
if not row is Dictionary:
|
|
errors.append("row_type:%s" % family)
|
|
return
|
|
_validate_common(row, names, all_ids, errors)
|
|
var row_id := String(row.get("id", ""))
|
|
if by_id.has(row_id):
|
|
errors.append("duplicate_id:%s" % row_id)
|
|
by_id[row_id] = row
|
|
for field in REQUIRED_FIELDS[family]:
|
|
if not row.has(field) or (row[field] is String and String(row[field]).strip_edges().is_empty()):
|
|
errors.append("field:%s:%s" % [row_id, field])
|
|
|
|
|
|
static func _validate_common(row: Dictionary, names: Dictionary, all_ids: Array[String], errors: Array[String]) -> void:
|
|
var row_id := String(row.get("id", ""))
|
|
if row_id.is_empty():
|
|
errors.append("empty_id")
|
|
return
|
|
if row_id in all_ids:
|
|
errors.append("duplicate_global_id:%s" % row_id)
|
|
all_ids.append(row_id)
|
|
if String(row.get("name_en", row.get("name", ""))).strip_edges().is_empty():
|
|
errors.append("name_en:%s" % row_id)
|
|
if String(row.get("name_zh", names.get(row_id, ""))).strip_edges().is_empty():
|
|
errors.append("name_zh:%s" % row_id)
|
|
if String(row.get("preview", "")).strip_edges().is_empty():
|
|
errors.append("preview:%s" % row_id)
|
|
var tags = row.get("response_tags", [])
|
|
if not tags is Array or tags.size() < 3 or not _unique(tags):
|
|
errors.append("response_tags:%s" % row_id)
|
|
if String(row.get("execution_key", "")) != "execute.%s" % row_id.to_lower().replace("-", "_"):
|
|
errors.append("execution_key:%s" % row_id)
|
|
if String(row.get("mechanic_signature", "")) != _signature(row):
|
|
errors.append("mechanic_signature:%s" % row_id)
|
|
var prose := JSON.stringify(row).to_lower()
|
|
for term in FILLER_TERMS:
|
|
if prose.contains(term):
|
|
errors.append("filler:%s:%s" % [row_id, term])
|
|
|
|
|
|
static func _validate_region_contract(content: Dictionary, rows: Dictionary, errors: Array[String]) -> void:
|
|
var region: Dictionary = content.get("region", {})
|
|
var contract: Dictionary = content.get("contract", {})
|
|
var boss: Dictionary = content.get("boss", {})
|
|
if String(contract.get("region_id", "")) != "RG-02" or String(boss.get("region_id", "")) != "RG-02":
|
|
errors.append("region_reference")
|
|
if String(contract.get("boss_id", "")) != "BO-R2-01" or bool(contract.get("boss_executable", true)) or bool(boss.get("executable", true)):
|
|
errors.append("boss_boundary")
|
|
if int(contract.get("market_count", 0)) != 12 or int(contract.get("slice_end_market", 0)) != 2:
|
|
errors.append("contract_markets")
|
|
var tide = region.get("tide", {})
|
|
if not tide is Dictionary:
|
|
errors.append("tide_type")
|
|
return
|
|
if int(tide.get("position_count", 0)) != 4 or int(tide.get("fixed_tick_ms", 0)) != 100:
|
|
errors.append("tide_positions")
|
|
if int(tide.get("shift_tick", 0)) != 50 or int(tide.get("warning_ticks", 0)) != 20:
|
|
errors.append("tide_countdown")
|
|
if String(tide.get("unsafe_status", "")) != "wet" or int(tide.get("unsafe_threshold", 0)) != 3 or String(tide.get("threshold_result", "")) != "pull_toward_safe_index":
|
|
errors.append("tide_pressure")
|
|
if rows.get("equipment", {}).size() != 8 or rows.get("enemies", {}).size() != 7:
|
|
errors.append("slice_counts")
|
|
|
|
|
|
static func _validate_routes(rows: Dictionary, errors: Array[String]) -> void:
|
|
var enemies: Dictionary = rows.get("enemies", {})
|
|
var routes: Dictionary = rows.get("routes", {})
|
|
var expected_compositions := {
|
|
"RO-R2-01": ["EN-R2-01", "EN-R2-07"],
|
|
"RO-R2-02": ["EN-R2-02", "EN-R2-06"],
|
|
"RO-R2-03": ["EN-R2-01", "EN-R2-02", "EN-R2-04"],
|
|
}
|
|
for route_id in routes:
|
|
var route: Dictionary = routes[route_id]
|
|
var risk := int(route.get("risk", -1))
|
|
if route_id != "RO-R2-%02d" % (risk + 1) or risk < 0 or risk > 2:
|
|
errors.append("route_risk:%s" % route_id)
|
|
var enemy_ids = route.get("enemy_ids", [])
|
|
if not enemy_ids is Array or enemy_ids != expected_compositions.get(route_id, []):
|
|
errors.append("route_composition:%s" % route_id)
|
|
else:
|
|
for enemy_id in enemy_ids:
|
|
if not enemies.has(String(enemy_id)):
|
|
errors.append("route_enemy:%s:%s" % [route_id, enemy_id])
|
|
if int(route.get("reward_coin", 0)) <= 0 or int(route.get("recovery_coin", -1)) < 0:
|
|
errors.append("route_economy:%s" % route_id)
|
|
if not route.get("wave_order", []) is Array or route.wave_order.is_empty():
|
|
errors.append("route_waves:%s" % route_id)
|
|
|
|
|
|
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 stored.length() != 64 or stored != _hash(candidate):
|
|
errors.append("catalog_hash")
|
|
|
|
|
|
static func _signature(row: Dictionary) -> String:
|
|
var projection := row.duplicate(true)
|
|
for key in ["name_en", "name_zh", "execution_key", "mechanic_signature"]:
|
|
projection.erase(key)
|
|
return _hash(projection)
|
|
|
|
|
|
static func _unique(values: Array) -> bool:
|
|
var seen := {}
|
|
for value in values:
|
|
var key := String(value)
|
|
if seen.has(key):
|
|
return false
|
|
seen[key] = true
|
|
return true
|
|
|
|
|
|
static func _hash(value) -> String:
|
|
var context := HashingContext.new()
|
|
context.start(HashingContext.HASH_SHA256)
|
|
context.update(JSON.stringify(value).to_utf8_buffer())
|
|
return context.finish().hex_encode()
|