feat: add FG3 second contract authority
This commit is contained in:
@@ -29,6 +29,18 @@
|
||||
"preview": "Clear Rounds 8 through 12 and arrive at one immutable Sluiceback Mother Muster.",
|
||||
"response_tags": ["five_round_arc", "boss_preparation", "market_8_continuation"]
|
||||
},
|
||||
"contract2": {
|
||||
"id": "CTR-R2-02",
|
||||
"name_en": "Second Siltwake Contract",
|
||||
"name_zh": "淤潮第二契约",
|
||||
"region_id": "RG-02",
|
||||
"slice_start_market": 1,
|
||||
"slice_end_market": 13,
|
||||
"boss_id": "BO-R2-02",
|
||||
"boss_executable": false,
|
||||
"preview": "Carry the surviving Company through twelve linked-pulse Rounds before the fixed Drowned Bell Muster.",
|
||||
"response_tags": ["same_run_company", "linked_pulse", "twelve_round_arc"]
|
||||
},
|
||||
"inherited": {
|
||||
"equipment_ids": ["EQ-041", "EQ-042", "EQ-043", "EQ-044", "EQ-045", "EQ-046", "EQ-047", "EQ-048", "EQ-049", "EQ-050", "EQ-051", "EQ-052", "EQ-053", "EQ-054", "EQ-055", "EQ-056", "EQ-057", "EQ-058", "EQ-059", "EQ-060", "EQ-061", "EQ-062", "EQ-063", "EQ-064"],
|
||||
"enemy_ids": ["EN-R2-01", "EN-R2-02", "EN-R2-03", "EN-R2-04", "EN-R2-05", "EN-R2-06", "EN-R2-07", "EN-R2-08", "EN-R2-09", "EN-R2-10", "EN-R2-11", "EN-R2-12"],
|
||||
@@ -85,5 +97,19 @@
|
||||
{"market_index":10,"round_index":10,"cleared_rounds_before":9,"line_capacity":8,"offer_level":4,"route_ids":["RO-R2-M10-Q0","RO-R2-M10-Q1","RO-R2-M10-Q2"],"required_equipment_ids":["EQ-069","EQ-070"],"first_order_changer_id":"EN-R2-12","milestone_disclosure":"patience_window"},
|
||||
{"market_index":11,"round_index":11,"cleared_rounds_before":10,"line_capacity":9,"offer_level":4,"route_ids":["RO-R2-M11-Q0","RO-R2-M11-Q1","RO-R2-M11-Q2"],"required_equipment_ids":["EQ-071","EQ-072"],"first_order_changer_id":"EN-R2-11","milestone_disclosure":"linked_cadence"},
|
||||
{"market_index":12,"round_index":12,"cleared_rounds_before":11,"line_capacity":9,"offer_level":4,"route_ids":["RO-R2-M12-Q0","RO-R2-M12-Q1","RO-R2-M12-Q2"],"required_equipment_ids":["EQ-073","EQ-078"],"first_order_changer_id":"BO-R2-01","milestone_disclosure":"boss_preparation"}
|
||||
],
|
||||
"contract2_markets": [
|
||||
{"market_index":1,"round_index":1,"line_capacity":10,"offer_level":1,"name_en":"Threaded Wake","name_zh":"丝潮初航","pressure":"A visible care thread and late-cleanse clock punish simultaneous recovery.","failure_cause":"Healing crossed the live thread before the hindrance midpoint.","preview":"Stagger care across the linked pair and hold cleanse for the midpoint notch.","response_tags":["linked_care","late_cleanse","staggered_actions"],"enemy_sets":[["EN-R2-06","EN-R2-11"],["EN-R2-06","EN-R2-09","EN-R2-11"],["EN-R2-02","EN-R2-06","EN-R2-11"]],"required_equipment_ids":["EQ-074","EQ-076"],"first_order_changer_id":"EN-R2-11","pulse_family":"healing","milestone_disclosure":"linked_care_pair"},
|
||||
{"market_index":2,"round_index":2,"line_capacity":10,"offer_level":1,"name_en":"Crab Bell Reach","name_zh":"蟹钟河湾","pressure":"Attacker tether and safe-index movement compete for one deliberate step.","failure_cause":"The tethered attacker crossed outside the next safe index.","preview":"Reserve one step until the attacker tether and next tide index agree.","response_tags":["attacker_tether","moving_safe_index","deliberate_movement"],"enemy_sets":[["EN-R2-07","EN-R2-12"],["EN-R2-01","EN-R2-07","EN-R2-12"],["EN-R2-07","EN-R2-10","EN-R2-12"]],"required_equipment_ids":["EQ-068","EQ-077"],"first_order_changer_id":"EN-R2-07","pulse_family":"movement","milestone_disclosure":"movement_echo"},
|
||||
{"market_index":3,"round_index":3,"line_capacity":10,"offer_level":1,"name_en":"Mimic Chime","name_zh":"拟声钟汊","pressure":"A copied target rule resolves through the first three-position pulse link.","failure_cause":"The mirrored target fed a chained cost through all three linked positions.","preview":"Seed a guarded target rule before the disclosed three-position pulse.","response_tags":["mirrored_target","triple_link","center_guard"],"enemy_sets":[["EN-R2-03","EN-R2-11"],["EN-R2-03","EN-R2-06","EN-R2-11"],["EN-R2-03","EN-R2-10","EN-R2-11"]],"required_equipment_ids":["EQ-069","EQ-071"],"first_order_changer_id":"EN-R2-03","pulse_family":"targeting","milestone_disclosure":"first_triple_link"},
|
||||
{"market_index":4,"round_index":4,"line_capacity":10,"offer_level":2,"name_en":"Mudthread Crossing","name_zh":"泥丝渡口","pressure":"Indexed mud blocks the adjacent swap that cuts a visible care thread.","failure_cause":"The thread-cutting swap crossed the marked mud index and lost its timing.","preview":"Trigger or avoid the mud mark before swapping the threaded pair adjacent.","response_tags":["indexed_hazard","adjacent_swap","linked_care"],"enemy_sets":[["EN-R2-04","EN-R2-11"],["EN-R2-01","EN-R2-04","EN-R2-11"],["EN-R2-04","EN-R2-09","EN-R2-11"]],"required_equipment_ids":["EQ-067","EQ-077"],"first_order_changer_id":"EN-R2-04","pulse_family":"movement","milestone_disclosure":"thread_cut_timing"},
|
||||
{"market_index":5,"round_index":5,"line_capacity":10,"offer_level":2,"name_en":"Piper Echo","name_zh":"笛声回汊","pressure":"Enemy reorder changes which linked partner receives the next pulse cost.","failure_cause":"The Piper moved the linked endpoint before the interrupt window.","preview":"Interrupt the command or preserve a target rule that survives the reorder.","response_tags":["interrupt_command","linked_partner","flexible_retarget"],"enemy_sets":[["EN-R2-05","EN-R2-11"],["EN-R2-05","EN-R2-08","EN-R2-11"],["EN-R2-05","EN-R2-10","EN-R2-11"]],"required_equipment_ids":["EQ-066","EQ-070"],"first_order_changer_id":"EN-R2-05","pulse_family":"command","milestone_disclosure":"reordered_link"},
|
||||
{"market_index":6,"round_index":6,"line_capacity":10,"offer_level":2,"name_en":"Widow's Tide","name_zh":"寡妇潮线","pressure":"Linked healing and the reversing safe index overlap on a three-position pulse.","failure_cause":"Care echoed across the triple link while Wet reached the pull threshold.","preview":"Move with the tide first, then stagger care across the disclosed triple.","response_tags":["triple_link","moving_safe_index","staggered_healing"],"enemy_sets":[["EN-R2-11","EN-R2-12"],["EN-R2-06","EN-R2-11","EN-R2-12"],["EN-R2-09","EN-R2-11","EN-R2-12"]],"required_equipment_ids":["EQ-071","EQ-074"],"first_order_changer_id":"EN-R2-12","pulse_family":"healing","milestone_disclosure":"tide_link_overlap"},
|
||||
{"market_index":7,"round_index":7,"line_capacity":10,"offer_level":3,"name_en":"Seed Chorus","name_zh":"种鸣长堤","pressure":"Defeat-triggered seed movement must land outside the next linked pulse.","failure_cause":"The seed escape entered a linked position and echoed its burst.","preview":"Choose the defeating ally and escape index before breaking the Pod.","response_tags":["seed_countdown","linked_positions","planned_defeat"],"enemy_sets":[["EN-R2-08","EN-R2-11"],["EN-R2-07","EN-R2-08","EN-R2-11"],["EN-R2-08","EN-R2-10","EN-R2-11"]],"required_equipment_ids":["EQ-065","EQ-077"],"first_order_changer_id":"EN-R2-08","pulse_family":"movement","milestone_disclosure":"seed_link_escape"},
|
||||
{"market_index":8,"round_index":8,"line_capacity":10,"offer_level":3,"name_en":"Fogmedic Toll","name_zh":"雾医钟站","pressure":"A reveal window and healing denial precede a pulse that can be interrupted.","failure_cause":"Healing fed the Leech while Fogjaw closed before the pulse interrupt.","preview":"Remove healing denial, bait the reveal, then interrupt the disclosed pulse.","response_tags":["healing_denial","reveal_window","pulse_interrupt"],"enemy_sets":[["EN-R2-02","EN-R2-10"],["EN-R2-02","EN-R2-09","EN-R2-10"],["EN-R2-02","EN-R2-10","EN-R2-11"]],"required_equipment_ids":["EQ-066","EQ-078"],"first_order_changer_id":"EN-R2-10","pulse_family":"interruptible","milestone_disclosure":"interruptible_pulse"},
|
||||
{"market_index":9,"round_index":9,"line_capacity":10,"offer_level":3,"name_en":"Rolling Banks","name_zh":"滚岸钟渠","pressure":"One endpoint swap must answer a reveal across a reversed three-position link.","failure_cause":"The endpoint response was spent before the triple link reversed.","preview":"Hold the free endpoint swap for the reveal after link direction changes.","response_tags":["endpoint_swap","triple_link","direction_reversal"],"enemy_sets":[["EN-R2-10","EN-R2-12"],["EN-R2-07","EN-R2-10","EN-R2-12"],["EN-R2-10","EN-R2-11","EN-R2-12"]],"required_equipment_ids":["EQ-077","EQ-080"],"first_order_changer_id":"EN-R2-12","pulse_family":"movement","milestone_disclosure":"reversing_triple"},
|
||||
{"market_index":10,"round_index":10,"line_capacity":10,"offer_level":4,"name_en":"Elder Resonance","name_zh":"闸老回声","pressure":"Moving safe index and linked care demand opposite timing bands.","failure_cause":"Both linked units acted together outside the safe index and chained Wet.","preview":"Place durable linked units, then alternate their movement and care cadences.","response_tags":["moving_safe_index","linked_care","opposite_cadence"],"enemy_sets":[["EN-R2-11","EN-R2-12"],["EN-R2-05","EN-R2-11","EN-R2-12"],["EN-R2-06","EN-R2-11","EN-R2-12"]],"required_equipment_ids":["EQ-071","EQ-076"],"first_order_changer_id":"EN-R2-12","pulse_family":"cadence","milestone_disclosure":"durable_linked_positions"},
|
||||
{"market_index":11,"round_index":11,"line_capacity":10,"offer_level":4,"name_en":"Threefold Undertow","name_zh":"三重暗潮","pressure":"Copied targeting, Widow thread and an approaching triple pulse share one forecast.","failure_cause":"The copied rule selected the fragile center and chained through the thread.","preview":"Seed a durable mirrored target and cut the thread before the third pulse.","response_tags":["mirrored_target","thread_cut","third_pulse"],"enemy_sets":[["EN-R2-03","EN-R2-11"],["EN-R2-03","EN-R2-09","EN-R2-11"],["EN-R2-03","EN-R2-10","EN-R2-11"]],"required_equipment_ids":["EQ-069","EQ-079"],"first_order_changer_id":"EN-R2-11","pulse_family":"targeting","milestone_disclosure":"third_pulse_forecast"},
|
||||
{"market_index":12,"round_index":12,"line_capacity":10,"offer_level":4,"name_en":"Bellward Causeway","name_zh":"向钟堤道","pressure":"A three-position linked cost rehearses the Drowned Bell's staggered-action check.","failure_cause":"Simultaneous movement, cleanse and healing chained across the final triple.","preview":"Assign durable linked positions and stagger movement, cleanse and healing before Muster.","response_tags":["boss_rehearsal","triple_link","staggered_actions"],"enemy_sets":[["EN-R2-06","EN-R2-11","EN-R2-12"],["EN-R2-05","EN-R2-11","EN-R2-12"],["EN-R2-10","EN-R2-11","EN-R2-12"]],"required_equipment_ids":["EQ-076","EQ-078"],"first_order_changer_id":"BO-R2-02","pulse_family":"mixed","milestone_disclosure":"drowned_bell_preparation"}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ static func validate(content: Dictionary) -> Dictionary:
|
||||
var all_ids: Array[String] = []
|
||||
_validate_singleton(content.get("region", {}), "RG-02", all_ids, errors)
|
||||
_validate_singleton(content.get("contract", {}), "CTR-R2-01", all_ids, errors)
|
||||
_validate_singleton(content.get("contract2", {}), "CTR-R2-02", all_ids, errors)
|
||||
var families := {}
|
||||
for family in EXPECTED_IDS:
|
||||
var rows = content.get(family, [])
|
||||
@@ -100,11 +101,14 @@ static func validate(content: Dictionary) -> Dictionary:
|
||||
errors.append("family_ids:%s" % family)
|
||||
|
||||
_validate_contract(content, errors)
|
||||
_validate_contract2(content, errors)
|
||||
_validate_equipment(families.get("equipment", {}), errors)
|
||||
_validate_artifacts(content, families.get("artifacts", {}), errors)
|
||||
_validate_bosses(content, families.get("bosses", {}), errors)
|
||||
_validate_routes(families.get("routes", {}), errors)
|
||||
_validate_contract2_routes(content, all_ids, errors)
|
||||
_validate_markets(content, families, errors)
|
||||
_validate_contract2_markets(content, errors)
|
||||
_validate_catalog_hash(content, errors)
|
||||
return {"ok": errors.is_empty(), "errors": errors}
|
||||
|
||||
@@ -126,7 +130,8 @@ static func markets_by_index(content: Dictionary) -> Dictionary:
|
||||
|
||||
|
||||
static func _enrich(content: Dictionary) -> void:
|
||||
for key in ["region", "contract"]:
|
||||
_build_contract2_routes(content)
|
||||
for key in ["region", "contract", "contract2"]:
|
||||
var singleton = content.get(key, {})
|
||||
if singleton is Dictionary:
|
||||
_enrich_row(singleton)
|
||||
@@ -134,6 +139,9 @@ static func _enrich(content: Dictionary) -> void:
|
||||
for row in content.get(family, []):
|
||||
if row is Dictionary:
|
||||
_enrich_row(row)
|
||||
for row in content.get("contract2_routes", []):
|
||||
if row is Dictionary:
|
||||
_enrich_row(row)
|
||||
|
||||
|
||||
static func _enrich_row(row: Dictionary) -> void:
|
||||
@@ -242,6 +250,18 @@ static func _validate_contract(content: Dictionary, errors: Array[String]) -> vo
|
||||
errors.append("boss_boundary")
|
||||
|
||||
|
||||
static func _validate_contract2(content: Dictionary, errors: Array[String]) -> void:
|
||||
var contract: Dictionary = content.get("contract2", {})
|
||||
if (
|
||||
String(contract.get("region_id", "")) != "RG-02"
|
||||
or int(contract.get("slice_start_market", 0)) != 1
|
||||
or int(contract.get("slice_end_market", 0)) != 13
|
||||
or String(contract.get("boss_id", "")) != "BO-R2-02"
|
||||
or bool(contract.get("boss_executable", true))
|
||||
):
|
||||
errors.append("contract2_boundary")
|
||||
|
||||
|
||||
static func _validate_equipment(equipment: Dictionary, errors: Array[String]) -> void:
|
||||
for item_id in equipment:
|
||||
var item: Dictionary = equipment[item_id]
|
||||
@@ -336,6 +356,72 @@ static func _validate_routes(routes: Dictionary, errors: Array[String]) -> void:
|
||||
errors.append("enemy_underused:%s" % enemy_id)
|
||||
|
||||
|
||||
static func _validate_contract2_routes(
|
||||
content: Dictionary,
|
||||
all_ids: Array[String],
|
||||
errors: Array[String]
|
||||
) -> void:
|
||||
var rows = content.get("contract2_routes", [])
|
||||
if not rows is Array or rows.size() != 36:
|
||||
errors.append("contract2_route_count")
|
||||
return
|
||||
var by_id := {}
|
||||
var enemy_usage := {}
|
||||
for enemy_id in EXPECTED_INHERITED.enemy_ids:
|
||||
enemy_usage[String(enemy_id)] = 0
|
||||
for row in rows:
|
||||
if not row is Dictionary:
|
||||
errors.append("contract2_route_type")
|
||||
continue
|
||||
_validate_common(row, all_ids, errors)
|
||||
var route_id := String(row.get("id", ""))
|
||||
if by_id.has(route_id):
|
||||
errors.append("contract2_route_duplicate:%s" % route_id)
|
||||
by_id[route_id] = row
|
||||
for field in [
|
||||
"market_index", "round_index", "risk", "enemy_ids", "wave_order",
|
||||
"reward_coin", "recovery_coin", "pressure", "failure_cause",
|
||||
]:
|
||||
if not row.has(field):
|
||||
errors.append("contract2_route_field:%s:%s" % [route_id, field])
|
||||
var market_index := int(row.get("market_index", -1))
|
||||
var risk := int(row.get("risk", -1))
|
||||
if route_id != "RO-R2-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(row.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(row.get("reward_coin", -1)) != 20 + 2 * market_index + 7 * risk:
|
||||
errors.append("contract2_route_reward:%s" % route_id)
|
||||
var recovery := int(row.get("recovery_coin", -1))
|
||||
if recovery <= 0 or recovery >= int(row.get("reward_coin", 0)):
|
||||
errors.append("contract2_route_recovery:%s" % route_id)
|
||||
var enemy_ids = row.get("enemy_ids", [])
|
||||
var waves = row.get("wave_order", [])
|
||||
if not enemy_ids is Array or enemy_ids.size() < 2 or enemy_ids.size() > 3 or not _unique(enemy_ids):
|
||||
errors.append("contract2_route_composition:%s" % route_id)
|
||||
elif not waves is Array or waves.size() != enemy_ids.size():
|
||||
errors.append("contract2_route_waves:%s" % route_id)
|
||||
else:
|
||||
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)
|
||||
for market_index in range(1, 13):
|
||||
for risk in range(3):
|
||||
var expected_id := "RO-R2-C02-M%02d-Q%d" % [market_index, risk]
|
||||
if not by_id.has(expected_id):
|
||||
errors.append("contract2_route_missing:%s" % expected_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_markets(
|
||||
content: Dictionary,
|
||||
families: Dictionary,
|
||||
@@ -389,6 +475,94 @@ static func _validate_markets(
|
||||
errors.append("market_indices")
|
||||
|
||||
|
||||
static func _validate_contract2_markets(content: 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 equipment := rows_by_id(content, "equipment")
|
||||
var allowed_changers: Array = Array(EXPECTED_INHERITED.enemy_ids).duplicate()
|
||||
allowed_changers.append("BO-R2-02")
|
||||
var seen := {}
|
||||
for market in markets:
|
||||
if not market is Dictionary:
|
||||
errors.append("contract2_market_type")
|
||||
continue
|
||||
var market_index := int(market.get("market_index", -1))
|
||||
seen[market_index] = true
|
||||
var expected_level := mini(10, 1 + int(floor(float(market_index - 1) / 3.0)))
|
||||
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)) != expected_level
|
||||
):
|
||||
errors.append("contract2_market_boundary:%d" % market_index)
|
||||
for field in [
|
||||
"name_en", "name_zh", "pressure", "failure_cause", "preview",
|
||||
"pulse_family", "milestone_disclosure",
|
||||
]:
|
||||
if String(market.get(field, "")).strip_edges().is_empty():
|
||||
errors.append("contract2_market_field:%d:%s" % [market_index, field])
|
||||
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" % market_index)
|
||||
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" % market_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" % market_index)
|
||||
else:
|
||||
for item_id in items:
|
||||
if not equipment.has(String(item_id)):
|
||||
errors.append("contract2_market_item:%d:%s" % [market_index, item_id])
|
||||
if String(market.get("first_order_changer_id", "")) not in allowed_changers:
|
||||
errors.append("contract2_market_changer:%d" % market_index)
|
||||
if seen.size() != 12:
|
||||
errors.append("contract2_market_indices")
|
||||
|
||||
|
||||
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-R2-C02-M%02d-Q%d" % [market_index, risk],
|
||||
"name_en": "%s - %s" % [String(market.get("name_en", "")), suffix_en[risk]],
|
||||
"name_zh": "%s - %s" % [String(market.get("name_zh", "")), suffix_zh[risk]],
|
||||
"market_index": market_index,
|
||||
"round_index": market_index,
|
||||
"risk": risk,
|
||||
"enemy_ids": enemy_ids,
|
||||
"wave_order": waves,
|
||||
"reward_coin": 20 + 2 * market_index + 7 * risk,
|
||||
"recovery_coin": 10 + market_index + 3 * risk,
|
||||
"pressure": String(market.get("pressure", "")),
|
||||
"failure_cause": String(market.get("failure_cause", "")),
|
||||
"preview": String(market.get("preview", "")),
|
||||
"response_tags": Array(market.get("response_tags", [])).duplicate(),
|
||||
"pulse_family": String(market.get("pulse_family", "")),
|
||||
"milestone_disclosure": String(market.get("milestone_disclosure", "")),
|
||||
})
|
||||
content["contract2_routes"] = rows
|
||||
|
||||
|
||||
static func _validate_catalog_hash(content: Dictionary, errors: Array[String]) -> void:
|
||||
var candidate := content.duplicate(true)
|
||||
var stored := String(candidate.get("catalog_hash", ""))
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
class_name FG3Contract2MarketGenerator
|
||||
extends RefCounted
|
||||
|
||||
const Content = preload("res://fg3/content_registry.gd")
|
||||
|
||||
const CONTRACT_ID := "CTR-R2-02"
|
||||
const EQUIPMENT_PRICE := 15
|
||||
|
||||
var _catalog: Dictionary
|
||||
var _validation: Dictionary
|
||||
|
||||
|
||||
func _init(catalog: Dictionary = {}) -> void:
|
||||
_catalog = Content.snapshot() if catalog.is_empty() else catalog.duplicate(true)
|
||||
_validation = Content.validate(_catalog)
|
||||
|
||||
|
||||
func validation() -> Dictionary:
|
||||
return _validation.duplicate(true)
|
||||
|
||||
|
||||
func generate(run_seed: String, market_index: int, refresh_index: int = 0) -> Dictionary:
|
||||
if not bool(_validation.get("ok", false)):
|
||||
return {"ok": false, "code": "invalid_catalog", "errors": _validation.errors.duplicate()}
|
||||
if not _is_seed(run_seed):
|
||||
return {"ok": false, "code": "invalid_run_seed"}
|
||||
if market_index < 1 or market_index > 12:
|
||||
return {"ok": false, "code": "outside_contract2_market"}
|
||||
if refresh_index < 0:
|
||||
return {"ok": false, "code": "invalid_refresh_index"}
|
||||
var market := _market(market_index)
|
||||
if market.is_empty():
|
||||
return {"ok": false, "code": "market_authority_missing"}
|
||||
var pulse := _pulse_disclosure(run_seed, market)
|
||||
var board := {
|
||||
"board_id": _low128(_digest(run_seed, "fg3.contract2.market", [market_index, refresh_index])),
|
||||
"run_seed": run_seed,
|
||||
"contract_id": CONTRACT_ID,
|
||||
"region_id": "RG-02",
|
||||
"market_index": market_index,
|
||||
"round_index": int(market.round_index),
|
||||
"cleared_rounds_before": 12 + market_index - 1,
|
||||
"line_capacity": 10,
|
||||
"offer_level": int(market.offer_level),
|
||||
"refresh_index": refresh_index,
|
||||
"recruit_slots": _recruit_slots(run_seed, market, refresh_index),
|
||||
"equipment_offers": _equipment_offers(run_seed, market, refresh_index),
|
||||
"battle_offers": _route_offers(run_seed, market, pulse),
|
||||
"pulse_disclosure": pulse,
|
||||
"milestone_disclosure": String(market.milestone_disclosure),
|
||||
"boss_disclosure": _boss_disclosure(market_index),
|
||||
}
|
||||
board["board_hash"] = _hash(board)
|
||||
return {"ok": true, "code": "ok", "board": board}
|
||||
|
||||
|
||||
func _market(market_index: int) -> Dictionary:
|
||||
for row in _catalog.get("contract2_markets", []):
|
||||
if int(row.get("market_index", -1)) == market_index:
|
||||
return Dictionary(row).duplicate(true)
|
||||
return {}
|
||||
|
||||
|
||||
func _recruit_slots(
|
||||
run_seed: String,
|
||||
market: Dictionary,
|
||||
refresh_index: int
|
||||
) -> Array[Dictionary]:
|
||||
var slots: Array[Dictionary] = []
|
||||
for slot in range(3):
|
||||
var generation_key := _digest(
|
||||
run_seed,
|
||||
"fg3.contract2.recruit",
|
||||
[int(market.market_index), refresh_index, slot]
|
||||
)
|
||||
slots.append({
|
||||
"slot": slot,
|
||||
"slot_id": _low128(generation_key),
|
||||
"generation_key": generation_key,
|
||||
"offer_level": int(market.offer_level),
|
||||
"binding": "p10.3-recruit-materialization-compatible",
|
||||
"base_profession_required": slot == 0,
|
||||
"advanced_profession_allowed": int(market.market_index) >= 7 and slot > 0,
|
||||
})
|
||||
return slots
|
||||
|
||||
|
||||
func _equipment_offers(
|
||||
run_seed: String,
|
||||
market: Dictionary,
|
||||
refresh_index: int
|
||||
) -> Array[Dictionary]:
|
||||
var equipment := Content.rows_by_id(_catalog, "equipment")
|
||||
var selected: Array[String] = []
|
||||
for item_id in market.required_equipment_ids:
|
||||
selected.append(String(item_id))
|
||||
var remaining: Array[String] = []
|
||||
for item_id in equipment:
|
||||
if item_id not in selected:
|
||||
remaining.append(String(item_id))
|
||||
remaining.sort()
|
||||
var offset := (
|
||||
_index(run_seed, "fg3.contract2.equipment.%d" % int(market.market_index), remaining.size())
|
||||
+ refresh_index
|
||||
) % remaining.size()
|
||||
selected.append(remaining[offset])
|
||||
selected.append(remaining[(offset + 1) % remaining.size()])
|
||||
var offers: Array[Dictionary] = []
|
||||
for slot in range(selected.size()):
|
||||
var catalog_id := selected[slot]
|
||||
var row: Dictionary = equipment[catalog_id]
|
||||
var offer := {
|
||||
"offer_id": _low128(_digest(
|
||||
run_seed,
|
||||
"fg3.contract2.equipment",
|
||||
[int(market.market_index), refresh_index, slot, catalog_id]
|
||||
)),
|
||||
"slot": slot,
|
||||
"catalog_id": catalog_id,
|
||||
"name_en": row.name_en,
|
||||
"name_zh": row.name_zh,
|
||||
"fit": row.fit,
|
||||
"effect": row.effect,
|
||||
"tradeoff": row.tradeoff,
|
||||
"preview": row.preview,
|
||||
"response_tags": Array(row.response_tags).duplicate(),
|
||||
"region_band": 1,
|
||||
"item_tier": 0,
|
||||
"list_price": EQUIPMENT_PRICE,
|
||||
}
|
||||
offer["offer_hash"] = _hash(offer)
|
||||
offers.append(offer)
|
||||
return offers
|
||||
|
||||
|
||||
func _route_offers(
|
||||
run_seed: String,
|
||||
market: Dictionary,
|
||||
pulse: Dictionary
|
||||
) -> Array[Dictionary]:
|
||||
var routes := Content.rows_by_id(_catalog, "contract2_routes")
|
||||
var offers: Array[Dictionary] = []
|
||||
for risk in range(3):
|
||||
var route_id := "RO-R2-C02-M%02d-Q%d" % [int(market.market_index), risk]
|
||||
var row: Dictionary = routes[route_id]
|
||||
var offer := {
|
||||
"offer_id": _low128(_digest(run_seed, "fg3.contract2.route", [route_id])),
|
||||
"route_id": route_id,
|
||||
"market_index": int(row.market_index),
|
||||
"round_index": int(row.round_index),
|
||||
"risk": int(row.risk),
|
||||
"name_en": row.name_en,
|
||||
"name_zh": row.name_zh,
|
||||
"enemy_ids": Array(row.enemy_ids).duplicate(),
|
||||
"wave_order": Array(row.wave_order).duplicate(true),
|
||||
"pressure": row.pressure,
|
||||
"failure_cause": row.failure_cause,
|
||||
"preview": row.preview,
|
||||
"response_tags": Array(row.response_tags).duplicate(),
|
||||
"reward_coin": int(row.reward_coin),
|
||||
"recovery_coin": int(row.recovery_coin),
|
||||
"pulse_disclosure": pulse.duplicate(true),
|
||||
}
|
||||
offer["offer_hash"] = _hash(offer)
|
||||
offers.append(offer)
|
||||
return offers
|
||||
|
||||
|
||||
func _pulse_disclosure(run_seed: String, market: Dictionary) -> Dictionary:
|
||||
var count := 3 if int(market.market_index) % 3 == 0 else 2
|
||||
var first := 1 + _index(
|
||||
run_seed,
|
||||
"fg3.contract2.pulse.%d" % int(market.market_index),
|
||||
10 - count + 1
|
||||
)
|
||||
var indices: Array[int] = []
|
||||
for offset in range(count):
|
||||
indices.append(first + offset)
|
||||
return {
|
||||
"pulse_ordinal": int(market.market_index),
|
||||
"pulse_family": String(market.pulse_family),
|
||||
"linked_indices": indices,
|
||||
"linked_count": count,
|
||||
"third_pulse_expansion": count == 3,
|
||||
"warning_ticks": 24,
|
||||
"resolve_tick": 48,
|
||||
"fixed_tick_ms": 100,
|
||||
"echo_cost": "linked_action_cost",
|
||||
"first_order_changer_id": String(market.first_order_changer_id),
|
||||
}
|
||||
|
||||
|
||||
func _boss_disclosure(market_index: int) -> Dictionary:
|
||||
if market_index != 12:
|
||||
return {}
|
||||
var boss: Dictionary = Content.rows_by_id(_catalog, "bosses")["BO-R2-02"]
|
||||
return {
|
||||
"boss_id": String(boss.id),
|
||||
"name_en": String(boss.name_en),
|
||||
"name_zh": String(boss.name_zh),
|
||||
"preview": String(boss.preview),
|
||||
"rule": String(boss.rule),
|
||||
"responses": String(boss.responses),
|
||||
"failure_cause": String(boss.failure_cause),
|
||||
"phase_change": String(boss.phase_change),
|
||||
"response_tags": Array(boss.response_tags).duplicate(),
|
||||
"fixed": true,
|
||||
"executable": false,
|
||||
}
|
||||
|
||||
|
||||
func _index(seed: String, label: String, size: int) -> int:
|
||||
return int(("0x" + _digest(seed, label).substr(0, 8)).hex_to_int() % size)
|
||||
|
||||
|
||||
func _digest(seed: String, label: String, parts: Array = []) -> String:
|
||||
return _hash({"seed": seed, "label": label, "parts": parts})
|
||||
|
||||
|
||||
func _is_seed(value: String) -> bool:
|
||||
if value.length() != 32:
|
||||
return false
|
||||
for character in value:
|
||||
if character not in "0123456789abcdef":
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _low128(value: String) -> String:
|
||||
return value.substr(0, 32)
|
||||
|
||||
|
||||
func _hash(value) -> String:
|
||||
return JSON.stringify(_canonical(value)).sha256_text()
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://abgfg3contract2market
|
||||
@@ -0,0 +1,169 @@
|
||||
class_name FG3Contract2Service
|
||||
extends RefCounted
|
||||
|
||||
const MarketGenerator = preload("res://fg3/contract2_market_generator.gd")
|
||||
const State = preload("res://fg3/run_state.gd")
|
||||
|
||||
var state := State.new()
|
||||
var valid := false
|
||||
var error_code := "invalid_state"
|
||||
|
||||
|
||||
func _init(snapshot_value: Dictionary) -> void:
|
||||
valid = state.restore_snapshot(snapshot_value)
|
||||
if valid:
|
||||
error_code = ""
|
||||
|
||||
|
||||
func activate_contract(
|
||||
operation_id: String,
|
||||
expected_revision: int,
|
||||
expected_handoff_hash: String
|
||||
) -> Dictionary:
|
||||
var intent := {
|
||||
"expected_revision": expected_revision,
|
||||
"expected_handoff_hash": expected_handoff_hash,
|
||||
}
|
||||
var prior := _prior_operation(operation_id, "activate_contract2", intent)
|
||||
if prior.known:
|
||||
return prior.result
|
||||
if not valid:
|
||||
return _reject(error_code)
|
||||
if expected_revision != state.revision:
|
||||
return _reject("stale_revision")
|
||||
if String(state.current_run.phase) != "contract2_handoff":
|
||||
return _reject("contract2_handoff_required")
|
||||
if expected_handoff_hash != String(state.current_run.market_board.get("handoff_hash", "")):
|
||||
return _reject("handoff_hash_mismatch")
|
||||
if "CLEAR-RG02-C01" not in state.guild_profile.contract_clear_ids:
|
||||
return _reject("contract1_clear_required")
|
||||
var generated := MarketGenerator.new().generate(String(state.current_run.run_seed), 1, 0)
|
||||
if not generated.ok:
|
||||
return _reject("contract2_market_generation_failed")
|
||||
|
||||
var before := state.snapshot()
|
||||
var company_hash := _hash({
|
||||
"company": Array(state.current_run.company).duplicate(),
|
||||
"records": Dictionary(state.current_run.recruit_records).duplicate(true),
|
||||
"inventory": Dictionary(state.current_run.inventory).duplicate(true),
|
||||
"equipped": Dictionary(state.current_run.equipped_by_recruit).duplicate(true),
|
||||
})
|
||||
var board: Dictionary = generated.board
|
||||
state.current_run.contract_id = "CTR-R2-02"
|
||||
state.current_run.phase = "contract2_market"
|
||||
state.current_run.market_index = 1
|
||||
state.current_run.round_index = 1
|
||||
state.current_run.line_capacity = 10
|
||||
state.current_run.refresh_count = 0
|
||||
state.current_run.market_board = board.duplicate(true)
|
||||
state.current_run.available_recruit_offer_ids = _offer_ids(board.recruit_slots, "slot_id")
|
||||
state.current_run.available_equipment_offer_ids = _offer_ids(board.equipment_offers, "offer_id")
|
||||
state.current_run.available_battle_offer_ids = _offer_ids(board.battle_offers, "offer_id")
|
||||
state.current_run.locked_offer_ids = []
|
||||
state.current_run.selected_route_id = ""
|
||||
state.current_run.pending_encounter = {}
|
||||
state.current_run.pre_battle_checkpoint_id = ""
|
||||
state.current_run.recovery_required = false
|
||||
state.current_run.recovery_cost = 0
|
||||
state.current_run.boss_muster = {}
|
||||
state.current_run.selected_boss_plan = ""
|
||||
state.current_run.pending_boss_result = {}
|
||||
state.current_run.next_contract_id = ""
|
||||
return _commit(operation_id, "activate_contract2", intent, "contract2_activated", {
|
||||
"operation_id": operation_id,
|
||||
"contract_id": "CTR-R2-02",
|
||||
"market_index": 1,
|
||||
"board_hash": String(board.board_hash),
|
||||
"source_handoff_hash": expected_handoff_hash,
|
||||
"company_hash": company_hash,
|
||||
"company_ids": Array(state.current_run.company).duplicate(),
|
||||
"cleared_rounds": int(state.current_run.cleared_rounds),
|
||||
"line_capacity": int(state.current_run.line_capacity),
|
||||
}, before)
|
||||
|
||||
|
||||
func _prior_operation(operation_id: String, kind: String, intent: Dictionary) -> Dictionary:
|
||||
if operation_id.is_empty():
|
||||
return {"known": true, "result": _reject("operation_id_required")}
|
||||
if not valid or not state.operation_receipts.has(operation_id):
|
||||
return {"known": false}
|
||||
var stored: Dictionary = state.operation_receipts[operation_id]
|
||||
if String(stored.kind) != kind or String(stored.intent_hash) != _hash(intent):
|
||||
return {"known": true, "result": _reject("operation_conflict")}
|
||||
return {"known": true, "result": {
|
||||
"ok": true,
|
||||
"code": String(stored.code),
|
||||
"duplicate": true,
|
||||
"revision": int(stored.revision),
|
||||
"receipt": Dictionary(stored.receipt).duplicate(true),
|
||||
"snapshot": state.snapshot(),
|
||||
}}
|
||||
|
||||
|
||||
func _commit(
|
||||
operation_id: String,
|
||||
kind: String,
|
||||
intent: Dictionary,
|
||||
code: String,
|
||||
receipt: Dictionary,
|
||||
before: Dictionary
|
||||
) -> Dictionary:
|
||||
state.revision += 1
|
||||
state.operation_receipts[operation_id] = {
|
||||
"operation_id": operation_id,
|
||||
"kind": kind,
|
||||
"intent_hash": _hash(intent),
|
||||
"status": "applied",
|
||||
"code": code,
|
||||
"revision": state.revision,
|
||||
"receipt": receipt.duplicate(true),
|
||||
}
|
||||
if not state.invariants_hold():
|
||||
state.restore_snapshot(before)
|
||||
return _reject("postcondition_failed")
|
||||
return {
|
||||
"ok": true,
|
||||
"code": code,
|
||||
"duplicate": false,
|
||||
"revision": state.revision,
|
||||
"receipt": receipt.duplicate(true),
|
||||
"snapshot": state.snapshot(),
|
||||
}
|
||||
|
||||
|
||||
func _reject(code: String) -> Dictionary:
|
||||
return {
|
||||
"ok": false,
|
||||
"code": code,
|
||||
"duplicate": false,
|
||||
"revision": state.revision if valid else -1,
|
||||
}
|
||||
|
||||
|
||||
func _offer_ids(offers: Array, field: String) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for offer in offers:
|
||||
result.append(String(offer.get(field, "")))
|
||||
return result
|
||||
|
||||
|
||||
func _hash(value) -> String:
|
||||
return JSON.stringify(_canonical(value)).sha256_text()
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
uid://abgfg3contract2service
|
||||
@@ -3,12 +3,13 @@ extends RefCounted
|
||||
|
||||
const P103State = preload("res://p10_3/run_state.gd")
|
||||
const MarketGenerator = preload("res://fg3/contract1_market_generator.gd")
|
||||
const Contract2MarketGenerator = preload("res://fg3/contract2_market_generator.gd")
|
||||
|
||||
const STATE_KIND := "fg3_complete_rg02"
|
||||
const RULESET := "fg3-complete-rg02-r1"
|
||||
const PHASES := [
|
||||
"market", "prepared", "recovery", "boss_muster", "boss_prepared",
|
||||
"boss_recovery", "contract_result", "contract2_handoff",
|
||||
"boss_recovery", "contract_result", "contract2_handoff", "contract2_market",
|
||||
]
|
||||
|
||||
var state_kind := STATE_KIND
|
||||
@@ -239,7 +240,6 @@ func _run_holds() -> bool:
|
||||
not bool(current_run.fg3_activated)
|
||||
or String(current_run.status) != "active"
|
||||
or String(current_run.region_id) != "RG-02"
|
||||
or String(current_run.contract_id) != "CTR-R2-01"
|
||||
or String(current_run.phase) not in PHASES
|
||||
or not _is_hex(String(current_run.run_id), 32)
|
||||
or not _is_hex(String(current_run.run_seed), 32)
|
||||
@@ -249,9 +249,14 @@ func _run_holds() -> bool:
|
||||
return false
|
||||
if current_run.route_history.size() != int(current_run.cleared_rounds):
|
||||
return false
|
||||
var phase := String(current_run.phase)
|
||||
var expected_contract := "CTR-R2-02" if phase == "contract2_market" else "CTR-R2-01"
|
||||
if String(current_run.contract_id) != expected_contract:
|
||||
return false
|
||||
if not _company_holds() or not _checkpoints_hold():
|
||||
return false
|
||||
var phase := String(current_run.phase)
|
||||
if phase == "contract2_market":
|
||||
return _contract2_market_holds()
|
||||
if phase in ["market", "prepared", "recovery"]:
|
||||
return _ordinary_phase_holds(phase)
|
||||
if phase in ["boss_muster", "boss_prepared", "boss_recovery", "contract_result"]:
|
||||
@@ -361,6 +366,42 @@ func _handoff_holds() -> bool:
|
||||
)
|
||||
|
||||
|
||||
func _contract2_market_holds() -> bool:
|
||||
var market_index := int(current_run.market_index)
|
||||
if (
|
||||
market_index < 1 or market_index > 12
|
||||
or int(current_run.round_index) != market_index
|
||||
or int(current_run.cleared_rounds) != 12 + market_index - 1
|
||||
or int(current_run.line_capacity) != 10
|
||||
or int(current_run.refresh_count) < 0
|
||||
or String(current_run.next_contract_id) != ""
|
||||
or not current_run.boss_muster.is_empty()
|
||||
or not current_run.pending_boss_result.is_empty()
|
||||
):
|
||||
return false
|
||||
var generated := Contract2MarketGenerator.new().generate(
|
||||
String(current_run.run_seed),
|
||||
market_index,
|
||||
int(current_run.refresh_count)
|
||||
)
|
||||
if not generated.ok or _hash(current_run.market_board) != _hash(generated.board):
|
||||
return false
|
||||
if (
|
||||
current_run.available_recruit_offer_ids != _offer_ids(current_run.market_board.recruit_slots, "slot_id")
|
||||
or current_run.available_equipment_offer_ids != _offer_ids(current_run.market_board.equipment_offers, "offer_id")
|
||||
or current_run.available_battle_offer_ids != _offer_ids(current_run.market_board.battle_offers, "offer_id")
|
||||
or not current_run.locked_offer_ids.is_empty()
|
||||
):
|
||||
return false
|
||||
return (
|
||||
String(current_run.selected_route_id).is_empty()
|
||||
and current_run.pending_encounter.is_empty()
|
||||
and String(current_run.pre_battle_checkpoint_id).is_empty()
|
||||
and not bool(current_run.recovery_required)
|
||||
and int(current_run.recovery_cost) == 0
|
||||
)
|
||||
|
||||
|
||||
func _company_holds() -> bool:
|
||||
if current_run.company.is_empty() or not _unique(current_run.company):
|
||||
return false
|
||||
|
||||
@@ -19,9 +19,10 @@ func run_test() -> void:
|
||||
expect(String(first.catalog_hash).length() == 64, "catalog hash is canonical")
|
||||
verify_counts_and_identity(first)
|
||||
verify_route_and_market_authority(first)
|
||||
verify_contract2_authority(first)
|
||||
verify_negative_boundaries(first)
|
||||
if failures.is_empty():
|
||||
print("ABG_FG3_CONTRACT1_CONTENT_OK new_equipment=16 complete_equipment=40 new_artifacts=4 complete_artifacts=7 enemies=12 bosses=2 routes=15 markets=5 localized=39 source=7_exact reward_formula=15 capacity=7,8,8,9,9 offer_levels=3,3,4,4,4 negative_cases=%d" % rejected_cases)
|
||||
print("ABG_FG3_CONTENT_GRAPH_OK new_equipment=16 complete_equipment=40 new_artifacts=4 complete_artifacts=7 enemies=12 bosses=2 contract1_routes=15 contract2_routes=36 markets=5+12 localized=76 source=7_exact negative_cases=%d" % rejected_cases)
|
||||
quit(0)
|
||||
else:
|
||||
for failure in failures:
|
||||
@@ -38,6 +39,7 @@ func verify_counts_and_identity(content: Dictionary) -> void:
|
||||
"Contract-1 ending boundary is exact"
|
||||
)
|
||||
expect(content.contract.boss_executable, "Contract 1 Boss execution is enabled in Step 2")
|
||||
expect(content.contract2.id == "CTR-R2-02" and content.contract2.boss_id == "BO-R2-02" and not content.contract2.boss_executable, "Contract 2 boundary is exact and Boss execution stays frozen")
|
||||
expect(content.equipment.size() == 16, "EQ-065..080 are exposed")
|
||||
expect(content.artifacts.size() == 4, "four missing Artifact rows are exposed")
|
||||
expect(content.bosses.size() == 2, "both Siltwake Boss identities are exposed")
|
||||
@@ -48,12 +50,12 @@ func verify_counts_and_identity(content: Dictionary) -> void:
|
||||
expect(content.source == Content.SOURCE_AUTHORITY, "seven source blobs are exact")
|
||||
|
||||
var ids := {}
|
||||
for singleton in [content.region, content.contract]:
|
||||
for singleton in [content.region, content.contract, content.contract2]:
|
||||
verify_row(singleton, ids)
|
||||
for family in [content.equipment, content.artifacts, content.bosses, content.routes]:
|
||||
for family in [content.equipment, content.artifacts, content.bosses, content.routes, content.contract2_routes]:
|
||||
for row in family:
|
||||
verify_row(row, ids)
|
||||
expect(ids.size() == 39, "all 39 bounded identities are unique and localized")
|
||||
expect(ids.size() == 76, "all 76 bounded identities are unique and localized")
|
||||
|
||||
|
||||
func verify_route_and_market_authority(content: Dictionary) -> void:
|
||||
@@ -83,6 +85,28 @@ func verify_route_and_market_authority(content: Dictionary) -> void:
|
||||
expect(markets[12].milestone_disclosure == "boss_preparation", "Market 12 discloses Boss preparation")
|
||||
|
||||
|
||||
func verify_contract2_authority(content: Dictionary) -> void:
|
||||
var routes := Content.rows_by_id(content, "contract2_routes")
|
||||
expect(routes.size() == 36 and content.contract2_markets.size() == 12, "Contract 2 has 36 routes and twelve Markets")
|
||||
var enemy_usage := {}
|
||||
for enemy_id in content.inherited.enemy_ids:
|
||||
enemy_usage[String(enemy_id)] = 0
|
||||
for market_index in range(1, 13):
|
||||
var market: Dictionary = content.contract2_markets[market_index - 1]
|
||||
expect(market.market_index == market_index and market.round_index == market_index, "Contract-2 Market %d identity is exact" % market_index)
|
||||
expect(market.line_capacity == 10 and market.offer_level == 1 + int(floor(float(market_index - 1) / 3.0)), "Contract-2 Market %d capacity and offer level are exact" % market_index)
|
||||
for risk in range(3):
|
||||
var route_id := "RO-R2-C02-M%02d-Q%d" % [market_index, risk]
|
||||
var route: Dictionary = routes[route_id]
|
||||
expect(route.reward_coin == 20 + 2 * market_index + 7 * risk, "%s reward formula is exact" % route_id)
|
||||
expect(route.wave_order.map(func(wave): return wave.enemy_id) == route.enemy_ids, "%s wave order is exact" % route_id)
|
||||
for enemy_id in route.enemy_ids:
|
||||
enemy_usage[String(enemy_id)] += 1
|
||||
for enemy_id in enemy_usage:
|
||||
expect(enemy_usage[enemy_id] >= 2, "%s remains exercised in Contract 2" % enemy_id)
|
||||
expect(content.contract2_markets[-1].first_order_changer_id == "BO-R2-02", "Market 12 discloses Drowned Bell as first-order changer")
|
||||
|
||||
|
||||
func verify_row(row: Dictionary, ids: Dictionary) -> void:
|
||||
expect(not ids.has(row.id), "%s is globally unique" % row.id)
|
||||
ids[row.id] = true
|
||||
@@ -121,6 +145,10 @@ func verify_negative_boundaries(canonical: Dictionary) -> void:
|
||||
executable_second_boss.bosses[1].executable = true
|
||||
expect_rejected(executable_second_boss, "second Boss execution leak", "boss_execution_boundary:BO-R2-02")
|
||||
|
||||
var missing_contract2_route := canonical.duplicate(true)
|
||||
missing_contract2_route.contract2_routes.pop_back()
|
||||
expect_rejected(missing_contract2_route, "missing Contract-2 route", "contract2_route_count")
|
||||
|
||||
var artifact_conflict := canonical.duplicate(true)
|
||||
artifact_conflict.artifacts[0].conflict_id = "AR-013"
|
||||
expect_rejected(artifact_conflict, "Artifact conflict", "artifact_conflict:AR-009")
|
||||
|
||||
@@ -93,3 +93,38 @@ func boss_muster_snapshot(artifact_id: String = "AR-011") -> Dictionary:
|
||||
if not settled.ok:
|
||||
return {}
|
||||
return service.state.snapshot()
|
||||
|
||||
|
||||
func contract2_handoff_snapshot(artifact_id: String = "AR-011") -> Dictionary:
|
||||
var service := FG3Contract.new(boss_muster_snapshot(artifact_id))
|
||||
if not service.valid:
|
||||
return {}
|
||||
var muster_hash := String(service.state.current_run.boss_muster.muster_hash)
|
||||
var prepared := service.commit_boss_plan(
|
||||
"fg3-fixture-boss-prepare",
|
||||
service.state.revision,
|
||||
muster_hash,
|
||||
"follow_index_clear_eggs"
|
||||
)
|
||||
if not prepared.ok:
|
||||
return {}
|
||||
var battle := FG3Battle.new(service.state.snapshot()).run_to_terminal(
|
||||
"follow_index_clear_eggs"
|
||||
)
|
||||
if not battle.ok or String(battle.snapshot.outcome) != "victory":
|
||||
return {}
|
||||
var settled := service.settle_boss(
|
||||
"fg3-fixture-boss-settle",
|
||||
service.state.revision,
|
||||
battle.snapshot
|
||||
)
|
||||
if not settled.ok:
|
||||
return {}
|
||||
var closed := service.close_contract(
|
||||
"fg3-fixture-contract1-close",
|
||||
service.state.revision,
|
||||
String(service.state.current_run.pending_boss_result.close_id)
|
||||
)
|
||||
if not closed.ok:
|
||||
return {}
|
||||
return service.state.snapshot()
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
extends SceneTree
|
||||
|
||||
const Content = preload("res://fg3/content_registry.gd")
|
||||
const Generator = preload("res://fg3/contract2_market_generator.gd")
|
||||
|
||||
const SEED_A := "0123456789abcdef0123456789abcdef"
|
||||
const SEED_B := "fedcba9876543210fedcba9876543210"
|
||||
|
||||
var failures: Array[String] = []
|
||||
var rejected_cases := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run_test")
|
||||
|
||||
|
||||
func run_test() -> void:
|
||||
var generator := Generator.new()
|
||||
expect(generator.validation().ok, "generator accepts the complete FG-3 graph")
|
||||
verify_twelve_markets(generator)
|
||||
verify_refresh_and_seed_boundaries(generator)
|
||||
verify_negative_boundaries(generator)
|
||||
if failures.is_empty():
|
||||
print("ABG_FG3_CONTRACT2_MARKETS_OK markets=1..12 boards=12 shape=3,4,3 routes=36 risks=0,1,2 capacity=10 offer_levels=1,1,1,2,2,2,3,3,3,4,4,4 rewards=22..65 pair_links=8 triple_links=4 boss=BO-R2-02_fixed_nonexecutable refresh=recruit,equipment_only negative_cases=%d" % rejected_cases)
|
||||
quit(0)
|
||||
else:
|
||||
for failure in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
|
||||
|
||||
func verify_twelve_markets(generator) -> void:
|
||||
for market_index in range(1, 13):
|
||||
var first: Dictionary = generator.generate(SEED_A, market_index)
|
||||
var second: Dictionary = Generator.new().generate(SEED_A, market_index)
|
||||
expect(first.ok and first == second, "Market %d is byte-identical" % market_index)
|
||||
if not first.ok:
|
||||
continue
|
||||
var board: Dictionary = first.board
|
||||
var expected_level := 1 + int(floor(float(market_index - 1) / 3.0))
|
||||
expect(board.contract_id == "CTR-R2-02" and board.region_id == "RG-02", "Market %d identity is exact" % market_index)
|
||||
expect(board.market_index == market_index and board.round_index == market_index, "Market %d Round binding is exact" % market_index)
|
||||
expect(board.cleared_rounds_before == 12 + market_index - 1 and board.line_capacity == 10, "Market %d preserves global clear lineage and capacity" % market_index)
|
||||
expect(board.offer_level == expected_level, "Market %d offer level follows authority" % market_index)
|
||||
expect(board.recruit_slots.size() == 3 and board.equipment_offers.size() == 4 and board.battle_offers.size() == 3, "Market %d has exact 3/4/3 shape" % market_index)
|
||||
expect(board.recruit_slots.filter(func(slot): return slot.base_profession_required).size() == 1, "Market %d guarantees one base slot" % market_index)
|
||||
expect(board.battle_offers.map(func(offer): return offer.risk) == [0, 1, 2], "Market %d risks are ordered" % market_index)
|
||||
expect(board.battle_offers.map(func(offer): return offer.reward_coin) == [20 + 2 * market_index, 27 + 2 * market_index, 34 + 2 * market_index], "Market %d rewards are exact" % market_index)
|
||||
expect(board.battle_offers.all(func(offer): return offer.pulse_disclosure == board.pulse_disclosure), "Market %d routes share one pulse disclosure" % market_index)
|
||||
var expected_count := 3 if market_index % 3 == 0 else 2
|
||||
expect(board.pulse_disclosure.linked_count == expected_count and board.pulse_disclosure.linked_indices.size() == expected_count, "Market %d linked count is exact" % market_index)
|
||||
expect(board.pulse_disclosure.third_pulse_expansion == (expected_count == 3), "Market %d third-pulse flag is exact" % market_index)
|
||||
expect(board.pulse_disclosure.warning_ticks == 24 and board.pulse_disclosure.resolve_tick == 48, "Market %d pulse timing is disclosed" % market_index)
|
||||
if market_index == 12:
|
||||
expect(board.boss_disclosure.boss_id == "BO-R2-02" and board.boss_disclosure.fixed and not board.boss_disclosure.executable, "Market 12 discloses fixed non-executable Drowned Bell")
|
||||
else:
|
||||
expect(board.boss_disclosure.is_empty(), "Market %d has no early Boss disclosure" % market_index)
|
||||
|
||||
|
||||
func verify_refresh_and_seed_boundaries(generator) -> void:
|
||||
for market_index in range(1, 13):
|
||||
var original: Dictionary = generator.generate(SEED_A, market_index, 0).board
|
||||
var refreshed: Dictionary = generator.generate(SEED_A, market_index, 1).board
|
||||
expect(original.recruit_slots != refreshed.recruit_slots, "Market %d refresh changes Recruit slots" % market_index)
|
||||
expect(original.equipment_offers != refreshed.equipment_offers, "Market %d refresh changes equipment" % market_index)
|
||||
expect(original.battle_offers == refreshed.battle_offers, "Market %d refresh never rerolls routes" % market_index)
|
||||
expect(original.pulse_disclosure == refreshed.pulse_disclosure, "Market %d refresh never rerolls pulse facts" % market_index)
|
||||
expect(original.boss_disclosure == refreshed.boss_disclosure, "Market %d refresh never rerolls Boss facts" % market_index)
|
||||
var other_seed: Dictionary = generator.generate(SEED_B, market_index).board
|
||||
expect(original != other_seed and original.board_hash != other_seed.board_hash, "Market %d seed changes owned identities" % market_index)
|
||||
|
||||
|
||||
func verify_negative_boundaries(generator) -> void:
|
||||
expect_rejected(generator.generate("bad-seed", 1), "malformed seed", "invalid_run_seed")
|
||||
expect_rejected(generator.generate(SEED_A, 0), "before bounded Markets", "outside_contract2_market")
|
||||
expect_rejected(generator.generate(SEED_A, 13), "after bounded Markets", "outside_contract2_market")
|
||||
expect_rejected(generator.generate(SEED_A, 1, -1), "negative refresh", "invalid_refresh_index")
|
||||
var malformed := Content.snapshot()
|
||||
malformed.contract2_routes[0].reward_coin += 1
|
||||
var rejected_generator := Generator.new(malformed)
|
||||
expect_rejected(rejected_generator.generate(SEED_A, 1), "malformed route graph", "invalid_catalog")
|
||||
|
||||
|
||||
func expect_rejected(result: Dictionary, label: String, expected_code: String) -> void:
|
||||
var accepted := not bool(result.get("ok", false)) and String(result.get("code", "")) == expected_code
|
||||
expect(accepted, "%s rejects with %s; got %s" % [label, expected_code, str(result)])
|
||||
if accepted:
|
||||
rejected_cases += 1
|
||||
|
||||
|
||||
func expect(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
failures.append("FG-3 Contract-2 Markets: %s" % message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://abgfg3contract2markettest
|
||||
@@ -0,0 +1,113 @@
|
||||
extends SceneTree
|
||||
|
||||
const Contract1Battle = preload("res://fg3/contract1_battle_service.gd")
|
||||
const Contract2Service = preload("res://fg3/contract2_service.gd")
|
||||
const Fixture = preload("res://tests/fg3/contract1_fixture.gd")
|
||||
const SaveV10 = preload("res://fg3/save_v10_service.gd")
|
||||
|
||||
var failures: Array[String] = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run_test")
|
||||
|
||||
|
||||
func run_test() -> void:
|
||||
verify_exact_handoff_activation()
|
||||
verify_atomic_boundaries()
|
||||
verify_save_roundtrip_and_corruption()
|
||||
if failures.is_empty():
|
||||
print("ABG_FG3_CONTRACT2_STATE_OK handoff=CTR-R2-01_to_CTR-R2-02 company,growth,equipment,economy,artifact,records=exact market=1 cleared=12 capacity=10 board=3,4,3 revision_bound=true duplicate=true save_v10=roundtrip battle_execution=frozen offline=zero")
|
||||
quit(0)
|
||||
else:
|
||||
for failure in failures:
|
||||
push_error(failure)
|
||||
quit(1)
|
||||
|
||||
|
||||
func verify_exact_handoff_activation() -> void:
|
||||
var handoff := Fixture.new().contract2_handoff_snapshot()
|
||||
expect(not handoff.is_empty(), "real Contract-1 clear creates handoff")
|
||||
if handoff.is_empty():
|
||||
return
|
||||
var service := Contract2Service.new(handoff)
|
||||
expect(service.valid, "handoff state validates")
|
||||
var before := service.state.snapshot()
|
||||
var handoff_hash := String(before.current_run.market_board.handoff_hash)
|
||||
var revision_before := int(service.state.revision)
|
||||
var activated := service.activate_contract(
|
||||
"fg3-contract2-activate",
|
||||
revision_before,
|
||||
handoff_hash
|
||||
)
|
||||
expect(activated.ok and service.state.revision == revision_before + 1, "Contract 2 activates exactly once")
|
||||
if not activated.ok:
|
||||
return
|
||||
var after := service.state.snapshot()
|
||||
expect(after.current_run.contract_id == "CTR-R2-02" and after.current_run.phase == "contract2_market", "state enters exact Contract-2 Market phase")
|
||||
expect(after.current_run.market_index == 1 and after.current_run.round_index == 1 and after.current_run.cleared_rounds == 12, "Market 1 preserves twelve global clears")
|
||||
expect(after.current_run.line_capacity == 10 and after.current_run.market_board.line_capacity == 10, "surviving capacity stays at core cap")
|
||||
expect(after.current_run.market_board.contract_id == "CTR-R2-02" and after.current_run.market_board.recruit_slots.size() == 3 and after.current_run.market_board.equipment_offers.size() == 4 and after.current_run.market_board.battle_offers.size() == 3, "first board is exact 3/4/3 Contract-2 authority")
|
||||
expect(after.current_run.company == before.current_run.company and after.current_run.recruit_records == before.current_run.recruit_records, "Company identity, HP, level and XP are byte-equivalent")
|
||||
expect(after.current_run.inventory == before.current_run.inventory and after.current_run.equipped_by_recruit == before.current_run.equipped_by_recruit, "one-slot equipment and inventory are byte-equivalent")
|
||||
expect(after.current_run.coin == before.current_run.coin and after.current_run.replacement_credit == before.current_run.replacement_credit, "Coin and Credit are exact")
|
||||
expect(after.current_run.active_artifact_ids == before.current_run.active_artifact_ids and after.current_run.artifact_rule_state == before.current_run.artifact_rule_state, "Artifact rule state is exact")
|
||||
expect(after.current_run.route_history == before.current_run.route_history and after.guild_profile == before.guild_profile, "route history and persistent first Record are exact")
|
||||
expect(after.current_run.boss_muster.is_empty() and after.current_run.pending_boss_result.is_empty(), "Contract-1 Boss transient state is cleared")
|
||||
expect(not Contract1Battle.new(after).valid, "Contract-1 battle executor cannot run Contract 2")
|
||||
var duplicate := service.activate_contract(
|
||||
"fg3-contract2-activate",
|
||||
revision_before,
|
||||
handoff_hash
|
||||
)
|
||||
expect(duplicate.ok and duplicate.duplicate and service.state.snapshot() == after, "activation retry cannot reset Company or board")
|
||||
|
||||
|
||||
func verify_atomic_boundaries() -> void:
|
||||
var handoff := Fixture.new().contract2_handoff_snapshot()
|
||||
var service := Contract2Service.new(handoff)
|
||||
var before := service.state.snapshot()
|
||||
var stale := service.activate_contract(
|
||||
"fg3-contract2-stale",
|
||||
service.state.revision + 1,
|
||||
String(service.state.current_run.market_board.handoff_hash)
|
||||
)
|
||||
expect(not stale.ok and stale.code == "stale_revision" and service.state.snapshot() == before, "stale activation rejects without mutation")
|
||||
var wrong_hash := service.activate_contract(
|
||||
"fg3-contract2-wrong-hash",
|
||||
service.state.revision,
|
||||
"0".repeat(64)
|
||||
)
|
||||
expect(not wrong_hash.ok and wrong_hash.code == "handoff_hash_mismatch" and service.state.snapshot() == before, "foreign handoff hash rejects without mutation")
|
||||
var missing_clear := handoff.duplicate(true)
|
||||
missing_clear.guild_profile.contract_clear_ids.erase("CLEAR-RG02-C01")
|
||||
expect(not Contract2Service.new(missing_clear).valid, "handoff without first clear is invalid")
|
||||
|
||||
|
||||
func verify_save_roundtrip_and_corruption() -> void:
|
||||
var handoff := Fixture.new().contract2_handoff_snapshot()
|
||||
var service := Contract2Service.new(handoff)
|
||||
var activated := service.activate_contract(
|
||||
"fg3-contract2-save-activate",
|
||||
service.state.revision,
|
||||
String(service.state.current_run.market_board.handoff_hash)
|
||||
)
|
||||
expect(activated.ok, "save fixture activates")
|
||||
if not activated.ok:
|
||||
return
|
||||
var saves := SaveV10.new()
|
||||
var encoded := saves.encode_snapshot(service.state.snapshot(), 100)
|
||||
var decoded := saves.decode_text(String(encoded.text), 100)
|
||||
expect(encoded.ok and decoded.ok and decoded.snapshot == service.state.snapshot(), "Contract-2 Market 1 roundtrips byte-equivalent")
|
||||
expect(decoded.offline_delta == SaveV10.OFFLINE_ZERO, "resume invents no offline value")
|
||||
var wrong_contract := service.state.snapshot()
|
||||
wrong_contract.current_run.contract_id = "CTR-R2-01"
|
||||
expect(not saves.encode_snapshot(wrong_contract, 101).ok, "Contract identity drift rejects")
|
||||
var wrong_board := service.state.snapshot()
|
||||
wrong_board.current_run.market_board.pulse_disclosure.linked_count = 9
|
||||
expect(not saves.encode_snapshot(wrong_board, 101).ok, "pulse authority drift rejects")
|
||||
|
||||
|
||||
func expect(condition: bool, message: String) -> void:
|
||||
if not condition:
|
||||
failures.append("FG-3 Contract-2 state: %s" % message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://abgfg3contract2statetest
|
||||
Reference in New Issue
Block a user