539 lines
21 KiB
GDScript
539 lines
21 KiB
GDScript
class_name P910ContractService
|
|
extends RefCounted
|
|
|
|
const Content = preload("res://content/p9_10_content.gd")
|
|
const State = preload("res://p9_10/contract_state.gd")
|
|
|
|
const DEFEAT_RECOVERY_COST := 6
|
|
const ROLE_ORDER := {
|
|
"contact_guard": 0, "front_control": 0, "edge_contact": 1, "flank": 1,
|
|
"reach_control": 2, "mixed_enable": 3, "construct_support": 4,
|
|
"healer": 5, "ranged": 6, "area_caster": 7,
|
|
}
|
|
|
|
var state := State.new()
|
|
var content: Dictionary = {}
|
|
|
|
|
|
func _init(p99_snapshot: Dictionary = {}) -> void:
|
|
if not p99_snapshot.is_empty():
|
|
assert(state.initialize_from_p99(p99_snapshot))
|
|
content = Content.snapshot(state.selected_charter_claim_id)
|
|
|
|
|
|
func restore_snapshot(snapshot: Dictionary) -> bool:
|
|
if not state.restore_snapshot(snapshot):
|
|
return false
|
|
content = Content.snapshot(state.selected_charter_claim_id)
|
|
return true
|
|
|
|
|
|
func recruit_offers() -> Array[Dictionary]:
|
|
var rows: Array[Dictionary] = []
|
|
for offer_id in state.available_recruit_offer_ids:
|
|
rows.append(Dictionary(content.recruit_offers[offer_id]).duplicate(true))
|
|
return rows
|
|
|
|
|
|
func equipment_offers() -> Array[Dictionary]:
|
|
var rows: Array[Dictionary] = []
|
|
for offer_id in state.available_equipment_offer_ids:
|
|
rows.append(Dictionary(content.equipment_offers[offer_id]).duplicate(true))
|
|
return rows
|
|
|
|
|
|
func hire(operation_id: String, offer_id: String, expected_revision := -1) -> Dictionary:
|
|
var intent := {"offer_id": offer_id}
|
|
var prior := _prior_operation(operation_id, "hire", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
if expected_revision >= 0 and expected_revision != state.revision:
|
|
return _reject("stale_revision")
|
|
if state.phase != State.Phase.MARKET or state.recovery_required:
|
|
return _reject("active_market_required")
|
|
if offer_id not in state.available_recruit_offer_ids or not content.recruit_offers.has(offer_id):
|
|
return _reject("recruit_offer_unavailable")
|
|
if state.company.size() >= state.line_capacity:
|
|
return _reject("line_capacity_reached")
|
|
var row: Dictionary = content.recruit_offers[offer_id]
|
|
if int(row.market) != state.current_market:
|
|
return _reject("offer_market_mismatch")
|
|
var price := int(row.list_price)
|
|
if state.coin < price:
|
|
return _reject("insufficient_coin")
|
|
var coin_before := state.coin
|
|
state.coin -= price
|
|
_add_recruit(row, price)
|
|
state.available_recruit_offer_ids.erase(offer_id)
|
|
state.deployed_recruit_ids.assign(auto_order_candidate())
|
|
var formation := {
|
|
"index": state.formation_receipts.size(),
|
|
"trigger_operation_id": operation_id,
|
|
"ordered_ids": state.deployed_recruit_ids.duplicate(),
|
|
"automatic": true,
|
|
}
|
|
state.formation_receipts.append(formation)
|
|
var receipt := {
|
|
"index": state.hire_receipts.size(),
|
|
"operation_id": operation_id,
|
|
"offer_id": offer_id,
|
|
"price": price,
|
|
"coin_before": coin_before,
|
|
"coin_after": state.coin,
|
|
"company_count": state.company.size(),
|
|
"line_capacity": state.line_capacity,
|
|
"automatic_order": state.deployed_recruit_ids.duplicate(),
|
|
}
|
|
state.hire_receipts.append(receipt)
|
|
return _commit(operation_id, "hire", intent, "hire_committed", receipt)
|
|
|
|
|
|
func equipment_preview(offer_id: String, recruit_id: String) -> Dictionary:
|
|
if offer_id not in state.available_equipment_offer_ids or not content.equipment_offers.has(offer_id):
|
|
return _reject("equipment_offer_unavailable")
|
|
if recruit_id not in state.company:
|
|
return _reject("recruit_unknown")
|
|
var row: Dictionary = content.equipment_offers[offer_id]
|
|
var current_instance := String(state.equipped_by_recruit.get(recruit_id, ""))
|
|
var current_catalog := String(state.inventory.get(current_instance, {}).get("catalog_id", "")) if not current_instance.is_empty() else ""
|
|
return {
|
|
"ok": true,
|
|
"code": "equipment_preview",
|
|
"offer_id": offer_id,
|
|
"recruit_id": recruit_id,
|
|
"catalog_id": String(row.catalog_id),
|
|
"current_catalog_id": current_catalog,
|
|
"price": int(row.list_price),
|
|
"behavior": String(row.behavior),
|
|
"benefit_zh": String(row.benefit_zh),
|
|
"benefit_en": String(row.benefit_en),
|
|
"cost_zh": String(row.cost_zh),
|
|
"cost_en": String(row.cost_en),
|
|
}
|
|
|
|
|
|
func buy_and_equip(operation_id: String, offer_id: String, recruit_id: String, expected_revision := -1) -> Dictionary:
|
|
var intent := {"offer_id": offer_id, "recruit_id": recruit_id}
|
|
var prior := _prior_operation(operation_id, "buy_and_equip", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
if expected_revision >= 0 and expected_revision != state.revision:
|
|
return _reject("stale_revision")
|
|
if state.phase != State.Phase.MARKET or state.recovery_required:
|
|
return _reject("active_market_required")
|
|
var preview := equipment_preview(offer_id, recruit_id)
|
|
if not preview.ok:
|
|
return preview
|
|
var row: Dictionary = content.equipment_offers[offer_id]
|
|
if int(row.market) != state.current_market:
|
|
return _reject("offer_market_mismatch")
|
|
var price := int(row.list_price)
|
|
if state.coin < price:
|
|
return _reject("insufficient_coin")
|
|
var coin_before := state.coin
|
|
var replaced_instance := String(state.equipped_by_recruit.get(recruit_id, ""))
|
|
var instance_id := "P910-EQINST-%02d" % (state.equipment_receipts.size() + 1)
|
|
state.coin -= price
|
|
state.inventory[instance_id] = {
|
|
"instance_id": instance_id,
|
|
"catalog_id": String(row.catalog_id),
|
|
"purchase_price": price,
|
|
"source_market": state.current_market,
|
|
}
|
|
state.equipped_by_recruit[recruit_id] = instance_id
|
|
state.available_equipment_offer_ids.erase(offer_id)
|
|
var receipt := {
|
|
"index": state.equipment_receipts.size(),
|
|
"operation_id": operation_id,
|
|
"offer_id": offer_id,
|
|
"instance_id": instance_id,
|
|
"catalog_id": String(row.catalog_id),
|
|
"recruit_id": recruit_id,
|
|
"replaced_instance_id": replaced_instance,
|
|
"price": price,
|
|
"coin_before": coin_before,
|
|
"coin_after": state.coin,
|
|
}
|
|
state.equipment_receipts.append(receipt)
|
|
return _commit(operation_id, "buy_and_equip", intent, "equipment_committed", receipt)
|
|
|
|
|
|
func auto_order_candidate() -> Array[String]:
|
|
var ordered: Array[String] = []
|
|
ordered.assign(state.company)
|
|
ordered.sort_custom(func(left: String, right: String):
|
|
var left_role := _profession_role(String(state.recruit_records[left].profession_id))
|
|
var right_role := _profession_role(String(state.recruit_records[right].profession_id))
|
|
var left_key := int(ROLE_ORDER.get(left_role, 99))
|
|
var right_key := int(ROLE_ORDER.get(right_role, 99))
|
|
return left_key < right_key if left_key != right_key else state.company.find(left) < state.company.find(right)
|
|
)
|
|
return ordered
|
|
|
|
|
|
func propose_final_order(operation_id: String, ordered_ids: Array[String], expected_revision := -1) -> Dictionary:
|
|
var intent := {"ordered_ids": ordered_ids.duplicate()}
|
|
var prior := _prior_operation(operation_id, "propose_final_order", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
if expected_revision >= 0 and expected_revision != state.revision:
|
|
return _reject("stale_revision")
|
|
if state.phase != State.Phase.MARKET or state.current_market != 12 or state.recovery_required:
|
|
return _reject("market_twelve_required")
|
|
if not _same_members(ordered_ids, state.company):
|
|
return _reject("order_members_mismatch")
|
|
state.proposed_final_order.assign(ordered_ids)
|
|
state.final_order_confirmed = false
|
|
var receipt := {
|
|
"index": state.reorder_receipts.size(),
|
|
"operation_id": operation_id,
|
|
"kind": "proposal",
|
|
"ordered_ids": ordered_ids.duplicate(),
|
|
}
|
|
state.reorder_receipts.append(receipt)
|
|
return _commit(operation_id, "propose_final_order", intent, "final_order_proposed", receipt)
|
|
|
|
|
|
func confirm_final_order(operation_id: String, expected_revision := -1) -> Dictionary:
|
|
var intent := {"ordered_ids": state.proposed_final_order.duplicate()}
|
|
var prior := _prior_operation(operation_id, "confirm_final_order", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
if expected_revision >= 0 and expected_revision != state.revision:
|
|
return _reject("stale_revision")
|
|
if state.phase != State.Phase.MARKET or state.current_market != 12 or state.proposed_final_order.is_empty():
|
|
return _reject("final_order_proposal_required")
|
|
state.deployed_recruit_ids.assign(state.proposed_final_order)
|
|
state.final_order_confirmed = true
|
|
var receipt := {
|
|
"index": state.reorder_receipts.size(),
|
|
"operation_id": operation_id,
|
|
"kind": "confirmation",
|
|
"ordered_ids": state.deployed_recruit_ids.duplicate(),
|
|
}
|
|
state.reorder_receipts.append(receipt)
|
|
return _commit(operation_id, "confirm_final_order", intent, "final_order_confirmed", receipt)
|
|
|
|
|
|
func route_preview(route_id: String) -> Dictionary:
|
|
if not content.routes.has(route_id) or route_id not in state.market_board.get("battle_offer_ids", []):
|
|
return _reject("route_unknown")
|
|
var route: Dictionary = content.routes[route_id]
|
|
var result := {
|
|
"ok": true,
|
|
"code": "route_preview",
|
|
"route_id": route_id,
|
|
"round": int(route.round),
|
|
"risk": int(route.risk),
|
|
"reward": int(route.reward),
|
|
"enemy_ids": Array(route.enemy_ids).duplicate(),
|
|
"pressure_zh": String(route.pressure_zh),
|
|
"pressure_en": String(route.pressure_en),
|
|
"mechanic_id": String(route.mechanic_id),
|
|
"battle_seed": int(route.seed),
|
|
"threat_intel_revealed": false,
|
|
"intel": {},
|
|
}
|
|
if state.selected_charter_claim_id == "CH-RG01-THREAT-01":
|
|
result.threat_intel_revealed = true
|
|
result.intel = {
|
|
"arrival_tick": int(route.arrival_tick),
|
|
"selected_target": String(route.selected_target),
|
|
"gust_cadence": int(route.gust_cadence),
|
|
"gust_direction": String(route.gust_direction),
|
|
}
|
|
return result
|
|
|
|
|
|
func commit_route(operation_id: String, route_id: String, expected_revision := -1) -> Dictionary:
|
|
var intent := {"route_id": route_id}
|
|
var prior := _prior_operation(operation_id, "commit_route", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
if expected_revision >= 0 and expected_revision != state.revision:
|
|
return _reject("stale_revision")
|
|
if state.phase != State.Phase.MARKET or state.current_market < 4 or state.current_market > 12:
|
|
return _reject("active_market_required")
|
|
if state.recovery_required:
|
|
return _reject("recovery_required")
|
|
if route_id not in state.available_battle_offer_ids or not content.routes.has(route_id):
|
|
return _reject("route_unavailable")
|
|
var route: Dictionary = content.routes[route_id]
|
|
if int(route.round) != state.current_market:
|
|
return _reject("route_round_mismatch")
|
|
state.battle_checkpoint = state.snapshot()
|
|
state.phase = State.Phase.ENCOUNTER
|
|
state.selected_route = route_id
|
|
state.route_history.append(route_id)
|
|
var receipt := {
|
|
"index": state.route_receipts.size(),
|
|
"operation_id": operation_id,
|
|
"route_id": route_id,
|
|
"round": int(route.round),
|
|
"risk": int(route.risk),
|
|
"reward": int(route.reward),
|
|
"enemy_ids": Array(route.enemy_ids).duplicate(),
|
|
"battle_seed": int(route.seed),
|
|
}
|
|
state.route_receipts.append(receipt)
|
|
return _commit(operation_id, "commit_route", intent, "route_committed", receipt)
|
|
|
|
|
|
func settle_battle(operation_id: String, battle_snapshot: Dictionary) -> Dictionary:
|
|
var intent := {
|
|
"route_id": String(battle_snapshot.get("route_id", "")),
|
|
"revision": int(battle_snapshot.get("revision", -1)),
|
|
"tick": int(battle_snapshot.get("tick", -1)),
|
|
"outcome": String(battle_snapshot.get("outcome", "")),
|
|
"member_hp_hash": JSON.stringify(battle_snapshot.get("member_hp", {})).sha256_text(),
|
|
"enemy_hp_hash": JSON.stringify(battle_snapshot.get("enemy_hp", {})).sha256_text(),
|
|
}
|
|
var prior := _prior_operation(operation_id, "settle_battle", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
var validation := _validate_terminal_battle(battle_snapshot)
|
|
if not validation.ok:
|
|
return _reject(String(validation.code))
|
|
var route_id := state.selected_route
|
|
var route: Dictionary = content.routes[route_id]
|
|
var outcome := String(battle_snapshot.outcome)
|
|
var coin_before := state.coin
|
|
var injured: Array[String] = []
|
|
for recruit_id in state.deployed_recruit_ids:
|
|
var hp := int(battle_snapshot.member_hp[recruit_id])
|
|
state.member_condition[recruit_id] = hp
|
|
if hp <= 0:
|
|
injured.append(recruit_id)
|
|
if outcome == "victory":
|
|
state.coin += int(route.reward)
|
|
state.cleared_rounds += 1
|
|
state.line_capacity = State.capacity_for_clears(state.cleared_rounds)
|
|
_clear_recovery()
|
|
if state.cleared_rounds == 12:
|
|
_open_boss_muster()
|
|
else:
|
|
_open_market(state.cleared_rounds + 1)
|
|
else:
|
|
state.phase = State.Phase.MARKET
|
|
state.recovery_required = true
|
|
state.recovery_kind = "defeat"
|
|
state.recovery_cost = DEFEAT_RECOVERY_COST
|
|
state.injured_recruit_ids.assign(injured)
|
|
state.retry_route = route_id
|
|
state.selected_route = ""
|
|
state.battle_checkpoint.clear()
|
|
var receipt := {
|
|
"index": state.battle_settlement_receipts.size(),
|
|
"operation_id": operation_id,
|
|
"route_id": route_id,
|
|
"round": int(route.round),
|
|
"risk": int(route.risk),
|
|
"outcome": outcome,
|
|
"tick": int(battle_snapshot.tick),
|
|
"coin_reward": int(route.reward) if outcome == "victory" else 0,
|
|
"coin_before": coin_before,
|
|
"coin_after": state.coin,
|
|
"member_hp": Dictionary(battle_snapshot.member_hp).duplicate(true),
|
|
"cleared_rounds": state.cleared_rounds,
|
|
"line_capacity": state.line_capacity,
|
|
"next_market": state.current_market,
|
|
"boss_muster_open": state.phase == State.Phase.BOSS_MUSTER,
|
|
}
|
|
state.battle_settlement_receipts.append(receipt)
|
|
return _commit(operation_id, "settle_battle", intent, "battle_settled", receipt)
|
|
|
|
|
|
func recover_defeat(operation_id: String) -> Dictionary:
|
|
var intent := {"action": "recover_defeat"}
|
|
var prior := _prior_operation(operation_id, "recover_defeat", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
if state.phase != State.Phase.MARKET or not state.recovery_required or state.recovery_kind != "defeat":
|
|
return _reject("defeat_recovery_unavailable")
|
|
if state.coin < state.recovery_cost:
|
|
return _reject("insufficient_coin")
|
|
var coin_before := state.coin
|
|
var recovered_ids := state.injured_recruit_ids.duplicate()
|
|
var retry := state.retry_route
|
|
state.coin -= state.recovery_cost
|
|
for recruit_id in recovered_ids:
|
|
state.member_condition[recruit_id] = _member_max_hp(recruit_id)
|
|
var receipt := {
|
|
"index": state.recovery_receipts.size(),
|
|
"operation_id": operation_id,
|
|
"cost": DEFEAT_RECOVERY_COST,
|
|
"coin_before": coin_before,
|
|
"coin_after": state.coin,
|
|
"recruit_ids": recovered_ids,
|
|
"retry_route": retry,
|
|
}
|
|
state.recovery_receipts.append(receipt)
|
|
_clear_recovery()
|
|
return _commit(operation_id, "recover_defeat", intent, "defeat_recovered", receipt)
|
|
|
|
|
|
func _add_recruit(row: Dictionary, coin_price_paid: int) -> void:
|
|
var recruit_id := String(row.id)
|
|
var profession_id := String(row.profession_id)
|
|
state.company.append(recruit_id)
|
|
state.recruit_records[recruit_id] = {
|
|
"id": recruit_id,
|
|
"name_zh": String(row.name_zh),
|
|
"name_en": String(row.name_en),
|
|
"profession_id": profession_id,
|
|
"trait_id": String(row.trait_id),
|
|
"generation_seed": String(row.generation_seed),
|
|
"appearance_seed": int(row.appearance_seed),
|
|
"level": 1,
|
|
"xp": 0,
|
|
"coin_price_paid": coin_price_paid,
|
|
"source": "p9_10_market",
|
|
}
|
|
state.member_condition[recruit_id] = int(content.professions[profession_id].max_hp)
|
|
state.equipped_by_recruit[recruit_id] = ""
|
|
|
|
|
|
func _open_market(market: int) -> void:
|
|
state.phase = State.Phase.MARKET
|
|
state.current_market = market
|
|
state.market_board = Dictionary(content.boards[market]).duplicate(true)
|
|
state.available_recruit_offer_ids.assign(state.market_board.recruit_offer_ids)
|
|
state.available_equipment_offer_ids.assign(state.market_board.equipment_offer_ids)
|
|
state.available_battle_offer_ids.assign(state.market_board.battle_offer_ids)
|
|
state.selected_route = ""
|
|
state.battle_checkpoint.clear()
|
|
state.boss_muster.clear()
|
|
state.proposed_final_order.clear()
|
|
state.final_order_confirmed = false
|
|
|
|
|
|
func _open_boss_muster() -> void:
|
|
var boss: Dictionary = content.bosses[Content.BOSS_ID]
|
|
var numbered_path: Array[int] = []
|
|
for index in state.deployed_recruit_ids.size():
|
|
numbered_path.append(index + 1)
|
|
var authority := {
|
|
"boss_id": Content.BOSS_ID,
|
|
"company_ids": state.deployed_recruit_ids.duplicate(),
|
|
"member_hp": state.member_condition.duplicate(true),
|
|
"equipment": state.equipped_by_recruit.duplicate(true),
|
|
"coin": state.coin,
|
|
"route_history": state.route_history.duplicate(),
|
|
"initial_gust_direction": String(boss.initial_gust_direction),
|
|
"numbered_company_path": numbered_path,
|
|
"enemy_helper_footprints": [1, 2],
|
|
"gust_cadence": int(boss.gust_cadence),
|
|
"half_vitality_threshold": int(int(boss.max_hp) / 2.0),
|
|
"half_vitality_opposed_gusts": bool(boss.half_vitality_opposed_gusts),
|
|
"core_timeout_tick": int(boss.timeout_tick),
|
|
"foremost_recruit_id": String(state.deployed_recruit_ids[0]),
|
|
"rearmost_recruit_id": String(state.deployed_recruit_ids[-1]),
|
|
"weak_spindle_target_id": "BO-R1-02-SPINDLE",
|
|
"weak_spindle_family": String(boss.weak_spindle_family),
|
|
"plans": Array(boss.plans).duplicate(true),
|
|
"recovery_cost": int(boss.recovery_cost),
|
|
"recovery_rerolls_muster": false,
|
|
}
|
|
state.phase = State.Phase.BOSS_MUSTER
|
|
state.current_market = 13
|
|
state.market_board.clear()
|
|
state.available_recruit_offer_ids.clear()
|
|
state.available_equipment_offer_ids.clear()
|
|
state.available_battle_offer_ids.clear()
|
|
state.selected_route = ""
|
|
state.battle_checkpoint.clear()
|
|
state.boss_muster = authority.duplicate(true)
|
|
state.boss_muster["muster_hash"] = JSON.stringify(authority).sha256_text()
|
|
|
|
|
|
func _validate_terminal_battle(battle_snapshot: Dictionary) -> Dictionary:
|
|
if state.phase != State.Phase.ENCOUNTER or state.selected_route.is_empty():
|
|
return {"ok": false, "code": "encounter_required"}
|
|
for key in ["route_id", "tick", "terminal", "outcome", "member_ids", "member_hp", "enemy_ids", "enemy_hp"]:
|
|
if not battle_snapshot.has(key):
|
|
return {"ok": false, "code": "battle_snapshot_malformed"}
|
|
if String(battle_snapshot.route_id) != state.selected_route or not bool(battle_snapshot.terminal):
|
|
return {"ok": false, "code": "battle_snapshot_mismatch"}
|
|
if String(battle_snapshot.outcome) not in ["victory", "defeat"]:
|
|
return {"ok": false, "code": "battle_not_settleable"}
|
|
if not _same_members(Array(battle_snapshot.member_ids), state.deployed_recruit_ids):
|
|
return {"ok": false, "code": "battle_company_mismatch"}
|
|
var route: Dictionary = content.routes[state.selected_route]
|
|
if Array(battle_snapshot.enemy_ids) != Array(route.enemy_ids):
|
|
return {"ok": false, "code": "battle_enemy_mismatch"}
|
|
var living_members := 0
|
|
for recruit_id in state.deployed_recruit_ids:
|
|
if not battle_snapshot.member_hp.has(recruit_id):
|
|
return {"ok": false, "code": "battle_hp_missing"}
|
|
var hp := int(battle_snapshot.member_hp[recruit_id])
|
|
if hp < 0 or hp > _member_max_hp(recruit_id):
|
|
return {"ok": false, "code": "battle_hp_invalid"}
|
|
living_members += 1 if hp > 0 else 0
|
|
var living_enemies := 0
|
|
for enemy_id in route.enemy_ids:
|
|
if not battle_snapshot.enemy_hp.has(enemy_id):
|
|
return {"ok": false, "code": "battle_hp_missing"}
|
|
var hp := int(battle_snapshot.enemy_hp[enemy_id])
|
|
if hp < 0 or hp > int(content.enemies[enemy_id].max_hp):
|
|
return {"ok": false, "code": "battle_hp_invalid"}
|
|
living_enemies += 1 if hp > 0 else 0
|
|
if String(battle_snapshot.outcome) == "victory" and living_enemies != 0:
|
|
return {"ok": false, "code": "victory_not_proven"}
|
|
if String(battle_snapshot.outcome) == "defeat" and living_members != 0:
|
|
return {"ok": false, "code": "defeat_not_proven"}
|
|
return {"ok": true}
|
|
|
|
|
|
func _member_max_hp(recruit_id: String) -> int:
|
|
var profession_id := String(state.recruit_records.get(recruit_id, {}).get("profession_id", ""))
|
|
return int(content.professions.get(profession_id, {}).get("max_hp", 0))
|
|
|
|
|
|
func _profession_role(profession_id: String) -> String:
|
|
return String(content.professions.get(profession_id, {}).get("role", ""))
|
|
|
|
|
|
func _clear_recovery() -> void:
|
|
state.recovery_required = false
|
|
state.recovery_kind = ""
|
|
state.recovery_cost = 0
|
|
state.injured_recruit_ids.clear()
|
|
state.retry_route = ""
|
|
|
|
|
|
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 state.operation_receipts.has(operation_id):
|
|
return {"known": false}
|
|
var stored: Dictionary = state.operation_receipts[operation_id]
|
|
if String(stored.get("kind", "")) != kind or String(stored.get("intent_hash", "")) != _intent_hash(kind, intent):
|
|
return {"known": true, "result": _reject("operation_id_conflict")}
|
|
return {"known": true, "result": {"ok": true, "code": "already_applied", "duplicate": true, "receipt": Dictionary(stored.receipt).duplicate(true), "snapshot": state.snapshot()}}
|
|
|
|
|
|
func _commit(operation_id: String, kind: String, intent: Dictionary, code: String, receipt: Dictionary) -> Dictionary:
|
|
state.operation_receipts[operation_id] = {"kind": kind, "intent_hash": _intent_hash(kind, intent), "receipt": receipt.duplicate(true)}
|
|
state.revision += 1
|
|
assert(state.invariants_hold())
|
|
return {"ok": true, "code": code, "duplicate": false, "receipt": receipt.duplicate(true), "snapshot": state.snapshot()}
|
|
|
|
|
|
func _intent_hash(kind: String, intent: Dictionary) -> String:
|
|
return JSON.stringify({"kind": kind, "intent": intent}).sha256_text()
|
|
|
|
|
|
func _same_members(left: Array, right: Array) -> bool:
|
|
if left.size() != right.size():
|
|
return false
|
|
var seen := {}
|
|
for value in left:
|
|
if seen.has(value) or value not in right:
|
|
return false
|
|
seen[value] = true
|
|
return true
|
|
|
|
|
|
func _reject(code: String) -> Dictionary:
|
|
return {"ok": false, "code": code}
|