1453 lines
65 KiB
GDScript
1453 lines
65 KiB
GDScript
class_name ABGInitialCompanyDraftService
|
|
extends RefCounted
|
|
|
|
const ContentRegistry = preload("res://p10_0/content_registry.gd")
|
|
const OfferGenerator = preload("res://p10_0/offer_generator.gd")
|
|
|
|
const DRAFT_VERSION := 1
|
|
const DRAFT_KIND := "aetherbound_initial_company_draft"
|
|
const SOURCE_REVISION := "dev2-persistent-initial-company-r1"
|
|
const DEFAULT_PATH := "user://aetherbound_initial_company_draft.json"
|
|
const COMPANY_VERSION := 2
|
|
const LEGACY_COMPANY_VERSION := 1
|
|
const COMPANY_KIND := "aetherbound_expedition_company"
|
|
const DEFAULT_COMPANY_PATH := "user://aetherbound_expedition_company.json"
|
|
const PROVIDER_CONTRACT_ID := "CTR-R1-01"
|
|
const ATTRIBUTE_IDS := ["might", "finesse", "insight", "vigor", "resolve"]
|
|
|
|
var path := DEFAULT_PATH
|
|
var company_path := DEFAULT_COMPANY_PATH
|
|
var _content: Dictionary
|
|
var _generator: RefCounted
|
|
|
|
|
|
func _init() -> void:
|
|
_content = ContentRegistry.snapshot()
|
|
_generator = OfferGenerator.new(_content)
|
|
|
|
|
|
func begin_or_load(seed_override := "") -> Dictionary:
|
|
var company := load_company()
|
|
if company.ok:
|
|
return {"ok": false, "code": "COMPANY_ALREADY_CREATED", "company": company.company}
|
|
if String(company.code) != "COMPANY_MISSING":
|
|
return company
|
|
var loaded := load_draft()
|
|
if loaded.ok:
|
|
return loaded
|
|
if String(loaded.code) != "DRAFT_MISSING":
|
|
return loaded
|
|
var seed := String(seed_override)
|
|
if seed.is_empty():
|
|
seed = Crypto.new().generate_random_bytes(16).hex_encode()
|
|
if not _is_hex(seed, 32):
|
|
return {"ok": false, "code": "DRAFT_SEED_INVALID"}
|
|
var created := _create_draft(seed)
|
|
if not created.ok:
|
|
return created
|
|
var saved := _save_draft(created.draft)
|
|
if not saved.ok:
|
|
return saved
|
|
return {"ok": true, "code": "created", "draft": saved.draft}
|
|
|
|
|
|
func load_draft() -> Dictionary:
|
|
var primary := _read_valid(path)
|
|
if primary.ok:
|
|
return {"ok": true, "code": "loaded", "draft": primary.draft}
|
|
var backup_path := _backup_path()
|
|
var backup := _read_valid(backup_path)
|
|
if backup.ok:
|
|
var restored := _restore_primary(backup.draft, false)
|
|
if not restored.ok:
|
|
return restored
|
|
return {"ok": true, "code": "recovered_backup", "draft": backup.draft}
|
|
var candidate := _read_valid(_candidate_path())
|
|
if candidate.ok:
|
|
var restored := _restore_primary(candidate.draft, true)
|
|
if not restored.ok:
|
|
return restored
|
|
return {"ok": true, "code": "recovered_candidate", "draft": candidate.draft}
|
|
if (
|
|
not FileAccess.file_exists(path)
|
|
and not FileAccess.file_exists(backup_path)
|
|
and not FileAccess.file_exists(_candidate_path())
|
|
and not FileAccess.file_exists(_backup_candidate_path())
|
|
):
|
|
return {"ok": false, "code": "DRAFT_MISSING"}
|
|
return {"ok": false, "code": "DRAFT_CORRUPT"}
|
|
|
|
|
|
func set_selection(selected_offer_ids: Array) -> Dictionary:
|
|
var loaded := load_draft()
|
|
if not loaded.ok:
|
|
return loaded
|
|
var draft: Dictionary = loaded.draft.duplicate(true)
|
|
var normalized := _normalize_selection(selected_offer_ids, draft.offers)
|
|
if not normalized.ok:
|
|
return normalized
|
|
if draft.selected_offer_ids == normalized.ids:
|
|
return {"ok": true, "code": "selection_unchanged", "draft": draft}
|
|
draft.selected_offer_ids = normalized.ids
|
|
draft.selection_revision = int(draft.selection_revision) + 1
|
|
draft.status = "selection_ready" if normalized.ids.size() == 2 else "offers_ready"
|
|
_refresh_draft_hash(draft)
|
|
var saved := _save_draft(draft)
|
|
if not saved.ok:
|
|
return saved
|
|
return {"ok": true, "code": "selection_saved", "draft": saved.draft}
|
|
|
|
|
|
func commit_company() -> Dictionary:
|
|
var existing := load_company()
|
|
if existing.ok:
|
|
var lingering := load_draft()
|
|
if lingering.ok and String(existing.company.created_from_draft_id) != String(lingering.draft.draft_id):
|
|
return {"ok": false, "code": "COMPANY_DRAFT_CONFLICT"}
|
|
if lingering.ok:
|
|
_remove_draft_files()
|
|
return {"ok": true, "code": "already_committed", "company": existing.company}
|
|
if String(existing.code) != "COMPANY_MISSING":
|
|
return existing
|
|
var loaded := load_draft()
|
|
if not loaded.ok:
|
|
return loaded
|
|
var draft: Dictionary = loaded.draft
|
|
if draft.selected_offer_ids.size() != 2 or String(draft.status) != "selection_ready":
|
|
return {"ok": false, "code": "COMPANY_SELECTION_INCOMPLETE"}
|
|
var created := _create_company(draft)
|
|
if not created.ok:
|
|
return created
|
|
var saved := _save_company(created.company)
|
|
if not saved.ok:
|
|
return saved
|
|
var cleanup_ok := _remove_draft_files()
|
|
return {
|
|
"ok": true,
|
|
"code": "committed" if cleanup_ok else "committed_cleanup_pending",
|
|
"company": saved.company,
|
|
}
|
|
|
|
|
|
func load_company() -> Dictionary:
|
|
var primary := _read_valid_company(company_path)
|
|
if primary.ok:
|
|
if bool(primary.get("migrated", false)):
|
|
var migration_save := _save_company(primary.company)
|
|
if not migration_save.ok:
|
|
return migration_save
|
|
return {"ok": true, "code": "migrated_v1", "company": migration_save.company}
|
|
return {"ok": true, "code": "loaded", "company": primary.company}
|
|
var backup_path := _company_backup_path()
|
|
var backup := _read_valid_company(backup_path)
|
|
if backup.ok:
|
|
var restored := _restore_company_primary(backup.company, false)
|
|
if not restored.ok:
|
|
return restored
|
|
return {"ok": true, "code": "recovered_backup", "company": backup.company}
|
|
var candidate := _read_valid_company(_company_candidate_path())
|
|
if candidate.ok:
|
|
var restored := _restore_company_primary(candidate.company, not bool(candidate.get("migrated", false)))
|
|
if not restored.ok:
|
|
return restored
|
|
return {"ok": true, "code": "recovered_candidate", "company": candidate.company}
|
|
if (
|
|
not FileAccess.file_exists(company_path)
|
|
and not FileAccess.file_exists(backup_path)
|
|
and not FileAccess.file_exists(_company_candidate_path())
|
|
and not FileAccess.file_exists(_company_backup_candidate_path())
|
|
):
|
|
return {"ok": false, "code": "COMPANY_MISSING"}
|
|
return {"ok": false, "code": "COMPANY_CORRUPT"}
|
|
|
|
|
|
func save_company(company: Dictionary) -> Dictionary:
|
|
return _save_company(company)
|
|
|
|
|
|
func validate_company(value) -> Dictionary:
|
|
if not value is Dictionary:
|
|
return {"ok": false, "code": "COMPANY_NOT_DICTIONARY"}
|
|
var company: Dictionary = value
|
|
var required := [
|
|
"company_version", "company_kind", "company_id", "created_from_draft_id",
|
|
"source_offer_set_hash", "source_revision", "owned_recruits",
|
|
"creation_receipt", "company_revision", "next_contract_ordinal",
|
|
"active_contract", "contract_receipts", "company_hash",
|
|
]
|
|
for field in required:
|
|
if not company.has(field):
|
|
return {"ok": false, "code": "COMPANY_FIELD_MISSING", "field": field}
|
|
if int(company.company_version) != COMPANY_VERSION or String(company.company_kind) != COMPANY_KIND:
|
|
return {"ok": false, "code": "COMPANY_VERSION_UNSUPPORTED"}
|
|
if not _is_hex(String(company.company_id), 32) or not _is_hex(String(company.created_from_draft_id), 32):
|
|
return {"ok": false, "code": "COMPANY_IDENTITY_INVALID"}
|
|
if not _is_hex(String(company.source_offer_set_hash), 64) or String(company.source_revision) != SOURCE_REVISION:
|
|
return {"ok": false, "code": "COMPANY_SOURCE_INVALID"}
|
|
if int(company.company_revision) < 0 or int(company.next_contract_ordinal) < 0:
|
|
return {"ok": false, "code": "COMPANY_REVISION_INVALID"}
|
|
if not company.owned_recruits is Array or company.owned_recruits.size() < 1:
|
|
return {"ok": false, "code": "COMPANY_ROSTER_COUNT_INVALID"}
|
|
var recruit_ids: Array[String] = []
|
|
for recruit in company.owned_recruits:
|
|
var validation := _validate_owned_recruit(recruit, String(company.created_from_draft_id))
|
|
if not validation.ok:
|
|
return validation
|
|
var recruit_id := String(recruit.recruit_id)
|
|
if recruit_id in recruit_ids:
|
|
return {"ok": false, "code": "COMPANY_RECRUIT_ID_DUPLICATE"}
|
|
recruit_ids.append(recruit_id)
|
|
var receipt_validation := _validate_company_receipt(company.creation_receipt, company)
|
|
if not receipt_validation.ok:
|
|
return receipt_validation
|
|
if not company.active_contract is Dictionary or not company.contract_receipts is Array:
|
|
return {"ok": false, "code": "COMPANY_CONTRACT_STATE_INVALID"}
|
|
if company.active_contract.is_empty():
|
|
if not company.contract_receipts.is_empty() or int(company.next_contract_ordinal) != 0:
|
|
return {"ok": false, "code": "COMPANY_CONTRACT_STATE_INVALID"}
|
|
else:
|
|
if String(company.active_contract.get("company_id", "")) != String(company.company_id):
|
|
return {"ok": false, "code": "COMPANY_CONTRACT_IDENTITY_INVALID"}
|
|
var contract_validation := _validate_active_contract(company.active_contract, company)
|
|
if not contract_validation.ok:
|
|
return contract_validation
|
|
var hire_count: int = company.active_contract.hire_receipts.size()
|
|
var equipment_count: int = company.active_contract.get("equipment_receipts", []).size()
|
|
var dismissal_count: int = company.active_contract.get("dismissal_receipts", []).size()
|
|
var return_count: int = company.active_contract.get("contract_return_receipts", []).size()
|
|
if int(company.next_contract_ordinal) != 1 or int(company.company_revision) != 1 + hire_count + equipment_count + dismissal_count + return_count:
|
|
return {"ok": false, "code": "COMPANY_CONTRACT_REVISION_INVALID"}
|
|
if company.contract_receipts.size() != 1 or company.contract_receipts[0] != company.active_contract.creation_receipt:
|
|
return {"ok": false, "code": "COMPANY_CONTRACT_RECEIPT_INVALID"}
|
|
if company.owned_recruits.size() != 2 + hire_count - dismissal_count:
|
|
return {"ok": false, "code": "COMPANY_HIRE_ROSTER_INVALID"}
|
|
var dismissed_ids: Array[String] = []
|
|
for dismissal in company.active_contract.get("dismissal_receipts", []):
|
|
dismissed_ids.append(String(dismissal.get("recruit_id", "")))
|
|
for hire_receipt in company.active_contract.hire_receipts:
|
|
var hired_recruit: Dictionary = {}
|
|
for recruit in company.owned_recruits:
|
|
if String(recruit.recruit_id) == String(hire_receipt.recruit_id):
|
|
hired_recruit = recruit
|
|
break
|
|
if hired_recruit.is_empty():
|
|
if String(hire_receipt.recruit_id) not in dismissed_ids:
|
|
return {"ok": false, "code": "COMPANY_HIRE_ROSTER_INVALID"}
|
|
elif String(hired_recruit.get("market_offer_id", "")) != String(hire_receipt.offer_id):
|
|
return {"ok": false, "code": "COMPANY_HIRE_ROSTER_INVALID"}
|
|
var unhashed := company.duplicate(true)
|
|
unhashed.erase("company_hash")
|
|
if String(company.company_hash) != _hash(unhashed):
|
|
return {"ok": false, "code": "COMPANY_HASH_INVALID"}
|
|
for forbidden in ["offers", "unchosen_offer_ids", "current_run"]:
|
|
if company.has(forbidden):
|
|
return {"ok": false, "code": "COMPANY_FORBIDDEN_STATE"}
|
|
return {"ok": true, "code": "valid"}
|
|
|
|
|
|
func validate_draft(value) -> Dictionary:
|
|
if not value is Dictionary:
|
|
return {"ok": false, "code": "DRAFT_NOT_DICTIONARY"}
|
|
var draft: Dictionary = value
|
|
var required := [
|
|
"draft_version", "draft_kind", "draft_id", "seed", "status",
|
|
"source_revision", "provider_revision", "offers", "offer_set_hash",
|
|
"selected_offer_ids", "selection_revision", "draft_hash",
|
|
]
|
|
for field in required:
|
|
if not draft.has(field):
|
|
return {"ok": false, "code": "DRAFT_FIELD_MISSING", "field": field}
|
|
if int(draft.draft_version) != DRAFT_VERSION or String(draft.draft_kind) != DRAFT_KIND:
|
|
return {"ok": false, "code": "DRAFT_VERSION_UNSUPPORTED"}
|
|
if not _is_hex(String(draft.draft_id), 32) or not _is_hex(String(draft.seed), 32):
|
|
return {"ok": false, "code": "DRAFT_IDENTITY_INVALID"}
|
|
if String(draft.source_revision) != SOURCE_REVISION:
|
|
return {"ok": false, "code": "DRAFT_SOURCE_UNSUPPORTED"}
|
|
if String(draft.provider_revision) != String(_content.source.authority_revision):
|
|
return {"ok": false, "code": "DRAFT_PROVIDER_UNSUPPORTED"}
|
|
if String(draft.status) not in ["offers_ready", "selection_ready"]:
|
|
return {"ok": false, "code": "DRAFT_STATUS_INVALID"}
|
|
if not draft.offers is Array or draft.offers.size() != 4:
|
|
return {"ok": false, "code": "DRAFT_OFFER_COUNT_INVALID"}
|
|
var offer_ids: Array[String] = []
|
|
var recruit_ids: Array[String] = []
|
|
for index in range(4):
|
|
var offer_validation := _validate_offer(draft.offers[index], index, String(draft.draft_id))
|
|
if not offer_validation.ok:
|
|
return offer_validation
|
|
var offer: Dictionary = draft.offers[index]
|
|
var offer_id := String(offer.offer_id)
|
|
var recruit_id := String(offer.recruit_id)
|
|
if offer_id in offer_ids:
|
|
return {"ok": false, "code": "DRAFT_OFFER_ID_DUPLICATE"}
|
|
if recruit_id in recruit_ids:
|
|
return {"ok": false, "code": "DRAFT_RECRUIT_ID_DUPLICATE"}
|
|
offer_ids.append(offer_id)
|
|
recruit_ids.append(recruit_id)
|
|
if String(draft.offer_set_hash) != _hash(draft.offers):
|
|
return {"ok": false, "code": "DRAFT_OFFER_SET_HASH_INVALID"}
|
|
if not draft.selected_offer_ids is Array:
|
|
return {"ok": false, "code": "DRAFT_SELECTION_TYPE_INVALID"}
|
|
var selection := _normalize_selection(draft.selected_offer_ids, draft.offers)
|
|
if not selection.ok or selection.ids != draft.selected_offer_ids:
|
|
return {"ok": false, "code": String(selection.get("code", "DRAFT_SELECTION_INVALID"))}
|
|
if String(draft.status) == "selection_ready" and selection.ids.size() != 2:
|
|
return {"ok": false, "code": "DRAFT_SELECTION_STATUS_INVALID"}
|
|
if String(draft.status) == "offers_ready" and selection.ids.size() == 2:
|
|
return {"ok": false, "code": "DRAFT_SELECTION_STATUS_INVALID"}
|
|
if int(draft.selection_revision) < 0:
|
|
return {"ok": false, "code": "DRAFT_SELECTION_REVISION_INVALID"}
|
|
var unhashed := draft.duplicate(true)
|
|
unhashed.erase("draft_hash")
|
|
if String(draft.draft_hash) != _hash(unhashed):
|
|
return {"ok": false, "code": "DRAFT_HASH_INVALID"}
|
|
for forbidden in ["company", "expedition_company", "current_run", "active_contract"]:
|
|
if draft.has(forbidden):
|
|
return {"ok": false, "code": "DRAFT_PRECOMMIT_STATE_INVALID"}
|
|
return {"ok": true, "code": "valid"}
|
|
|
|
|
|
func _create_draft(seed: String) -> Dictionary:
|
|
var content_validation := ContentRegistry.validate(_content)
|
|
if not content_validation.ok:
|
|
return {"ok": false, "code": "DRAFT_CONTENT_INVALID"}
|
|
var draft_id := _low128(_digest(seed, "initial-company-draft"))
|
|
var role_weights := {}
|
|
for role in [
|
|
"front-hold", "single-pressure", "reach", "reorder", "projectile",
|
|
"area", "restore", "enable", "construct", "impair", "tempo", "supply",
|
|
]:
|
|
role_weights[role] = 1
|
|
var provider_draft := {
|
|
"draft_id": draft_id,
|
|
"run_seed": seed,
|
|
"contract_id": PROVIDER_CONTRACT_ID,
|
|
"difficulty": "standard",
|
|
"ruleset": SOURCE_REVISION,
|
|
"content_revision": String(_content.source.authority_revision),
|
|
"role_weights": role_weights,
|
|
"region_floor": 1,
|
|
"seed_reservation_applied": true,
|
|
"read_back_validated": true,
|
|
}
|
|
var generated: Dictionary = _generator.opening_offers(provider_draft)
|
|
if not generated.ok or generated.offers.size() != 4:
|
|
return {"ok": false, "code": "DRAFT_GENERATION_FAILED"}
|
|
var offers: Array[Dictionary] = []
|
|
for index in range(4):
|
|
offers.append(_project_offer(generated.offers[index], index, draft_id))
|
|
var draft := {
|
|
"draft_version": DRAFT_VERSION,
|
|
"draft_kind": DRAFT_KIND,
|
|
"draft_id": draft_id,
|
|
"seed": seed,
|
|
"status": "offers_ready",
|
|
"source_revision": SOURCE_REVISION,
|
|
"provider_revision": String(_content.source.authority_revision),
|
|
"offers": offers,
|
|
"offer_set_hash": _hash(offers),
|
|
"selected_offer_ids": [],
|
|
"selection_revision": 0,
|
|
}
|
|
_refresh_draft_hash(draft)
|
|
var validation := validate_draft(draft)
|
|
if not validation.ok:
|
|
return validation
|
|
return {"ok": true, "code": "draft_materialized", "draft": draft}
|
|
|
|
|
|
func _create_company(draft: Dictionary) -> Dictionary:
|
|
var owned_recruits: Array[Dictionary] = []
|
|
for offer in draft.offers:
|
|
if String(offer.offer_id) not in draft.selected_offer_ids:
|
|
continue
|
|
var recruit: Dictionary = Dictionary(offer).duplicate(true)
|
|
recruit.erase("offer_id")
|
|
recruit.erase("canonical_output_hash")
|
|
recruit["initial_offer_index"] = int(recruit.get("offer_index", -1))
|
|
recruit.erase("offer_index")
|
|
recruit.erase("source_draft_id")
|
|
recruit["joined_from_draft_id"] = String(draft.draft_id)
|
|
recruit["equipped_item_id"] = String(recruit.starting_item_id)
|
|
recruit["state"] = "owned"
|
|
recruit["battle_history"] = {
|
|
"battles_entered": 0,
|
|
"victories": 0,
|
|
"knockouts": 0,
|
|
}
|
|
recruit["recruit_hash"] = _hash(recruit)
|
|
owned_recruits.append(recruit)
|
|
if owned_recruits.size() != 2:
|
|
return {"ok": false, "code": "COMPANY_SELECTION_MATERIALIZATION_FAILED"}
|
|
var selected_recruit_ids: Array[String] = []
|
|
for recruit in owned_recruits:
|
|
selected_recruit_ids.append(String(recruit.recruit_id))
|
|
var intent := {
|
|
"draft_id": String(draft.draft_id),
|
|
"offer_set_hash": String(draft.offer_set_hash),
|
|
"selected_recruit_ids": selected_recruit_ids,
|
|
}
|
|
var intent_hash := _hash(intent)
|
|
var company_id := _low128(_digest(String(draft.draft_id), "persistent-expedition-company", [intent_hash]))
|
|
var receipt := {
|
|
"operation_id": _low128(_digest(String(draft.draft_id), "create-company-operation", [intent_hash])),
|
|
"intent_hash": intent_hash,
|
|
"draft_id": String(draft.draft_id),
|
|
"company_id": company_id,
|
|
"selected_recruit_ids": selected_recruit_ids,
|
|
}
|
|
receipt["receipt_hash"] = _hash(receipt)
|
|
var company := {
|
|
"company_version": COMPANY_VERSION,
|
|
"company_kind": COMPANY_KIND,
|
|
"company_id": company_id,
|
|
"created_from_draft_id": String(draft.draft_id),
|
|
"source_offer_set_hash": String(draft.offer_set_hash),
|
|
"source_revision": SOURCE_REVISION,
|
|
"owned_recruits": owned_recruits,
|
|
"equipment_inventory": [],
|
|
"creation_receipt": receipt,
|
|
"company_revision": 0,
|
|
"next_contract_ordinal": 0,
|
|
"active_contract": {},
|
|
"contract_receipts": [],
|
|
}
|
|
company["company_hash"] = _hash(company)
|
|
var validation := validate_company(company)
|
|
if not validation.ok:
|
|
return validation
|
|
return {"ok": true, "code": "company_materialized", "company": company}
|
|
|
|
|
|
func _validate_owned_recruit(value, draft_id: String) -> Dictionary:
|
|
if not value is Dictionary:
|
|
return {"ok": false, "code": "COMPANY_RECRUIT_TYPE_INVALID"}
|
|
var recruit: Dictionary = value
|
|
var required := [
|
|
"recruit_id", "generation_seed", "name_component_ids",
|
|
"rendered_name_snapshot", "appearance_seed", "profession_id",
|
|
"profession_name", "profession_job", "attributes", "trait_id",
|
|
"trait_name", "trait_benefit", "trait_cost", "starting_item_id",
|
|
"starting_item_name", "starting_item_state", "equipped_item_id",
|
|
"level", "xp", "state", "battle_history", "recruit_hash",
|
|
]
|
|
for field in required:
|
|
if not recruit.has(field):
|
|
return {"ok": false, "code": "COMPANY_RECRUIT_FIELD_MISSING", "field": field}
|
|
if not _is_hex(String(recruit.recruit_id), 32) or not _is_hex(String(recruit.generation_seed), 32):
|
|
return {"ok": false, "code": "COMPANY_RECRUIT_ID_INVALID"}
|
|
if String(recruit.state) != "owned":
|
|
return {"ok": false, "code": "COMPANY_RECRUIT_STATE_INVALID"}
|
|
if recruit.has("joined_from_market_id"):
|
|
for field in ["joined_from_market_id", "market_offer_id", "market_offer_hash"]:
|
|
if not recruit.has(field):
|
|
return {"ok": false, "code": "COMPANY_RECRUIT_FIELD_MISSING", "field": field}
|
|
if (
|
|
String(recruit.joined_from_market_id).is_empty()
|
|
or not _is_hex(String(recruit.market_offer_id), 32)
|
|
or not _is_hex(String(recruit.market_offer_hash), 64)
|
|
or recruit.has("joined_from_draft_id")
|
|
or recruit.has("initial_offer_index")
|
|
):
|
|
return {"ok": false, "code": "COMPANY_RECRUIT_SOURCE_INVALID"}
|
|
else:
|
|
if not recruit.has("joined_from_draft_id") or not recruit.has("initial_offer_index"):
|
|
return {"ok": false, "code": "COMPANY_RECRUIT_SOURCE_INVALID"}
|
|
if String(recruit.joined_from_draft_id) != draft_id or int(recruit.initial_offer_index) < 0 or int(recruit.initial_offer_index) > 3:
|
|
return {"ok": false, "code": "COMPANY_RECRUIT_SOURCE_INVALID"}
|
|
var starting_item_state := String(recruit.starting_item_state)
|
|
if starting_item_state not in ["equipped", "inventory"] or not _catalog_id(String(recruit.equipped_item_id), "EQ-", 3):
|
|
return {"ok": false, "code": "COMPANY_RECRUIT_ITEM_INVALID"}
|
|
if (starting_item_state == "equipped") != (String(recruit.equipped_item_id) == String(recruit.starting_item_id)):
|
|
return {"ok": false, "code": "COMPANY_RECRUIT_ITEM_INVALID"}
|
|
var recruit_level := int(recruit.level)
|
|
var recruit_xp := int(recruit.xp)
|
|
if recruit_level < 1 or recruit_level > 10 or recruit_xp < 0:
|
|
return {"ok": false, "code": "COMPANY_RECRUIT_GROWTH_INVALID"}
|
|
if recruit_level < 10 and recruit_xp >= 20 + 10 * recruit_level:
|
|
return {"ok": false, "code": "COMPANY_RECRUIT_GROWTH_INVALID"}
|
|
if not recruit.attributes is Dictionary:
|
|
return {"ok": false, "code": "COMPANY_RECRUIT_ATTRIBUTES_INVALID"}
|
|
var total := 0
|
|
for attribute_id in ATTRIBUTE_IDS:
|
|
if not recruit.attributes.has(attribute_id):
|
|
return {"ok": false, "code": "COMPANY_RECRUIT_ATTRIBUTES_INVALID"}
|
|
total += int(recruit.attributes[attribute_id])
|
|
if total != 35:
|
|
return {"ok": false, "code": "COMPANY_RECRUIT_ATTRIBUTES_INVALID"}
|
|
if not recruit.battle_history is Dictionary:
|
|
return {"ok": false, "code": "COMPANY_RECRUIT_HISTORY_INVALID"}
|
|
for field in ["battles_entered", "victories", "knockouts"]:
|
|
if int(recruit.battle_history.get(field, -1)) < 0:
|
|
return {"ok": false, "code": "COMPANY_RECRUIT_HISTORY_INVALID"}
|
|
var unhashed := recruit.duplicate(true)
|
|
unhashed.erase("recruit_hash")
|
|
if String(recruit.recruit_hash) != _hash(unhashed):
|
|
return {"ok": false, "code": "COMPANY_RECRUIT_HASH_INVALID"}
|
|
for forbidden in ["offer_id", "source_offer_id", "source_draft_id", "canonical_output_hash"]:
|
|
if recruit.has(forbidden):
|
|
return {"ok": false, "code": "COMPANY_RECRUIT_OFFER_STATE_RETAINED"}
|
|
return {"ok": true, "code": "valid"}
|
|
|
|
|
|
func _validate_company_receipt(value, company: Dictionary) -> Dictionary:
|
|
if not value is Dictionary:
|
|
return {"ok": false, "code": "COMPANY_RECEIPT_TYPE_INVALID"}
|
|
var receipt: Dictionary = value
|
|
for field in ["operation_id", "intent_hash", "draft_id", "company_id", "selected_recruit_ids", "receipt_hash"]:
|
|
if not receipt.has(field):
|
|
return {"ok": false, "code": "COMPANY_RECEIPT_FIELD_MISSING", "field": field}
|
|
if not _is_hex(String(receipt.operation_id), 32) or not _is_hex(String(receipt.intent_hash), 64):
|
|
return {"ok": false, "code": "COMPANY_RECEIPT_ID_INVALID"}
|
|
if String(receipt.draft_id) != String(company.created_from_draft_id) or String(receipt.company_id) != String(company.company_id):
|
|
return {"ok": false, "code": "COMPANY_RECEIPT_SOURCE_INVALID"}
|
|
if company.owned_recruits.size() < 1:
|
|
return {"ok": false, "code": "COMPANY_RECEIPT_ROSTER_INVALID"}
|
|
var expected_ids: Array[String] = []
|
|
for selected_id in receipt.selected_recruit_ids:
|
|
expected_ids.append(String(selected_id))
|
|
if expected_ids.size() != 2:
|
|
return {"ok": false, "code": "COMPANY_RECEIPT_ROSTER_INVALID"}
|
|
var current_ids: Array[String] = []
|
|
for recruit in company.owned_recruits:
|
|
current_ids.append(String(recruit.recruit_id))
|
|
var dismissed_ids: Array[String] = []
|
|
for dismissal in company.get("dismissal_receipts", []):
|
|
dismissed_ids.append(String(dismissal.get("recruit_id", "")))
|
|
for selected_id in expected_ids:
|
|
if selected_id not in current_ids and selected_id not in dismissed_ids:
|
|
return {"ok": false, "code": "COMPANY_RECEIPT_ROSTER_INVALID"}
|
|
var expected_intent := _hash({
|
|
"draft_id": String(company.created_from_draft_id),
|
|
"offer_set_hash": String(company.source_offer_set_hash),
|
|
"selected_recruit_ids": expected_ids,
|
|
})
|
|
if String(receipt.intent_hash) != expected_intent:
|
|
return {"ok": false, "code": "COMPANY_RECEIPT_INTENT_INVALID"}
|
|
var unhashed := receipt.duplicate(true)
|
|
unhashed.erase("receipt_hash")
|
|
if String(receipt.receipt_hash) != _hash(unhashed):
|
|
return {"ok": false, "code": "COMPANY_RECEIPT_HASH_INVALID"}
|
|
return {"ok": true, "code": "valid"}
|
|
|
|
|
|
func _validate_active_contract(value: Dictionary, company: Dictionary) -> Dictionary:
|
|
var required := [
|
|
"contract_version", "company_id", "contract_id", "run_id", "run_seed", "difficulty",
|
|
"status", "market_index", "coin", "line_capacity", "market_board",
|
|
"available_recruit_offer_ids", "available_equipment_offer_ids",
|
|
"available_battle_offer_ids", "hire_receipts", "creation_receipt",
|
|
"contract_hash",
|
|
]
|
|
for field in required:
|
|
if not value.has(field):
|
|
return {"ok": false, "code": "CONTRACT_FIELD_MISSING", "field": field}
|
|
if int(value.contract_version) != 1:
|
|
return {"ok": false, "code": "CONTRACT_VERSION_UNSUPPORTED"}
|
|
if String(value.contract_id) != "CTR-R1-01" or not _is_hex(String(value.run_id), 32) or not _is_hex(String(value.run_seed), 32):
|
|
return {"ok": false, "code": "CONTRACT_IDENTITY_INVALID"}
|
|
if String(value.difficulty) not in ["wayfinder", "standard", "stormbound", "iron_oath"]:
|
|
return {"ok": false, "code": "CONTRACT_DIFFICULTY_INVALID"}
|
|
if String(value.status) not in ["market", "battle", "result", "recovery", "returned"] or int(value.market_index) != 1:
|
|
return {"ok": false, "code": "CONTRACT_PHASE_INVALID"}
|
|
if int(value.coin) < 0 or int(value.coin) > 999 or int(value.line_capacity) != 4:
|
|
return {"ok": false, "code": "CONTRACT_RESOURCE_INVALID"}
|
|
if not value.market_board is Dictionary:
|
|
return {"ok": false, "code": "CONTRACT_MARKET_INVALID"}
|
|
var board_validation := _validate_first_market_board(value.market_board, value)
|
|
if not board_validation.ok:
|
|
return board_validation
|
|
for field in ["available_recruit_offer_ids", "available_equipment_offer_ids", "available_battle_offer_ids", "hire_receipts"]:
|
|
if not value[field] is Array:
|
|
return {"ok": false, "code": "CONTRACT_COLLECTION_INVALID", "field": field}
|
|
var id_fields := {
|
|
"available_recruit_offer_ids": [value.market_board.recruit_offers, "source_offer_id"],
|
|
"available_equipment_offer_ids": [value.market_board.equipment_offers, "offer_id"],
|
|
"available_battle_offer_ids": [value.market_board.battle_offers, "offer_id"],
|
|
}
|
|
for field in id_fields:
|
|
var authority: Array[String] = []
|
|
for offer in id_fields[field][0]:
|
|
authority.append(String(offer.get(id_fields[field][1], "")))
|
|
var seen: Array[String] = []
|
|
for offer_id in value[field]:
|
|
if String(offer_id) not in authority or String(offer_id) in seen:
|
|
return {"ok": false, "code": "CONTRACT_AVAILABLE_OFFER_INVALID", "field": field}
|
|
seen.append(String(offer_id))
|
|
if value.hire_receipts.size() > 2:
|
|
return {"ok": false, "code": "CONTRACT_HIRE_LIMIT_INVALID"}
|
|
var hired_offer_ids: Array[String] = []
|
|
var hired_recruit_ids: Array[String] = []
|
|
for index in range(value.hire_receipts.size()):
|
|
var hire_validation := _validate_hire_receipt(value.hire_receipts[index], value, index)
|
|
if not hire_validation.ok:
|
|
return hire_validation
|
|
var receipt: Dictionary = value.hire_receipts[index]
|
|
if String(receipt.offer_id) in hired_offer_ids or String(receipt.recruit_id) in hired_recruit_ids:
|
|
return {"ok": false, "code": "CONTRACT_HIRE_RECEIPT_DUPLICATE"}
|
|
if String(receipt.offer_id) in value.available_recruit_offer_ids:
|
|
return {"ok": false, "code": "CONTRACT_HIRED_OFFER_AVAILABLE"}
|
|
hired_offer_ids.append(String(receipt.offer_id))
|
|
hired_recruit_ids.append(String(receipt.recruit_id))
|
|
var opening_coin: int = 52 - 14 * value.hire_receipts.size()
|
|
var equipment_receipts: Array = value.get("equipment_receipts", [])
|
|
var equipment_spend := 0
|
|
for equipment_receipt in equipment_receipts:
|
|
equipment_spend += int(equipment_receipt.get("list_price", 0))
|
|
var market_floor_coin := opening_coin - equipment_spend
|
|
var has_battle_progress: bool = value.has("battle_state") or (value.has("battle_settlement_receipts") and not value.battle_settlement_receipts.is_empty())
|
|
if (
|
|
value.available_recruit_offer_ids.size() != 3 - value.hire_receipts.size()
|
|
or value.available_equipment_offer_ids.size() != 4 - equipment_receipts.size()
|
|
or value.available_battle_offer_ids.size() != 3
|
|
or market_floor_coin < 0
|
|
or (not has_battle_progress and int(value.coin) != market_floor_coin)
|
|
or (has_battle_progress and int(value.coin) < market_floor_coin)
|
|
):
|
|
return {"ok": false, "code": "CONTRACT_OPENING_STATE_INVALID"}
|
|
var receipt_validation := _validate_contract_creation_receipt(value.creation_receipt, value)
|
|
if not receipt_validation.ok:
|
|
return receipt_validation
|
|
if value.has("contract_return_receipts") and not value.contract_return_receipts is Array:
|
|
return {"ok": false, "code": "CONTRACT_RETURN_RECEIPTS_INVALID"}
|
|
var return_receipts: Array = Array(value.get("contract_return_receipts", []))
|
|
if return_receipts.size() > 1:
|
|
return {"ok": false, "code": "CONTRACT_RETURN_RECEIPT_DUPLICATE"}
|
|
for return_receipt in return_receipts:
|
|
var return_validation := _validate_contract_return_receipt(return_receipt, value, company)
|
|
if not return_validation.ok:
|
|
return return_validation
|
|
if String(value.status) == "returned":
|
|
if return_receipts.size() != 1:
|
|
return {"ok": false, "code": "CONTRACT_RETURN_RECEIPT_MISSING"}
|
|
elif not return_receipts.is_empty():
|
|
return {"ok": false, "code": "CONTRACT_RETURN_STATUS_INVALID"}
|
|
if value.has("formation"):
|
|
var formation_validation := _validate_formation(value.formation, value, company)
|
|
if not formation_validation.ok:
|
|
return formation_validation
|
|
var unhashed := value.duplicate(true)
|
|
unhashed.erase("contract_hash")
|
|
if String(value.contract_hash) != _hash(unhashed):
|
|
return {"ok": false, "code": "CONTRACT_HASH_INVALID"}
|
|
return {"ok": true, "code": "valid"}
|
|
|
|
|
|
func _validate_formation(value, contract: Dictionary, company: Dictionary) -> Dictionary:
|
|
if not value is Dictionary or value.is_empty():
|
|
return {"ok": false, "code": "FORMATION_STATE_INVALID"}
|
|
var formation: Dictionary = value
|
|
var required := [
|
|
"foundation_version", "line_capacity", "orientation", "front_position",
|
|
"status", "base_front_to_rear_ids", "draft_front_to_rear_ids",
|
|
"saved_front_to_rear_ids", "order_revision", "relations", "coverage",
|
|
"first_threat", "risk_options", "order_receipts", "readiness_receipts",
|
|
"accepted_party_snapshot", "formation_hash",
|
|
]
|
|
for field in required:
|
|
if not formation.has(field):
|
|
return {"ok": false, "code": "FORMATION_FIELD_MISSING", "field": field}
|
|
if (
|
|
int(formation.foundation_version) != 1
|
|
or int(formation.line_capacity) != int(contract.line_capacity)
|
|
or String(formation.orientation) != "rear_left_front_right"
|
|
or int(formation.front_position) != 0
|
|
or String(formation.status) not in ["draft", "ordered", "ready"]
|
|
or int(formation.order_revision) < 0
|
|
):
|
|
return {"ok": false, "code": "FORMATION_HEADER_INVALID"}
|
|
if company.owned_recruits.size() != int(contract.line_capacity) or contract.hire_receipts.size() != 2:
|
|
return {"ok": false, "code": "FORMATION_ROSTER_STATE_INVALID"}
|
|
var owned_ids: Array[String] = []
|
|
for recruit in company.owned_recruits:
|
|
owned_ids.append(String(recruit.recruit_id))
|
|
for field in ["base_front_to_rear_ids", "draft_front_to_rear_ids"]:
|
|
if not _is_exact_id_permutation(formation[field], owned_ids):
|
|
return {"ok": false, "code": "FORMATION_ORDER_INVALID", "field": field}
|
|
if not formation.saved_front_to_rear_ids is Array:
|
|
return {"ok": false, "code": "FORMATION_ORDER_INVALID", "field": "saved_front_to_rear_ids"}
|
|
if not formation.saved_front_to_rear_ids.is_empty() and not _is_exact_id_permutation(formation.saved_front_to_rear_ids, owned_ids):
|
|
return {"ok": false, "code": "FORMATION_ORDER_INVALID", "field": "saved_front_to_rear_ids"}
|
|
if not formation.relations is Array or formation.relations.size() != int(contract.line_capacity):
|
|
return {"ok": false, "code": "FORMATION_RELATIONS_INVALID"}
|
|
var relation_ids: Array[String] = []
|
|
for position in range(formation.relations.size()):
|
|
var relation = formation.relations[position]
|
|
if not relation is Dictionary:
|
|
return {"ok": false, "code": "FORMATION_RELATION_INVALID"}
|
|
for field in ["position", "recruit_id", "profession_id", "exposure_id", "expected_target_rank", "protected_by_recruit_ids", "protects_recruit_ids", "reach_id", "can_reach_first_threat", "first_threat_target", "relation_hash"]:
|
|
if not relation.has(field):
|
|
return {"ok": false, "code": "FORMATION_RELATION_FIELD_MISSING", "field": field}
|
|
if int(relation.position) != position or String(relation.recruit_id) != String(formation.draft_front_to_rear_ids[position]):
|
|
return {"ok": false, "code": "FORMATION_RELATION_POSITION_INVALID"}
|
|
if String(relation.recruit_id) in relation_ids:
|
|
return {"ok": false, "code": "FORMATION_RELATION_DUPLICATE"}
|
|
relation_ids.append(String(relation.recruit_id))
|
|
var unhashed_relation: Dictionary = relation.duplicate(true)
|
|
unhashed_relation.erase("relation_hash")
|
|
if String(relation.relation_hash) != _hash(unhashed_relation):
|
|
return {"ok": false, "code": "FORMATION_RELATION_HASH_INVALID"}
|
|
if not formation.coverage is Dictionary:
|
|
return {"ok": false, "code": "FORMATION_COVERAGE_INVALID"}
|
|
for field in ["protection", "long_reach", "sustain", "control", "coverage_hash"]:
|
|
if not formation.coverage.has(field):
|
|
return {"ok": false, "code": "FORMATION_COVERAGE_FIELD_MISSING", "field": field}
|
|
var unhashed_coverage: Dictionary = formation.coverage.duplicate(true)
|
|
unhashed_coverage.erase("coverage_hash")
|
|
if String(formation.coverage.coverage_hash) != _hash(unhashed_coverage):
|
|
return {"ok": false, "code": "FORMATION_COVERAGE_HASH_INVALID"}
|
|
if not formation.first_threat is Dictionary or not formation.risk_options is Array or formation.risk_options.size() != 3:
|
|
return {"ok": false, "code": "FORMATION_RISK_STATE_INVALID"}
|
|
for risk in range(formation.risk_options.size()):
|
|
var option = formation.risk_options[risk]
|
|
if not option is Dictionary:
|
|
return {"ok": false, "code": "FORMATION_RISK_OPTION_INVALID"}
|
|
for field in ["risk_option_version", "risk_option_id", "battle_offer_hash", "risk", "tier_id", "enemy_ids", "known_threat", "uncertainty_id", "entry_cost", "reward_coin", "reward_tier", "reward_class_id", "recovery_exposure_id", "knockout_rule_id", "option_hash"]:
|
|
if not option.has(field):
|
|
return {"ok": false, "code": "FORMATION_RISK_FIELD_MISSING", "field": field}
|
|
var battle_offer: Dictionary = contract.market_board.battle_offers[risk]
|
|
if (
|
|
int(option.risk_option_version) != 1
|
|
or int(option.risk) != risk
|
|
or String(option.risk_option_id) != String(battle_offer.offer_id)
|
|
or String(option.battle_offer_hash) != String(battle_offer.offer_hash)
|
|
or String(option.knockout_rule_id) != "returns_next_stage"
|
|
):
|
|
return {"ok": false, "code": "FORMATION_RISK_IDENTITY_INVALID"}
|
|
var unhashed_option: Dictionary = option.duplicate(true)
|
|
unhashed_option.erase("option_hash")
|
|
if String(option.option_hash) != _hash(unhashed_option):
|
|
return {"ok": false, "code": "FORMATION_RISK_HASH_INVALID"}
|
|
if formation.first_threat != formation.risk_options[0].known_threat:
|
|
return {"ok": false, "code": "FORMATION_FIRST_THREAT_INVALID"}
|
|
for field in ["order_receipts", "readiness_receipts"]:
|
|
if not formation[field] is Array:
|
|
return {"ok": false, "code": "FORMATION_RECEIPTS_INVALID", "field": field}
|
|
for receipt in formation.order_receipts:
|
|
if not _validate_formation_receipt(receipt, company, contract, "order").ok:
|
|
return {"ok": false, "code": "FORMATION_ORDER_RECEIPT_INVALID"}
|
|
for receipt in formation.readiness_receipts:
|
|
if not _validate_formation_receipt(receipt, company, contract, "readiness").ok:
|
|
return {"ok": false, "code": "FORMATION_READINESS_RECEIPT_INVALID"}
|
|
if String(formation.status) == "draft":
|
|
if not formation.saved_front_to_rear_ids.is_empty() or not formation.order_receipts.is_empty() or not formation.readiness_receipts.is_empty() or not formation.accepted_party_snapshot.is_empty():
|
|
return {"ok": false, "code": "FORMATION_DRAFT_STATE_INVALID"}
|
|
elif formation.saved_front_to_rear_ids.is_empty() or formation.order_receipts.is_empty():
|
|
return {"ok": false, "code": "FORMATION_SAVED_STATE_INVALID"}
|
|
if String(formation.status) == "ready":
|
|
if formation.readiness_receipts.size() != 1 or not _validate_party_snapshot(formation.accepted_party_snapshot, formation, company, contract).ok:
|
|
return {"ok": false, "code": "FORMATION_READY_STATE_INVALID"}
|
|
elif not formation.readiness_receipts.is_empty() or not formation.accepted_party_snapshot.is_empty():
|
|
return {"ok": false, "code": "FORMATION_PREMATURE_READY_STATE"}
|
|
var unhashed: Dictionary = formation.duplicate(true)
|
|
unhashed.erase("formation_hash")
|
|
if String(formation.formation_hash) != _hash(unhashed):
|
|
return {"ok": false, "code": "FORMATION_HASH_INVALID"}
|
|
return {"ok": true, "code": "valid"}
|
|
|
|
|
|
func _validate_formation_receipt(value, company: Dictionary, contract: Dictionary, kind: String) -> Dictionary:
|
|
if not value is Dictionary:
|
|
return {"ok": false}
|
|
var receipt: Dictionary = value
|
|
var fields := ["operation_id", "intent_hash", "company_id", "run_id", "order_hash", "order_revision", "receipt_hash"]
|
|
if kind == "order":
|
|
fields.append_array(["front_to_rear_ids", "line_capacity", "relations_hash", "coverage_hash"])
|
|
else:
|
|
fields.append_array(["snapshot_id", "snapshot_hash"])
|
|
for field in fields:
|
|
if not receipt.has(field):
|
|
return {"ok": false}
|
|
if (
|
|
not _is_hex(String(receipt.operation_id), 32)
|
|
or not _is_hex(String(receipt.intent_hash), 64)
|
|
or not _is_hex(String(receipt.order_hash), 64)
|
|
or String(receipt.company_id) != String(company.company_id)
|
|
or String(receipt.run_id) != String(contract.run_id)
|
|
):
|
|
return {"ok": false}
|
|
var unhashed: Dictionary = receipt.duplicate(true)
|
|
unhashed.erase("receipt_hash")
|
|
return {"ok": String(receipt.receipt_hash) == _hash(unhashed)}
|
|
|
|
|
|
func _validate_party_snapshot(value, formation: Dictionary, company: Dictionary, contract: Dictionary) -> Dictionary:
|
|
if not value is Dictionary:
|
|
return {"ok": false}
|
|
var snapshot: Dictionary = value
|
|
for field in ["snapshot_id", "company_id", "run_id", "front_to_rear_ids", "order_hash", "order_revision", "line_capacity", "orientation", "front_position", "relations", "coverage", "risk_option_ids", "snapshot_hash"]:
|
|
if not snapshot.has(field):
|
|
return {"ok": false}
|
|
if (
|
|
not _is_hex(String(snapshot.snapshot_id), 32)
|
|
or String(snapshot.company_id) != String(company.company_id)
|
|
or String(snapshot.run_id) != String(contract.run_id)
|
|
or snapshot.front_to_rear_ids != formation.saved_front_to_rear_ids
|
|
or String(snapshot.order_hash) != _hash(snapshot.front_to_rear_ids)
|
|
or int(snapshot.order_revision) != int(formation.order_revision)
|
|
or int(snapshot.line_capacity) != int(contract.line_capacity)
|
|
or String(snapshot.orientation) != "rear_left_front_right"
|
|
or int(snapshot.front_position) != 0
|
|
):
|
|
return {"ok": false}
|
|
var unhashed: Dictionary = snapshot.duplicate(true)
|
|
unhashed.erase("snapshot_hash")
|
|
return {"ok": String(snapshot.snapshot_hash) == _hash(unhashed)}
|
|
|
|
|
|
func _is_exact_id_permutation(value, expected: Array[String]) -> bool:
|
|
if not value is Array or value.size() != expected.size():
|
|
return false
|
|
var actual: Array[String] = []
|
|
for item in value:
|
|
var recruit_id := String(item)
|
|
if recruit_id in actual:
|
|
return false
|
|
actual.append(recruit_id)
|
|
actual.sort()
|
|
var sorted_expected := expected.duplicate()
|
|
sorted_expected.sort()
|
|
return actual == sorted_expected
|
|
|
|
|
|
func _validate_first_market_board(board: Dictionary, contract: Dictionary) -> Dictionary:
|
|
var required := [
|
|
"board_id", "board_hash", "run_seed", "contract_id", "market_index",
|
|
"refresh_index", "recruit_offers", "equipment_offers", "battle_offers",
|
|
]
|
|
for field in required:
|
|
if not board.has(field):
|
|
return {"ok": false, "code": "CONTRACT_MARKET_FIELD_MISSING", "field": field}
|
|
if (
|
|
String(board.run_seed) != String(contract.run_seed)
|
|
or String(board.contract_id) != String(contract.contract_id)
|
|
or int(board.market_index) != 1
|
|
or int(board.refresh_index) != 0
|
|
or not _is_hex(String(board.board_id), 32)
|
|
):
|
|
return {"ok": false, "code": "CONTRACT_MARKET_IDENTITY_INVALID"}
|
|
if not board.recruit_offers is Array or board.recruit_offers.size() != 3:
|
|
return {"ok": false, "code": "CONTRACT_RECRUIT_OFFER_COUNT_INVALID"}
|
|
if not board.equipment_offers is Array or board.equipment_offers.size() != 4:
|
|
return {"ok": false, "code": "CONTRACT_EQUIPMENT_OFFER_COUNT_INVALID"}
|
|
if not board.battle_offers is Array or board.battle_offers.size() != 3:
|
|
return {"ok": false, "code": "CONTRACT_BATTLE_OFFER_COUNT_INVALID"}
|
|
var ids: Array[String] = []
|
|
for offer in board.recruit_offers:
|
|
for field in ["source_offer_id", "recruit_id", "profession_id", "trait_id", "starting_equipment_id", "canonical_output_hash", "list_price", "offer_level"]:
|
|
if not offer.has(field):
|
|
return {"ok": false, "code": "CONTRACT_RECRUIT_OFFER_INVALID", "field": field}
|
|
var offer_id := String(offer.source_offer_id)
|
|
if not _is_hex(offer_id, 32) or offer_id in ids or int(offer.list_price) != 14 or int(offer.offer_level) != 1:
|
|
return {"ok": false, "code": "CONTRACT_RECRUIT_OFFER_INVALID"}
|
|
ids.append(offer_id)
|
|
for collection in [board.equipment_offers, board.battle_offers]:
|
|
for offer in collection:
|
|
var offer_id := String(offer.get("offer_id", ""))
|
|
if not _is_hex(offer_id, 32) or offer_id in ids:
|
|
return {"ok": false, "code": "CONTRACT_OFFER_ID_INVALID"}
|
|
ids.append(offer_id)
|
|
var unhashed := board.duplicate(true)
|
|
unhashed.erase("board_hash")
|
|
if String(board.board_hash) != _hash(unhashed):
|
|
return {"ok": false, "code": "CONTRACT_MARKET_HASH_INVALID"}
|
|
return {"ok": true, "code": "valid"}
|
|
|
|
|
|
func _validate_contract_creation_receipt(value, contract: Dictionary) -> Dictionary:
|
|
if not value is Dictionary:
|
|
return {"ok": false, "code": "CONTRACT_RECEIPT_TYPE_INVALID"}
|
|
var receipt: Dictionary = value
|
|
for field in ["operation_id", "intent_hash", "company_id", "contract_ordinal", "contract_id", "difficulty", "run_id", "run_seed", "board_hash", "coin", "line_capacity", "receipt_hash"]:
|
|
if not receipt.has(field):
|
|
return {"ok": false, "code": "CONTRACT_RECEIPT_FIELD_MISSING", "field": field}
|
|
if not _is_hex(String(receipt.operation_id), 32) or not _is_hex(String(receipt.intent_hash), 64):
|
|
return {"ok": false, "code": "CONTRACT_RECEIPT_ID_INVALID"}
|
|
if (
|
|
String(receipt.company_id) != String(contract.company_id)
|
|
or int(receipt.contract_ordinal) != 0
|
|
or String(receipt.contract_id) != String(contract.contract_id)
|
|
or String(receipt.difficulty) != String(contract.difficulty)
|
|
or String(receipt.run_id) != String(contract.run_id)
|
|
or String(receipt.run_seed) != String(contract.run_seed)
|
|
or String(receipt.board_hash) != String(contract.market_board.board_hash)
|
|
or int(receipt.coin) != 52
|
|
or int(receipt.line_capacity) != 4
|
|
):
|
|
return {"ok": false, "code": "CONTRACT_RECEIPT_STATE_INVALID"}
|
|
var expected_intent := _hash({
|
|
"company_id": String(contract.company_id),
|
|
"contract_ordinal": 0,
|
|
"contract_id": String(contract.contract_id),
|
|
"difficulty": String(contract.difficulty),
|
|
})
|
|
if String(receipt.intent_hash) != expected_intent:
|
|
return {"ok": false, "code": "CONTRACT_RECEIPT_INTENT_INVALID"}
|
|
var unhashed: Dictionary = receipt.duplicate(true)
|
|
unhashed.erase("receipt_hash")
|
|
if String(receipt.receipt_hash) != _hash(unhashed):
|
|
return {"ok": false, "code": "CONTRACT_RECEIPT_HASH_INVALID"}
|
|
return {"ok": true, "code": "valid"}
|
|
|
|
|
|
func _validate_hire_receipt(value, contract: Dictionary, index: int) -> Dictionary:
|
|
if not value is Dictionary:
|
|
return {"ok": false, "code": "CONTRACT_HIRE_RECEIPT_TYPE_INVALID"}
|
|
var receipt: Dictionary = value
|
|
var required := [
|
|
"operation_id", "intent_hash", "company_id", "run_id", "board_hash",
|
|
"offer_id", "recruit_id", "expected_price", "list_price",
|
|
"coin_before", "coin_after", "company_before", "company_after",
|
|
"line_capacity", "receipt_hash",
|
|
]
|
|
for field in required:
|
|
if not receipt.has(field):
|
|
return {"ok": false, "code": "CONTRACT_HIRE_RECEIPT_FIELD_MISSING", "field": field}
|
|
if not _is_hex(String(receipt.operation_id), 32) or not _is_hex(String(receipt.intent_hash), 64):
|
|
return {"ok": false, "code": "CONTRACT_HIRE_RECEIPT_ID_INVALID"}
|
|
var offer: Dictionary = {}
|
|
for candidate in contract.market_board.recruit_offers:
|
|
if String(candidate.source_offer_id) == String(receipt.offer_id):
|
|
offer = candidate
|
|
break
|
|
if offer.is_empty() or String(offer.recruit_id) != String(receipt.recruit_id):
|
|
return {"ok": false, "code": "CONTRACT_HIRE_RECEIPT_OFFER_INVALID"}
|
|
if (
|
|
String(receipt.company_id) != String(contract.company_id)
|
|
or String(receipt.run_id) != String(contract.run_id)
|
|
or String(receipt.board_hash) != String(contract.market_board.board_hash)
|
|
or int(receipt.expected_price) != 14
|
|
or int(receipt.list_price) != 14
|
|
or int(receipt.coin_before) != 52 - 14 * index
|
|
or int(receipt.coin_after) != 38 - 14 * index
|
|
or int(receipt.company_before) != 2 + index
|
|
or int(receipt.company_after) != 3 + index
|
|
or int(receipt.line_capacity) != 4
|
|
):
|
|
return {"ok": false, "code": "CONTRACT_HIRE_RECEIPT_STATE_INVALID"}
|
|
var expected_intent := _hash({
|
|
"company_id": String(contract.company_id),
|
|
"run_id": String(contract.run_id),
|
|
"board_hash": String(contract.market_board.board_hash),
|
|
"offer_id": String(receipt.offer_id),
|
|
"expected_price": 14,
|
|
})
|
|
if String(receipt.intent_hash) != expected_intent:
|
|
return {"ok": false, "code": "CONTRACT_HIRE_RECEIPT_INTENT_INVALID"}
|
|
var unhashed: Dictionary = receipt.duplicate(true)
|
|
unhashed.erase("receipt_hash")
|
|
if String(receipt.receipt_hash) != _hash(unhashed):
|
|
return {"ok": false, "code": "CONTRACT_HIRE_RECEIPT_HASH_INVALID"}
|
|
return {"ok": true, "code": "valid"}
|
|
|
|
|
|
func _validate_contract_return_receipt(value, contract: Dictionary, company: Dictionary) -> Dictionary:
|
|
if not value is Dictionary:
|
|
return {"ok": false, "code": "CONTRACT_RETURN_RECEIPT_TYPE_INVALID"}
|
|
var receipt: Dictionary = value
|
|
for field in [
|
|
"operation_id", "intent_hash", "company_id", "run_id", "contract_id",
|
|
"retained_coin", "retained_recruit_ids", "destination", "receipt_hash",
|
|
]:
|
|
if not receipt.has(field):
|
|
return {"ok": false, "code": "CONTRACT_RETURN_RECEIPT_FIELD_MISSING", "field": field}
|
|
if (
|
|
not _is_hex(String(receipt.operation_id), 32)
|
|
or not _is_hex(String(receipt.intent_hash), 64)
|
|
or not _is_hex(String(receipt.receipt_hash), 64)
|
|
):
|
|
return {"ok": false, "code": "CONTRACT_RETURN_RECEIPT_ID_INVALID"}
|
|
if (
|
|
String(receipt.company_id) != String(company.company_id)
|
|
or String(receipt.run_id) != String(contract.run_id)
|
|
or String(receipt.contract_id) != String(contract.contract_id)
|
|
or String(receipt.destination) != "guild"
|
|
or int(receipt.retained_coin) != int(contract.coin)
|
|
or int(receipt.retained_coin) < 0
|
|
):
|
|
return {"ok": false, "code": "CONTRACT_RETURN_RECEIPT_STATE_INVALID"}
|
|
if not receipt.retained_recruit_ids is Array:
|
|
return {"ok": false, "code": "CONTRACT_RETURN_RECEIPT_ROSTER_INVALID"}
|
|
var expected_ids: Array[String] = []
|
|
for recruit in company.owned_recruits:
|
|
expected_ids.append(String(recruit.recruit_id))
|
|
var retained_ids: Array[String] = []
|
|
for retained_id in receipt.retained_recruit_ids:
|
|
var recruit_id := String(retained_id)
|
|
if not _is_hex(recruit_id, 32) or recruit_id in retained_ids:
|
|
return {"ok": false, "code": "CONTRACT_RETURN_RECEIPT_ROSTER_INVALID"}
|
|
retained_ids.append(recruit_id)
|
|
if retained_ids != expected_ids:
|
|
return {"ok": false, "code": "CONTRACT_RETURN_RECEIPT_ROSTER_INVALID"}
|
|
var expected_intent := _hash({
|
|
"company_id": String(company.company_id),
|
|
"run_id": String(contract.run_id),
|
|
"contract_id": String(contract.contract_id),
|
|
})
|
|
if String(receipt.intent_hash) != expected_intent:
|
|
return {"ok": false, "code": "CONTRACT_RETURN_RECEIPT_INTENT_INVALID"}
|
|
var unhashed: Dictionary = receipt.duplicate(true)
|
|
unhashed.erase("receipt_hash")
|
|
if String(receipt.receipt_hash) != _hash(unhashed):
|
|
return {"ok": false, "code": "CONTRACT_RETURN_RECEIPT_HASH_INVALID"}
|
|
return {"ok": true, "code": "valid"}
|
|
|
|
|
|
func _project_offer(provider: Dictionary, index: int, draft_id: String) -> Dictionary:
|
|
var professions := ContentRegistry.rows_by_id(_content, "professions")
|
|
var traits := ContentRegistry.rows_by_id(_content, "traits")
|
|
var equipment := ContentRegistry.rows_by_id(_content, "equipment")
|
|
var profession_id := String(provider.profession_id)
|
|
var trait_id := String(provider.trait_id)
|
|
var item_id := String(provider.starting_equipment_id)
|
|
var profession: Dictionary = professions.get(profession_id, {})
|
|
var trait_row: Dictionary = traits.get(trait_id, {})
|
|
var item: Dictionary = equipment.get(item_id, {})
|
|
var name_tokens: Array = Array(provider.display_name_tokens).duplicate()
|
|
var display_name := "%s %s" % [_name_token(name_tokens[0]), _name_token(name_tokens[1])]
|
|
var offer := {
|
|
"offer_id": String(provider.source_offer_id),
|
|
"offer_index": index,
|
|
"recruit_id": String(provider.recruit_id),
|
|
"source_draft_id": draft_id,
|
|
"generation_seed": String(provider.generation_seed),
|
|
"name_component_ids": name_tokens,
|
|
"rendered_name_snapshot": {"en": display_name, "zh_CN": display_name},
|
|
"appearance_seed": String(provider.appearance_seed),
|
|
"profession_id": profession_id,
|
|
"profession_name": {
|
|
"en": String(profession.get("name_en", profession.get("name", ""))),
|
|
"zh_CN": String(profession.get("name_zh", "")),
|
|
},
|
|
"profession_job": String(profession.get("role", "")),
|
|
"attributes": _numeric_attributes(String(provider.generation_seed)),
|
|
"trait_id": trait_id,
|
|
"trait_name": {
|
|
"en": String(trait_row.get("name_en", trait_row.get("name", ""))),
|
|
"zh_CN": String(trait_row.get("name_zh", "")),
|
|
},
|
|
"trait_benefit": String(trait_row.get("benefit", "")),
|
|
"trait_cost": String(trait_row.get("cost", "")),
|
|
"starting_item_id": item_id,
|
|
"starting_item_name": {
|
|
"en": String(item.get("name_en", item.get("name", ""))),
|
|
"zh_CN": String(item.get("name_zh", "")),
|
|
},
|
|
"starting_item_state": "equipped",
|
|
"level": 1,
|
|
"xp": 0,
|
|
"state": "provisional",
|
|
}
|
|
offer["canonical_output_hash"] = _hash(offer)
|
|
return offer
|
|
|
|
|
|
func _validate_offer(value, expected_index: int, draft_id: String) -> Dictionary:
|
|
if not value is Dictionary:
|
|
return {"ok": false, "code": "DRAFT_OFFER_TYPE_INVALID"}
|
|
var offer: Dictionary = value
|
|
var required := [
|
|
"offer_id", "offer_index", "recruit_id", "source_draft_id",
|
|
"generation_seed", "name_component_ids", "rendered_name_snapshot",
|
|
"appearance_seed", "profession_id", "profession_name", "profession_job",
|
|
"attributes", "trait_id", "trait_name", "trait_benefit", "trait_cost",
|
|
"starting_item_id", "starting_item_name", "starting_item_state", "level",
|
|
"xp", "state", "canonical_output_hash",
|
|
]
|
|
for field in required:
|
|
if not offer.has(field):
|
|
return {"ok": false, "code": "DRAFT_OFFER_FIELD_MISSING", "field": field}
|
|
if int(offer.offer_index) != expected_index:
|
|
return {"ok": false, "code": "DRAFT_OFFER_POSITION_INVALID"}
|
|
if not _is_hex(String(offer.offer_id), 32) or not _is_hex(String(offer.recruit_id), 32):
|
|
return {"ok": false, "code": "DRAFT_OFFER_ID_INVALID"}
|
|
if String(offer.source_draft_id) != draft_id or not _is_hex(String(offer.generation_seed), 32):
|
|
return {"ok": false, "code": "DRAFT_OFFER_SOURCE_INVALID"}
|
|
if not offer.name_component_ids is Array or offer.name_component_ids.size() != 2:
|
|
return {"ok": false, "code": "DRAFT_OFFER_NAME_INVALID"}
|
|
for localized_field in ["rendered_name_snapshot", "profession_name", "trait_name", "starting_item_name"]:
|
|
var localized = offer[localized_field]
|
|
if (
|
|
not localized is Dictionary
|
|
or String(localized.get("en", "")).strip_edges().is_empty()
|
|
or String(localized.get("zh_CN", "")).strip_edges().is_empty()
|
|
):
|
|
return {"ok": false, "code": "DRAFT_OFFER_LOCALIZATION_INVALID", "field": localized_field}
|
|
var professions := ContentRegistry.rows_by_id(_content, "professions")
|
|
var traits := ContentRegistry.rows_by_id(_content, "traits")
|
|
var equipment := ContentRegistry.rows_by_id(_content, "equipment")
|
|
if not _catalog_id(String(offer.profession_id), "PF-B", 2) or not professions.has(String(offer.profession_id)):
|
|
return {"ok": false, "code": "DRAFT_OFFER_PROFESSION_INVALID"}
|
|
if not _catalog_id(String(offer.trait_id), "TR-", 3) or not traits.has(String(offer.trait_id)):
|
|
return {"ok": false, "code": "DRAFT_OFFER_TRAIT_INVALID"}
|
|
if not _catalog_id(String(offer.starting_item_id), "EQ-", 3) or not equipment.has(String(offer.starting_item_id)) or String(offer.starting_item_state) != "equipped":
|
|
return {"ok": false, "code": "DRAFT_OFFER_ITEM_INVALID"}
|
|
if String(offer.profession_job).strip_edges().is_empty() or String(offer.trait_benefit).strip_edges().is_empty() or String(offer.trait_cost).strip_edges().is_empty():
|
|
return {"ok": false, "code": "DRAFT_OFFER_MECHANIC_INVALID"}
|
|
if not offer.attributes is Dictionary:
|
|
return {"ok": false, "code": "DRAFT_OFFER_ATTRIBUTES_INVALID"}
|
|
var attribute_keys: Array = offer.attributes.keys()
|
|
attribute_keys.sort()
|
|
var expected_keys := ATTRIBUTE_IDS.duplicate()
|
|
expected_keys.sort()
|
|
if attribute_keys != expected_keys:
|
|
return {"ok": false, "code": "DRAFT_OFFER_ATTRIBUTES_INVALID"}
|
|
var total := 0
|
|
for attribute_id in ATTRIBUTE_IDS:
|
|
var amount := int(offer.attributes[attribute_id])
|
|
if amount < 4 or amount > 15:
|
|
return {"ok": false, "code": "DRAFT_OFFER_ATTRIBUTE_RANGE_INVALID"}
|
|
total += amount
|
|
if total != 35:
|
|
return {"ok": false, "code": "DRAFT_OFFER_ATTRIBUTE_BUDGET_INVALID"}
|
|
if int(offer.level) != 1 or int(offer.xp) != 0 or String(offer.state) != "provisional":
|
|
return {"ok": false, "code": "DRAFT_OFFER_STATE_INVALID"}
|
|
var unhashed := offer.duplicate(true)
|
|
unhashed.erase("canonical_output_hash")
|
|
if String(offer.canonical_output_hash) != _hash(unhashed):
|
|
return {"ok": false, "code": "DRAFT_OFFER_HASH_INVALID"}
|
|
return {"ok": true, "code": "valid"}
|
|
|
|
|
|
func _normalize_selection(values: Array, offers: Array) -> Dictionary:
|
|
if values.size() > 2:
|
|
return {"ok": false, "code": "DRAFT_SELECTION_LIMIT"}
|
|
var positions := {}
|
|
for offer in offers:
|
|
positions[String(offer.offer_id)] = int(offer.offer_index)
|
|
var ids: Array[String] = []
|
|
for value in values:
|
|
var offer_id := String(value)
|
|
if not positions.has(offer_id):
|
|
return {"ok": false, "code": "DRAFT_SELECTION_UNKNOWN_ID"}
|
|
if offer_id in ids:
|
|
return {"ok": false, "code": "DRAFT_SELECTION_DUPLICATE_ID"}
|
|
ids.append(offer_id)
|
|
ids.sort_custom(func(left: String, right: String): return int(positions[left]) < int(positions[right]))
|
|
return {"ok": true, "code": "selection_valid", "ids": ids}
|
|
|
|
|
|
func _numeric_attributes(seed: String) -> Dictionary:
|
|
var result := {}
|
|
for attribute_id in ATTRIBUTE_IDS:
|
|
result[attribute_id] = 4
|
|
for point in range(15):
|
|
var candidates := ATTRIBUTE_IDS.duplicate()
|
|
candidates.sort_custom(func(left: String, right: String):
|
|
return _digest(seed, "attribute-point", [point, left]) < _digest(seed, "attribute-point", [point, right])
|
|
)
|
|
for attribute_id in candidates:
|
|
if int(result[attribute_id]) < 15:
|
|
result[attribute_id] = int(result[attribute_id]) + 1
|
|
break
|
|
return result
|
|
|
|
|
|
func _save_draft(draft: Dictionary) -> Dictionary:
|
|
_refresh_draft_hash(draft)
|
|
var validation := validate_draft(draft)
|
|
if not validation.ok:
|
|
return validation
|
|
var candidate_path := _candidate_path()
|
|
var write := _write_verified(candidate_path, draft)
|
|
if not write.ok:
|
|
return write
|
|
if FileAccess.file_exists(path):
|
|
if FileAccess.file_exists(_backup_path()):
|
|
DirAccess.remove_absolute(_backup_path())
|
|
var backup_error := DirAccess.rename_absolute(path, _backup_path())
|
|
if backup_error != OK:
|
|
DirAccess.remove_absolute(candidate_path)
|
|
return {"ok": false, "code": "DRAFT_BACKUP_FAILED"}
|
|
var promote_error := DirAccess.rename_absolute(candidate_path, path)
|
|
if promote_error != OK:
|
|
if FileAccess.file_exists(_backup_path()) and not FileAccess.file_exists(path):
|
|
DirAccess.rename_absolute(_backup_path(), path)
|
|
return {"ok": false, "code": "DRAFT_PROMOTE_FAILED"}
|
|
var mirror := _write_verified(_backup_candidate_path(), draft)
|
|
if mirror.ok:
|
|
if FileAccess.file_exists(_backup_path()):
|
|
DirAccess.remove_absolute(_backup_path())
|
|
DirAccess.rename_absolute(_backup_candidate_path(), _backup_path())
|
|
return {"ok": true, "code": "saved", "draft": draft.duplicate(true)}
|
|
|
|
|
|
func _restore_primary(draft: Dictionary, use_existing_candidate: bool) -> Dictionary:
|
|
var candidate_path := _candidate_path()
|
|
if not use_existing_candidate or not FileAccess.file_exists(candidate_path):
|
|
var write := _write_verified(candidate_path, draft)
|
|
if not write.ok:
|
|
return {"ok": false, "code": "DRAFT_BACKUP_RESTORE_FAILED"}
|
|
var corrupt_path := "%s.corrupt" % path
|
|
if FileAccess.file_exists(corrupt_path):
|
|
DirAccess.remove_absolute(corrupt_path)
|
|
if FileAccess.file_exists(path):
|
|
if DirAccess.rename_absolute(path, corrupt_path) != OK:
|
|
DirAccess.remove_absolute(candidate_path)
|
|
return {"ok": false, "code": "DRAFT_BACKUP_RESTORE_FAILED"}
|
|
if DirAccess.rename_absolute(candidate_path, path) != OK:
|
|
if FileAccess.file_exists(corrupt_path):
|
|
DirAccess.rename_absolute(corrupt_path, path)
|
|
return {"ok": false, "code": "DRAFT_BACKUP_RESTORE_FAILED"}
|
|
return {"ok": true, "code": "restored"}
|
|
|
|
|
|
func _read_valid(file_path: String) -> Dictionary:
|
|
if not FileAccess.file_exists(file_path):
|
|
return {"ok": false, "code": "missing"}
|
|
var parser := JSON.new()
|
|
if parser.parse(FileAccess.get_file_as_string(file_path)) != OK:
|
|
return {"ok": false, "code": "invalid_json"}
|
|
var validation := validate_draft(parser.data)
|
|
if not validation.ok:
|
|
return validation
|
|
return {"ok": true, "code": "valid", "draft": Dictionary(parser.data).duplicate(true)}
|
|
|
|
|
|
func _write_verified(file_path: String, draft: Dictionary) -> Dictionary:
|
|
var file := FileAccess.open(file_path, FileAccess.WRITE)
|
|
if file == null:
|
|
return {"ok": false, "code": "DRAFT_WRITE_UNAVAILABLE"}
|
|
file.store_string(JSON.stringify(_canonical(draft)))
|
|
file.close()
|
|
var verified := _read_valid(file_path)
|
|
if not verified.ok:
|
|
DirAccess.remove_absolute(file_path)
|
|
return {
|
|
"ok": false,
|
|
"code": "DRAFT_WRITE_VERIFY_FAILED",
|
|
"reason": String(verified.get("code", "unknown")),
|
|
}
|
|
return {"ok": true, "code": "written"}
|
|
|
|
|
|
func _save_company(company: Dictionary) -> Dictionary:
|
|
var validation := validate_company(company)
|
|
if not validation.ok:
|
|
return validation
|
|
var candidate_path := _company_candidate_path()
|
|
var write := _write_verified_company(candidate_path, company)
|
|
if not write.ok:
|
|
return write
|
|
if FileAccess.file_exists(company_path):
|
|
if FileAccess.file_exists(_company_backup_path()):
|
|
DirAccess.remove_absolute(_company_backup_path())
|
|
var backup_error := DirAccess.rename_absolute(company_path, _company_backup_path())
|
|
if backup_error != OK:
|
|
DirAccess.remove_absolute(candidate_path)
|
|
return {"ok": false, "code": "COMPANY_BACKUP_FAILED"}
|
|
var promote_error := DirAccess.rename_absolute(candidate_path, company_path)
|
|
if promote_error != OK:
|
|
if FileAccess.file_exists(_company_backup_path()) and not FileAccess.file_exists(company_path):
|
|
DirAccess.rename_absolute(_company_backup_path(), company_path)
|
|
return {"ok": false, "code": "COMPANY_PROMOTE_FAILED"}
|
|
var mirror := _write_verified_company(_company_backup_candidate_path(), company)
|
|
if mirror.ok:
|
|
if FileAccess.file_exists(_company_backup_path()):
|
|
DirAccess.remove_absolute(_company_backup_path())
|
|
DirAccess.rename_absolute(_company_backup_candidate_path(), _company_backup_path())
|
|
return {"ok": true, "code": "saved", "company": company.duplicate(true)}
|
|
|
|
|
|
func _read_valid_company(file_path: String) -> Dictionary:
|
|
if not FileAccess.file_exists(file_path):
|
|
return {"ok": false, "code": "missing"}
|
|
var parser := JSON.new()
|
|
if parser.parse(FileAccess.get_file_as_string(file_path)) != OK:
|
|
return {"ok": false, "code": "invalid_json"}
|
|
if parser.data is Dictionary and int(parser.data.get("company_version", -1)) == LEGACY_COMPANY_VERSION:
|
|
var legacy_validation := _validate_legacy_company(parser.data)
|
|
if not legacy_validation.ok:
|
|
return legacy_validation
|
|
var migrated := _migrate_company_v1(parser.data)
|
|
var migrated_validation := validate_company(migrated)
|
|
if not migrated_validation.ok:
|
|
return migrated_validation
|
|
return {"ok": true, "code": "valid_migrated", "company": migrated, "migrated": true}
|
|
var validation := validate_company(parser.data)
|
|
if not validation.ok:
|
|
return validation
|
|
return {"ok": true, "code": "valid", "company": Dictionary(parser.data).duplicate(true)}
|
|
|
|
|
|
func _validate_legacy_company(value) -> Dictionary:
|
|
if not value is Dictionary:
|
|
return {"ok": false, "code": "COMPANY_NOT_DICTIONARY"}
|
|
var company: Dictionary = value
|
|
var required := [
|
|
"company_version", "company_kind", "company_id", "created_from_draft_id",
|
|
"source_offer_set_hash", "source_revision", "owned_recruits",
|
|
"creation_receipt", "company_hash",
|
|
]
|
|
for field in required:
|
|
if not company.has(field):
|
|
return {"ok": false, "code": "COMPANY_FIELD_MISSING", "field": field}
|
|
if int(company.company_version) != LEGACY_COMPANY_VERSION or String(company.company_kind) != COMPANY_KIND:
|
|
return {"ok": false, "code": "COMPANY_VERSION_UNSUPPORTED"}
|
|
if not _is_hex(String(company.company_id), 32) or not _is_hex(String(company.created_from_draft_id), 32):
|
|
return {"ok": false, "code": "COMPANY_IDENTITY_INVALID"}
|
|
if not _is_hex(String(company.source_offer_set_hash), 64) or String(company.source_revision) != SOURCE_REVISION:
|
|
return {"ok": false, "code": "COMPANY_SOURCE_INVALID"}
|
|
if not company.owned_recruits is Array or company.owned_recruits.size() != 2:
|
|
return {"ok": false, "code": "COMPANY_ROSTER_COUNT_INVALID"}
|
|
var recruit_ids: Array[String] = []
|
|
for recruit in company.owned_recruits:
|
|
var recruit_validation := _validate_owned_recruit(recruit, String(company.created_from_draft_id))
|
|
if not recruit_validation.ok:
|
|
return recruit_validation
|
|
if String(recruit.recruit_id) in recruit_ids:
|
|
return {"ok": false, "code": "COMPANY_RECRUIT_ID_DUPLICATE"}
|
|
recruit_ids.append(String(recruit.recruit_id))
|
|
var receipt_validation := _validate_company_receipt(company.creation_receipt, company)
|
|
if not receipt_validation.ok:
|
|
return receipt_validation
|
|
for forbidden in ["offers", "unchosen_offer_ids", "current_run", "active_contract"]:
|
|
if company.has(forbidden):
|
|
return {"ok": false, "code": "COMPANY_FORBIDDEN_STATE"}
|
|
var unhashed := company.duplicate(true)
|
|
unhashed.erase("company_hash")
|
|
if String(company.company_hash) != _hash(unhashed):
|
|
return {"ok": false, "code": "COMPANY_HASH_INVALID"}
|
|
return {"ok": true, "code": "valid"}
|
|
|
|
|
|
func _migrate_company_v1(value: Dictionary) -> Dictionary:
|
|
var migrated := value.duplicate(true)
|
|
migrated.company_version = COMPANY_VERSION
|
|
migrated.company_revision = 0
|
|
migrated.next_contract_ordinal = 0
|
|
migrated.active_contract = {}
|
|
migrated.contract_receipts = []
|
|
migrated.erase("company_hash")
|
|
migrated.company_hash = _hash(migrated)
|
|
return migrated
|
|
|
|
|
|
func _write_verified_company(file_path: String, company: Dictionary) -> Dictionary:
|
|
var file := FileAccess.open(file_path, FileAccess.WRITE)
|
|
if file == null:
|
|
return {"ok": false, "code": "COMPANY_WRITE_UNAVAILABLE"}
|
|
file.store_string(JSON.stringify(_canonical(company)))
|
|
file.close()
|
|
var verified := _read_valid_company(file_path)
|
|
if not verified.ok:
|
|
DirAccess.remove_absolute(file_path)
|
|
return {
|
|
"ok": false,
|
|
"code": "COMPANY_WRITE_VERIFY_FAILED",
|
|
"reason": String(verified.get("code", "unknown")),
|
|
}
|
|
return {"ok": true, "code": "written"}
|
|
|
|
|
|
func _restore_company_primary(company: Dictionary, use_existing_candidate: bool) -> Dictionary:
|
|
var candidate_path := _company_candidate_path()
|
|
if not use_existing_candidate or not FileAccess.file_exists(candidate_path):
|
|
var write := _write_verified_company(candidate_path, company)
|
|
if not write.ok:
|
|
return {"ok": false, "code": "COMPANY_BACKUP_RESTORE_FAILED"}
|
|
var corrupt_path := "%s.corrupt" % company_path
|
|
if FileAccess.file_exists(corrupt_path):
|
|
DirAccess.remove_absolute(corrupt_path)
|
|
if FileAccess.file_exists(company_path):
|
|
if DirAccess.rename_absolute(company_path, corrupt_path) != OK:
|
|
DirAccess.remove_absolute(candidate_path)
|
|
return {"ok": false, "code": "COMPANY_BACKUP_RESTORE_FAILED"}
|
|
if DirAccess.rename_absolute(candidate_path, company_path) != OK:
|
|
if FileAccess.file_exists(corrupt_path):
|
|
DirAccess.rename_absolute(corrupt_path, company_path)
|
|
return {"ok": false, "code": "COMPANY_BACKUP_RESTORE_FAILED"}
|
|
return {"ok": true, "code": "restored"}
|
|
|
|
|
|
func _remove_draft_files() -> bool:
|
|
var ok := true
|
|
for file_path in [path, _backup_path(), _candidate_path(), _backup_candidate_path(), "%s.corrupt" % path]:
|
|
if FileAccess.file_exists(file_path) and DirAccess.remove_absolute(file_path) != OK:
|
|
ok = false
|
|
return ok
|
|
|
|
|
|
func _refresh_draft_hash(draft: Dictionary) -> void:
|
|
draft.offer_set_hash = _hash(draft.offers)
|
|
draft.erase("draft_hash")
|
|
draft.draft_hash = _hash(draft)
|
|
|
|
|
|
func _name_token(token) -> String:
|
|
var parts := String(token).split(".")
|
|
return String(parts[parts.size() - 1])
|
|
|
|
|
|
func _catalog_id(value: String, prefix: String, digits: int) -> bool:
|
|
if not value.begins_with(prefix) or value.length() != prefix.length() + digits:
|
|
return false
|
|
return String(value.substr(prefix.length())).is_valid_int()
|
|
|
|
|
|
func _is_hex(value: String, length: int) -> bool:
|
|
if value.length() != length:
|
|
return false
|
|
for character in value:
|
|
if character not in "0123456789abcdef":
|
|
return false
|
|
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) -> 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
|
|
|
|
|
|
func _candidate_path() -> String:
|
|
return "%s.candidate" % path
|
|
|
|
|
|
func _backup_path() -> String:
|
|
return "%s.bak" % path
|
|
|
|
|
|
func _backup_candidate_path() -> String:
|
|
return "%s.bak.candidate" % path
|
|
|
|
|
|
func _company_candidate_path() -> String:
|
|
return "%s.candidate" % company_path
|
|
|
|
|
|
func _company_backup_path() -> String:
|
|
return "%s.bak" % company_path
|
|
|
|
|
|
func _company_backup_candidate_path() -> String:
|
|
return "%s.bak.candidate" % company_path
|