Files
aetherbound-guild/runtime/content/p9_7_content.gd
T

426 lines
22 KiB
GDScript

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()