453 lines
21 KiB
GDScript
453 lines
21 KiB
GDScript
class_name P100ContractService
|
|
extends RefCounted
|
|
|
|
const Content = preload("res://p10_0/content_registry.gd")
|
|
const MarketService = preload("res://p10_0/market_service.gd")
|
|
const ResolutionService = preload("res://p10_0/resolution_service.gd")
|
|
const State = preload("res://p10_0/run_state.gd")
|
|
|
|
const BOSS_PLANS := {
|
|
"BO-R1-01": ["turn_panes", "brace_sweep"],
|
|
"BO-R1-02": ["break_spindle", "hold_line"],
|
|
}
|
|
|
|
var state := State.new()
|
|
var catalog: Dictionary = Content.snapshot()
|
|
var valid := false
|
|
var error_code := ""
|
|
|
|
|
|
func _init(run_snapshot: Dictionary = {}) -> void:
|
|
if run_snapshot.is_empty():
|
|
error_code = "run_snapshot_required"
|
|
return
|
|
valid = state.restore_snapshot(run_snapshot)
|
|
if not valid:
|
|
error_code = "invalid_run_snapshot"
|
|
|
|
|
|
func restore_snapshot(raw: Dictionary) -> bool:
|
|
valid = state.restore_snapshot(raw)
|
|
error_code = "" if valid else "invalid_run_snapshot"
|
|
return valid
|
|
|
|
|
|
func enter_boss_muster(operation_id: String, expected_revision: int) -> Dictionary:
|
|
var current_muster_hash := String(state.current_run.get("boss_muster", {}).get("muster_hash", ""))
|
|
var intent := {"expected_revision": expected_revision, "existing_muster_hash": current_muster_hash}
|
|
var prior := _prior_operation(operation_id, "enter_boss_muster", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
if not valid:
|
|
return _reject(error_code)
|
|
if expected_revision != state.revision:
|
|
return _reject("stale_revision")
|
|
if state.current_run.is_empty() or String(state.current_run.phase) != "market":
|
|
return _reject("market_required")
|
|
if int(state.current_run.cleared_rounds) != 12 or int(state.current_run.market_index) != 12:
|
|
return _reject("twelve_clears_required")
|
|
if not state.current_run.pending_reward.is_empty():
|
|
return _reject("reward_pending")
|
|
if state.current_run.company.is_empty() or state.current_run.deployed_recruit_ids.is_empty():
|
|
return _reject("deployment_required")
|
|
if bool(state.current_run.recovery_required):
|
|
return _reject("recovery_required")
|
|
var before := state.snapshot()
|
|
var boss_id := _contract_boss_id(String(state.current_run.contract_id))
|
|
var muster_id := String(state.current_run.boss_muster.get("muster_id", _low128(_digest(String(state.current_run.run_seed), "boss-muster", [state.current_run.contract_id, boss_id]))))
|
|
var muster := {
|
|
"muster_id": muster_id,
|
|
"contract_id": String(state.current_run.contract_id),
|
|
"boss_id": boss_id,
|
|
"market_index": 12,
|
|
"board_hash": String(state.current_run.market_board.board_hash),
|
|
"deployed_recruit_ids": Array(state.current_run.deployed_recruit_ids).duplicate(),
|
|
"company_ids": Array(state.current_run.company).duplicate(),
|
|
"member_hp": _member_hp(),
|
|
"plans": Array(BOSS_PLANS[boss_id]).duplicate(),
|
|
}
|
|
muster.muster_hash = _hash(muster)
|
|
state.current_run.boss_muster = muster
|
|
state.current_run.phase = "boss_muster"
|
|
var receipt := {"operation_id": operation_id, "muster_id": muster_id, "muster_hash": String(muster.muster_hash), "boss_id": boss_id, "plans": muster.plans.duplicate(), "deployed_recruit_ids": muster.deployed_recruit_ids.duplicate()}
|
|
return _commit(operation_id, "enter_boss_muster", intent, "boss_muster_entered", receipt, before)
|
|
|
|
|
|
func commit_boss_plan(operation_id: String, expected_revision: int, expected_muster_hash: String, plan_id: String) -> Dictionary:
|
|
var intent := {"expected_revision": expected_revision, "expected_muster_hash": expected_muster_hash, "plan_id": plan_id}
|
|
var prior := _prior_operation(operation_id, "commit_boss_plan", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
if not valid:
|
|
return _reject(error_code)
|
|
if expected_revision != state.revision:
|
|
return _reject("stale_revision")
|
|
if state.current_run.is_empty() or String(state.current_run.phase) != "boss_muster":
|
|
return _reject("boss_muster_required")
|
|
var muster: Dictionary = state.current_run.boss_muster
|
|
if String(muster.muster_hash) != expected_muster_hash:
|
|
return _reject("muster_hash_mismatch")
|
|
if plan_id not in muster.plans:
|
|
return _reject("boss_plan_invalid")
|
|
var before := state.snapshot()
|
|
state.current_run.phase = "boss_encounter"
|
|
state.current_run.encounter = {
|
|
"kind": "boss",
|
|
"boss_id": String(muster.boss_id),
|
|
"plan_id": plan_id,
|
|
"muster_id": String(muster.muster_id),
|
|
"muster_hash": String(muster.muster_hash),
|
|
"deployed_recruit_ids": Array(state.current_run.deployed_recruit_ids).duplicate(),
|
|
}
|
|
var receipt: Dictionary = state.current_run.encounter.duplicate(true)
|
|
receipt.operation_id = operation_id
|
|
return _commit(operation_id, "commit_boss_plan", intent, "boss_plan_committed", receipt, before)
|
|
|
|
|
|
func cancel_boss_plan(operation_id: String, expected_revision: int) -> Dictionary:
|
|
var intent := {"expected_revision": expected_revision, "muster_hash": String(state.current_run.get("boss_muster", {}).get("muster_hash", ""))}
|
|
var prior := _prior_operation(operation_id, "cancel_boss_plan", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
if not valid:
|
|
return _reject(error_code)
|
|
if expected_revision != state.revision:
|
|
return _reject("stale_revision")
|
|
if state.current_run.is_empty() or String(state.current_run.phase) != "boss_encounter":
|
|
return _reject("boss_encounter_required")
|
|
var before := state.snapshot()
|
|
var plan_id := String(state.current_run.encounter.plan_id)
|
|
state.current_run.encounter = {}
|
|
state.current_run.phase = "boss_muster"
|
|
return _commit(operation_id, "cancel_boss_plan", intent, "boss_plan_cancelled", {"operation_id": operation_id, "plan_id": plan_id}, before)
|
|
|
|
|
|
func settle_boss(operation_id: String, expected_revision: int, expected_battle_hash: String, battle_snapshot: Dictionary) -> Dictionary:
|
|
var intent := {"expected_revision": expected_revision, "expected_battle_hash": expected_battle_hash}
|
|
var prior := _prior_operation(operation_id, "settle_boss", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
if not valid:
|
|
return _reject(error_code)
|
|
if expected_revision != state.revision:
|
|
return _reject("stale_revision")
|
|
if state.current_run.is_empty() or String(state.current_run.phase) != "boss_encounter":
|
|
return _reject("boss_encounter_required")
|
|
var validation := _validate_boss_snapshot(battle_snapshot, expected_battle_hash)
|
|
if not validation.ok:
|
|
return _reject(String(validation.code))
|
|
var before := state.snapshot()
|
|
var fallen_ids: Array = Array(battle_snapshot.fallen_recruit_ids).duplicate()
|
|
var casualties: Array[Dictionary] = []
|
|
for recruit_id in fallen_ids:
|
|
var casualty := _apply_casualty(String(recruit_id))
|
|
if casualty.is_empty():
|
|
state.restore_snapshot(before)
|
|
return _reject("casualty_snapshot_mismatch")
|
|
casualties.append(casualty)
|
|
for recruit_id in Array(state.current_run.deployed_recruit_ids).duplicate():
|
|
if recruit_id in state.current_run.company and battle_snapshot.actors.has(recruit_id):
|
|
state.current_run.recruit_records[recruit_id].current_hp = maxi(1, int(battle_snapshot.actors[recruit_id].hp))
|
|
state.current_run.formation_proposal = Array(state.current_run.company).slice(0, mini(int(state.current_run.line_capacity), state.current_run.company.size()))
|
|
var outcome := String(battle_snapshot.outcome)
|
|
var fee := 0
|
|
state.current_run.encounter = {}
|
|
if outcome == "victory":
|
|
var contract_row := _contract_row(String(state.current_run.contract_id))
|
|
state.current_run.pending_close = {
|
|
"close_id": _low128(_digest(String(state.current_run.run_seed), "contract-close", [state.current_run.contract_id, battle_snapshot.snapshot_hash])),
|
|
"contract_id": String(state.current_run.contract_id),
|
|
"boss_id": String(battle_snapshot.boss_id),
|
|
"battle_hash": expected_battle_hash,
|
|
"clear_id": String(contract_row.clear_fact),
|
|
"first_clear_renown": int(contract_row.first_clear_renown),
|
|
"muster_hash": String(state.current_run.boss_muster.muster_hash),
|
|
"plan_id": String(battle_snapshot.plan_id),
|
|
}
|
|
state.current_run.phase = "contract_result"
|
|
state.current_run.recovery_required = false
|
|
state.current_run.protected_recovery_offer_id = ""
|
|
else:
|
|
fee = _failure_fee(String(state.current_run.difficulty), outcome, int(state.current_run.coin))
|
|
state.current_run.coin = int(state.current_run.coin) - fee
|
|
state.current_run.deployed_recruit_ids = _surviving_order(state.current_run.boss_muster.deployed_recruit_ids)
|
|
state.current_run.reserve_recruit_ids = _remaining_reserve()
|
|
_update_muster_after_failure()
|
|
if casualties.is_empty():
|
|
state.current_run.phase = "boss_muster"
|
|
else:
|
|
_install_protected_recovery_offer()
|
|
state.current_run.phase = "market"
|
|
var receipt := {
|
|
"operation_id": operation_id,
|
|
"boss_id": String(battle_snapshot.boss_id),
|
|
"plan_id": String(battle_snapshot.plan_id),
|
|
"outcome": outcome,
|
|
"cause": String(battle_snapshot.cause),
|
|
"battle_hash": expected_battle_hash,
|
|
"fallen_recruit_ids": fallen_ids,
|
|
"casualties": casualties,
|
|
"failure_fee": fee,
|
|
"pending_close_id": String(state.current_run.pending_close.get("close_id", "")),
|
|
}
|
|
return _commit(operation_id, "settle_boss", intent, "boss_settled", receipt, before)
|
|
|
|
|
|
func close_contract(operation_id: String, expected_revision: int, expected_close_id: String) -> Dictionary:
|
|
var intent := {"expected_revision": expected_revision, "expected_close_id": expected_close_id}
|
|
var prior := _prior_operation(operation_id, "close_contract", intent)
|
|
if prior.known:
|
|
return prior.result
|
|
if not valid:
|
|
return _reject(error_code)
|
|
if expected_revision != state.revision:
|
|
return _reject("stale_revision")
|
|
if state.current_run.is_empty() or String(state.current_run.phase) != "contract_result":
|
|
return _reject("contract_result_required")
|
|
var pending: Dictionary = state.current_run.pending_close
|
|
if String(pending.close_id) != expected_close_id:
|
|
return _reject("close_id_mismatch")
|
|
var before := state.snapshot()
|
|
var clear_id := String(pending.clear_id)
|
|
var first_clear: bool = clear_id not in state.guild_profile.contract_clear_ids
|
|
var renown_grant := int(pending.first_clear_renown) if first_clear else 0
|
|
if first_clear:
|
|
state.guild_profile.contract_clear_ids.append(clear_id)
|
|
state.guild_profile.persistent_renown = mini(9999, int(state.guild_profile.persistent_renown) + renown_grant)
|
|
var boss_discovery := "BOSS-%s" % String(pending.boss_id)
|
|
if boss_discovery not in state.guild_profile.discovery_ids:
|
|
state.guild_profile.discovery_ids.append(boss_discovery)
|
|
var record := _company_record(pending, renown_grant)
|
|
state.guild_profile.company_records.append(record)
|
|
state.guild_profile.clear_receipts[expected_close_id] = {"clear_id": clear_id, "record_id": String(record.record_id), "renown_grant": renown_grant}
|
|
if "CLEAR-RG01-C01" in state.guild_profile.contract_clear_ids and "CLEAR-RG01-C02" in state.guild_profile.contract_clear_ids and "RG-02" not in state.guild_profile.unlocked_region_ids:
|
|
state.guild_profile.unlocked_region_ids.append("RG-02")
|
|
var receipt := {
|
|
"operation_id": operation_id,
|
|
"close_id": expected_close_id,
|
|
"clear_id": clear_id,
|
|
"first_clear": first_clear,
|
|
"renown_grant": renown_grant,
|
|
"persistent_renown": int(state.guild_profile.persistent_renown),
|
|
"record": record,
|
|
"unlocked_region_ids": Array(state.guild_profile.unlocked_region_ids).duplicate(),
|
|
}
|
|
state.current_run = {}
|
|
return _commit(operation_id, "close_contract", intent, "contract_closed", receipt, before)
|
|
|
|
|
|
func _validate_boss_snapshot(snapshot_value: Dictionary, expected_hash: String) -> Dictionary:
|
|
for key in ["run_id", "contract_id", "boss_id", "plan_id", "muster_hash", "tick", "terminal", "outcome", "cause", "actors", "fallen_recruit_ids", "event_log", "first_decisive_event", "event_log_hash", "snapshot_hash"]:
|
|
if not snapshot_value.has(key):
|
|
return {"ok": false, "code": "boss_snapshot_missing:%s" % key}
|
|
if not bool(snapshot_value.terminal) or String(snapshot_value.outcome) not in ["victory", "defeat", "timeout"]:
|
|
return {"ok": false, "code": "boss_not_terminal"}
|
|
if String(snapshot_value.snapshot_hash) != expected_hash:
|
|
return {"ok": false, "code": "boss_hash_mismatch"}
|
|
var hashed := snapshot_value.duplicate(true)
|
|
hashed.erase("snapshot_hash")
|
|
if String(hashed.event_log_hash) != _hash(hashed.event_log) or _hash(hashed) != expected_hash:
|
|
return {"ok": false, "code": "boss_snapshot_tampered"}
|
|
var encounter: Dictionary = state.current_run.encounter
|
|
if String(snapshot_value.run_id) != String(state.current_run.run_id) or String(snapshot_value.contract_id) != String(state.current_run.contract_id):
|
|
return {"ok": false, "code": "boss_run_mismatch"}
|
|
if String(snapshot_value.boss_id) != String(encounter.boss_id) or String(snapshot_value.plan_id) != String(encounter.plan_id) or String(snapshot_value.muster_hash) != String(encounter.muster_hash):
|
|
return {"ok": false, "code": "boss_encounter_mismatch"}
|
|
if snapshot_value.first_decisive_event.is_empty():
|
|
return {"ok": false, "code": "decisive_event_missing"}
|
|
var fallen: Array = snapshot_value.fallen_recruit_ids
|
|
if not _unique(fallen) or not _subset(fallen, encounter.deployed_recruit_ids):
|
|
return {"ok": false, "code": "boss_casualty_invalid"}
|
|
for recruit_id in encounter.deployed_recruit_ids:
|
|
if not snapshot_value.actors.has(recruit_id) or ((recruit_id in fallen) != (int(snapshot_value.actors[recruit_id].hp) == 0)):
|
|
return {"ok": false, "code": "boss_actor_invalid"}
|
|
return {"ok": true, "code": "ok"}
|
|
|
|
|
|
func _apply_casualty(recruit_id: String) -> Dictionary:
|
|
if recruit_id not in state.current_run.company:
|
|
return {}
|
|
var record: Dictionary = state.current_run.recruit_records[recruit_id]
|
|
var item_id := String(state.current_run.equipped_by_recruit.get(recruit_id, ""))
|
|
if not item_id.is_empty():
|
|
if not state.current_run.inventory.has(item_id):
|
|
return {}
|
|
state.current_run.inventory[item_id].wearer_id = ""
|
|
var grant := ResolutionService.death_credit(int(record.get("original_list_price", 14)), int(record.get("coin_price_paid", 0)))
|
|
var credit_before := int(state.current_run.replacement_credit)
|
|
state.current_run.replacement_credit = mini(54, credit_before + grant)
|
|
state.current_run.company.erase(recruit_id)
|
|
state.current_run.deployed_recruit_ids.erase(recruit_id)
|
|
state.current_run.reserve_recruit_ids.erase(recruit_id)
|
|
state.current_run.recruit_records.erase(recruit_id)
|
|
state.current_run.equipped_by_recruit.erase(recruit_id)
|
|
state.current_run.recruits_fallen_total = int(state.current_run.recruits_fallen_total) + 1
|
|
return {"recruit_id": recruit_id, "returned_equipment_instance_id": item_id, "credit_grant": int(state.current_run.replacement_credit) - credit_before}
|
|
|
|
|
|
func _install_protected_recovery_offer() -> void:
|
|
var offers: Array = state.current_run.market_board.recruit_offers
|
|
var selected: Dictionary = Dictionary(offers[0]).duplicate(true)
|
|
var replaced_id := String(selected.source_offer_id)
|
|
var offer_level := maxi(1, int(selected.get("offer_level", 4)) - 1)
|
|
selected.source_offer_id = _low128(_digest(String(state.current_run.run_seed), "boss-protected-recovery", [state.current_run.contract_id, state.current_run.recruits_fallen_total]))
|
|
selected.level = offer_level
|
|
selected.offer_level = offer_level
|
|
selected.profession_tier = 0
|
|
selected.list_price = MarketService.hire_list_price(offer_level, 0)
|
|
selected.recovery_offer = true
|
|
selected.erase("canonical_output_hash")
|
|
selected.canonical_output_hash = _hash(selected)
|
|
offers[0] = selected
|
|
state.current_run.market_board.recruit_offers = offers
|
|
state.current_run.market_board.erase("board_hash")
|
|
state.current_run.market_board.board_hash = _hash(state.current_run.market_board)
|
|
state.current_run.available_recruit_offer_ids.erase(replaced_id)
|
|
state.current_run.available_recruit_offer_ids.append(String(selected.source_offer_id))
|
|
state.current_run.recovery_required = true
|
|
state.current_run.protected_recovery_offer_id = String(selected.source_offer_id)
|
|
|
|
|
|
func _update_muster_after_failure() -> void:
|
|
var muster: Dictionary = state.current_run.boss_muster
|
|
muster.deployed_recruit_ids = Array(state.current_run.deployed_recruit_ids).duplicate()
|
|
muster.company_ids = Array(state.current_run.company).duplicate()
|
|
muster.member_hp = _member_hp()
|
|
var hashless := muster.duplicate(true)
|
|
hashless.erase("muster_hash")
|
|
muster.muster_hash = _hash(hashless)
|
|
state.current_run.boss_muster = muster
|
|
|
|
|
|
func _company_record(pending: Dictionary, renown_grant: int) -> Dictionary:
|
|
var survivors: Array[Dictionary] = []
|
|
for recruit_id in state.current_run.company:
|
|
var record: Dictionary = state.current_run.recruit_records[recruit_id]
|
|
survivors.append({"recruit_id": recruit_id, "profession_id": String(record.profession_id), "trait_id": String(record.trait_id), "level": int(record.level), "survived_battles": int(record.survived_battles)})
|
|
return {
|
|
"record_id": _low128(_digest(String(state.current_run.run_seed), "company-record", [pending.clear_id, pending.battle_hash])),
|
|
"run_id": String(state.current_run.run_id),
|
|
"contract_id": String(state.current_run.contract_id),
|
|
"clear_id": String(pending.clear_id),
|
|
"boss_id": String(pending.boss_id),
|
|
"plan_id": String(pending.plan_id),
|
|
"renown_grant": renown_grant,
|
|
"survivors": survivors,
|
|
"fallen_total": int(state.current_run.recruits_fallen_total),
|
|
"dismissed_total": int(state.current_run.recruits_dismissed_total),
|
|
"active_artifact_ids": Array(state.current_run.active_artifact_ids).duplicate(),
|
|
}
|
|
|
|
|
|
func _contract_row(contract_id: String) -> Dictionary:
|
|
return Content.rows_by_id(catalog, "contracts").get(contract_id, {})
|
|
|
|
|
|
func _contract_boss_id(contract_id: String) -> String:
|
|
return String(_contract_row(contract_id).get("boss_id", ""))
|
|
|
|
|
|
func _member_hp() -> Dictionary:
|
|
var result := {}
|
|
for recruit_id in state.current_run.company:
|
|
result[recruit_id] = int(state.current_run.recruit_records[recruit_id].current_hp)
|
|
return result
|
|
|
|
|
|
func _failure_fee(difficulty: String, outcome: String, coin: int) -> int:
|
|
var base: int = int({
|
|
"wayfinder": {"defeat": 0, "timeout": 2},
|
|
"standard": {"defeat": 2, "timeout": 4},
|
|
"stormbound": {"defeat": 3, "timeout": 5},
|
|
"iron_oath": {"defeat": 4, "timeout": 6},
|
|
}.get(difficulty, {}).get(outcome, 0))
|
|
return mini(coin, base)
|
|
|
|
|
|
func _surviving_order(previous: Array) -> Array[String]:
|
|
var result: Array[String] = []
|
|
for recruit_id in previous:
|
|
if recruit_id in state.current_run.company:
|
|
result.append(String(recruit_id))
|
|
return result
|
|
|
|
|
|
func _remaining_reserve() -> Array[String]:
|
|
var result: Array[String] = []
|
|
for recruit_id in state.current_run.company:
|
|
if recruit_id not in state.current_run.deployed_recruit_ids:
|
|
result.append(String(recruit_id))
|
|
return result
|
|
|
|
|
|
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.intent_hash) != _hash({"kind": kind, "intent": intent}):
|
|
return {"known": true, "result": _reject("operation_id_conflict")}
|
|
return {"known": true, "result": {"ok": true, "code": "already_applied", "duplicate": true, "applied": false, "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.operation_receipts[operation_id] = {"kind": kind, "intent_hash": _hash({"kind": kind, "intent": intent}), "status": "applied", "receipt": receipt.duplicate(true)}
|
|
state.revision += 1
|
|
if not state.invariants_hold():
|
|
state.restore_snapshot(before)
|
|
return _reject("invariant_failure")
|
|
return {"ok": true, "code": code, "duplicate": false, "applied": true, "receipt": receipt.duplicate(true), "snapshot": state.snapshot()}
|
|
|
|
|
|
func _reject(code: String) -> Dictionary:
|
|
return {"ok": false, "code": code, "duplicate": false, "applied": false, "receipt": {}, "snapshot": state.snapshot()}
|
|
|
|
|
|
func _subset(values: Array, authority: Array) -> bool:
|
|
for value in values:
|
|
if value not in authority:
|
|
return false
|
|
return true
|
|
|
|
|
|
func _unique(values: Array) -> bool:
|
|
var seen := {}
|
|
for value in values:
|
|
if seen.has(value):
|
|
return false
|
|
seen[value] = true
|
|
return true
|
|
|
|
|
|
func _digest(seed: String, label: String, parts: Array = []) -> String:
|
|
var framed: Array[String] = ["%d:%s" % [seed.length(), seed], "%d:%s" % [label.length(), label]]
|
|
for part in parts:
|
|
var value := str(part)
|
|
framed.append("%d:%s" % [value.length(), value])
|
|
return "|".join(framed).sha256_text()
|
|
|
|
|
|
func _low128(value: String) -> String:
|
|
return value.substr(0, 32)
|
|
|
|
|
|
func _hash(value: Variant) -> String:
|
|
return JSON.stringify(_canonical(value)).sha256_text()
|
|
|
|
|
|
func _canonical(value: Variant) -> Variant:
|
|
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
|