401 lines
17 KiB
GDScript
401 lines
17 KiB
GDScript
class_name P98ContractState
|
|
extends "res://p9_7/market_state.gd"
|
|
|
|
const P97State = preload("res://p9_7/market_state.gd")
|
|
const P97Content = preload("res://content/p9_7_content.gd")
|
|
const P98Content = preload("res://content/p9_8_content.gd")
|
|
|
|
enum ContractPhase { MARKET, ENCOUNTER, BOSS_MUSTER, BOSS_ENCOUNTER, CONTRACT_RESULT, RUN_CLOSED }
|
|
|
|
var p98_activated := false
|
|
var contract_phase := ContractPhase.MARKET
|
|
var artifact_draft: Dictionary = {}
|
|
var selected_artifact_id := ""
|
|
var artifact_receipts: Array[Dictionary] = []
|
|
var promotion_draft: Dictionary = {}
|
|
var promotion_receipts: Array[Dictionary] = []
|
|
var boss_muster: Dictionary = {}
|
|
var selected_boss_plan := ""
|
|
var boss_plan_receipts: Array[Dictionary] = []
|
|
var pending_boss_result: Dictionary = {}
|
|
var boss_result_receipts: Array[Dictionary] = []
|
|
var persistent_renown := 0
|
|
var contract_clear_ids: Array[String] = []
|
|
var discovery_ids: Array[String] = []
|
|
var company_records: Array[Dictionary] = []
|
|
var run_closed := false
|
|
var close_receipts: Array[Dictionary] = []
|
|
|
|
|
|
func initialize_from_p97(raw: Dictionary) -> bool:
|
|
var prior = P97State.new()
|
|
if not prior.restore_snapshot(raw):
|
|
return false
|
|
if not _assign_snapshot(prior.snapshot()):
|
|
return false
|
|
_reset_contract_fields()
|
|
p98_activated = current_market == 8 and cleared_rounds == 7 and continuation_locked and relay_restored
|
|
if p98_activated:
|
|
var content: Dictionary = P98Content.snapshot()
|
|
content_revision = String(content.revision)
|
|
content_hash = String(content.content_hash)
|
|
continuation_locked = false
|
|
market_board = market_board_for(8, "")
|
|
available_recruit_offer_ids.assign(market_board.recruit_offer_ids)
|
|
available_equipment_offer_ids.assign(market_board.equipment_offer_ids)
|
|
available_battle_offer_ids.assign(market_board.battle_offer_ids)
|
|
artifact_draft = Dictionary(content.artifact_drafts["P98-ARTIFACT-DRAFT-1"]).duplicate(true)
|
|
_open_initial_promotion_draft()
|
|
_refresh_focus()
|
|
revision += 1
|
|
return invariants_hold()
|
|
|
|
|
|
func snapshot() -> Dictionary:
|
|
var result: Dictionary = super.snapshot()
|
|
result.merge({
|
|
"p98_activated": p98_activated,
|
|
"contract_phase": contract_phase,
|
|
"artifact_draft": artifact_draft.duplicate(true),
|
|
"selected_artifact_id": selected_artifact_id,
|
|
"artifact_receipts": artifact_receipts.duplicate(true),
|
|
"promotion_draft": promotion_draft.duplicate(true),
|
|
"promotion_receipts": promotion_receipts.duplicate(true),
|
|
"boss_muster": boss_muster.duplicate(true),
|
|
"selected_boss_plan": selected_boss_plan,
|
|
"boss_plan_receipts": boss_plan_receipts.duplicate(true),
|
|
"pending_boss_result": pending_boss_result.duplicate(true),
|
|
"boss_result_receipts": boss_result_receipts.duplicate(true),
|
|
"persistent_renown": persistent_renown,
|
|
"contract_clear_ids": contract_clear_ids.duplicate(),
|
|
"discovery_ids": discovery_ids.duplicate(),
|
|
"company_records": company_records.duplicate(true),
|
|
"run_closed": run_closed,
|
|
"close_receipts": close_receipts.duplicate(true),
|
|
}, true)
|
|
return result.duplicate(true)
|
|
|
|
|
|
func restore_snapshot(raw: Dictionary) -> bool:
|
|
var normalized_value = _normalize_serialized(raw)
|
|
if not normalized_value is Dictionary:
|
|
return false
|
|
var normalized: Dictionary = normalized_value
|
|
var required := [
|
|
"p98_activated", "contract_phase", "artifact_draft", "selected_artifact_id",
|
|
"artifact_receipts", "promotion_draft", "promotion_receipts",
|
|
"boss_muster", "selected_boss_plan", "boss_plan_receipts", "pending_boss_result",
|
|
"boss_result_receipts", "persistent_renown", "contract_clear_ids",
|
|
"discovery_ids", "company_records", "run_closed", "close_receipts",
|
|
]
|
|
for key in required:
|
|
if not normalized.has(key):
|
|
return false
|
|
var before := snapshot()
|
|
if not _assign_snapshot(normalized) or not _assign_contract_fields(normalized) or not invariants_hold():
|
|
_assign_snapshot(before)
|
|
_assign_contract_fields(before)
|
|
return false
|
|
return true
|
|
|
|
|
|
func invariants_hold() -> bool:
|
|
if not p98_activated:
|
|
return super.invariants_hold() and contract_phase == int(phase) and _contract_fields_empty()
|
|
if revision < 0 or coin < 0 or phase < Phase.MARKET or phase > Phase.ENCOUNTER:
|
|
return false
|
|
if contract_phase < ContractPhase.MARKET or contract_phase > ContractPhase.RUN_CLOSED:
|
|
return false
|
|
if contract_phase in [ContractPhase.MARKET, ContractPhase.ENCOUNTER] and int(phase) != int(contract_phase):
|
|
return false
|
|
if contract_phase == ContractPhase.BOSS_ENCOUNTER and phase != Phase.ENCOUNTER:
|
|
return false
|
|
if contract_phase in [ContractPhase.BOSS_MUSTER, ContractPhase.CONTRACT_RESULT, ContractPhase.RUN_CLOSED] and phase != Phase.MARKET:
|
|
return false
|
|
if cleared_rounds < 7 or cleared_rounds > 12:
|
|
return false
|
|
if line_capacity != P98Content.capacity_for_clears(cleared_rounds):
|
|
return false
|
|
if current_market != cleared_rounds + 1 or current_market < 8 or current_market > 13:
|
|
return false
|
|
if company.is_empty() and not run_closed:
|
|
return false
|
|
if not run_closed and (deployed_recruit_ids.is_empty() or deployed_recruit_ids.size() > line_capacity):
|
|
return false
|
|
if not _unique(company) or not _unique(deployed_recruit_ids) or not _unique(reserve_recruit_ids):
|
|
return false
|
|
if not _validate_company_collections():
|
|
return false
|
|
var content: Dictionary = P98Content.snapshot()
|
|
if content_revision != String(content.revision) or content_hash != String(content.content_hash):
|
|
return false
|
|
if contract_phase in [ContractPhase.MARKET, ContractPhase.ENCOUNTER]:
|
|
if current_market > 12 or run_closed:
|
|
return false
|
|
var expected_board := market_board_for(current_market, selected_artifact_id)
|
|
if market_board != expected_board:
|
|
return false
|
|
if not _subset(available_recruit_offer_ids, market_board.recruit_offer_ids):
|
|
return false
|
|
if not _subset(available_equipment_offer_ids, market_board.equipment_offer_ids):
|
|
return false
|
|
if not _subset(available_battle_offer_ids, market_board.battle_offer_ids):
|
|
return false
|
|
if not focused_recruit_offer.is_empty() and focused_recruit_offer not in available_recruit_offer_ids:
|
|
return false
|
|
if not focused_equipment_offer.is_empty() and focused_equipment_offer not in available_equipment_offer_ids:
|
|
return false
|
|
if not focused_route_offer.is_empty() and focused_route_offer not in available_battle_offer_ids:
|
|
return false
|
|
if not selected_route.is_empty() and selected_route not in market_board.battle_offer_ids:
|
|
return false
|
|
if contract_phase == ContractPhase.ENCOUNTER and (selected_route.is_empty() or battle_checkpoint.is_empty() or recovery_required):
|
|
return false
|
|
if contract_phase == ContractPhase.MARKET and not selected_route.is_empty():
|
|
return false
|
|
if contract_phase in [ContractPhase.BOSS_MUSTER, ContractPhase.BOSS_ENCOUNTER] and cleared_rounds != 12:
|
|
return false
|
|
if contract_phase == ContractPhase.BOSS_MUSTER and (boss_muster.is_empty() or not selected_route.is_empty()):
|
|
return false
|
|
if contract_phase == ContractPhase.BOSS_ENCOUNTER and (boss_muster.is_empty() or selected_boss_plan.is_empty() or battle_checkpoint.is_empty()):
|
|
return false
|
|
if contract_phase == ContractPhase.CONTRACT_RESULT and pending_boss_result.is_empty():
|
|
return false
|
|
if contract_phase == ContractPhase.RUN_CLOSED and not run_closed:
|
|
return false
|
|
if not _validate_new_decisions(content):
|
|
return false
|
|
if not _validate_recovery(content):
|
|
return false
|
|
if not pending_dismissal.is_empty():
|
|
var dismissal_target := String(pending_dismissal.get("recruit_id", ""))
|
|
if contract_phase != ContractPhase.MARKET or dismissal_target not in company or company.size() <= 1:
|
|
return false
|
|
for receipts in [hire_receipts, purchase_receipts, equip_receipts, dismissal_receipts, deployment_receipts, route_receipts, battle_settlement_receipts, recovery_receipts, artifact_receipts, promotion_receipts, boss_plan_receipts, boss_result_receipts, close_receipts]:
|
|
if not _receipt_indices_hold(receipts):
|
|
return false
|
|
for operation_id in operation_receipts:
|
|
var operation = operation_receipts[operation_id]
|
|
if String(operation_id).is_empty() or not operation is Dictionary:
|
|
return false
|
|
if String(operation.get("intent_hash", "")).length() != 64 or not operation.get("receipt", {}) is Dictionary:
|
|
return false
|
|
if persistent_renown < 0 or not _unique(contract_clear_ids) or not _unique(discovery_ids):
|
|
return false
|
|
return true
|
|
|
|
|
|
func first_eligible_promotion_recruit() -> String:
|
|
for recruit_id in company:
|
|
var record: Dictionary = recruit_records.get(recruit_id, {})
|
|
if int(record.get("level", 0)) >= 4 and int(record.get("survival_battles", 0)) >= 3 and String(record.get("promotion_id", "")).is_empty():
|
|
var profession_id := String(record.get("profession_id", ""))
|
|
if profession_id in P98Content.BASE_PROFESSION_IDS:
|
|
return recruit_id
|
|
return ""
|
|
|
|
|
|
func promotion_options(recruit_id: String) -> Array[String]:
|
|
if recruit_id not in company:
|
|
return []
|
|
var parent_id := String(recruit_records[recruit_id].get("profession_id", ""))
|
|
var options: Array[String] = []
|
|
for promotion_id in P98Content.PROMOTIONS:
|
|
if String(P98Content.PROMOTIONS[promotion_id].parent_id) == parent_id:
|
|
options.append(String(promotion_id))
|
|
options.sort()
|
|
return options
|
|
|
|
|
|
static func market_board_for(market_index: int, artifact_id: String) -> Dictionary:
|
|
var content: Dictionary = P98Content.snapshot()
|
|
if not content.boards.has(market_index):
|
|
return {}
|
|
var source: Dictionary = content.boards[market_index]
|
|
var board := {
|
|
"id": String(source.id),
|
|
"market": market_index,
|
|
"recruit_offer_ids": Array(source.recruit_offer_ids).duplicate(),
|
|
"equipment_offer_ids": Array(source.equipment_offer_ids).duplicate(),
|
|
"battle_offer_ids": Array(source.battle_offer_ids).duplicate(),
|
|
"artifact_id": artifact_id,
|
|
"reserve_capacity_bonus": 0,
|
|
"reserve_upkeep_per_member": 0,
|
|
}
|
|
if market_index >= 9 and artifact_id == "AR-001":
|
|
board.recruit_offer_ids.append(String(source.extra_recruit_offer_id))
|
|
board.equipment_offer_ids.pop_back()
|
|
elif market_index >= 9 and artifact_id == "AR-002":
|
|
board.equipment_offer_ids.append(String(source.extra_equipment_offer_id))
|
|
board.recruit_offer_ids.pop_back()
|
|
elif market_index >= 9 and artifact_id == "AR-007":
|
|
board.reserve_capacity_bonus = 2
|
|
board.reserve_upkeep_per_member = 1
|
|
board["board_hash"] = JSON.stringify(board).sha256_text()
|
|
return board
|
|
|
|
|
|
func _assign_contract_fields(raw: Dictionary) -> bool:
|
|
for key in ["artifact_receipts", "promotion_receipts", "boss_plan_receipts", "boss_result_receipts", "contract_clear_ids", "discovery_ids", "company_records", "close_receipts"]:
|
|
if not raw[key] is Array:
|
|
return false
|
|
for key in ["artifact_draft", "promotion_draft", "boss_muster", "pending_boss_result"]:
|
|
if not raw[key] is Dictionary:
|
|
return false
|
|
p98_activated = bool(raw.p98_activated)
|
|
contract_phase = int(raw.contract_phase)
|
|
artifact_draft = Dictionary(raw.artifact_draft).duplicate(true)
|
|
selected_artifact_id = String(raw.selected_artifact_id)
|
|
artifact_receipts.assign(raw.artifact_receipts)
|
|
promotion_draft = Dictionary(raw.promotion_draft).duplicate(true)
|
|
promotion_receipts.assign(raw.promotion_receipts)
|
|
boss_muster = Dictionary(raw.boss_muster).duplicate(true)
|
|
selected_boss_plan = String(raw.selected_boss_plan)
|
|
boss_plan_receipts.assign(raw.boss_plan_receipts)
|
|
pending_boss_result = Dictionary(raw.pending_boss_result).duplicate(true)
|
|
boss_result_receipts.assign(raw.boss_result_receipts)
|
|
persistent_renown = int(raw.persistent_renown)
|
|
contract_clear_ids.assign(raw.contract_clear_ids)
|
|
discovery_ids.assign(raw.discovery_ids)
|
|
company_records.assign(raw.company_records)
|
|
run_closed = bool(raw.run_closed)
|
|
close_receipts.assign(raw.close_receipts)
|
|
return true
|
|
|
|
|
|
func _reset_contract_fields() -> void:
|
|
p98_activated = false
|
|
contract_phase = ContractPhase.MARKET
|
|
artifact_draft.clear()
|
|
selected_artifact_id = ""
|
|
artifact_receipts.clear()
|
|
promotion_draft.clear()
|
|
promotion_receipts.clear()
|
|
boss_muster.clear()
|
|
selected_boss_plan = ""
|
|
boss_plan_receipts.clear()
|
|
pending_boss_result.clear()
|
|
boss_result_receipts.clear()
|
|
persistent_renown = 0
|
|
contract_clear_ids.clear()
|
|
discovery_ids.clear()
|
|
company_records.clear()
|
|
run_closed = false
|
|
close_receipts.clear()
|
|
|
|
|
|
func _contract_fields_empty() -> bool:
|
|
return artifact_draft.is_empty() and selected_artifact_id.is_empty() and artifact_receipts.is_empty() and promotion_draft.is_empty() and promotion_receipts.is_empty() and boss_muster.is_empty() and selected_boss_plan.is_empty() and boss_plan_receipts.is_empty() and pending_boss_result.is_empty() and boss_result_receipts.is_empty() and persistent_renown == 0 and contract_clear_ids.is_empty() and discovery_ids.is_empty() and company_records.is_empty() and not run_closed and close_receipts.is_empty()
|
|
|
|
|
|
func _validate_company_collections() -> bool:
|
|
var expected_reserve: Array[String] = []
|
|
for recruit_id in company:
|
|
if not recruit_records.has(recruit_id) or not member_condition.has(recruit_id) or not equipped_by_recruit.has(recruit_id):
|
|
return false
|
|
if recruit_id not in deployed_recruit_ids:
|
|
expected_reserve.append(recruit_id)
|
|
if int(member_condition[recruit_id]) < 0:
|
|
return false
|
|
for recruit_id in deployed_recruit_ids:
|
|
if recruit_id not in company:
|
|
return false
|
|
if reserve_recruit_ids != expected_reserve:
|
|
return false
|
|
for recruit_id in recruit_records:
|
|
if recruit_id not in company:
|
|
return false
|
|
var profession_id := String(recruit_records[recruit_id].get("profession_id", ""))
|
|
if profession_id not in P98Content.BASE_PROFESSION_IDS and not P98Content.PROMOTIONS.has(profession_id):
|
|
return false
|
|
for recruit_id in member_condition:
|
|
if recruit_id not in company:
|
|
return false
|
|
var worn := {}
|
|
for recruit_id in equipped_by_recruit:
|
|
if recruit_id not in company:
|
|
return false
|
|
var instance_id := String(equipped_by_recruit[recruit_id])
|
|
if not instance_id.is_empty() and (not inventory.has(instance_id) or String(inventory[instance_id].get("wearer_id", "")) != recruit_id):
|
|
return false
|
|
for instance_id in inventory:
|
|
var item = inventory[instance_id]
|
|
if not item is Dictionary or String(item.get("instance_id", "")) != String(instance_id) or not _known_equipment(String(item.get("catalog_id", ""))):
|
|
return false
|
|
var wearer_id := String(item.get("wearer_id", ""))
|
|
if not wearer_id.is_empty():
|
|
if wearer_id not in company or String(equipped_by_recruit.get(wearer_id, "")) != instance_id or worn.has(instance_id):
|
|
return false
|
|
worn[instance_id] = true
|
|
return true
|
|
|
|
|
|
func _validate_new_decisions(content: Dictionary) -> bool:
|
|
if not artifact_draft.is_empty():
|
|
if String(artifact_draft.get("id", "")) != "P98-ARTIFACT-DRAFT-1" or artifact_draft != content.artifact_drafts["P98-ARTIFACT-DRAFT-1"]:
|
|
return false
|
|
if not selected_artifact_id.is_empty():
|
|
if artifact_receipts.size() != 1 or selected_artifact_id not in content.artifacts:
|
|
return false
|
|
if selected_artifact_id not in artifact_draft.get("offer_ids", []):
|
|
return false
|
|
if not promotion_draft.is_empty():
|
|
var recruit_id := String(promotion_draft.get("recruit_id", ""))
|
|
var offer_ids = promotion_draft.get("offer_ids", [])
|
|
if recruit_id not in company or not offer_ids is Array or offer_ids != promotion_options(recruit_id):
|
|
return false
|
|
for receipt in promotion_receipts:
|
|
var recruit_id := String(receipt.get("recruit_id", ""))
|
|
var promotion_id := String(receipt.get("promotion_id", ""))
|
|
if recruit_id not in company or not content.promotions.has(promotion_id) or String(recruit_records[recruit_id].get("profession_id", "")) != promotion_id:
|
|
return false
|
|
if not boss_muster.is_empty():
|
|
if String(boss_muster.get("boss_id", "")) != "BO-R1-01" or String(boss_muster.get("boss_hash", "")).length() != 64:
|
|
return false
|
|
if not selected_boss_plan.is_empty() and selected_boss_plan not in ["rotate", "brace"]:
|
|
return false
|
|
return true
|
|
|
|
|
|
func _validate_recovery(content: Dictionary) -> bool:
|
|
if recovery_required:
|
|
if contract_phase not in [ContractPhase.MARKET, ContractPhase.BOSS_MUSTER] or recovery_kind not in ["defeat", "victory", "boss_defeat"] or recovery_cost <= 0 or injured_recruit_ids.is_empty():
|
|
return false
|
|
for recruit_id in injured_recruit_ids:
|
|
if recruit_id not in company or int(member_condition.get(recruit_id, 1)) > 0:
|
|
return false
|
|
else:
|
|
if not recovery_kind.is_empty() or recovery_cost != 0 or not injured_recruit_ids.is_empty():
|
|
return false
|
|
if not retry_route.is_empty() and not content.routes.has(retry_route):
|
|
return false
|
|
return true
|
|
|
|
|
|
func _refresh_focus() -> void:
|
|
focused_recruit_offer = ""
|
|
focused_equipment_offer = ""
|
|
focused_route_offer = ""
|
|
pending_dismissal.clear()
|
|
selected_route = ""
|
|
battle_checkpoint.clear()
|
|
|
|
|
|
func _open_initial_promotion_draft() -> void:
|
|
var recruit_id := first_eligible_promotion_recruit()
|
|
if recruit_id.is_empty():
|
|
return
|
|
var record: Dictionary = recruit_records[recruit_id]
|
|
promotion_draft = {
|
|
"id": "P98-PROMOTION-%s" % recruit_id,
|
|
"recruit_id": recruit_id,
|
|
"parent_profession_id": String(record.profession_id),
|
|
"offer_ids": promotion_options(recruit_id),
|
|
"level": int(record.level),
|
|
"survival_battles": int(record.survival_battles),
|
|
}
|
|
|
|
|
|
func _known_equipment(catalog_id: String) -> bool:
|
|
return catalog_id in LEGACY_EQUIPMENT or P98Content.snapshot().equipment.has(catalog_id)
|