feat: add FG3 second contract authority

This commit is contained in:
2026-08-16 18:54:12 +08:00
parent 70caf3adc8
commit 06d8f9ca97
13 changed files with 943 additions and 8 deletions
+26
View File
@@ -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"}
]
}
+175 -1
View File
@@ -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", ""))
+252
View File
@@ -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
+169
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://abgfg3contract2service
+44 -3
View File
@@ -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
+32 -4
View File
@@ -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")
+35
View File
@@ -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
+113
View File
@@ -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