From 06d8f9ca978a710db81fc129b6093b099ebdf0b5 Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Sun, 16 Aug 2026 18:54:12 +0800 Subject: [PATCH] feat: add FG3 second contract authority --- runtime/fg3/catalog.json | 26 ++ runtime/fg3/content_registry.gd | 176 +++++++++++- runtime/fg3/contract2_market_generator.gd | 252 ++++++++++++++++++ runtime/fg3/contract2_market_generator.gd.uid | 1 + runtime/fg3/contract2_service.gd | 169 ++++++++++++ runtime/fg3/contract2_service.gd.uid | 1 + runtime/fg3/run_state.gd | 47 +++- runtime/tests/fg3/content_registry_test.gd | 36 ++- runtime/tests/fg3/contract1_fixture.gd | 35 +++ .../fg3/contract2_market_generator_test.gd | 93 +++++++ .../contract2_market_generator_test.gd.uid | 1 + runtime/tests/fg3/contract2_state_test.gd | 113 ++++++++ runtime/tests/fg3/contract2_state_test.gd.uid | 1 + 13 files changed, 943 insertions(+), 8 deletions(-) create mode 100644 runtime/fg3/contract2_market_generator.gd create mode 100644 runtime/fg3/contract2_market_generator.gd.uid create mode 100644 runtime/fg3/contract2_service.gd create mode 100644 runtime/fg3/contract2_service.gd.uid create mode 100644 runtime/tests/fg3/contract2_market_generator_test.gd create mode 100644 runtime/tests/fg3/contract2_market_generator_test.gd.uid create mode 100644 runtime/tests/fg3/contract2_state_test.gd create mode 100644 runtime/tests/fg3/contract2_state_test.gd.uid diff --git a/runtime/fg3/catalog.json b/runtime/fg3/catalog.json index 0d4f9f4c..e425770d 100644 --- a/runtime/fg3/catalog.json +++ b/runtime/fg3/catalog.json @@ -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"} ] } diff --git a/runtime/fg3/content_registry.gd b/runtime/fg3/content_registry.gd index 7ee6dc63..630996e9 100644 --- a/runtime/fg3/content_registry.gd +++ b/runtime/fg3/content_registry.gd @@ -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", "")) diff --git a/runtime/fg3/contract2_market_generator.gd b/runtime/fg3/contract2_market_generator.gd new file mode 100644 index 00000000..1e749db9 --- /dev/null +++ b/runtime/fg3/contract2_market_generator.gd @@ -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 diff --git a/runtime/fg3/contract2_market_generator.gd.uid b/runtime/fg3/contract2_market_generator.gd.uid new file mode 100644 index 00000000..5c73757e --- /dev/null +++ b/runtime/fg3/contract2_market_generator.gd.uid @@ -0,0 +1 @@ +uid://abgfg3contract2market diff --git a/runtime/fg3/contract2_service.gd b/runtime/fg3/contract2_service.gd new file mode 100644 index 00000000..9b68fe25 --- /dev/null +++ b/runtime/fg3/contract2_service.gd @@ -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 diff --git a/runtime/fg3/contract2_service.gd.uid b/runtime/fg3/contract2_service.gd.uid new file mode 100644 index 00000000..80eb9f67 --- /dev/null +++ b/runtime/fg3/contract2_service.gd.uid @@ -0,0 +1 @@ +uid://abgfg3contract2service diff --git a/runtime/fg3/run_state.gd b/runtime/fg3/run_state.gd index dbaf57cd..53b948ef 100644 --- a/runtime/fg3/run_state.gd +++ b/runtime/fg3/run_state.gd @@ -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 diff --git a/runtime/tests/fg3/content_registry_test.gd b/runtime/tests/fg3/content_registry_test.gd index 352f1ab7..c80f84db 100644 --- a/runtime/tests/fg3/content_registry_test.gd +++ b/runtime/tests/fg3/content_registry_test.gd @@ -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") diff --git a/runtime/tests/fg3/contract1_fixture.gd b/runtime/tests/fg3/contract1_fixture.gd index f9b92e1b..8ba35bfa 100644 --- a/runtime/tests/fg3/contract1_fixture.gd +++ b/runtime/tests/fg3/contract1_fixture.gd @@ -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() diff --git a/runtime/tests/fg3/contract2_market_generator_test.gd b/runtime/tests/fg3/contract2_market_generator_test.gd new file mode 100644 index 00000000..57c7e0a6 --- /dev/null +++ b/runtime/tests/fg3/contract2_market_generator_test.gd @@ -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) diff --git a/runtime/tests/fg3/contract2_market_generator_test.gd.uid b/runtime/tests/fg3/contract2_market_generator_test.gd.uid new file mode 100644 index 00000000..2403c954 --- /dev/null +++ b/runtime/tests/fg3/contract2_market_generator_test.gd.uid @@ -0,0 +1 @@ +uid://abgfg3contract2markettest diff --git a/runtime/tests/fg3/contract2_state_test.gd b/runtime/tests/fg3/contract2_state_test.gd new file mode 100644 index 00000000..660de421 --- /dev/null +++ b/runtime/tests/fg3/contract2_state_test.gd @@ -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) diff --git a/runtime/tests/fg3/contract2_state_test.gd.uid b/runtime/tests/fg3/contract2_state_test.gd.uid new file mode 100644 index 00000000..c498af08 --- /dev/null +++ b/runtime/tests/fg3/contract2_state_test.gd.uid @@ -0,0 +1 @@ +uid://abgfg3contract2statetest