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

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