1014 lines
38 KiB
GDScript
1014 lines
38 KiB
GDScript
class_name FG4ContentRegistry
|
|
extends RefCounted
|
|
|
|
const CATALOG_PATH := "res://fg4/catalog.json"
|
|
const RG04_CATALOG_PATH := "res://fg4/rg04_catalog.json"
|
|
const SCHEMA_VERSION := 1
|
|
const CONTRACT_REVISION := "fg4-midgame-r1"
|
|
const SOURCE_AUTHORITY := {
|
|
"region_catalog_blob": "2ca533feb82bdedf6f141c6381319c3a56759ba3",
|
|
"enemy_catalog_blob": "cdf05128cfd1832835712e37c3eb200595983613",
|
|
"equipment_catalog_blob": "3fd34ef951056b02749137f7bff2e66fb94b8375",
|
|
"artifact_catalog_blob": "009265f793e646a0ebb45d4018eb6c2d2c6e51e3",
|
|
"economy_blob": "0cb57e7b44d983f61c27e8b60da43d4728950e33",
|
|
"product_blob": "0946c2e1f3380ab97d9fd93515719283bad9816b",
|
|
"save_failure_blob": "29ed55adc6b45c4d0e21a8c58bec381f55390f5f",
|
|
}
|
|
const RG04_SOURCE_AUTHORITY := {
|
|
"prototype_catalog_sha256": "fb99bcd75ca6308091fa56b45f8da551276a496a1f96acbb44225efe491fdfef",
|
|
"region_id": "RG-04",
|
|
}
|
|
const FILLER_TERMS := ["todo", "tbd", "placeholder", "same as", "generic"]
|
|
|
|
|
|
static func snapshot(
|
|
path: String = CATALOG_PATH,
|
|
rg04_path: String = RG04_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)
|
|
var rg04_text := FileAccess.get_file_as_string(rg04_path)
|
|
if rg04_text.is_empty():
|
|
return {"load_error": "catalog_unreadable:%s" % rg04_path}
|
|
var rg04_parsed = JSON.parse_string(rg04_text)
|
|
if not rg04_parsed is Dictionary:
|
|
return {"load_error": "catalog_not_dictionary:%s" % rg04_path}
|
|
_merge_rg04(content, rg04_parsed)
|
|
_enrich(content)
|
|
content["catalog_hash"] = _hash(content)
|
|
return content
|
|
|
|
|
|
static func validate(content: Dictionary) -> Dictionary:
|
|
var errors: Array[String] = []
|
|
if content.has("load_error"):
|
|
return {"ok": false, "errors": [String(content.load_error)]}
|
|
if int(content.get("schema", -1)) != SCHEMA_VERSION:
|
|
errors.append("schema")
|
|
if String(content.get("contract_revision", "")) != CONTRACT_REVISION:
|
|
errors.append("contract_revision")
|
|
if int(content.get("rg04_schema", -1)) != SCHEMA_VERSION:
|
|
errors.append("rg04_schema")
|
|
if String(content.get("rg04_contract_revision", "")) != CONTRACT_REVISION:
|
|
errors.append("rg04_contract_revision")
|
|
_validate_source(content.get("source", {}), errors)
|
|
_validate_rg04_source(content.get("rg04_source", {}), errors)
|
|
_validate_singleton(content.get("region", {}), "RG-03", errors)
|
|
_validate_singleton(content.get("region4", {}), "RG-04", errors)
|
|
_validate_contract(content.get("contract", {}), errors)
|
|
_validate_contract2(content.get("contract2", {}), errors)
|
|
_validate_rg04_contract(content.get("contract3", {}), "CTR-R4-01", "BO-R4-01", errors)
|
|
_validate_rg04_contract(content.get("contract4", {}), "CTR-R4-02", "BO-R4-02", errors)
|
|
|
|
var equipment := _validate_family(
|
|
content.get("equipment", []), "equipment", _expected_ids("EQ-", 81, 160),
|
|
["fit", "class", "effect", "tradeoff"], errors
|
|
)
|
|
var artifacts := _validate_family(
|
|
content.get("artifacts", []), "artifacts", _expected_ids("AR-", 16, 30),
|
|
["rule", "tradeoff", "conflict_id"], errors
|
|
)
|
|
var enemies := _validate_family(
|
|
content.get("enemies", []), "enemies",
|
|
_expected_ids("EN-R3-", 1, 12, 2) + _expected_ids("EN-R4-", 1, 12, 2),
|
|
["class", "rule", "responses", "escalation"], errors
|
|
)
|
|
var bosses := _validate_family(
|
|
content.get("bosses", []), "bosses",
|
|
["BO-R3-01", "BO-R3-02", "BO-R4-01", "BO-R4-02"],
|
|
["rule", "responses", "failure", "phase_change", "executable"], errors
|
|
)
|
|
_validate_equipment(equipment, errors)
|
|
_validate_artifacts(artifacts, errors)
|
|
_validate_enemies(enemies, errors)
|
|
_validate_bosses(bosses, errors)
|
|
_validate_markets(content, equipment, enemies, errors)
|
|
_validate_routes(content, enemies, errors)
|
|
_validate_contract2_markets(content, equipment, enemies, errors)
|
|
_validate_contract2_routes(content, enemies, errors)
|
|
_validate_rg04_markets(content, "rg04_markets", 1, equipment, enemies, errors)
|
|
_validate_rg04_markets(content, "rg04_contract2_markets", 2, equipment, enemies, errors)
|
|
_validate_rg04_routes(content, "rg04_routes", 1, enemies, errors)
|
|
_validate_rg04_routes(content, "rg04_contract2_routes", 2, enemies, errors)
|
|
_validate_rg04_coverage(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 markets_by_index(content: Dictionary) -> Dictionary:
|
|
var result := {}
|
|
for market in content.get("markets", []):
|
|
if market is Dictionary:
|
|
result[int(market.get("market_index", -1))] = market
|
|
return result
|
|
|
|
|
|
static func _enrich(content: Dictionary) -> void:
|
|
_build_routes(content)
|
|
_build_contract2_routes(content)
|
|
_build_rg04_routes(content, "rg04_markets", "rg04_routes", 1)
|
|
_build_rg04_routes(content, "rg04_contract2_markets", "rg04_contract2_routes", 2)
|
|
for key in ["region", "region4", "contract", "contract2", "contract3", "contract4"]:
|
|
var singleton = content.get(key, {})
|
|
if singleton is Dictionary:
|
|
_enrich_row(singleton)
|
|
for family in [
|
|
"equipment", "artifacts", "enemies", "bosses", "routes", "contract2_routes",
|
|
"rg04_routes", "rg04_contract2_routes",
|
|
]:
|
|
for row in content.get(family, []):
|
|
if row is Dictionary:
|
|
_enrich_row(row)
|
|
|
|
|
|
static func _merge_rg04(content: Dictionary, supplement: Dictionary) -> void:
|
|
content["rg04_schema"] = int(supplement.get("schema", -1))
|
|
content["rg04_contract_revision"] = String(supplement.get("contract_revision", ""))
|
|
content["rg04_source"] = Dictionary(supplement.get("source", {})).duplicate(true)
|
|
for key in ["region4", "contract3", "contract4", "rg04_markets", "rg04_contract2_markets"]:
|
|
content[key] = supplement.get(key, {}).duplicate(true)
|
|
for family in ["equipment", "artifacts", "enemies", "bosses"]:
|
|
var combined: Array = Array(content.get(family, [])).duplicate(true)
|
|
combined.append_array(Array(supplement.get(family, [])).duplicate(true))
|
|
content[family] = combined
|
|
|
|
|
|
static func _enrich_row(row: Dictionary) -> void:
|
|
var row_id := String(row.get("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
|
|
if source.size() != SOURCE_AUTHORITY.size():
|
|
errors.append("source_keys")
|
|
for key in SOURCE_AUTHORITY:
|
|
if String(source.get(key, "")) != String(SOURCE_AUTHORITY[key]):
|
|
errors.append("source:%s" % key)
|
|
|
|
|
|
static func _validate_rg04_source(source, errors: Array[String]) -> void:
|
|
if not source is Dictionary:
|
|
errors.append("rg04_source_type")
|
|
return
|
|
if source.size() != RG04_SOURCE_AUTHORITY.size():
|
|
errors.append("rg04_source_keys")
|
|
for key in RG04_SOURCE_AUTHORITY:
|
|
if String(source.get(key, "")) != String(RG04_SOURCE_AUTHORITY[key]):
|
|
errors.append("rg04_source:%s" % key)
|
|
|
|
|
|
static func _validate_singleton(row, expected_id: String, errors: Array[String]) -> void:
|
|
if not row is Dictionary:
|
|
errors.append("singleton_type:%s" % expected_id)
|
|
return
|
|
if String(row.get("id", "")) != expected_id:
|
|
errors.append("singleton_id:%s" % expected_id)
|
|
_validate_common(row, "singleton", errors)
|
|
|
|
|
|
static func _validate_contract(contract, errors: Array[String]) -> void:
|
|
_validate_singleton(contract, "CTR-R3-01", errors)
|
|
if not contract is Dictionary:
|
|
return
|
|
if (
|
|
String(contract.get("region_id", "")) != "RG-03"
|
|
or int(contract.get("market_start", 0)) != 1
|
|
or int(contract.get("market_end", 0)) != 12
|
|
or int(contract.get("boss_market", 0)) != 12
|
|
or String(contract.get("boss_id", "")) != "BO-R3-01"
|
|
):
|
|
errors.append("contract_boundary")
|
|
|
|
|
|
static func _validate_contract2(contract, errors: Array[String]) -> void:
|
|
_validate_singleton(contract, "CTR-R3-02", errors)
|
|
if not contract is Dictionary:
|
|
return
|
|
if (
|
|
String(contract.get("region_id", "")) != "RG-03"
|
|
or int(contract.get("market_start", 0)) != 1
|
|
or int(contract.get("market_end", 0)) != 12
|
|
or int(contract.get("boss_market", 0)) != 12
|
|
or String(contract.get("boss_id", "")) != "BO-R3-02"
|
|
):
|
|
errors.append("contract2_boundary")
|
|
|
|
|
|
static func _validate_rg04_contract(
|
|
contract,
|
|
expected_id: String,
|
|
expected_boss: String,
|
|
errors: Array[String]
|
|
) -> void:
|
|
_validate_singleton(contract, expected_id, errors)
|
|
if not contract is Dictionary:
|
|
return
|
|
if (
|
|
String(contract.get("region_id", "")) != "RG-04"
|
|
or int(contract.get("market_start", 0)) != 1
|
|
or int(contract.get("market_end", 0)) != 12
|
|
or int(contract.get("boss_market", 0)) != 12
|
|
or String(contract.get("boss_id", "")) != expected_boss
|
|
):
|
|
errors.append("rg04_contract_boundary:%s" % expected_id)
|
|
|
|
|
|
static func _validate_family(
|
|
rows,
|
|
family: String,
|
|
expected_ids: Array,
|
|
required_fields: Array,
|
|
errors: Array[String]
|
|
) -> Dictionary:
|
|
var by_id := {}
|
|
if not rows is Array:
|
|
errors.append("family_type:%s" % family)
|
|
return by_id
|
|
for row in rows:
|
|
if not row is Dictionary:
|
|
errors.append("row_type:%s" % family)
|
|
continue
|
|
_validate_common(row, family, 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:
|
|
if not row.has(field):
|
|
errors.append("field:%s:%s" % [row_id, field])
|
|
elif row[field] is Dictionary:
|
|
_validate_localized(row[field], "%s:%s" % [row_id, field], errors)
|
|
var actual: Array = by_id.keys()
|
|
actual.sort()
|
|
var expected: Array = expected_ids.duplicate()
|
|
expected.sort()
|
|
if actual != expected:
|
|
errors.append("family_ids:%s" % family)
|
|
return by_id
|
|
|
|
|
|
static func _validate_common(row: Dictionary, family: String, errors: Array[String]) -> void:
|
|
var row_id := String(row.get("id", ""))
|
|
if row_id.is_empty():
|
|
errors.append("empty_id:%s" % family)
|
|
return
|
|
_validate_localized(row.get("name", {}), "%s:name" % row_id, errors)
|
|
_validate_localized(row.get("preview", {}), "%s:preview" % row_id, errors)
|
|
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_localized(value, label: String, errors: Array[String]) -> void:
|
|
if (
|
|
not value is Dictionary
|
|
or String(value.get("en", "")).strip_edges().is_empty()
|
|
or String(value.get("zh_CN", "")).strip_edges().is_empty()
|
|
or value.size() != 2
|
|
):
|
|
errors.append("localized:%s" % label)
|
|
|
|
|
|
static func _validate_equipment(equipment: Dictionary, errors: Array[String]) -> void:
|
|
for item_id in equipment:
|
|
var item: Dictionary = equipment[item_id]
|
|
if String(item.get("class", "")) != "B":
|
|
errors.append("equipment_class:%s" % item_id)
|
|
var fit = item.get("fit", [])
|
|
if not fit is Array or fit.size() != 2 or not _unique(fit):
|
|
errors.append("equipment_fit:%s" % item_id)
|
|
|
|
|
|
static func _validate_artifacts(artifacts: Dictionary, errors: Array[String]) -> void:
|
|
var conflicts := {
|
|
"AR-016": "AR-015", "AR-017": "AR-018", "AR-018": "AR-017",
|
|
"AR-019": "AR-020", "AR-020": "AR-019", "AR-021": "AR-022",
|
|
"AR-022": "AR-021", "AR-023": "AR-024", "AR-024": "AR-023",
|
|
"AR-025": "AR-026", "AR-026": "AR-025", "AR-027": "AR-028",
|
|
"AR-028": "AR-027", "AR-029": "AR-030", "AR-030": "AR-029",
|
|
}
|
|
for artifact_id in artifacts:
|
|
if String(artifacts[artifact_id].get("conflict_id", "")) != conflicts[artifact_id]:
|
|
errors.append("artifact_conflict:%s" % artifact_id)
|
|
|
|
|
|
static func _validate_enemies(enemies: Dictionary, errors: Array[String]) -> void:
|
|
for enemy_id in enemies:
|
|
var expected_class := "elite" if enemy_id in [
|
|
"EN-R3-11", "EN-R3-12", "EN-R4-11", "EN-R4-12",
|
|
] else "ordinary"
|
|
if String(enemies[enemy_id].get("class", "")) != expected_class:
|
|
errors.append("enemy_class:%s" % enemy_id)
|
|
|
|
|
|
static func _validate_bosses(bosses: Dictionary, errors: Array[String]) -> void:
|
|
for boss_id in bosses:
|
|
var expected_executable := (
|
|
String(boss_id).begins_with("BO-R3-")
|
|
or String(boss_id).begins_with("BO-R4-")
|
|
)
|
|
if bool(bosses[boss_id].get("executable", false)) != expected_executable:
|
|
errors.append("boss_execution_boundary:%s" % boss_id)
|
|
|
|
|
|
static func _validate_markets(
|
|
content: Dictionary,
|
|
equipment: Dictionary,
|
|
enemies: Dictionary,
|
|
errors: Array[String]
|
|
) -> void:
|
|
var markets = content.get("markets", [])
|
|
if not markets is Array or markets.size() != 12:
|
|
errors.append("market_count")
|
|
return
|
|
var seen := {}
|
|
for market in markets:
|
|
if not market is Dictionary:
|
|
errors.append("market_type")
|
|
continue
|
|
var index := int(market.get("market_index", -1))
|
|
seen[index] = true
|
|
if (
|
|
index < 1 or index > 12
|
|
or int(market.get("round_index", -1)) != index
|
|
or int(market.get("line_capacity", -1)) != 10
|
|
or int(market.get("offer_level", -1)) != 1 + int(floor(float(index - 1) / 3.0))
|
|
):
|
|
errors.append("market_boundary:%d" % index)
|
|
for field in ["name", "preview", "pressure", "failure", "milestone"]:
|
|
_validate_localized(market.get(field, {}), "market_%d:%s" % [index, field], errors)
|
|
var tags = market.get("response_tags", [])
|
|
if not tags is Array or tags.size() < 3 or not _unique(tags):
|
|
errors.append("market_tags:%d" % index)
|
|
var items = market.get("required_equipment_ids", [])
|
|
if not items is Array or items.size() != 2 or not _unique(items):
|
|
errors.append("market_equipment:%d" % index)
|
|
else:
|
|
for item_id in items:
|
|
if not equipment.has(String(item_id)):
|
|
errors.append("market_item:%d:%s" % [index, item_id])
|
|
var enemy_sets = market.get("enemy_sets", [])
|
|
if not enemy_sets is Array or enemy_sets.size() != 3:
|
|
errors.append("market_enemy_sets:%d" % index)
|
|
else:
|
|
for enemy_set in enemy_sets:
|
|
if not enemy_set is Array or enemy_set.size() < 3 or enemy_set.size() > 4 or not _unique(enemy_set):
|
|
errors.append("market_enemy_set:%d" % index)
|
|
continue
|
|
for enemy_id in enemy_set:
|
|
if not enemies.has(String(enemy_id)):
|
|
errors.append("market_enemy:%d:%s" % [index, enemy_id])
|
|
_validate_valve(market.get("pressure_valve", {}), index, errors)
|
|
if seen.size() != 12:
|
|
errors.append("market_indices")
|
|
|
|
|
|
static func _validate_valve(valve, market_index: int, errors: Array[String]) -> void:
|
|
if not valve is Dictionary:
|
|
errors.append("valve_type:%d" % market_index)
|
|
return
|
|
if String(valve.get("id", "")) != "RV-R3-M%02d" % market_index:
|
|
errors.append("valve_id:%d" % market_index)
|
|
if int(valve.get("position", 0)) < 1 or int(valve.get("position", 0)) > 10:
|
|
errors.append("valve_position:%d" % market_index)
|
|
if String(valve.get("trigger", "")) not in ["occupy", "vacate", "cross"]:
|
|
errors.append("valve_trigger:%d" % market_index)
|
|
if (
|
|
int(valve.get("warning_ticks", 0)) < 8
|
|
or int(valve.get("resolve_tick", 0)) <= int(valve.get("warning_ticks", 0))
|
|
or int(valve.get("fixed_tick_ms", 0)) != 100
|
|
):
|
|
errors.append("valve_timing:%d" % market_index)
|
|
|
|
|
|
static func _validate_routes(content: Dictionary, enemies: Dictionary, errors: Array[String]) -> void:
|
|
var routes = content.get("routes", [])
|
|
if not routes is Array or routes.size() != 36:
|
|
errors.append("route_count")
|
|
return
|
|
var by_id := {}
|
|
var enemy_usage := {}
|
|
for enemy_id in enemies:
|
|
if String(enemy_id).begins_with("EN-R3-"):
|
|
enemy_usage[enemy_id] = 0
|
|
for route in routes:
|
|
if not route is Dictionary:
|
|
errors.append("route_type")
|
|
continue
|
|
_validate_common(route, "routes", errors)
|
|
var route_id := String(route.get("id", ""))
|
|
by_id[route_id] = route
|
|
var market_index := int(route.get("market_index", -1))
|
|
var risk := int(route.get("risk", -1))
|
|
if route_id != "RO-R3-C01-M%02d-Q%d" % [market_index, risk]:
|
|
errors.append("route_identity:%s" % route_id)
|
|
if market_index < 1 or market_index > 12 or int(route.get("round_index", -1)) != market_index:
|
|
errors.append("route_round:%s" % route_id)
|
|
if risk < 0 or risk > 2:
|
|
errors.append("route_risk:%s" % route_id)
|
|
if int(route.get("reward_coin", -1)) != 22 + 2 * market_index + 7 * risk:
|
|
errors.append("route_reward:%s" % route_id)
|
|
if int(route.get("recovery_coin", -1)) != 12 + market_index + 3 * risk:
|
|
errors.append("route_recovery:%s" % route_id)
|
|
var enemy_ids = route.get("enemy_ids", [])
|
|
var waves = route.get("wave_order", [])
|
|
if not enemy_ids is Array or not waves is Array or enemy_ids.size() != waves.size():
|
|
errors.append("route_waves:%s" % route_id)
|
|
else:
|
|
var previous_tick := -1
|
|
for index in range(enemy_ids.size()):
|
|
var enemy_id := String(enemy_ids[index])
|
|
if not enemy_usage.has(enemy_id):
|
|
errors.append("route_enemy:%s:%s" % [route_id, enemy_id])
|
|
else:
|
|
enemy_usage[enemy_id] += 1
|
|
if String(waves[index].get("enemy_id", "")) != enemy_id:
|
|
errors.append("route_wave_enemy:%s" % route_id)
|
|
var tick := int(waves[index].get("tick", -1))
|
|
if tick <= previous_tick:
|
|
errors.append("route_wave_order:%s" % route_id)
|
|
previous_tick = tick
|
|
for market_index in range(1, 13):
|
|
for risk in range(3):
|
|
var route_id := "RO-R3-C01-M%02d-Q%d" % [market_index, risk]
|
|
if not by_id.has(route_id):
|
|
errors.append("route_missing:%s" % route_id)
|
|
for enemy_id in enemy_usage:
|
|
if int(enemy_usage[enemy_id]) < 2:
|
|
errors.append("enemy_underused:%s" % enemy_id)
|
|
|
|
|
|
static func _validate_contract2_markets(
|
|
content: Dictionary,
|
|
equipment: Dictionary,
|
|
enemies: Dictionary,
|
|
errors: Array[String]
|
|
) -> void:
|
|
var markets = content.get("contract2_markets", [])
|
|
if not markets is Array or markets.size() != 12:
|
|
errors.append("contract2_market_count")
|
|
return
|
|
var seen := {}
|
|
for market in markets:
|
|
if not market is Dictionary:
|
|
errors.append("contract2_market_type")
|
|
continue
|
|
var index := int(market.get("market_index", -1))
|
|
seen[index] = true
|
|
if (
|
|
index < 1 or index > 12
|
|
or int(market.get("round_index", -1)) != index
|
|
or int(market.get("line_capacity", -1)) != 10
|
|
or int(market.get("offer_level", -1)) != 1 + int(floor(float(index - 1) / 3.0))
|
|
):
|
|
errors.append("contract2_market_boundary:%d" % index)
|
|
for field in ["name", "preview", "pressure", "failure", "milestone"]:
|
|
_validate_localized(market.get(field, {}), "contract2_market_%d:%s" % [index, field], errors)
|
|
var tags = market.get("response_tags", [])
|
|
if not tags is Array or tags.size() < 3 or not _unique(tags):
|
|
errors.append("contract2_market_tags:%d" % index)
|
|
var items = market.get("required_equipment_ids", [])
|
|
if not items is Array or items.size() != 2 or not _unique(items):
|
|
errors.append("contract2_market_equipment:%d" % index)
|
|
else:
|
|
for item_id in items:
|
|
if not equipment.has(String(item_id)):
|
|
errors.append("contract2_market_item:%d:%s" % [index, item_id])
|
|
var enemy_sets = market.get("enemy_sets", [])
|
|
if not enemy_sets is Array or enemy_sets.size() != 3:
|
|
errors.append("contract2_market_enemy_sets:%d" % index)
|
|
else:
|
|
for enemy_set in enemy_sets:
|
|
if not enemy_set is Array or enemy_set.size() < 3 or enemy_set.size() > 4 or not _unique(enemy_set):
|
|
errors.append("contract2_market_enemy_set:%d" % index)
|
|
continue
|
|
for enemy_id in enemy_set:
|
|
if not enemies.has(String(enemy_id)):
|
|
errors.append("contract2_market_enemy:%d:%s" % [index, enemy_id])
|
|
_validate_spark(market.get("spark_disclosure", {}), index, errors)
|
|
if seen.size() != 12:
|
|
errors.append("contract2_market_indices")
|
|
|
|
|
|
static func _validate_spark(spark, market_index: int, errors: Array[String]) -> void:
|
|
if not spark is Dictionary:
|
|
errors.append("spark_type:%d" % market_index)
|
|
return
|
|
if String(spark.get("id", "")) != "SP-R3-C02-M%02d" % market_index:
|
|
errors.append("spark_id:%d" % market_index)
|
|
if (
|
|
int(spark.get("repeat_threshold", 0)) != 3
|
|
or String(spark.get("tracked_tag", "")).is_empty()
|
|
or int(spark.get("next_spark_index", 0)) < 1
|
|
or int(spark.get("next_spark_index", 0)) > 8
|
|
or String(spark.get("copied_target_rule", "")).is_empty()
|
|
or int(spark.get("safe_promotion_reward", 0)) < 2
|
|
or int(spark.get("warning_ticks", 0)) < 8
|
|
or int(spark.get("resolve_tick", 0)) <= int(spark.get("warning_ticks", 0))
|
|
or int(spark.get("fixed_tick_ms", 0)) != 100
|
|
):
|
|
errors.append("spark_boundary:%d" % market_index)
|
|
|
|
|
|
static func _validate_contract2_routes(
|
|
content: Dictionary,
|
|
enemies: Dictionary,
|
|
errors: Array[String]
|
|
) -> void:
|
|
var routes = content.get("contract2_routes", [])
|
|
if not routes is Array or routes.size() != 36:
|
|
errors.append("contract2_route_count")
|
|
return
|
|
var by_id := {}
|
|
var enemy_usage := {}
|
|
for enemy_id in enemies:
|
|
if String(enemy_id).begins_with("EN-R3-"):
|
|
enemy_usage[enemy_id] = 0
|
|
for route in routes:
|
|
if not route is Dictionary:
|
|
errors.append("contract2_route_type")
|
|
continue
|
|
_validate_common(route, "contract2_routes", errors)
|
|
var route_id := String(route.get("id", ""))
|
|
by_id[route_id] = route
|
|
var market_index := int(route.get("market_index", -1))
|
|
var risk := int(route.get("risk", -1))
|
|
if route_id != "RO-R3-C02-M%02d-Q%d" % [market_index, risk]:
|
|
errors.append("contract2_route_identity:%s" % route_id)
|
|
if market_index < 1 or market_index > 12 or int(route.get("round_index", -1)) != market_index:
|
|
errors.append("contract2_route_round:%s" % route_id)
|
|
if risk < 0 or risk > 2:
|
|
errors.append("contract2_route_risk:%s" % route_id)
|
|
if int(route.get("reward_coin", -1)) != 22 + 2 * market_index + 7 * risk:
|
|
errors.append("contract2_route_reward:%s" % route_id)
|
|
if int(route.get("recovery_coin", -1)) != 12 + market_index + 3 * risk:
|
|
errors.append("contract2_route_recovery:%s" % route_id)
|
|
var enemy_ids = route.get("enemy_ids", [])
|
|
var waves = route.get("wave_order", [])
|
|
if not enemy_ids is Array or not waves is Array or enemy_ids.size() != waves.size():
|
|
errors.append("contract2_route_waves:%s" % route_id)
|
|
else:
|
|
var previous_tick := -1
|
|
for index in range(enemy_ids.size()):
|
|
var enemy_id := String(enemy_ids[index])
|
|
if not enemy_usage.has(enemy_id):
|
|
errors.append("contract2_route_enemy:%s:%s" % [route_id, enemy_id])
|
|
else:
|
|
enemy_usage[enemy_id] += 1
|
|
if String(waves[index].get("enemy_id", "")) != enemy_id:
|
|
errors.append("contract2_route_wave_enemy:%s" % route_id)
|
|
var tick := int(waves[index].get("tick", -1))
|
|
if tick <= previous_tick:
|
|
errors.append("contract2_route_wave_order:%s" % route_id)
|
|
previous_tick = tick
|
|
for market_index in range(1, 13):
|
|
for risk in range(3):
|
|
var route_id := "RO-R3-C02-M%02d-Q%d" % [market_index, risk]
|
|
if not by_id.has(route_id):
|
|
errors.append("contract2_route_missing:%s" % route_id)
|
|
for enemy_id in enemy_usage:
|
|
if int(enemy_usage[enemy_id]) < 2:
|
|
errors.append("contract2_enemy_underused:%s" % enemy_id)
|
|
|
|
|
|
static func _validate_rg04_markets(
|
|
content: Dictionary,
|
|
market_key: String,
|
|
contract_index: int,
|
|
equipment: Dictionary,
|
|
enemies: Dictionary,
|
|
errors: Array[String]
|
|
) -> void:
|
|
var markets = content.get(market_key, [])
|
|
if not markets is Array or markets.size() != 12:
|
|
errors.append("rg04_market_count:%d" % contract_index)
|
|
return
|
|
var seen := {}
|
|
for market in markets:
|
|
if not market is Dictionary:
|
|
errors.append("rg04_market_type:%d" % contract_index)
|
|
continue
|
|
var market_index := int(market.get("market_index", -1))
|
|
seen[market_index] = true
|
|
if (
|
|
market_index < 1 or market_index > 12
|
|
or int(market.get("round_index", -1)) != market_index
|
|
or int(market.get("line_capacity", -1)) != 10
|
|
or int(market.get("offer_level", -1)) != 1 + int(floor(float(market_index - 1) / 3.0))
|
|
):
|
|
errors.append("rg04_market_boundary:C%d:M%d" % [contract_index, market_index])
|
|
for field in ["name", "preview", "pressure", "failure", "milestone"]:
|
|
_validate_localized(
|
|
market.get(field, {}),
|
|
"rg04_market_C%d_M%d:%s" % [contract_index, market_index, field],
|
|
errors
|
|
)
|
|
var tags = market.get("response_tags", [])
|
|
if not tags is Array or tags.size() < 3 or not _unique(tags):
|
|
errors.append("rg04_market_tags:C%d:M%d" % [contract_index, market_index])
|
|
var items = market.get("required_equipment_ids", [])
|
|
if not items is Array or items.size() != 2 or not _unique(items):
|
|
errors.append("rg04_market_equipment:C%d:M%d" % [contract_index, market_index])
|
|
else:
|
|
for item_id in items:
|
|
var id := String(item_id)
|
|
if not equipment.has(id) or not _id_in_range(id, "EQ-", 121, 160):
|
|
errors.append("rg04_market_item:C%d:M%d:%s" % [contract_index, market_index, id])
|
|
var enemy_sets = market.get("enemy_sets", [])
|
|
if not enemy_sets is Array or enemy_sets.size() != 3:
|
|
errors.append("rg04_market_enemy_sets:C%d:M%d" % [contract_index, market_index])
|
|
else:
|
|
for enemy_set in enemy_sets:
|
|
if (
|
|
not enemy_set is Array
|
|
or enemy_set.size() < 3
|
|
or enemy_set.size() > 4
|
|
or not _unique(enemy_set)
|
|
):
|
|
errors.append("rg04_market_enemy_set:C%d:M%d" % [contract_index, market_index])
|
|
continue
|
|
for enemy_id in enemy_set:
|
|
var id := String(enemy_id)
|
|
if not enemies.has(id) or not id.begins_with("EN-R4-"):
|
|
errors.append("rg04_market_enemy:C%d:M%d:%s" % [contract_index, market_index, id])
|
|
_validate_rg04_clause(market.get("archive_clause", {}), contract_index, market_index, errors)
|
|
_validate_rg04_copy(market.get("copy_disclosure", {}), contract_index, market_index, errors)
|
|
if seen.size() != 12:
|
|
errors.append("rg04_market_indices:%d" % contract_index)
|
|
|
|
|
|
static func _validate_rg04_clause(
|
|
clause,
|
|
contract_index: int,
|
|
market_index: int,
|
|
errors: Array[String]
|
|
) -> void:
|
|
if not clause is Dictionary:
|
|
errors.append("rg04_clause_type:C%d:M%d" % [contract_index, market_index])
|
|
return
|
|
var kind := String(clause.get("kind", ""))
|
|
if String(clause.get("id", "")) != "CL-R4-C%02d-M%02d" % [contract_index, market_index]:
|
|
errors.append("rg04_clause_id:C%d:M%d" % [contract_index, market_index])
|
|
if kind not in ["repeat", "alternate", "preserve"]:
|
|
errors.append("rg04_clause_kind:C%d:M%d" % [contract_index, market_index])
|
|
if String(clause.get("affected_field", "")) not in [
|
|
"action_tag", "target_rule", "status_source", "equipment_trigger",
|
|
]:
|
|
errors.append("rg04_clause_field:C%d:M%d" % [contract_index, market_index])
|
|
_validate_localized(
|
|
clause.get("obey_result", {}),
|
|
"rg04_clause_obey:C%d:M%d" % [contract_index, market_index],
|
|
errors
|
|
)
|
|
_validate_localized(
|
|
clause.get("break_result", {}),
|
|
"rg04_clause_break:C%d:M%d" % [contract_index, market_index],
|
|
errors
|
|
)
|
|
if int(clause.get("counter_limit", 0)) < 2 or int(clause.get("counter_limit", 0)) > 4:
|
|
errors.append("rg04_clause_counter:C%d:M%d" % [contract_index, market_index])
|
|
|
|
|
|
static func _validate_rg04_copy(
|
|
disclosure,
|
|
contract_index: int,
|
|
market_index: int,
|
|
errors: Array[String]
|
|
) -> void:
|
|
if not disclosure is Dictionary:
|
|
errors.append("rg04_copy_type:C%d:M%d" % [contract_index, market_index])
|
|
return
|
|
if String(disclosure.get("id", "")) != "CP-R4-C%02d-M%02d" % [contract_index, market_index]:
|
|
errors.append("rg04_copy_id:C%d:M%d" % [contract_index, market_index])
|
|
if String(disclosure.get("pressure_family", "")) not in [
|
|
"copy", "redirect", "cleanse_trap", "clause",
|
|
]:
|
|
errors.append("rg04_copy_family:C%d:M%d" % [contract_index, market_index])
|
|
if String(disclosure.get("source_rule", "")) not in [
|
|
"action_tag", "target_rule", "status_source", "equipment_trigger",
|
|
]:
|
|
errors.append("rg04_copy_source:C%d:M%d" % [contract_index, market_index])
|
|
if String(disclosure.get("target_rule", "")) not in [
|
|
"foremost", "rearmost", "same_index", "next_occupied",
|
|
]:
|
|
errors.append("rg04_copy_target:C%d:M%d" % [contract_index, market_index])
|
|
if (
|
|
int(disclosure.get("warning_ticks", 0)) < 14
|
|
or int(disclosure.get("resolve_tick", 0)) <= int(disclosure.get("warning_ticks", 0))
|
|
or int(disclosure.get("fixed_tick_ms", 0)) != 100
|
|
):
|
|
errors.append("rg04_copy_timing:C%d:M%d" % [contract_index, market_index])
|
|
|
|
|
|
static func _validate_rg04_routes(
|
|
content: Dictionary,
|
|
route_key: String,
|
|
contract_index: int,
|
|
enemies: Dictionary,
|
|
errors: Array[String]
|
|
) -> void:
|
|
var routes = content.get(route_key, [])
|
|
if not routes is Array or routes.size() != 36:
|
|
errors.append("rg04_route_count:%d" % contract_index)
|
|
return
|
|
var market_key := "rg04_markets" if contract_index == 1 else "rg04_contract2_markets"
|
|
var markets := _markets_by_index(content.get(market_key, []))
|
|
var by_id := {}
|
|
var enemy_usage := {}
|
|
for enemy_id in enemies:
|
|
if String(enemy_id).begins_with("EN-R4-"):
|
|
enemy_usage[enemy_id] = 0
|
|
for route in routes:
|
|
if not route is Dictionary:
|
|
errors.append("rg04_route_type:%d" % contract_index)
|
|
continue
|
|
_validate_common(route, route_key, errors)
|
|
var route_id := String(route.get("id", ""))
|
|
var market_index := int(route.get("market_index", -1))
|
|
var risk := int(route.get("risk", -1))
|
|
by_id[route_id] = route
|
|
if route_id != "RO-R4-C%02d-M%02d-Q%d" % [contract_index, market_index, risk]:
|
|
errors.append("rg04_route_identity:%s" % route_id)
|
|
if market_index < 1 or market_index > 12 or int(route.get("round_index", -1)) != market_index:
|
|
errors.append("rg04_route_round:%s" % route_id)
|
|
if risk < 0 or risk > 2:
|
|
errors.append("rg04_route_risk:%s" % route_id)
|
|
if int(route.get("reward_coin", -1)) != 24 + 2 * market_index + 7 * risk:
|
|
errors.append("rg04_route_reward:%s" % route_id)
|
|
if int(route.get("recovery_coin", -1)) != 14 + market_index + 3 * risk:
|
|
errors.append("rg04_route_recovery:%s" % route_id)
|
|
var market: Dictionary = markets.get(market_index, {})
|
|
if (
|
|
market.is_empty()
|
|
or route.get("archive_clause", {}) != market.get("archive_clause", {})
|
|
or route.get("copy_disclosure", {}) != market.get("copy_disclosure", {})
|
|
):
|
|
errors.append("rg04_route_disclosure:%s" % route_id)
|
|
var enemy_ids = route.get("enemy_ids", [])
|
|
var waves = route.get("wave_order", [])
|
|
if not enemy_ids is Array or not waves is Array or enemy_ids.size() != waves.size():
|
|
errors.append("rg04_route_waves:%s" % route_id)
|
|
else:
|
|
var previous_tick := -1
|
|
for index in range(enemy_ids.size()):
|
|
var enemy_id := String(enemy_ids[index])
|
|
if not enemy_usage.has(enemy_id):
|
|
errors.append("rg04_route_enemy:%s:%s" % [route_id, enemy_id])
|
|
else:
|
|
enemy_usage[enemy_id] += 1
|
|
if String(waves[index].get("enemy_id", "")) != enemy_id:
|
|
errors.append("rg04_route_wave_enemy:%s" % route_id)
|
|
var tick := int(waves[index].get("tick", -1))
|
|
if tick <= previous_tick:
|
|
errors.append("rg04_route_wave_order:%s" % route_id)
|
|
previous_tick = tick
|
|
for market_index in range(1, 13):
|
|
for risk in range(3):
|
|
var route_id := "RO-R4-C%02d-M%02d-Q%d" % [contract_index, market_index, risk]
|
|
if not by_id.has(route_id):
|
|
errors.append("rg04_route_missing:%s" % route_id)
|
|
for enemy_id in enemy_usage:
|
|
if int(enemy_usage[enemy_id]) < 2:
|
|
errors.append("rg04_enemy_underused:C%d:%s" % [contract_index, enemy_id])
|
|
|
|
|
|
static func _validate_rg04_coverage(content: Dictionary, errors: Array[String]) -> void:
|
|
var clause_kinds := {}
|
|
var pressure_families := {}
|
|
for market_key in ["rg04_markets", "rg04_contract2_markets"]:
|
|
for market in content.get(market_key, []):
|
|
if not market is Dictionary:
|
|
continue
|
|
clause_kinds[String(market.get("archive_clause", {}).get("kind", ""))] = true
|
|
pressure_families[String(market.get("copy_disclosure", {}).get("pressure_family", ""))] = true
|
|
for expected in ["repeat", "alternate", "preserve"]:
|
|
if not clause_kinds.has(expected):
|
|
errors.append("rg04_clause_coverage:%s" % expected)
|
|
for expected in ["copy", "redirect", "cleanse_trap", "clause"]:
|
|
if not pressure_families.has(expected):
|
|
errors.append("rg04_pressure_coverage:%s" % expected)
|
|
|
|
|
|
static func _markets_by_index(markets) -> Dictionary:
|
|
var result := {}
|
|
if markets is Array:
|
|
for market in markets:
|
|
if market is Dictionary:
|
|
result[int(market.get("market_index", -1))] = market
|
|
return result
|
|
|
|
|
|
static func _id_in_range(identifier: String, prefix: String, first: int, last: int) -> bool:
|
|
if not identifier.begins_with(prefix):
|
|
return false
|
|
var suffix := identifier.trim_prefix(prefix)
|
|
return suffix.is_valid_int() and int(suffix) >= first and int(suffix) <= last
|
|
|
|
|
|
static func _build_routes(content: Dictionary) -> void:
|
|
var rows: Array[Dictionary] = []
|
|
var suffix_en := ["Survey", "Press", "Hazard"]
|
|
var suffix_zh := ["勘察", "推进", "险行"]
|
|
for market in content.get("markets", []):
|
|
if not market is Dictionary:
|
|
continue
|
|
var market_index := int(market.get("market_index", -1))
|
|
var enemy_sets = market.get("enemy_sets", [])
|
|
if not enemy_sets is Array or enemy_sets.size() != 3:
|
|
continue
|
|
for risk in range(3):
|
|
var enemy_ids: Array = Array(enemy_sets[risk]).duplicate()
|
|
var waves: Array[Dictionary] = []
|
|
for index in range(enemy_ids.size()):
|
|
waves.append({
|
|
"tick": index * (12 - risk) + (0 if index == 0 else risk),
|
|
"enemy_id": String(enemy_ids[index]),
|
|
})
|
|
rows.append({
|
|
"id": "RO-R3-C01-M%02d-Q%d" % [market_index, risk],
|
|
"name": {
|
|
"en": "%s - %s" % [String(market.name.en), suffix_en[risk]],
|
|
"zh_CN": "%s - %s" % [String(market.name.zh_CN), suffix_zh[risk]],
|
|
},
|
|
"market_index": market_index,
|
|
"round_index": market_index,
|
|
"risk": risk,
|
|
"enemy_ids": enemy_ids,
|
|
"wave_order": waves,
|
|
"reward_coin": 22 + 2 * market_index + 7 * risk,
|
|
"recovery_coin": 12 + market_index + 3 * risk,
|
|
"pressure": Dictionary(market.pressure).duplicate(true),
|
|
"failure": Dictionary(market.failure).duplicate(true),
|
|
"preview": Dictionary(market.preview).duplicate(true),
|
|
"response_tags": Array(market.response_tags).duplicate(),
|
|
"pressure_valve": Dictionary(market.pressure_valve).duplicate(true),
|
|
})
|
|
content["routes"] = rows
|
|
|
|
|
|
static func _build_contract2_routes(content: Dictionary) -> void:
|
|
var rows: Array[Dictionary] = []
|
|
var suffix_en := ["Survey", "Press", "Hazard"]
|
|
var suffix_zh := ["勘察", "推进", "险行"]
|
|
for market in content.get("contract2_markets", []):
|
|
if not market is Dictionary:
|
|
continue
|
|
var market_index := int(market.get("market_index", -1))
|
|
var enemy_sets = market.get("enemy_sets", [])
|
|
if not enemy_sets is Array or enemy_sets.size() != 3:
|
|
continue
|
|
for risk in range(3):
|
|
var enemy_ids: Array = Array(enemy_sets[risk]).duplicate()
|
|
var waves: Array[Dictionary] = []
|
|
for index in range(enemy_ids.size()):
|
|
waves.append({
|
|
"tick": index * (12 - risk) + (0 if index == 0 else risk),
|
|
"enemy_id": String(enemy_ids[index]),
|
|
})
|
|
rows.append({
|
|
"id": "RO-R3-C02-M%02d-Q%d" % [market_index, risk],
|
|
"name": {
|
|
"en": "%s - %s" % [String(market.name.en), suffix_en[risk]],
|
|
"zh_CN": "%s - %s" % [String(market.name.zh_CN), suffix_zh[risk]],
|
|
},
|
|
"market_index": market_index,
|
|
"round_index": market_index,
|
|
"risk": risk,
|
|
"enemy_ids": enemy_ids,
|
|
"wave_order": waves,
|
|
"reward_coin": 22 + 2 * market_index + 7 * risk,
|
|
"recovery_coin": 12 + market_index + 3 * risk,
|
|
"pressure": Dictionary(market.pressure).duplicate(true),
|
|
"failure": Dictionary(market.failure).duplicate(true),
|
|
"preview": Dictionary(market.preview).duplicate(true),
|
|
"response_tags": Array(market.response_tags).duplicate(),
|
|
"spark_disclosure": Dictionary(market.spark_disclosure).duplicate(true),
|
|
})
|
|
content["contract2_routes"] = rows
|
|
|
|
|
|
static func _build_rg04_routes(
|
|
content: Dictionary,
|
|
market_key: String,
|
|
route_key: String,
|
|
contract_index: int
|
|
) -> void:
|
|
var rows: Array[Dictionary] = []
|
|
var suffix_en := ["Survey", "Press", "Hazard"]
|
|
var suffix_zh := ["勘查", "推进", "险行"]
|
|
for market in content.get(market_key, []):
|
|
if not market is Dictionary:
|
|
continue
|
|
var market_index := int(market.get("market_index", -1))
|
|
var enemy_sets = market.get("enemy_sets", [])
|
|
if not enemy_sets is Array or enemy_sets.size() != 3:
|
|
continue
|
|
for risk in range(3):
|
|
var enemy_ids: Array = Array(enemy_sets[risk]).duplicate()
|
|
var waves: Array[Dictionary] = []
|
|
for index in range(enemy_ids.size()):
|
|
waves.append({
|
|
"tick": index * (13 - risk) + (0 if index == 0 else risk),
|
|
"enemy_id": String(enemy_ids[index]),
|
|
})
|
|
rows.append({
|
|
"id": "RO-R4-C%02d-M%02d-Q%d" % [contract_index, market_index, risk],
|
|
"name": {
|
|
"en": "%s - %s" % [String(market.name.en), suffix_en[risk]],
|
|
"zh_CN": "%s - %s" % [String(market.name.zh_CN), suffix_zh[risk]],
|
|
},
|
|
"market_index": market_index,
|
|
"round_index": market_index,
|
|
"risk": risk,
|
|
"enemy_ids": enemy_ids,
|
|
"wave_order": waves,
|
|
"reward_coin": 24 + 2 * market_index + 7 * risk,
|
|
"recovery_coin": 14 + market_index + 3 * risk,
|
|
"pressure": Dictionary(market.pressure).duplicate(true),
|
|
"failure": Dictionary(market.failure).duplicate(true),
|
|
"preview": Dictionary(market.preview).duplicate(true),
|
|
"response_tags": Array(market.response_tags).duplicate(),
|
|
"archive_clause": Dictionary(market.archive_clause).duplicate(true),
|
|
"copy_disclosure": Dictionary(market.copy_disclosure).duplicate(true),
|
|
})
|
|
content[route_key] = rows
|
|
|
|
|
|
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)
|
|
projection.erase("execution_key")
|
|
projection.erase("mechanic_signature")
|
|
return _hash(projection)
|
|
|
|
|
|
static func _expected_ids(prefix: String, first: int, last: int, width: int = 3) -> Array:
|
|
var result: Array[String] = []
|
|
for value in range(first, last + 1):
|
|
result.append("%s%0*d" % [prefix, width, value])
|
|
return result
|
|
|
|
|
|
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:
|
|
return JSON.stringify(_canonical(value)).sha256_text()
|
|
|
|
|
|
static func _canonical(value):
|
|
if value is float and is_equal_approx(value, round(value)):
|
|
return int(round(value))
|
|
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
|