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

253 lines
16 KiB
GDScript

class_name P99Content
extends RefCounted
const P98Content = preload("res://content/p9_8_content.gd")
const CHARTER_CLAIMS := {
"CH-RG01-CATALOG-01": {
"id": "CH-RG01-CATALOG-01",
"region_id": "RG-01",
"renown_cost": 4,
"effect_family": "catalog_pool_packet",
"name_zh": "邻墙器具卷",
"name_en": "Neighborwall Tools",
"effect_zh": "第二座市场的一件基础器具替换为 EQ-041 邻墙盾",
"effect_en": "Market 2 replaces one base tool with EQ-041 Neighborwall",
"prerequisite_ids": ["CLEAR-RG01-C01"],
"market": 2,
"catalog_id": "EQ-041",
"no_power": true,
},
"CH-RG01-THREAT-01": {
"id": "CH-RG01-THREAT-01",
"region_id": "RG-01",
"renown_cost": 6,
"effect_family": "threat_archive_upgrade",
"name_zh": "风律档案",
"name_en": "Gale Rule Archive",
"effect_zh": "已遭遇风晶敌人的预览显示到场、目标与阵风节奏",
"effect_en": "Encountered Windglass previews show arrival, target and gust cadence",
"prerequisite_ids": ["CLEAR-RG01-C01"],
"preview_fields": ["arrival_tick", "selected_target", "gust_cadence"],
"no_power": true,
},
}
const RUN_SEED := 9902001
const RUN_REVISION := "p9.9-run-content-r1"
const DRAFT_ORDER := ["P99-D1", "P99-D2", "P99-D3", "P99-D4"]
const MARKET_HIRE_ORDER := ["P99-M1-H1", "P99-M1-H2", "P99-M1-H3", "P99-M1-H4"]
const DRAFT_RECRUITS := {
"P99-D1": {"id": "P99-D1", "offer_index": 0, "generation_seed": "p99-draft-hearth-01", "name_zh": "奥妲 · 炉灯", "name_en": "Oda Hearthlamp", "profession_id": "PF-B01", "trait_id": "TR-027", "appearance_seed": 99101, "attribute_distribution": {"vigor": 4, "tempo": 1, "focus": 2}, "benefit_zh": "稳住最前方并保护相邻队员", "benefit_en": "Holds the front and protects an adjacent ally", "cost_zh": "推进和输出较慢", "cost_en": "Slow advance and damage"},
"P99-D2": {"id": "P99-D2", "offer_index": 1, "generation_seed": "p99-draft-string-02", "name_zh": "梨沙 · 远弦", "name_en": "Risa Farstring", "profession_id": "PF-B05", "trait_id": "TR-017", "appearance_seed": 99102, "attribute_distribution": {"vigor": 1, "tempo": 2, "focus": 4}, "benefit_zh": "接敌前持续攻击后方目标", "benefit_en": "Pressures rear targets before contact", "cost_zh": "被后排弹道命中时脆弱", "cost_en": "Fragile under rear projectiles"},
"P99-D3": {"id": "P99-D3", "offer_index": 2, "generation_seed": "p99-draft-kind-03", "name_zh": "米侬 · 仁手", "name_en": "Minon Kindhand", "profession_id": "PF-B07", "trait_id": "TR-010", "appearance_seed": 99103, "attribute_distribution": {"vigor": 2, "tempo": 2, "focus": 3}, "benefit_zh": "自动治疗生命比例最低的队员", "benefit_en": "Automatically heals the lowest-health ally", "cost_zh": "直接伤害较低", "cost_en": "Low direct damage"},
"P99-D4": {"id": "P99-D4", "offer_index": 3, "generation_seed": "p99-draft-edge-04", "name_zh": "凯罗 · 双刃", "name_en": "Caro Twinedge", "profession_id": "PF-B02", "trait_id": "TR-006", "appearance_seed": 99104, "attribute_distribution": {"vigor": 2, "tempo": 4, "focus": 1}, "benefit_zh": "交替目标能加快下一击", "benefit_en": "Alternating targets accelerates the next strike", "cost_zh": "接敌前无法处理远处威胁", "cost_en": "Cannot answer distant threats before contact"},
}
const MARKET_HIRES := {
"P99-M1-H1": {"id": "P99-M1-H1", "offer_index": 0, "generation_seed": "p99-hire-spoke-01", "name_zh": "维拉 · 长辐", "name_en": "Vera Longspoke", "profession_id": "PF-B03", "trait_id": "TR-021", "appearance_seed": 99201, "list_price": 12},
"P99-M1-H2": {"id": "P99-M1-H2", "offer_index": 1, "generation_seed": "p99-hire-way-02", "name_zh": "泽恩 · 逐风", "name_en": "Zen Wayrunner", "profession_id": "PF-B04", "trait_id": "TR-005", "appearance_seed": 99202, "list_price": 13},
"P99-M1-H3": {"id": "P99-M1-H3", "offer_index": 2, "generation_seed": "p99-hire-rift-03", "name_zh": "露弥 · 裂星", "name_en": "Lumi Riftstar", "profession_id": "PF-B06", "trait_id": "TR-014", "appearance_seed": 99203, "list_price": 13},
"P99-M1-H4": {"id": "P99-M1-H4", "offer_index": 3, "generation_seed": "p99-hire-coil-04", "name_zh": "塔洛 · 铜簧", "name_en": "Taro Copperspring", "profession_id": "PF-B09", "trait_id": "TR-029", "appearance_seed": 99204, "list_price": 14},
}
const NEIGHBORWALL := {
"id": "EQ-041", "name_zh": "邻墙盾", "name_en": "Neighborwall",
"fit": ["contact_guard", "edge_contact", "reach_control", "flank"],
"behavior": "redirect_first_adjacent_chip", "list_price": 12,
"benefit_zh": "把第一次相邻削血转给佩戴者", "benefit_en": "Redirects the first adjacent chip to its wearer",
"cost_zh": "不会减少总伤害", "cost_en": "Does not reduce total damage",
}
const ROUTE_ORDER := [
"P99-R1-Q0", "P99-R1-Q1", "P99-R1-Q2",
"P99-R2-Q0", "P99-R2-Q1", "P99-R2-Q2",
"P99-R3-Q0", "P99-R3-Q1", "P99-R3-Q2",
]
const ROUTES := {
"P99-R1-Q0": {"id": "P99-R1-Q0", "round": 1, "risk": 0, "seed": 9910, "reward": 14, "name_zh": "雀鸣窗道", "name_en": "Finchpane Walk", "enemy_ids": ["EN-R1-02", "EN-R1-01"], "pressure_zh": "后排风弹 + 相邻削血", "pressure_en": "Rear projectile + adjacent chip", "arrival_tick": 0, "selected_target": "rear_then_front", "gust_cadence": 10},
"P99-R1-Q1": {"id": "P99-R1-Q1", "round": 1, "risk": 1, "seed": 9911, "reward": 18, "name_zh": "逆光窗道", "name_en": "Backlit Pane", "enemy_ids": ["EN-R1-02", "EN-R1-01", "EN-R1-05"], "pressure_zh": "后排风弹 + 相邻削血 + 投射屏障", "pressure_en": "Rear projectile + adjacent chip + projectile screen", "arrival_tick": 0, "selected_target": "rear_then_front", "gust_cadence": 9},
"P99-R1-Q2": {"id": "P99-R1-Q2", "round": 1, "risk": 2, "seed": 9912, "reward": 22, "name_zh": "密雀回廊", "name_en": "Crowded Finchway", "enemy_ids": ["EN-R1-02", "EN-R1-01", "EN-R1-07"], "pressure_zh": "后排风弹 + 相邻削血 + 多数站位延迟", "pressure_en": "Rear projectile + adjacent chip + majority-band delay", "arrival_tick": 0, "selected_target": "rear_then_majority", "gust_cadence": 8},
"P99-R2-Q0": {"id": "P99-R2-Q0", "round": 2, "risk": 0, "seed": 9920, "reward": 16, "name_zh": "横风台地", "name_en": "Crosswind Terrace", "enemy_ids": ["EN-R1-03", "EN-R1-09"], "pressure_zh": "最前推位 + 跨线阻碍", "pressure_en": "Foremost push + cross-line hinder", "arrival_tick": 2, "selected_target": "foremost", "gust_cadence": 11},
"P99-R2-Q1": {"id": "P99-R2-Q1", "round": 2, "risk": 1, "seed": 9921, "reward": 20, "name_zh": "羊角岔路", "name_en": "Ramfork", "enemy_ids": ["EN-R1-03", "EN-R1-09", "EN-R1-01"], "pressure_zh": "推位 + 跨线阻碍 + 前排削血", "pressure_en": "Push + cross-line hinder + front chip", "arrival_tick": 2, "selected_target": "foremost", "gust_cadence": 10},
"P99-R2-Q2": {"id": "P99-R2-Q2", "round": 2, "risk": 2, "seed": 9922, "reward": 25, "name_zh": "执达风口", "name_en": "Bailiff Vent", "enemy_ids": ["EN-R1-03", "EN-R1-09", "EN-R1-11"], "pressure_zh": "推位 + 跨线阻碍 + 施法打断", "pressure_en": "Push + cross-line hinder + cast interrupt", "arrival_tick": 1, "selected_target": "foremost_then_second", "gust_cadence": 9},
"P99-R3-Q0": {"id": "P99-R3-Q0", "round": 3, "risk": 0, "seed": 9930, "reward": 18, "name_zh": "群带坡道", "name_en": "Bandrise", "enemy_ids": ["EN-R1-07", "EN-R1-10"], "pressure_zh": "多数站位延迟 + 换目标追击", "pressure_en": "Majority-band delay + changed-target pursuit", "arrival_tick": 3, "selected_target": "majority_then_changed", "gust_cadence": 10},
"P99-R3-Q1": {"id": "P99-R3-Q1", "round": 3, "risk": 1, "seed": 9931, "reward": 23, "name_zh": "折返风阶", "name_en": "Turning Stair", "enemy_ids": ["EN-R1-07", "EN-R1-10", "EN-R1-02"], "pressure_zh": "多数延迟 + 换目标追击 + 后排风弹", "pressure_en": "Majority delay + changed-target pursuit + rear projectile", "arrival_tick": 2, "selected_target": "majority_then_changed", "gust_cadence": 9},
"P99-R3-Q2": {"id": "P99-R3-Q2", "round": 3, "risk": 2, "seed": 9932, "reward": 28, "name_zh": "棱风追猎", "name_en": "Prismwind Pursuit", "enemy_ids": ["EN-R1-07", "EN-R1-10", "EN-R1-12"], "pressure_zh": "多数延迟 + 换目标追击 + 三系屏障", "pressure_en": "Majority delay + changed-target pursuit + rotating screen", "arrival_tick": 1, "selected_target": "majority_then_changed", "gust_cadence": 8},
}
static func snapshot() -> Dictionary:
var previous: Dictionary = P98Content.snapshot()
var result := {
"revision": "p9.9-content-r1",
"predecessor_hash": String(previous.content_hash),
"region_seals": _region_seals(),
"charter_claims": CHARTER_CLAIMS.duplicate(true),
}
result["content_hash"] = _hash(result)
return result
static func validate(content: Dictionary) -> Dictionary:
var errors: Array[String] = []
for key in ["revision", "predecessor_hash", "region_seals", "charter_claims", "content_hash"]:
if not content.has(key):
errors.append("missing:%s" % key)
if not errors.is_empty():
return {"ok": false, "errors": errors}
if not content.region_seals is Dictionary or not content.charter_claims is Dictionary:
return {"ok": false, "errors": ["families_not_dictionary"]}
if content.region_seals.size() != 8 or content.charter_claims.size() != 2:
errors.append("count")
for index in range(1, 9):
var region_id := "RG-%02d" % index
if not content.region_seals.has(region_id):
errors.append("region_missing:%s" % region_id)
continue
var seal: Dictionary = content.region_seals[region_id]
if String(seal.get("id", "")) != region_id or int(seal.get("order", 0)) != index:
errors.append("region_identity:%s" % region_id)
if index == 1 and seal.get("prerequisite_ids", []) != ["CLEAR-RG01-C01"]:
errors.append("region_one_prerequisite")
for claim_id in content.charter_claims:
var row: Dictionary = content.charter_claims[claim_id]
if String(row.get("id", "")) != claim_id or String(row.get("region_id", "")) != "RG-01":
errors.append("claim_identity:%s" % claim_id)
if int(row.get("renown_cost", 0)) not in range(4, 11) or row.get("prerequisite_ids", []) != ["CLEAR-RG01-C01"]:
errors.append("claim_cost_or_prerequisite:%s" % claim_id)
if not bool(row.get("no_power", false)) or String(row.get("effect_family", "")).is_empty():
errors.append("claim_power_boundary:%s" % claim_id)
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 canonical_first_clear_renown(region_number: int, contract_rank: int) -> int:
return 8 + 2 * region_number + 4 * contract_rank
static func run_snapshot(selected_claim_id: String) -> Dictionary:
var previous: Dictionary = P98Content.snapshot()
var equipment: Dictionary = Dictionary(previous.equipment).duplicate(true)
equipment["EQ-041"] = NEIGHBORWALL.duplicate(true)
var boards := {}
for market_index in range(1, 5):
boards[market_index] = market_board(market_index, selected_claim_id)
var result := {
"revision": RUN_REVISION,
"predecessor_hash": String(previous.content_hash),
"selected_claim_id": selected_claim_id,
"run_seed": RUN_SEED,
"draft_order": DRAFT_ORDER.duplicate(),
"draft_recruits": DRAFT_RECRUITS.duplicate(true),
"market_hire_order": MARKET_HIRE_ORDER.duplicate(),
"market_hires": MARKET_HIRES.duplicate(true),
"professions": Dictionary(previous.base_professions).duplicate(true),
"traits": Dictionary(previous.traits).duplicate(true),
"equipment": equipment,
"enemies": Dictionary(previous.enemies).duplicate(true),
"route_order": ROUTE_ORDER.duplicate(),
"routes": ROUTES.duplicate(true),
"boards": boards,
}
result["content_hash"] = _hash(result)
return result
static func market_board(market_index: int, selected_claim_id: String) -> Dictionary:
var recruit_ids: Array = MARKET_HIRE_ORDER.duplicate() if market_index == 1 else []
var equipment_ids: Array = []
if market_index == 2:
equipment_ids = ["EQ-021", "EQ-041"] if selected_claim_id == "CH-RG01-CATALOG-01" else ["EQ-021", "EQ-023"]
var route_ids: Array = []
if market_index in range(1, 4):
for risk in range(3):
route_ids.append("P99-R%d-Q%d" % [market_index, risk])
var board := {
"id": "P99-MARKET-%d" % market_index,
"market": market_index,
"recruit_offer_ids": recruit_ids,
"equipment_offer_ids": equipment_ids,
"battle_offer_ids": route_ids,
"continuation_reason": "gale_loom_ahead" if market_index == 4 else "",
}
board["board_hash"] = _hash(board)
return board
static func validate_run(content: Dictionary) -> Dictionary:
var errors: Array[String] = []
for key in ["revision", "selected_claim_id", "run_seed", "draft_order", "draft_recruits", "market_hire_order", "market_hires", "professions", "traits", "equipment", "enemies", "route_order", "routes", "boards", "content_hash"]:
if not content.has(key):
errors.append("missing:%s" % key)
if not errors.is_empty():
return {"ok": false, "errors": errors}
if String(content.selected_claim_id) not in CHARTER_CLAIMS:
errors.append("claim")
if content.draft_order != DRAFT_ORDER or content.draft_recruits.size() != 4 or content.market_hire_order != MARKET_HIRE_ORDER or content.market_hires.size() != 4:
errors.append("recruits")
var professions := {}
for row in content.draft_recruits.values():
professions[String(row.profession_id)] = true
if not row.get("attribute_distribution", {}) is Dictionary or String(row.get("benefit_en", "")).is_empty() or String(row.get("cost_en", "")).is_empty():
errors.append("draft_readability")
if professions.size() != 4:
errors.append("draft_professions")
if content.route_order != ROUTE_ORDER or content.routes.size() != 9 or content.boards.size() != 4:
errors.append("route_or_board_count")
for market_index in range(1, 4):
var board: Dictionary = content.boards[market_index]
if board.battle_offer_ids.size() != 3:
errors.append("route_board:%d" % market_index)
for risk in range(3):
var route_id := "P99-R%d-Q%d" % [market_index, risk]
if not content.routes.has(route_id):
errors.append("route_missing:%s" % route_id)
continue
var route: Dictionary = content.routes[route_id]
if int(route.round) != market_index or int(route.risk) != risk or int(route.reward) <= 0:
errors.append("route_identity:%s" % route_id)
for field in ["arrival_tick", "selected_target", "gust_cadence"]:
if not route.has(field):
errors.append("route_intel:%s:%s" % [route_id, field])
for enemy_id in route.enemy_ids:
if not content.enemies.has(enemy_id):
errors.append("enemy_missing:%s" % enemy_id)
var market_two: Array = content.boards[2].equipment_offer_ids
if String(content.selected_claim_id) == "CH-RG01-CATALOG-01":
if market_two != ["EQ-021", "EQ-041"]:
errors.append("catalog_effect")
elif market_two != ["EQ-021", "EQ-023"]:
errors.append("catalog_non_effect")
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 _region_seals() -> Dictionary:
var result := {}
for index in range(1, 9):
var region_id := "RG-%02d" % index
result[region_id] = {
"id": region_id,
"order": index,
"name_zh": ["风晶边境", "淤光泽地", "余烬织坊", "羽根典库", "潮镜远岸", "天轨果园", "夜流盆地", "晨冠环路"][index - 1],
"name_en": ["Windglass Verge", "Siltwake Fens", "Emberloom Works", "Quillroot Archive", "Tideglass Reach", "Skyrail Orchard", "Nightcurrent Basin", "Crown of Morn"][index - 1],
"prerequisite_ids": ["CLEAR-RG01-C01"] if index == 1 else ["CLEAR-RG%02d-C02" % (index - 1)],
"claim_ids": CHARTER_CLAIMS.keys() if index == 1 else [],
}
return result
static func _hash(value: Variant) -> String:
return JSON.stringify(value).sha256_text()