Files
aetherbound-guild/runtime/p10_2/content_registry.gd
T

368 lines
13 KiB
GDScript

class_name P102ContentRegistry
extends RefCounted
const CATALOG_PATH := "res://p10_2/catalog.json"
const SCHEMA_VERSION := 1
const CONTRACT_REVISION := "p10.2-siltwake-mid-contract-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",
"economy_path": "docs/product/ECONOMY_AND_BALANCE.md",
"economy_blob": "0cb57e7b44d983f61c27e8b60da43d4728950e33",
}
const EXPECTED_IDS := {
"equipment": ["EQ-049", "EQ-050", "EQ-051", "EQ-052", "EQ-053", "EQ-054", "EQ-055", "EQ-056"],
"enemies": ["EN-R2-08", "EN-R2-09", "EN-R2-10"],
"routes": [
"RO-R2-M02-Q0", "RO-R2-M02-Q1", "RO-R2-M02-Q2",
"RO-R2-M03-Q0", "RO-R2-M03-Q1", "RO-R2-M03-Q2",
"RO-R2-M04-Q0", "RO-R2-M04-Q1", "RO-R2-M04-Q2",
],
}
const REQUIRED_FIELDS := {
"equipment": ["fit", "class", "effect", "tradeoff"],
"enemies": ["class", "rule", "responses"],
"routes": [
"market_index", "round_index", "risk", "enemy_ids", "wave_order",
"reward_coin", "recovery_coin", "pressure", "failure_cause",
],
}
const ALLOWED_ENEMY_IDS := [
"EN-R2-01", "EN-R2-02", "EN-R2-03", "EN-R2-04", "EN-R2-05",
"EN-R2-06", "EN-R2-07", "EN-R2-08", "EN-R2-09", "EN-R2-10",
]
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)
var families := {}
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)
families[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_contract(content, errors)
_validate_routes(families, errors)
_validate_markets(content, families, 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 markets_by_index(content: Dictionary) -> Dictionary:
var result := {}
for row in content.get("markets", []):
if row is Dictionary:
result[int(row.get("market_index", -1))] = 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"]:
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"]:
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_contract(content: Dictionary, errors: Array[String]) -> void:
var contract: Dictionary = content.get("contract", {})
if String(contract.get("region_id", "")) != "RG-02":
errors.append("contract_region")
if int(contract.get("slice_start_market", 0)) != 2 or int(contract.get("slice_end_market", 0)) != 5:
errors.append("contract_markets")
if bool(contract.get("boss_executable", true)):
errors.append("boss_boundary")
static func _validate_routes(families: Dictionary, errors: Array[String]) -> void:
var routes: Dictionary = families.get("routes", {})
var new_enemy_usage := {"EN-R2-08": 0, "EN-R2-09": 0, "EN-R2-10": 0}
for route_id in routes:
var route: Dictionary = routes[route_id]
var market_index := int(route.get("market_index", -1))
var round_index := int(route.get("round_index", -1))
var risk := int(route.get("risk", -1))
if market_index < 2 or market_index > 4 or round_index != market_index:
errors.append("route_round:%s" % route_id)
if risk < 0 or risk > 2 or route_id != "RO-R2-M%02d-Q%d" % [market_index, risk]:
errors.append("route_identity:%s" % route_id)
var enemy_ids = route.get("enemy_ids", [])
if not enemy_ids is Array or enemy_ids.size() < 2 or enemy_ids.size() > 3 or not _unique(enemy_ids):
errors.append("route_composition:%s" % route_id)
else:
for enemy_id in enemy_ids:
if String(enemy_id) not in ALLOWED_ENEMY_IDS:
errors.append("route_enemy:%s:%s" % [route_id, enemy_id])
if new_enemy_usage.has(String(enemy_id)):
new_enemy_usage[String(enemy_id)] += 1
var waves = route.get("wave_order", [])
if not waves is Array or waves.size() != enemy_ids.size():
errors.append("route_waves:%s" % route_id)
else:
var wave_ids: Array = []
var previous_tick := -1
for wave in waves:
if not wave is Dictionary:
errors.append("route_wave_type:%s" % route_id)
continue
var tick := int(wave.get("tick", -1))
wave_ids.append(String(wave.get("enemy_id", "")))
if tick <= previous_tick:
errors.append("route_wave_order:%s" % route_id)
previous_tick = tick
if wave_ids != enemy_ids:
errors.append("route_wave_enemy:%s" % route_id)
var expected_reward := 18 + 2 * round_index + 2 + 7 * risk
if int(route.get("reward_coin", -1)) != expected_reward:
errors.append("route_reward:%s" % route_id)
var recovery := int(route.get("recovery_coin", -1))
if recovery <= 0 or recovery > expected_reward:
errors.append("route_recovery:%s" % route_id)
for enemy_id in new_enemy_usage:
if int(new_enemy_usage[enemy_id]) < 2:
errors.append("new_enemy_underused:%s" % enemy_id)
static func _validate_markets(
content: Dictionary,
families: Dictionary,
errors: Array[String]
) -> void:
var markets = content.get("markets", [])
if not markets is Array or markets.size() != 3:
errors.append("market_count")
return
var routes: Dictionary = families.get("routes", {})
var equipment: Dictionary = families.get("equipment", {})
var seen_markets := {}
for market in markets:
if not market is Dictionary:
errors.append("market_type")
continue
var market_index := int(market.get("market_index", -1))
if seen_markets.has(market_index):
errors.append("market_duplicate:%d" % market_index)
seen_markets[market_index] = true
var cleared := int(market.get("cleared_rounds_before", -1))
var expected_capacity := mini(10, 4 + cleared / 2)
var expected_level := mini(10, 1 + (market_index - 1) / 3)
if market_index < 2 or market_index > 4 or int(market.get("round_index", -1)) != market_index:
errors.append("market_index:%d" % market_index)
if cleared != market_index - 1 or int(market.get("line_capacity", -1)) != expected_capacity:
errors.append("market_capacity:%d" % market_index)
if int(market.get("offer_level", -1)) != expected_level:
errors.append("market_offer_level:%d" % market_index)
var route_ids = market.get("route_ids", [])
if not route_ids is Array or route_ids.size() != 3:
errors.append("market_routes:%d" % market_index)
else:
for risk in range(3):
var expected_id := "RO-R2-M%02d-Q%d" % [market_index, risk]
if String(route_ids[risk]) != expected_id or not routes.has(expected_id):
errors.append("market_route:%d:%d" % [market_index, risk])
var required_items = market.get("required_equipment_ids", [])
if not required_items is Array or required_items.size() != 2 or not _unique(required_items):
errors.append("market_required_equipment:%d" % market_index)
else:
for item_id in required_items:
if not equipment.has(String(item_id)):
errors.append("market_equipment:%d:%s" % [market_index, item_id])
if String(market.get("first_order_changer_id", "")) not in ALLOWED_ENEMY_IDS:
errors.append("market_order_changer:%d" % market_index)
if seen_markets.keys().size() != 3 or not seen_markets.has(2) or not seen_markets.has(3) or not seen_markets.has(4):
errors.append("market_indices")
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()