class_name P97Content extends RefCounted const KNOWN_PROFESSION_IDS := [ "PF-B01", "PF-B02", "PF-B03", "PF-B04", "PF-B05", "PF-B06", "PF-B07", "PF-B08", "PF-B09", "PF-B11", ] const PROFESSION_ORDER := [ "PF-B02", "PF-B03", "PF-B06", "PF-B08", "PF-B09", "PF-B11", ] const PROFESSIONS := { "PF-B02": { "id": "PF-B02", "name_zh": "刃契士", "name_en": "Edgehand", "role": "edge_contact", "band": "front_center", "max_hp": 104, "damage": 17, "cooldown": 8, "behavior": "alternate_finish", "strength_zh": "交替目标会加快下一击", "strength_en": "Alternating targets accelerates the next strike", "limit_zh": "接敌前无法处理远处威胁", "limit_en": "Cannot answer distant threats before contact", "visual_rig": "Wayrunner", "palette": "coral_steel", }, "PF-B03": { "id": "PF-B03", "name_zh": "长辐手", "name_en": "Longspoke", "role": "reach_control", "band": "center", "max_hp": 112, "damage": 14, "cooldown": 10, "behavior": "aligned_pierce", "strength_zh": "接敌前贯穿前两名敌人", "strength_en": "Pierces the first two enemies before contact", "limit_zh": "被推位后会失去下一次贯穿", "limit_en": "Forced movement cancels the next pierce", "visual_rig": "Hearthguard", "palette": "jade_bronze", }, "PF-B06": { "id": "PF-B06", "name_zh": "裂隙召唤师", "name_en": "Riftcaller", "role": "area_caster", "band": "rear", "max_hp": 82, "damage": 20, "cooldown": 15, "behavior": "two_target_cast", "strength_zh": "蓄力完成后同时打击两名敌人", "strength_en": "A completed cast hits two enemies", "limit_zh": "指令打断会清空本次蓄力", "limit_en": "Command interrupts clear the current cast", "visual_rig": "Kindhand", "palette": "violet_cyan", }, "PF-B08": { "id": "PF-B08", "name_zh": "结契师", "name_en": "Bondwright", "role": "mixed_enable", "band": "center", "max_hp": 96, "damage": 8, "cooldown": 10, "behavior": "alternate_enable", "strength_zh": "不同职责连续行动会推进较慢的邻员", "strength_en": "Mixed roles advance the slower neighbor", "limit_zh": "同职责队列不会触发推进", "limit_en": "Repeated role tags do not trigger the advance", "visual_rig": "Kindhand", "palette": "rose_jade", }, "PF-B09": { "id": "PF-B09", "name_zh": "线圈匠", "name_en": "Coilsmith", "role": "construct_support", "band": "center_rear", "max_hp": 102, "damage": 10, "cooldown": 10, "behavior": "needle_post", "strength_zh": "每两次普攻部署一次后排针塔射击", "strength_en": "Every second basic deploys a rear Needle Post shot", "limit_zh": "后排受压会摧毁待发针塔", "limit_en": "Rear pressure destroys a pending post", "visual_rig": "Farstring", "palette": "copper_teal", }, "PF-B11": { "id": "PF-B11", "name_zh": "守钟人", "name_en": "Bellkeeper", "role": "tempo_control", "band": "center", "max_hp": 98, "damage": 9, "cooldown": 9, "behavior": "third_action_delay", "strength_zh": "每三次行动延后最长的敌方蓄力", "strength_en": "Every third action delays the longest enemy wind-up", "limit_zh": "高速同频敌人会削弱延后价值", "limit_en": "Uniform fast enemies reduce the delay value", "visual_rig": "Hearthguard", "palette": "gold_navy", }, } const TRAITS := { "TR-005": {"id": "TR-005", "name_zh": "备用巧手", "name_en": "Spare Hand"}, "TR-006": {"id": "TR-006", "name_zh": "开放架势", "name_en": "Open Stance"}, "TR-010": {"id": "TR-010", "name_zh": "共饮", "name_en": "Shared Cup"}, "TR-014": {"id": "TR-014", "name_zh": "沉袖", "name_en": "Heavy Sleeve"}, "TR-017": {"id": "TR-017", "name_zh": "严谨", "name_en": "Exacting"}, "TR-021": {"id": "TR-021", "name_zh": "散阵", "name_en": "Loose Formation"}, "TR-027": {"id": "TR-027", "name_zh": "定声", "name_en": "Rooted Voice"}, "TR-029": {"id": "TR-029", "name_zh": "远援之手", "name_en": "Open Palm"}, } const EQUIPMENT_ORDER := ["EQ-021", "EQ-023", "EQ-028", "EQ-036"] const EQUIPMENT := { "EQ-021": { "id": "EQ-021", "name_zh": "威胁镜片", "name_en": "Threatglass Lens", "fit": ["any"], "behavior": "longest_windup", "list_price": 12, "benefit_zh": "首次普攻优先最长蓄力", "benefit_en": "First basic prefers the longest wind-up", "cost_zh": "无蓄力目标时等待 1 tick", "cost_en": "Waits 1 tick when no wind-up exists", }, "EQ-023": { "id": "EQ-023", "name_zh": "后召哨", "name_en": "Rearcall Whistle", "fit": ["reach_control", "area_caster", "ranged"], "behavior": "first_rear_target", "list_price": 12, "benefit_zh": "首次合法远程攻击瞄准最后排", "benefit_en": "First legal ranged attack targets the rearmost enemy", "cost_zh": "该次攻击不触发职业附加效果", "cost_en": "That attack gives up its profession rider", }, "EQ-028": { "id": "EQ-028", "name_zh": "护缝锥", "name_en": "Guard-Seam Pick", "fit": ["any"], "behavior": "protector_nonlethal", "list_price": 12, "benefit_zh": "首次普攻优先保护者", "benefit_en": "First basic prefers a protecting enemy", "cost_zh": "触发攻击不能击倒保护者", "cost_en": "The triggered hit cannot defeat the protector", }, "EQ-036": { "id": "EQ-036", "name_zh": "双心准镜", "name_en": "Twin Reticle", "fit": ["area_caster"], "behavior": "center_adjacent_pair", "list_price": 12, "benefit_zh": "范围攻击同时覆盖相邻两敌", "benefit_en": "Area actions center on two adjacent enemies", "cost_zh": "仅一名敌人时不会触发", "cost_en": "Does not trigger against a lone enemy", }, } const ENEMY_ORDER := [ "EN-R1-01", "EN-R1-02", "EN-R1-03", "EN-R1-05", "EN-R1-08", "EN-R1-11", ] const ENEMIES := { "EN-R1-01": {"id": "EN-R1-01", "name_zh": "晶窗啮兽", "name_en": "Pane Nibbler", "role": "contact_biter", "max_hp": 132, "damage": 11, "cooldown": 8, "pressure": "guard_chip"}, "EN-R1-02": {"id": "EN-R1-02", "name_zh": "疾风雀", "name_en": "Gust Finch", "role": "rear_projectile", "max_hp": 86, "damage": 9, "cooldown": 10, "pressure": "rear_target"}, "EN-R1-03": {"id": "EN-R1-03", "name_zh": "台地角羊", "name_en": "Terrace Ram", "role": "contact_push", "max_hp": 168, "damage": 13, "cooldown": 11, "pressure": "front_push"}, "EN-R1-05": {"id": "EN-R1-05", "name_zh": "镜甲兽", "name_en": "Shardback", "role": "projectile_screen", "max_hp": 156, "damage": 8, "cooldown": 12, "pressure": "first_projectile_reflect"}, "EN-R1-08": {"id": "EN-R1-08", "name_zh": "玻根幼株", "name_en": "Glassroot Sprout", "role": "growing_screen", "max_hp": 118, "damage": 7, "cooldown": 9, "pressure": "growth_guard"}, "EN-R1-11": {"id": "EN-R1-11", "name_zh": "风律执达官", "name_en": "Gale Bailiff", "role": "elite_command", "max_hp": 220, "damage": 14, "cooldown": 13, "pressure": "stand_order_interrupt"}, } const RECRUIT_ORDER := [ "P97-M5-R1", "P97-M5-R2", "P97-M5-R3", "P97-M6-R1", "P97-M6-R2", "P97-M6-R3", "P97-M7-R1", "P97-M7-R2", "P97-M7-R3", ] const RECRUITS := { "P97-M5-R1": {"id": "P97-M5-R1", "market": 5, "offer_index": 0, "generation_seed": "52a7b84c36e24193a1d3c8fe7b105501", "name_zh": "伊文 · 羽河", "name_en": "Iven Quillwater", "profession_id": "PF-B02", "trait_id": "TR-006", "equipment_id": "EQ-028"}, "P97-M5-R2": {"id": "P97-M5-R2", "market": 5, "offer_index": 1, "generation_seed": "7c9d021a528e4af89ab9018ea7f16602", "name_zh": "佩拉 · 高辐", "name_en": "Pella Highspoke", "profession_id": "PF-B03", "trait_id": "TR-017", "equipment_id": "EQ-023"}, "P97-M5-R3": {"id": "P97-M5-R3", "market": 5, "offer_index": 2, "generation_seed": "9e3b6a714fd2415ea6c8e5a93b9b3303", "name_zh": "奈里斯 · 烬步", "name_en": "Neris Emberstep", "profession_id": "PF-B06", "trait_id": "TR-014", "equipment_id": "EQ-036"}, "P97-M6-R1": {"id": "P97-M6-R1", "market": 6, "offer_index": 0, "generation_seed": "164f8a25d7d943dfb024fa2cc0bdb604", "name_zh": "卡莎 · 潮井", "name_en": "Cassa Tidewell", "profession_id": "PF-B08", "trait_id": "TR-029", "equipment_id": "EQ-021"}, "P97-M6-R2": {"id": "P97-M6-R2", "market": 6, "offer_index": 1, "generation_seed": "bca0549cfa5045b59d2606316918c205", "name_zh": "塔文 · 北线圈", "name_en": "Tavin Northcoil", "profession_id": "PF-B09", "trait_id": "TR-005", "equipment_id": "EQ-028"}, "P97-M6-R3": {"id": "P97-M6-R3", "market": 6, "offer_index": 2, "generation_seed": "e6f30b19d3594c5b931294e8b19b3d06", "name_zh": "赫莎 · 钟醒", "name_en": "Hessa Bellwake", "profession_id": "PF-B11", "trait_id": "TR-027", "equipment_id": "EQ-021"}, "P97-M7-R1": {"id": "P97-M7-R1", "market": 7, "offer_index": 0, "generation_seed": "3c69ab71087948b480fd84f9f4392707", "name_zh": "布林 · 晨苇", "name_en": "Brin Dawnreed", "profession_id": "PF-B02", "trait_id": "TR-021", "equipment_id": "EQ-028"}, "P97-M7-R2": {"id": "P97-M7-R2", "market": 7, "offer_index": 1, "generation_seed": "8bd37e2c4ea94d858088be4a02de5808", "name_zh": "玛恩 · 松镜", "name_en": "Marn Pineglass", "profession_id": "PF-B09", "trait_id": "TR-005", "equipment_id": "EQ-021"}, "P97-M7-R3": {"id": "P97-M7-R3", "market": 7, "offer_index": 2, "generation_seed": "f1403de234b84bac861509542132a609", "name_zh": "维莎 · 石花", "name_en": "Vessa Stonebloom", "profession_id": "PF-B07", "trait_id": "TR-010", "equipment_id": "EQ-021"}, } const ROUTE_ORDER := [ "P97-R5-Q0", "P97-R5-Q1", "P97-R5-Q2", "P97-R6-Q0", "P97-R6-Q1", "P97-R6-Q2", "P97-R7-Q0", "P97-R7-Q1", "P97-R7-Q2", ] const ROUTES := { "P97-R5-Q0": {"id": "P97-R5-Q0", "round": 5, "risk": 0, "seed": 9750, "name_zh": "玻道巡查", "name_en": "Glassway Patrol", "enemy_ids": ["EN-R1-01", "EN-R1-02"], "pressure_zh": "前排啃咬 + 后排风弹", "pressure_en": "Front bite + rear projectile"}, "P97-R5-Q1": {"id": "P97-R5-Q1", "round": 5, "risk": 1, "seed": 9751, "name_zh": "棱镜逆风", "name_en": "Prism Wind", "enemy_ids": ["EN-R1-05", "EN-R1-03", "EN-R1-02"], "pressure_zh": "投射屏障 + 推位", "pressure_en": "Projectile screen + push"}, "P97-R5-Q2": {"id": "P97-R5-Q2", "round": 5, "risk": 2, "seed": 9752, "name_zh": "根晶岔道", "name_en": "Rootglass Spur", "enemy_ids": ["EN-R1-08", "EN-R1-01", "EN-R1-11"], "pressure_zh": "生长屏障 + 站位号令", "pressure_en": "Growing screen + Stand Order"}, "P97-R6-Q0": {"id": "P97-R6-Q0", "round": 6, "risk": 0, "seed": 9760, "name_zh": "台地护送", "name_en": "Terrace Escort", "enemy_ids": ["EN-R1-03", "EN-R1-01"], "pressure_zh": "推位后的前排接替", "pressure_en": "Front replacement after push"}, "P97-R6-Q1": {"id": "P97-R6-Q1", "round": 6, "risk": 1, "seed": 9761, "name_zh": "镜甲弯道", "name_en": "Shardback Bend", "enemy_ids": ["EN-R1-05", "EN-R1-08", "EN-R1-02"], "pressure_zh": "屏障轮换 + 后排射击", "pressure_en": "Screen rotation + rear shot"}, "P97-R6-Q2": {"id": "P97-R6-Q2", "round": 6, "risk": 2, "seed": 9762, "name_zh": "执达官号令", "name_en": "Bailiff's Order", "enemy_ids": ["EN-R1-11", "EN-R1-03", "EN-R1-01"], "pressure_zh": "定点号令 + 推位", "pressure_en": "Indexed command + push"}, "P97-R7-Q0": {"id": "P97-R7-Q0", "round": 7, "risk": 0, "seed": 9770, "name_zh": "风雀渡口", "name_en": "Finch Crossing", "enemy_ids": ["EN-R1-02", "EN-R1-01", "EN-R1-08"], "pressure_zh": "生长前的后排压力", "pressure_en": "Rear pressure before growth"}, "P97-R7-Q1": {"id": "P97-R7-Q1", "round": 7, "risk": 1, "seed": 9771, "name_zh": "破碎棱面", "name_en": "Broken Prism", "enemy_ids": ["EN-R1-05", "EN-R1-03", "EN-R1-08"], "pressure_zh": "目标屏障 + 推位", "pressure_en": "Target-family screen + push"}, "P97-R7-Q2": {"id": "P97-R7-Q2", "round": 7, "risk": 2, "seed": 9772, "name_zh": "中继站夺还", "name_en": "Relay Reclamation", "enemy_ids": ["EN-R1-11", "EN-R1-05", "EN-R1-03"], "pressure_zh": "号令打断 + 屏障 + 推位", "pressure_en": "Command interrupt + screen + push"}, } const MARKET_RECRUITS := { 5: ["P97-M5-R1", "P97-M5-R2", "P97-M5-R3"], 6: ["P97-M6-R1", "P97-M6-R2", "P97-M6-R3"], 7: ["P97-M7-R1", "P97-M7-R2", "P97-M7-R3"], } const MARKET_ROUTES := { 5: ["P97-R5-Q0", "P97-R5-Q1", "P97-R5-Q2"], 6: ["P97-R6-Q0", "P97-R6-Q1", "P97-R6-Q2"], 7: ["P97-R7-Q0", "P97-R7-Q1", "P97-R7-Q2"], } static func snapshot() -> Dictionary: var recruits := {} for recruit_id in RECRUIT_ORDER: var recruit: Dictionary = Dictionary(RECRUITS[recruit_id]).duplicate(true) var profession_id := String(recruit.profession_id) var profession: Dictionary = PROFESSIONS.get(profession_id, {}) if profession.is_empty() and profession_id == "PF-B07": profession = {"max_hp": 90, "visual_rig": "Kindhand", "palette": "jade_gold"} recruit["level"] = 2 recruit["xp"] = 0 recruit["max_hp"] = int(profession.get("max_hp", 0)) recruit["current_hp"] = int(recruit.max_hp) recruit["list_price"] = hire_price(2, 0) recruit["coin_price_paid"] = 0 recruit["survival_battles"] = 0 recruit["starting_equipment"] = { "instance_id": "%s-ITEM" % recruit_id, "catalog_id": String(recruit.equipment_id), } recruit.erase("equipment_id") recruit["canonical_output_hash"] = _hash(recruit) recruits[recruit_id] = recruit var equipment_offers := {} var boards := {} for market_index in [5, 6, 7]: var equipment_offer_ids: Array[String] = [] for equipment_id in EQUIPMENT_ORDER: var offer_id := "P97-M%d-%s" % [market_index, equipment_id] var offer := { "id": offer_id, "market": market_index, "catalog_id": equipment_id, "list_price": 12, "instance_id": "%s-ITEM" % offer_id, } offer["canonical_output_hash"] = _hash(offer) equipment_offers[offer_id] = offer equipment_offer_ids.append(offer_id) var board := { "id": "P97-MARKET-%d" % market_index, "market": market_index, "recruit_offer_ids": Array(MARKET_RECRUITS[market_index]).duplicate(), "equipment_offer_ids": equipment_offer_ids, "battle_offer_ids": Array(MARKET_ROUTES[market_index]).duplicate(), } board["board_hash"] = _hash(board) boards[market_index] = board var routes := {} for route_id in ROUTE_ORDER: var route: Dictionary = Dictionary(ROUTES[route_id]).duplicate(true) route["reward"] = battle_reward(int(route.round), int(route.risk)) routes[route_id] = route var result := { "revision": "p9.7-content-r1", "professions": PROFESSIONS.duplicate(true), "traits": TRAITS.duplicate(true), "equipment": EQUIPMENT.duplicate(true), "enemies": ENEMIES.duplicate(true), "recruits": recruits, "equipment_offers": equipment_offers, "routes": routes, "boards": boards, } result["content_hash"] = _hash(result) return result static func validate(content: Dictionary) -> Dictionary: var errors: Array[String] = [] for key in ["revision", "professions", "traits", "equipment", "enemies", "recruits", "equipment_offers", "routes", "boards", "content_hash"]: if not content.has(key): errors.append("missing:%s" % key) if not errors.is_empty(): return {"ok": false, "errors": errors} for key in ["professions", "traits", "equipment", "enemies", "recruits", "equipment_offers", "routes", "boards"]: if not content[key] is Dictionary: errors.append("not_dictionary:%s" % key) if not errors.is_empty(): return {"ok": false, "errors": errors} var professions: Dictionary = content.professions var traits: Dictionary = content.traits var equipment: Dictionary = content.equipment var enemies: Dictionary = content.enemies var recruits: Dictionary = content.recruits var equipment_offers: Dictionary = content.equipment_offers var routes: Dictionary = content.routes var boards: Dictionary = content.boards _validate_family(professions, 6, "profession", errors) _validate_family(equipment, 4, "equipment", errors) _validate_family(enemies, 6, "enemy", errors) _validate_family(recruits, 9, "recruit", errors) _validate_family(equipment_offers, 12, "equipment_offer", errors) _validate_family(routes, 9, "route", errors) _validate_family(boards, 3, "board", errors) var global_ids := {} for family in [professions, traits, equipment, enemies, recruits, equipment_offers, routes]: for row_id in family: if global_ids.has(row_id): errors.append("duplicate_global_id:%s" % row_id) global_ids[row_id] = true var generation_seeds := {} var equipment_instances := {} for recruit_id in recruits: var recruit: Dictionary = recruits[recruit_id] var profession_id := String(recruit.get("profession_id", "")) if profession_id not in KNOWN_PROFESSION_IDS: errors.append("recruit_profession:%s" % recruit_id) if not traits.has(String(recruit.get("trait_id", ""))): errors.append("recruit_trait:%s" % recruit_id) var seed := String(recruit.get("generation_seed", "")) if not _is_lower_hex(seed, 32) or generation_seeds.has(seed): errors.append("recruit_seed:%s" % recruit_id) generation_seeds[seed] = true if String(recruit.get("name_zh", "")).is_empty() or String(recruit.get("name_en", "")).is_empty(): errors.append("recruit_name:%s" % recruit_id) var starting = recruit.get("starting_equipment", {}) if not starting is Dictionary or not equipment.has(String(starting.get("catalog_id", ""))): errors.append("recruit_equipment:%s" % recruit_id) else: var equipment_instance := String(starting.get("instance_id", "")) if equipment_instance.is_empty() or equipment_instances.has(equipment_instance): errors.append("recruit_equipment_instance:%s" % recruit_id) equipment_instances[equipment_instance] = true var role := String(professions.get(profession_id, {}).get("role", "healer" if profession_id == "PF-B07" else "")) if not _equipment_fits(equipment[String(starting.catalog_id)], role): errors.append("recruit_equipment_fit:%s" % recruit_id) if int(recruit.get("level", 0)) != 2 or int(recruit.get("list_price", -1)) != hire_price(2, 0): errors.append("recruit_price:%s" % recruit_id) var canonical := recruit.duplicate(true) var actual_hash := String(canonical.get("canonical_output_hash", "")) canonical.erase("canonical_output_hash") if actual_hash != _hash(canonical): errors.append("recruit_hash:%s" % recruit_id) for equipment_id in equipment: if int(equipment[equipment_id].get("list_price", -1)) != 12: errors.append("equipment_price:%s" % equipment_id) for offer_id in equipment_offers: var offer: Dictionary = equipment_offers[offer_id] if not equipment.has(String(offer.get("catalog_id", ""))): errors.append("equipment_offer_reference:%s" % offer_id) var canonical := offer.duplicate(true) var actual_hash := String(canonical.get("canonical_output_hash", "")) canonical.erase("canonical_output_hash") if actual_hash != _hash(canonical): errors.append("equipment_offer_hash:%s" % offer_id) for route_id in routes: var route: Dictionary = routes[route_id] var round_index := int(route.get("round", 0)) var risk := int(route.get("risk", -1)) if round_index not in [5, 6, 7] or risk not in [0, 1, 2]: errors.append("route_band:%s" % route_id) if int(route.get("reward", -1)) != battle_reward(round_index, risk): errors.append("route_reward:%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: 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]) for market_index in [5, 6, 7]: if not boards.has(market_index): errors.append("board_missing:%d" % market_index) continue var board: Dictionary = boards[market_index] _validate_board_refs(board, recruits, equipment_offers, routes, errors) for recruit_id in board.get("recruit_offer_ids", []): if recruits.has(recruit_id) and int(recruits[recruit_id].get("market", -1)) != market_index: errors.append("board_recruit_market:%d:%s" % [market_index, recruit_id]) for offer_id in board.get("equipment_offer_ids", []): if equipment_offers.has(offer_id) and int(equipment_offers[offer_id].get("market", -1)) != market_index: errors.append("board_equipment_market:%d:%s" % [market_index, offer_id]) var route_risks: Array[int] = [] for route_id in board.get("battle_offer_ids", []): if routes.has(route_id): route_risks.append(int(routes[route_id].risk)) if int(routes[route_id].round) != market_index: errors.append("board_route_round:%d:%s" % [market_index, route_id]) route_risks.sort() if route_risks != [0, 1, 2]: errors.append("board_risks:%d" % market_index) var canonical := board.duplicate(true) var actual_hash := String(canonical.get("board_hash", "")) canonical.erase("board_hash") if actual_hash != _hash(canonical): errors.append("board_hash:%d" % market_index) var canonical_content := content.duplicate(true) var actual_content_hash := String(canonical_content.get("content_hash", "")) canonical_content.erase("content_hash") if actual_content_hash != _hash(canonical_content): errors.append("content_hash") return {"ok": errors.is_empty(), "errors": errors} static func localized_name(row: Dictionary, locale: String) -> String: return String(row.get("name_en" if locale == "en" else "name_zh", row.get("id", ""))) static func hire_price(level: int, profession_tier: int) -> int: return 10 + 4 * level + 10 * profession_tier static func battle_reward(round_index: int, risk: int) -> int: return 18 + 2 * round_index + 7 * risk static func _validate_family(rows: Dictionary, expected_count: int, label: String, errors: Array[String]) -> void: if rows.size() != expected_count: errors.append("%s_count" % label) for row_id in rows: var expected_id := "P97-MARKET-%s" % str(row_id) if label == "board" else str(row_id) if not rows[row_id] is Dictionary or str(rows[row_id].get("id", "")) != expected_id: errors.append("%s_id:%s" % [label, row_id]) static func _validate_board_refs(board: Dictionary, recruits: Dictionary, equipment_offers: Dictionary, routes: Dictionary, errors: Array[String]) -> void: var families := [ ["recruit_offer_ids", 3, recruits], ["equipment_offer_ids", 4, equipment_offers], ["battle_offer_ids", 3, routes], ] for family in families: var ids = board.get(family[0], []) if not ids is Array or ids.size() != int(family[1]): errors.append("board_count:%s" % family[0]) continue var seen := {} for row_id in ids: if seen.has(row_id) or not family[2].has(row_id): errors.append("board_reference:%s:%s" % [family[0], row_id]) seen[row_id] = true static func _equipment_fits(item: Dictionary, role: String) -> bool: var fits = item.get("fit", []) return fits is Array and ("any" in fits or role in fits) static func _is_lower_hex(value: String, expected_length: int) -> bool: if value.length() != expected_length: return false for index in value.length(): if value.substr(index, 1) not in "0123456789abcdef": return false return true static func _hash(value: Variant) -> String: return JSON.stringify(value).sha256_text()