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

435 lines
30 KiB
GDScript

class_name P98Content
extends RefCounted
const P97Content = preload("res://content/p9_7_content.gd")
const MARKET_INDICES := [8, 9, 10, 11, 12]
const BASE_PROFESSION_IDS := [
"PF-B01", "PF-B02", "PF-B03", "PF-B04", "PF-B05",
"PF-B06", "PF-B07", "PF-B08", "PF-B09", "PF-B11",
]
const LEGACY_PROFESSIONS := {
"PF-B01": {"id": "PF-B01", "name_zh": "炉卫", "name_en": "Hearthguard", "role": "contact_guard", "band": "front", "max_hp": 130, "damage": 10, "cooldown": 10, "behavior": "guard_neighbor", "visual_rig": "Hearthguard", "palette": "amber_steel"},
"PF-B04": {"id": "PF-B04", "name_zh": "巡路者", "name_en": "Wayrunner", "role": "flank", "band": "any", "max_hp": 100, "damage": 12, "cooldown": 8, "behavior": "passing_strike", "visual_rig": "Wayrunner", "palette": "coral_teal"},
"PF-B05": {"id": "PF-B05", "name_zh": "远弦手", "name_en": "Farstring", "role": "ranged", "band": "rear", "max_hp": 85, "damage": 14, "cooldown": 12, "behavior": "draw_shot", "visual_rig": "Farstring", "palette": "blue_gold"},
"PF-B07": {"id": "PF-B07", "name_zh": "仁手", "name_en": "Kindhand", "role": "healer", "band": "center_rear", "max_hp": 90, "damage": 5, "cooldown": 10, "heal": 10, "behavior": "tending_pulse", "visual_rig": "Kindhand", "palette": "jade_gold"},
}
const PROMOTIONS := {
"PF-A01": {"id": "PF-A01", "parent_id": "PF-B01", "name_zh": "门卫", "name_en": "Gatebearer", "role": "contact_guard", "behavior": "directional_intercept", "choice_zh": "预测穿越攻击,正确时返还守势", "choice_en": "Predict crossing attacks and refund Guard on a correct read"},
"PF-A02": {"id": "PF-A02", "parent_id": "PF-B01", "name_zh": "冲城卫", "name_en": "Ramward", "role": "front_control", "behavior": "driving_wall", "choice_zh": "用守势推进敌方前排", "choice_en": "Spend Guard to drive the enemy front backward"},
"PF-A03": {"id": "PF-A03", "parent_id": "PF-B02", "name_zh": "末光刃", "name_en": "Lastlight", "role": "finish", "behavior": "final_measure", "choice_zh": "押注处决,失败后暴露", "choice_en": "Wager on an execution and become Exposed on failure"},
"PF-A04": {"id": "PF-A04", "parent_id": "PF-B02", "name_zh": "回锋者", "name_en": "Turnblade", "role": "counter", "behavior": "turn_aside", "choice_zh": "保留锋势格挡并反击", "choice_en": "Reserve Edge to block and return a direct strike"},
"PF-A05": {"id": "PF-A05", "parent_id": "PF-B03", "name_zh": "钩阵官", "name_en": "Hookmarshal", "role": "reach_control", "behavior": "crook_pull", "choice_zh": "消耗定势拉前关键敌人", "choice_en": "Spend Poise to pull a key enemy forward"},
"PF-A06": {"id": "PF-A06", "parent_id": "PF-B03", "name_zh": "日枪", "name_en": "Sunlance", "role": "line_pressure", "behavior": "noon_line", "choice_zh": "稳定站位后贯穿整线", "choice_en": "Pierce the full line after preserving position"},
"PF-A07": {"id": "PF-A07", "parent_id": "PF-B04", "name_zh": "接力刃", "name_en": "Relayknife", "role": "tempo_enable", "behavior": "pass_the_turn", "choice_zh": "换位时把行动交给队友", "choice_en": "Hand tempo to the swapped ally"},
"PF-A08": {"id": "PF-A08", "parent_id": "PF-B04", "name_zh": "逆焰", "name_en": "Backdraft", "role": "reorder_pressure", "behavior": "wake_cut", "choice_zh": "穿越整线造成路径伤害", "choice_en": "Damage each unique enemy crossed on a full-line sprint"},
"PF-A09": {"id": "PF-A09", "parent_id": "PF-B05", "name_zh": "寻针手", "name_en": "Pinseeker", "role": "precision", "behavior": "joint_pin", "choice_zh": "牺牲伤害延后关键位移", "choice_en": "Trade damage to delay a critical movement"},
"PF-A10": {"id": "PF-A10", "parent_id": "PF-B05", "name_zh": "虹弦", "name_en": "Rainbow", "role": "ranged_area", "behavior": "falling_string", "choice_zh": "消耗瞄准分射三个目标", "choice_en": "Spend Aim on three distinct targets"},
"PF-A11": {"id": "PF-A11", "parent_id": "PF-B06", "name_zh": "风暴账士", "name_en": "Stormledger", "role": "spell_area", "behavior": "rolling_sum", "choice_zh": "按行动时序连锁法术", "choice_en": "Chain a spell through later enemy cadences"},
"PF-A12": {"id": "PF-A12", "parent_id": "PF-B06", "name_zh": "空星", "name_en": "Hollowstar", "role": "spell_pressure", "behavior": "held_rift", "choice_zh": "承受迟缓保存被打断的蓄力", "choice_en": "Keep interrupted Charge at the cost of Hindered"},
"PF-A13": {"id": "PF-A13", "parent_id": "PF-B07", "name_zh": "野战医", "name_en": "Fieldmender", "role": "mobile_restore", "behavior": "walking_triage", "choice_zh": "治疗后把伤员撤向后方", "choice_en": "Heal and extract the patient one position rearward"},
"PF-A14": {"id": "PF-A14", "parent_id": "PF-B07", "name_zh": "后疗师", "name_en": "Aftercare", "role": "delayed_restore", "behavior": "promise_dose", "choice_zh": "预先安排行动后的强化治疗", "choice_en": "Schedule stronger healing after the target's next action"},
"PF-A15": {"id": "PF-A15", "parent_id": "PF-B08", "name_zh": "链主", "name_en": "Chainhost", "role": "network_enable", "behavior": "third_knot", "choice_zh": "连接三种不同职责", "choice_en": "Link three distinct role tags"},
"PF-A16": {"id": "PF-A16", "parent_id": "PF-B08", "name_zh": "承负者", "name_en": "Burdenmate", "role": "cost_protect", "behavior": "take_the_clause", "choice_zh": "把邻员的阻碍转移给自己", "choice_en": "Take a neighbor's visible hindrance"},
"PF-A17": {"id": "PF-A17", "parent_id": "PF-B09", "name_zh": "哨塔匠", "name_en": "Sentrymaker", "role": "construct_rear", "behavior": "needle_post", "choice_zh": "在后方部署追踪针塔", "choice_en": "Deploy a rear post that repeats the last target"},
"PF-A18": {"id": "PF-A18", "parent_id": "PF-B09", "name_zh": "索阱匠", "name_en": "Snarewright", "role": "construct_control", "behavior": "latch_tile", "choice_zh": "在空位布置位移陷阱", "choice_en": "Arm a vacated position against displacement"},
"PF-A21": {"id": "PF-A21", "parent_id": "PF-B11", "name_zh": "行军钟", "name_en": "Marchbell", "role": "ally_tempo", "behavior": "common_step", "choice_zh": "同步两名邻员的下一次行动", "choice_en": "Synchronize both neighbors' next basics"},
"PF-A22": {"id": "PF-A22", "parent_id": "PF-B11", "name_zh": "止声者", "name_en": "Quietus", "role": "command_control", "behavior": "held_clapper", "choice_zh": "取消一次声音或号令蓄力", "choice_en": "Cancel one sound or command wind-up"},
}
const NEW_EQUIPMENT := {
"EQ-005": {"id": "EQ-005", "name_zh": "铅坠腰带", "name_en": "Leadweight Belt", "fit": ["contact_guard", "edge_contact", "reach_control", "mixed_enable"], "behavior": "transfer_first_pull", "list_price": 12},
"EQ-009": {"id": "EQ-009", "name_zh": "救援环", "name_en": "Rescue Loop", "fit": ["healer", "mixed_enable", "construct_support"], "behavior": "heal_extract_front", "list_price": 12},
"EQ-014": {"id": "EQ-014", "name_zh": "静后甲", "name_en": "Quiet Rearplate", "fit": ["ranged", "area_caster", "healer", "construct_support"], "behavior": "area_to_direct", "list_price": 12},
"EQ-019": {"id": "EQ-019", "name_zh": "定落石", "name_en": "Settle Stone", "fit": ["any"], "behavior": "keep_target_after_move", "list_price": 12},
}
const NEW_ENEMIES := {
"EN-R1-04": {"id": "EN-R1-04", "name_zh": "钟茎食兽", "name_en": "Bellstem Grazer", "role": "tag_eater", "max_hp": 148, "damage": 9, "cooldown": 11, "pressure": "oldest_positive_tag"},
"EN-R1-06": {"id": "EN-R1-06", "name_zh": "边崖潜猎者", "name_en": "Verge Stalker", "role": "reach_hunter", "max_hp": 142, "damage": 13, "cooldown": 10, "pressure": "long_reach_target"},
"EN-R1-07": {"id": "EN-R1-07", "name_zh": "鸣哨蠓群", "name_en": "Whistle Midge", "role": "band_delay", "max_hp": 112, "damage": 7, "cooldown": 8, "pressure": "largest_band_delay"},
"EN-R1-09": {"id": "EN-R1-09", "name_zh": "横风书记", "name_en": "Crosswind Scribe", "role": "swap_hinder", "max_hp": 136, "damage": 10, "cooldown": 11, "pressure": "cross_line_hinder"},
"EN-R1-10": {"id": "EN-R1-10", "name_zh": "崖跃者", "name_en": "Ledge Hopper", "role": "target_change", "max_hp": 154, "damage": 14, "cooldown": 10, "pressure": "changed_target_strike"},
"EN-R1-12": {"id": "EN-R1-12", "name_zh": "棱镜牛", "name_en": "Prism Ox", "role": "rotating_screen", "max_hp": 260, "damage": 16, "cooldown": 13, "pressure": "three_family_screen"},
}
const ARTIFACTS := {
"AR-001": {"id": "AR-001", "name_zh": "开放账簿", "name_en": "Open Ledger", "behavior": "extra_recruit_less_equipment", "benefit_zh": "后续市场多一名招募候选", "benefit_en": "Later Markets show one extra Recruit", "cost_zh": "装备候选少一件", "cost_en": "One fewer equipment offer", "conflict_id": "AR-002"},
"AR-002": {"id": "AR-002", "name_zh": "工匠橱窗", "name_en": "Toolwright's Window", "behavior": "extra_equipment_less_recruit", "benefit_zh": "后续市场多一件装备", "benefit_en": "Later Markets show one extra equipment item", "cost_zh": "招募候选少一名", "cost_en": "One fewer Recruit offer", "conflict_id": "AR-001"},
"AR-007": {"id": "AR-007", "name_zh": "满席长凳", "name_en": "Full Bench", "behavior": "reserve_capacity_upkeep", "benefit_zh": "后备容量 +2", "benefit_en": "Reserve capacity +2", "cost_zh": "每名未部署成员使市场维护费 +1", "cost_en": "Each undeployed Recruit adds 1 Market upkeep", "conflict_id": "AR-008"},
}
const ARTIFACT_DRAFTS := {
"P98-ARTIFACT-DRAFT-1": {"id": "P98-ARTIFACT-DRAFT-1", "market": 8, "offer_ids": ["AR-001", "AR-002"], "unoffered_id": "AR-007", "apply_from_market": 9},
}
const BOSS_DEFINITIONS := {
"BO-R1-01": {
"id": "BO-R1-01", "name_zh": "多棱镜鹿", "name_en": "The Many-Paned Stag",
"max_hp": 1380, "timeout_tick": 1500, "footprints": 2,
"pane_order": ["projectile", "spell", "forced_movement"],
"initial_pane": "projectile", "half_rotation_steps": 2,
"sweep_damage": 24, "sweep_push": 1, "recovery_cost": 14,
"plans": [
{"id": "rotate", "name_zh": "转棱破面", "name_en": "Rotate the Pane", "summary_zh": "先用低价值受阻行动转棱,再用开放类型集火", "summary_en": "Rotate with a low-value resisted action, then commit the open family"},
{"id": "brace", "name_zh": "固阵反击", "name_en": "Brace and Punish", "summary_zh": "承受节奏损失守住前排,在鹿角横扫后的恢复期集火", "summary_en": "Trade cadence to hold formation and punish sweep recovery"},
],
}
}
const RECRUIT_TEMPLATES := [
[8, "PF-B01", "TR-027", "EQ-005", "奥琳 · 炉阶", "Orin Hearthstep"],
[8, "PF-B05", "TR-017", "EQ-014", "塞娅 · 远岚", "Seya Farwind"],
[8, "PF-B08", "TR-010", "EQ-009", "诺娅 · 共线", "Noa Commonline"],
[8, "PF-B03", "TR-006", "EQ-019", "德伦 · 长昼", "Deren Longday"],
[9, "PF-B04", "TR-021", "EQ-019", "芮克 · 风径", "Rike Windpath"],
[9, "PF-B07", "TR-029", "EQ-009", "米拉 · 暖灯", "Mira Warmlamp"],
[9, "PF-B09", "TR-005", "EQ-014", "托恩 · 铜簧", "Torn Copperspring"],
[9, "PF-B02", "TR-014", "EQ-005", "伊娅 · 双痕", "Iya Twinmark"],
[10, "PF-B06", "TR-017", "EQ-014", "维奥 · 裂星", "Vio Riftstar"],
[10, "PF-B11", "TR-027", "EQ-019", "凯伦 · 静钟", "Kellen Stillbell"],
[10, "PF-B01", "TR-010", "EQ-005", "芬 · 高门", "Fen Highgate"],
[10, "PF-B05", "TR-006", "EQ-014", "阿莎 · 云弦", "Asha Cloudstring"],
[11, "PF-B03", "TR-021", "EQ-019", "帕文 · 直辐", "Paven Truespoke"],
[11, "PF-B08", "TR-029", "EQ-009", "露玛 · 三结", "Luma Threeknot"],
[11, "PF-B04", "TR-005", "EQ-005", "哈文 · 返风", "Haven Backwind"],
[11, "PF-B07", "TR-014", "EQ-009", "希尔 · 余温", "Sill Afterwarm"],
[12, "PF-B09", "TR-005", "EQ-014", "柯拉 · 针塔", "Cora Needlewatch"],
[12, "PF-B02", "TR-006", "EQ-005", "亚恩 · 晨刃", "Yarn Dawnedge"],
[12, "PF-B06", "TR-017", "EQ-014", "瑞萨 · 空隙", "Rysa Hollowgap"],
[12, "PF-B11", "TR-027", "EQ-019", "贝尔 · 三拍", "Bell Threebeat"],
]
const MARKET_EQUIPMENT := {
8: ["EQ-005", "EQ-021", "EQ-023", "EQ-028", "EQ-014"],
9: ["EQ-009", "EQ-019", "EQ-028", "EQ-036", "EQ-021"],
10: ["EQ-014", "EQ-023", "EQ-036", "EQ-005", "EQ-019"],
11: ["EQ-019", "EQ-021", "EQ-009", "EQ-028", "EQ-014"],
12: ["EQ-005", "EQ-014", "EQ-023", "EQ-036", "EQ-009"],
}
const ROUTES := {
"P98-R8-Q0": {"id": "P98-R8-Q0", "round": 8, "risk": 0, "seed": 9880, "name_zh": "镜面试路", "name_en": "Pane Trial", "enemy_ids": ["EN-R1-05", "EN-R1-04", "EN-R1-01"], "pressure_zh": "投射屏障 + 正面消益", "pressure_en": "Projectile screen + front tag removal"},
"P98-R8-Q1": {"id": "P98-R8-Q1", "round": 8, "risk": 1, "seed": 9881, "name_zh": "远猎风口", "name_en": "Longreach Vent", "enemy_ids": ["EN-R1-06", "EN-R1-05", "EN-R1-02"], "pressure_zh": "长射程猎杀 + 屏障", "pressure_en": "Long-reach hunting + screen"},
"P98-R8-Q2": {"id": "P98-R8-Q2", "round": 8, "risk": 2, "seed": 9882, "name_zh": "棱镜重压", "name_en": "Prism Weight", "enemy_ids": ["EN-R1-12", "EN-R1-06", "EN-R1-11"], "pressure_zh": "三系屏障 + 定点号令", "pressure_en": "Three-family screen + indexed command"},
"P98-R9-Q0": {"id": "P98-R9-Q0", "round": 9, "risk": 0, "seed": 9890, "name_zh": "前后夹风", "name_en": "Splitwind Escort", "enemy_ids": ["EN-R1-03", "EN-R1-02", "EN-R1-01"], "pressure_zh": "前排推位 + 后排射击", "pressure_en": "Front push + rear projectile"},
"P98-R9-Q1": {"id": "P98-R9-Q1", "round": 9, "risk": 1, "seed": 9891, "name_zh": "猎弦坡", "name_en": "Stringhunter Rise", "enemy_ids": ["EN-R1-06", "EN-R1-03", "EN-R1-07"], "pressure_zh": "射程猎杀 + 多数职责延迟", "pressure_en": "Reach hunter + majority-band delay"},
"P98-R9-Q2": {"id": "P98-R9-Q2", "round": 9, "risk": 2, "seed": 9892, "name_zh": "执令双锋", "name_en": "Bailiff Pincer", "enemy_ids": ["EN-R1-11", "EN-R1-02", "EN-R1-10"], "pressure_zh": "前后目标切换 + 站位号令", "pressure_en": "Front/rear target change + Stand Order"},
"P98-R10-Q0": {"id": "P98-R10-Q0", "round": 10, "risk": 0, "seed": 9900, "name_zh": "横风换位", "name_en": "Crosswind Shift", "enemy_ids": ["EN-R1-09", "EN-R1-03", "EN-R1-04"], "pressure_zh": "换位阻碍 + 推位", "pressure_en": "Swap hindrance + push"},
"P98-R10-Q1": {"id": "P98-R10-Q1", "round": 10, "risk": 1, "seed": 9901, "name_zh": "崖跃乱线", "name_en": "Hopper Disorder", "enemy_ids": ["EN-R1-10", "EN-R1-09", "EN-R1-03"], "pressure_zh": "目标变化追击 + 横线阻碍", "pressure_en": "Target-change strike + crossing hinder"},
"P98-R10-Q2": {"id": "P98-R10-Q2", "round": 10, "risk": 2, "seed": 9902, "name_zh": "棱牛推阵", "name_en": "Prism Drive", "enemy_ids": ["EN-R1-12", "EN-R1-03", "EN-R1-09"], "pressure_zh": "屏障旋转 + 连续推位", "pressure_en": "Rotating screen + repeated displacement"},
"P98-R11-Q0": {"id": "P98-R11-Q0", "round": 11, "risk": 0, "seed": 9910, "name_zh": "鸣群消耗", "name_en": "Midge Attrition", "enemy_ids": ["EN-R1-07", "EN-R1-04", "EN-R1-01"], "pressure_zh": "行动延迟 + 正面消益", "pressure_en": "Action delay + front tag removal"},
"P98-R11-Q1": {"id": "P98-R11-Q1", "round": 11, "risk": 1, "seed": 9911, "name_zh": "根晶远猎", "name_en": "Rootglass Hunt", "enemy_ids": ["EN-R1-08", "EN-R1-06", "EN-R1-07"], "pressure_zh": "生长保护 + 治疗节奏压力", "pressure_en": "Growing guard + care cadence pressure"},
"P98-R11-Q2": {"id": "P98-R11-Q2", "round": 11, "risk": 2, "seed": 9912, "name_zh": "钟令围场", "name_en": "Bell Order Ring", "enemy_ids": ["EN-R1-11", "EN-R1-04", "EN-R1-10"], "pressure_zh": "号令、消益与目标追击", "pressure_en": "Command, tag removal and target pursuit"},
"P98-R12-Q0": {"id": "P98-R12-Q0", "round": 12, "risk": 0, "seed": 9920, "name_zh": "鹿径前哨", "name_en": "Stagroad Scout", "enemy_ids": ["EN-R1-05", "EN-R1-03", "EN-R1-07"], "pressure_zh": "屏障、推位与节奏延迟", "pressure_en": "Screen, push and cadence delay"},
"P98-R12-Q1": {"id": "P98-R12-Q1", "round": 12, "risk": 1, "seed": 9921, "name_zh": "三棱合流", "name_en": "Three-Pane Confluence", "enemy_ids": ["EN-R1-12", "EN-R1-06", "EN-R1-09"], "pressure_zh": "三系屏障、远猎与换位惩罚", "pressure_en": "Three-family screen, reach hunt and swap punishment"},
"P98-R12-Q2": {"id": "P98-R12-Q2", "round": 12, "risk": 2, "seed": 9922, "name_zh": "风律终试", "name_en": "Final Gale Writ", "enemy_ids": ["EN-R1-11", "EN-R1-12", "EN-R1-10"], "pressure_zh": "号令、旋转屏障与目标追击", "pressure_en": "Command, rotating screen and target pursuit"},
}
static func snapshot() -> Dictionary:
var previous: Dictionary = P97Content.snapshot()
var professions: Dictionary = previous.professions.duplicate(true)
professions.merge(LEGACY_PROFESSIONS, true)
var equipment: Dictionary = previous.equipment.duplicate(true)
equipment.merge(NEW_EQUIPMENT, true)
var enemies: Dictionary = previous.enemies.duplicate(true)
enemies.merge(NEW_ENEMIES, true)
var recruits := _build_recruits(professions, equipment)
var equipment_offers := _build_equipment_offers()
var routes: Dictionary = ROUTES.duplicate(true)
for route_id in routes:
routes[route_id]["reward"] = battle_reward(int(routes[route_id].round), int(routes[route_id].risk))
var boards := _build_boards(recruits, equipment_offers, routes)
var result := {
"revision": "p9.8-content-r1",
"base_professions": professions,
"promotions": PROMOTIONS.duplicate(true),
"traits": previous.traits.duplicate(true),
"equipment": equipment,
"enemies": enemies,
"recruits": recruits,
"equipment_offers": equipment_offers,
"routes": routes,
"boards": boards,
"artifacts": ARTIFACTS.duplicate(true),
"artifact_drafts": ARTIFACT_DRAFTS.duplicate(true),
"bosses": BOSS_DEFINITIONS.duplicate(true),
}
result["content_hash"] = _hash(result)
return result
static func validate(content: Dictionary) -> Dictionary:
var errors: Array[String] = []
var families := ["base_professions", "promotions", "traits", "equipment", "enemies", "recruits", "equipment_offers", "routes", "boards", "artifacts", "artifact_drafts", "bosses"]
for key in ["revision", "content_hash"] + families:
if not content.has(key):
errors.append("missing:%s" % key)
if not errors.is_empty():
return {"ok": false, "errors": errors}
for key in families:
if not content[key] is Dictionary:
errors.append("not_dictionary:%s" % key)
if not errors.is_empty():
return {"ok": false, "errors": errors}
var expected_counts := {"base_professions": 10, "promotions": 20, "traits": 8, "equipment": 8, "enemies": 12, "recruits": 20, "equipment_offers": 25, "routes": 15, "boards": 5, "artifacts": 3, "artifact_drafts": 1, "bosses": 1}
for key in expected_counts:
if content[key].size() != int(expected_counts[key]):
errors.append("%s_count" % key)
var professions: Dictionary = content.base_professions
var promotions: Dictionary = content.promotions
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
for family_name in families:
for row_id in content[family_name]:
if not content[family_name][row_id] is Dictionary or String(content[family_name][row_id].get("id", "")) != _expected_row_id(family_name, row_id):
errors.append("%s_id:%s" % [family_name, row_id])
_validate_promotions(promotions, professions, errors)
_validate_recruits(recruits, professions, equipment, errors)
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)
_validate_canonical_hash(offer, "canonical_output_hash", "equipment_offer_hash:%s" % offer_id, errors)
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 MARKET_INDICES 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() != 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])
_validate_boards(boards, recruits, equipment_offers, routes, errors)
_validate_artifacts(content.artifacts, content.artifact_drafts, errors)
_validate_bosses(content.bosses, errors)
var canonical := content.duplicate(true)
var actual_hash := String(canonical.get("content_hash", ""))
canonical.erase("content_hash")
if actual_hash != _hash(canonical):
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 := 0) -> 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 capacity_for_clears(cleared_rounds: int) -> int:
return mini(10, 4 + floori(float(cleared_rounds) / 2.0))
static func _build_recruits(professions: Dictionary, equipment: Dictionary) -> Dictionary:
var recruits := {}
var market_offer_indices := {}
for index in RECRUIT_TEMPLATES.size():
var template: Array = RECRUIT_TEMPLATES[index]
var market_index := int(template[0])
var offer_index := int(market_offer_indices.get(market_index, 0))
market_offer_indices[market_index] = offer_index + 1
var recruit_id := "P98-M%d-R%d" % [market_index, offer_index + 1]
var profession_id := String(template[1])
var level := 3 if market_index <= 10 else 4
var max_hp := int(professions[profession_id].max_hp)
var recruit := {
"id": recruit_id, "market": market_index, "offer_index": offer_index,
"generation_seed": ("%032x" % (980000 + index * 7919)),
"name_zh": String(template[4]), "name_en": String(template[5]),
"profession_id": profession_id, "trait_id": String(template[2]),
"level": level, "xp": 0, "max_hp": max_hp, "current_hp": max_hp,
"list_price": hire_price(level), "coin_price_paid": 0,
"survival_battles": 0,
"starting_equipment": {"instance_id": "%s-ITEM" % recruit_id, "catalog_id": String(template[3])},
}
assert(equipment.has(String(recruit.starting_equipment.catalog_id)))
recruit["canonical_output_hash"] = _hash(recruit)
recruits[recruit_id] = recruit
return recruits
static func _build_equipment_offers() -> Dictionary:
var offers := {}
for market_index in MARKET_INDICES:
for catalog_id in MARKET_EQUIPMENT[market_index]:
var offer_id := "P98-M%d-%s" % [market_index, catalog_id]
var offer := {"id": offer_id, "market": market_index, "catalog_id": catalog_id, "list_price": 12, "instance_id": "%s-ITEM" % offer_id}
offer["canonical_output_hash"] = _hash(offer)
offers[offer_id] = offer
return offers
static func _build_boards(recruits: Dictionary, equipment_offers: Dictionary, routes: Dictionary) -> Dictionary:
var boards := {}
for market_index in MARKET_INDICES:
var recruit_ids: Array[String] = []
for recruit_id in recruits:
if int(recruits[recruit_id].market) == market_index:
recruit_ids.append(String(recruit_id))
recruit_ids.sort_custom(func(left: String, right: String) -> bool: return int(recruits[left].offer_index) < int(recruits[right].offer_index))
var item_ids: Array[String] = []
for catalog_id in MARKET_EQUIPMENT[market_index]:
item_ids.append("P98-M%d-%s" % [market_index, catalog_id])
var route_ids: Array[String] = []
for risk in [0, 1, 2]:
route_ids.append("P98-R%d-Q%d" % [market_index, risk])
var board := {
"id": "P98-MARKET-%d" % market_index, "market": market_index,
"recruit_offer_ids": recruit_ids.slice(0, 3),
"extra_recruit_offer_id": recruit_ids[3],
"equipment_offer_ids": item_ids.slice(0, 4),
"extra_equipment_offer_id": item_ids[4],
"battle_offer_ids": route_ids,
"capacity_after_clear": capacity_for_clears(market_index),
}
board["board_hash"] = _hash(board)
boards[market_index] = board
return boards
static func _validate_promotions(promotions: Dictionary, professions: Dictionary, errors: Array[String]) -> void:
var counts := {}
for promotion_id in promotions:
var row: Dictionary = promotions[promotion_id]
var parent_id := String(row.get("parent_id", ""))
if parent_id not in BASE_PROFESSION_IDS or not professions.has(parent_id):
errors.append("promotion_parent:%s" % promotion_id)
counts[parent_id] = int(counts.get(parent_id, 0)) + 1
if String(row.get("behavior", "")).is_empty() or String(row.get("choice_en", "")).is_empty() or String(row.get("choice_zh", "")).is_empty():
errors.append("promotion_rule:%s" % promotion_id)
for profession_id in BASE_PROFESSION_IDS:
if int(counts.get(profession_id, 0)) != 2:
errors.append("promotion_branch_count:%s" % profession_id)
static func _validate_recruits(recruits: Dictionary, professions: Dictionary, equipment: Dictionary, errors: Array[String]) -> void:
var seeds := {}
var instances := {}
for recruit_id in recruits:
var row: Dictionary = recruits[recruit_id]
var market_index := int(row.get("market", 0))
var profession_id := String(row.get("profession_id", ""))
if market_index not in MARKET_INDICES:
errors.append("recruit_market:%s" % recruit_id)
if not professions.has(profession_id):
errors.append("recruit_profession:%s" % recruit_id)
var seed := String(row.get("generation_seed", ""))
if seed.length() != 32 or seeds.has(seed):
errors.append("recruit_seed:%s" % recruit_id)
seeds[seed] = true
var item = row.get("starting_equipment", {})
if not item is Dictionary or not equipment.has(String(item.get("catalog_id", ""))):
errors.append("recruit_equipment:%s" % recruit_id)
else:
var instance_id := String(item.get("instance_id", ""))
if instance_id.is_empty() or instances.has(instance_id):
errors.append("recruit_equipment_instance:%s" % recruit_id)
instances[instance_id] = true
var expected_level := 3 if market_index <= 10 else 4
if int(row.get("level", 0)) != expected_level or int(row.get("list_price", -1)) != hire_price(expected_level):
errors.append("recruit_price:%s" % recruit_id)
_validate_canonical_hash(row, "canonical_output_hash", "recruit_hash:%s" % recruit_id, errors)
static func _validate_boards(boards: Dictionary, recruits: Dictionary, equipment_offers: Dictionary, routes: Dictionary, errors: Array[String]) -> void:
for market_index in MARKET_INDICES:
if not boards.has(market_index):
errors.append("board_missing:%d" % market_index)
continue
var board: Dictionary = boards[market_index]
for spec in [["recruit_offer_ids", 3, recruits], ["equipment_offer_ids", 4, equipment_offers], ["battle_offer_ids", 3, routes]]:
var ids = board.get(spec[0], [])
if not ids is Array or ids.size() != int(spec[1]) or not _all_unique_and_known(ids, spec[2]):
errors.append("board_reference:%d:%s" % [market_index, spec[0]])
var extra_recruit := String(board.get("extra_recruit_offer_id", ""))
var extra_item := String(board.get("extra_equipment_offer_id", ""))
if not recruits.has(extra_recruit) or extra_recruit in board.get("recruit_offer_ids", []):
errors.append("board_extra_recruit:%d" % market_index)
if not equipment_offers.has(extra_item) or extra_item in board.get("equipment_offer_ids", []):
errors.append("board_extra_equipment:%d" % market_index)
var risks: Array[int] = []
for route_id in board.get("battle_offer_ids", []):
if routes.has(route_id):
risks.append(int(routes[route_id].risk))
risks.sort()
if risks != [0, 1, 2]:
errors.append("board_risks:%d" % market_index)
if int(board.get("capacity_after_clear", 0)) != capacity_for_clears(market_index):
errors.append("board_capacity:%d" % market_index)
_validate_canonical_hash(board, "board_hash", "board_hash:%d" % market_index, errors)
static func _validate_artifacts(artifacts: Dictionary, drafts: Dictionary, errors: Array[String]) -> void:
for artifact_id in artifacts:
var row: Dictionary = artifacts[artifact_id]
if String(row.get("behavior", "")).is_empty() or String(row.get("benefit_en", "")).is_empty() or String(row.get("cost_en", "")).is_empty():
errors.append("artifact_rule:%s" % artifact_id)
for draft_id in drafts:
var draft: Dictionary = drafts[draft_id]
var offer_ids = draft.get("offer_ids", [])
if not offer_ids is Array or offer_ids.size() != 2 or not _all_unique_and_known(offer_ids, artifacts):
errors.append("artifact_draft_offers:%s" % draft_id)
if not artifacts.has(String(draft.get("unoffered_id", ""))) or String(draft.get("unoffered_id", "")) in offer_ids:
errors.append("artifact_draft_unoffered:%s" % draft_id)
if int(draft.get("market", 0)) != 8 or int(draft.get("apply_from_market", 0)) != 9:
errors.append("artifact_draft_market:%s" % draft_id)
static func _validate_bosses(bosses: Dictionary, errors: Array[String]) -> void:
if not bosses.has("BO-R1-01"):
errors.append("boss_missing")
return
var boss: Dictionary = bosses["BO-R1-01"]
if boss.get("pane_order", []) != ["projectile", "spell", "forced_movement"]:
errors.append("boss_panes")
if int(boss.get("footprints", 0)) != 2 or int(boss.get("timeout_tick", 0)) != 1500:
errors.append("boss_shape")
var plans = boss.get("plans", [])
if not plans is Array or plans.size() != 2 or String(plans[0].get("id", "")) != "rotate" or String(plans[1].get("id", "")) != "brace":
errors.append("boss_plans")
static func _validate_canonical_hash(row: Dictionary, key: String, error: String, errors: Array[String]) -> void:
var canonical := row.duplicate(true)
var actual := String(canonical.get(key, ""))
canonical.erase(key)
if actual != _hash(canonical):
errors.append(error)
static func _expected_row_id(family_name: String, row_id: Variant) -> String:
return "P98-MARKET-%s" % str(row_id) if family_name == "boards" else String(row_id)
static func _all_unique_and_known(ids: Array, rows: Dictionary) -> bool:
var seen := {}
for row_id in ids:
if seen.has(row_id) or not rows.has(row_id):
return false
seen[row_id] = true
return true
static func _hash(value: Variant) -> String:
return JSON.stringify(value).sha256_text()