Files
aetherbound-guild/runtime/entry/guild_contract_service.gd
T

584 lines
23 KiB
GDScript

class_name ABGGuildContractService
extends RefCounted
const DraftService = preload("res://entry/initial_company_draft_service.gd")
const ContentRegistry = preload("res://p10_0/content_registry.gd")
const OfferGenerator = preload("res://p10_0/offer_generator.gd")
const DEFAULT_COMPANY_PATH := "user://aetherbound_expedition_company.json"
const FIRST_CONTRACT_ID := "CTR-R1-01"
const DIFFICULTIES := ["wayfinder", "standard", "stormbound", "iron_oath"]
const STARTING_COIN := 52
const STARTING_LINE_CAPACITY := 4
const COMPANY_ATTRIBUTE_IDS := ["might", "finesse", "insight", "vigor", "resolve"]
var company_path := DEFAULT_COMPANY_PATH
var _content: Dictionary
var _generator: RefCounted
func _init() -> void:
_content = ContentRegistry.snapshot()
_generator = OfferGenerator.new(_content)
func load_company() -> Dictionary:
return _store().load_company()
func create_contract(operation_id: String, contract_id: String, difficulty: String) -> Dictionary:
if not _is_hex(operation_id, 32):
return {"ok": false, "code": "CONTRACT_OPERATION_ID_INVALID"}
if contract_id != FIRST_CONTRACT_ID:
return {"ok": false, "code": "CONTRACT_LOCKED"}
if difficulty not in DIFFICULTIES:
return {"ok": false, "code": "CONTRACT_DIFFICULTY_INVALID"}
var store := _store()
var loaded: Dictionary = store.load_company()
if not loaded.ok:
return loaded
var company: Dictionary = loaded.company.duplicate(true)
if not company.active_contract.is_empty():
var existing_receipt: Dictionary = company.active_contract.creation_receipt
var same_intent := String(existing_receipt.contract_id) == contract_id and String(existing_receipt.difficulty) == difficulty
if String(existing_receipt.operation_id) == operation_id and not same_intent:
return {"ok": false, "code": "CONTRACT_OPERATION_CONFLICT"}
if same_intent:
return {
"ok": true,
"code": "already_created",
"company": company,
"contract": company.active_contract,
"receipt": existing_receipt,
}
return {"ok": false, "code": "CONTRACT_ALREADY_ACTIVE"}
var contract_ordinal := int(company.next_contract_ordinal)
var intent := {
"company_id": String(company.company_id),
"contract_ordinal": contract_ordinal,
"contract_id": contract_id,
"difficulty": difficulty,
}
var intent_hash := _hash(intent)
if contract_ordinal != 0:
return {"ok": false, "code": "CONTRACT_ORDINAL_INVALID"}
var run_seed := _low128(_digest(String(company.company_id), "dev3-contract-run", [contract_ordinal]))
var run_id := _low128(_digest(run_seed, "dev3-contract-id", [contract_id]))
var generated: Dictionary = _generator.market_board(run_seed, contract_id, 1, 0)
if not generated.ok:
return {"ok": false, "code": "CONTRACT_MARKET_GENERATION_FAILED"}
var board := _price_first_board(generated.board)
var receipt := {
"operation_id": operation_id,
"intent_hash": intent_hash,
"company_id": String(company.company_id),
"contract_ordinal": contract_ordinal,
"contract_id": contract_id,
"difficulty": difficulty,
"run_id": run_id,
"run_seed": run_seed,
"board_hash": String(board.board_hash),
"coin": STARTING_COIN,
"line_capacity": STARTING_LINE_CAPACITY,
}
receipt.receipt_hash = _hash(receipt)
var contract := {
"contract_version": 1,
"company_id": String(company.company_id),
"contract_id": contract_id,
"run_id": run_id,
"run_seed": run_seed,
"difficulty": difficulty,
"status": "market",
"market_index": 1,
"coin": STARTING_COIN,
"line_capacity": STARTING_LINE_CAPACITY,
"market_board": board,
"available_recruit_offer_ids": _offer_ids(board.recruit_offers, "source_offer_id"),
"available_equipment_offer_ids": _offer_ids(board.equipment_offers, "offer_id"),
"available_battle_offer_ids": _offer_ids(board.battle_offers, "offer_id"),
"hire_receipts": [],
"creation_receipt": receipt,
}
contract.contract_hash = _hash(contract)
company.company_revision = int(company.company_revision) + 1
company.next_contract_ordinal = contract_ordinal + 1
company.active_contract = contract
company.contract_receipts = [receipt]
company.erase("company_hash")
company.company_hash = _hash(company)
var saved: Dictionary = store.save_company(company)
if not saved.ok:
return saved
return {
"ok": true,
"code": "created",
"company": saved.company,
"contract": saved.company.active_contract,
"receipt": saved.company.active_contract.creation_receipt,
}
func hire_recruit(operation_id: String, offer_id: String, expected_price: int) -> Dictionary:
if not _is_hex(operation_id, 32):
return {"ok": false, "code": "HIRE_OPERATION_ID_INVALID"}
if not _is_hex(offer_id, 32):
return {"ok": false, "code": "HIRE_OFFER_ID_INVALID"}
var store := _store()
var loaded: Dictionary = store.load_company()
if not loaded.ok:
return loaded
var company: Dictionary = loaded.company.duplicate(true)
if company.active_contract.is_empty() or String(company.active_contract.get("status", "")) != "market":
return {"ok": false, "code": "HIRE_MARKET_REQUIRED"}
var contract: Dictionary = company.active_contract
var intent := {
"company_id": String(company.company_id),
"run_id": String(contract.run_id),
"board_hash": String(contract.market_board.board_hash),
"offer_id": offer_id,
"expected_price": expected_price,
}
var intent_hash := _hash(intent)
for stored_receipt in contract.hire_receipts:
var prior_receipt: Dictionary = stored_receipt
if String(prior_receipt.operation_id) == operation_id:
if String(prior_receipt.intent_hash) != intent_hash:
return {"ok": false, "code": "HIRE_OPERATION_CONFLICT"}
return {"ok": true, "code": "already_hired", "company": company, "receipt": prior_receipt}
if String(prior_receipt.offer_id) == offer_id:
if String(prior_receipt.intent_hash) == intent_hash:
return {"ok": true, "code": "already_hired", "company": company, "receipt": prior_receipt}
return {"ok": false, "code": "HIRE_OFFER_ALREADY_APPLIED_CONFLICT"}
if contract.hire_receipts.size() >= 2:
return {"ok": false, "code": "OPENING_HIRES_COMPLETE"}
var offer: Dictionary = _find_recruit_offer(contract, offer_id)
if offer.is_empty() or offer_id not in contract.available_recruit_offer_ids:
return {"ok": false, "code": "HIRE_OFFER_UNAVAILABLE"}
var list_price: int = int(offer.get("list_price", -1))
if expected_price != list_price:
return {"ok": false, "code": "HIRE_PRICE_MISMATCH"}
if int(contract.coin) < list_price:
return {"ok": false, "code": "HIRE_INSUFFICIENT_COIN"}
var recruit: Dictionary = _materialize_market_recruit(offer)
var company_before: int = company.owned_recruits.size()
var coin_before: int = int(contract.coin)
var receipt: Dictionary = {
"operation_id": operation_id,
"intent_hash": intent_hash,
"company_id": String(company.company_id),
"run_id": String(contract.run_id),
"board_hash": String(contract.market_board.board_hash),
"offer_id": offer_id,
"recruit_id": String(recruit.recruit_id),
"expected_price": expected_price,
"list_price": list_price,
"coin_before": coin_before,
"coin_after": coin_before - list_price,
"company_before": company_before,
"company_after": company_before + 1,
"line_capacity": int(contract.line_capacity),
}
receipt.receipt_hash = _hash(receipt)
company.owned_recruits.append(recruit)
contract.coin = coin_before - list_price
contract.available_recruit_offer_ids.erase(offer_id)
contract.hire_receipts.append(receipt)
contract.erase("contract_hash")
contract.contract_hash = _hash(contract)
company.active_contract = contract
company.company_revision = int(company.company_revision) + 1
company.erase("company_hash")
company.company_hash = _hash(company)
var saved: Dictionary = store.save_company(company)
if not saved.ok:
return saved
return {"ok": true, "code": "hired", "company": saved.company, "receipt": receipt}
func purchase_equipment(operation_id: String, offer_id: String, recruit_id: String, expected_price: int) -> Dictionary:
if not _is_hex(operation_id, 32):
return {"ok": false, "code": "EQUIPMENT_OPERATION_ID_INVALID"}
if not _is_hex(offer_id, 32) or not _is_hex(recruit_id, 32):
return {"ok": false, "code": "EQUIPMENT_TARGET_ID_INVALID"}
var store := _store()
var loaded: Dictionary = store.load_company()
if not loaded.ok:
return loaded
var company: Dictionary = loaded.company.duplicate(true)
if company.active_contract.is_empty() or String(company.active_contract.get("status", "")) != "market":
return {"ok": false, "code": "EQUIPMENT_MARKET_REQUIRED"}
var contract: Dictionary = company.active_contract
var intent := {
"company_id": String(company.company_id),
"run_id": String(contract.run_id),
"offer_id": offer_id,
"recruit_id": recruit_id,
"expected_price": expected_price,
"board_hash": String(contract.market_board.board_hash),
}
var intent_hash := _hash(intent)
var equipment_receipts: Array = contract.get("equipment_receipts", [])
for stored_receipt in equipment_receipts:
var prior: Dictionary = stored_receipt
if String(prior.operation_id) == operation_id:
if String(prior.intent_hash) != intent_hash:
return {"ok": false, "code": "EQUIPMENT_OPERATION_CONFLICT"}
return {"ok": true, "code": "already_equipped", "company": company, "receipt": prior}
if String(prior.offer_id) == offer_id:
if String(prior.intent_hash) == intent_hash:
return {"ok": true, "code": "already_equipped", "company": company, "receipt": prior}
return {"ok": false, "code": "EQUIPMENT_OFFER_ALREADY_APPLIED_CONFLICT"}
var offer: Dictionary = {}
for candidate in contract.market_board.get("equipment_offers", []):
if String(candidate.get("offer_id", "")) == offer_id:
offer = Dictionary(candidate).duplicate(true)
break
if offer.is_empty() or offer_id not in contract.available_equipment_offer_ids:
return {"ok": false, "code": "EQUIPMENT_OFFER_UNAVAILABLE"}
var list_price := int(offer.get("list_price", -1))
if expected_price != list_price:
return {"ok": false, "code": "EQUIPMENT_PRICE_MISMATCH"}
if int(contract.coin) < list_price:
return {"ok": false, "code": "EQUIPMENT_INSUFFICIENT_COIN"}
var target: Dictionary = {}
for recruit in company.owned_recruits:
if String(recruit.get("recruit_id", "")) == recruit_id:
target = recruit
break
if target.is_empty():
return {"ok": false, "code": "EQUIPMENT_RECRUIT_UNAVAILABLE"}
var previous_item_id := String(target.get("equipped_item_id", ""))
var previous_item_name := Dictionary(target.get("starting_item_name", {})).duplicate(true)
var item_record := {
"catalog_id": String(offer.get("catalog_id", "")),
"instance_id": String(offer.get("instance_id", offer.get("offer_id", ""))),
"source_offer_id": offer_id,
"list_price": list_price,
"acquired_operation_id": operation_id,
}
var inventory: Array = company.get("equipment_inventory", []).duplicate(true)
if not previous_item_id.is_empty():
inventory.append({"catalog_id": previous_item_id, "instance_id": ("%s|returned|%s" % [recruit_id, previous_item_id]).sha256_text().substr(0, 32), "source": "replaced_equipment", "name": previous_item_name})
var coin_before := int(contract.coin)
var receipt := {
"operation_id": operation_id,
"intent_hash": intent_hash,
"company_id": String(company.company_id),
"run_id": String(contract.run_id),
"offer_id": offer_id,
"catalog_id": String(offer.catalog_id),
"instance_id": String(item_record.instance_id),
"recruit_id": recruit_id,
"previous_equipped_item_id": previous_item_id,
"new_equipped_item_id": String(offer.catalog_id),
"coin_before": coin_before,
"coin_after": coin_before - list_price,
"list_price": list_price,
}
receipt.receipt_hash = _hash(receipt)
target.equipped_item_id = String(offer.catalog_id)
target.starting_item_state = "inventory"
target.recruit_hash = _hash_without(target, "recruit_hash")
company.equipment_inventory = inventory
contract.coin = coin_before - list_price
contract.available_equipment_offer_ids.erase(offer_id)
equipment_receipts.append(receipt)
contract.equipment_receipts = equipment_receipts
contract.erase("contract_hash")
contract.contract_hash = _hash(contract)
company.active_contract = contract
company.company_revision = int(company.company_revision) + 1
company.erase("company_hash")
company.company_hash = _hash(company)
var saved: Dictionary = store.save_company(company)
if not saved.ok:
return saved
return {"ok": true, "code": "equipment_equipped", "company": saved.company, "receipt": receipt, "item": item_record}
func dismiss_recruit(operation_id: String, recruit_id: String) -> Dictionary:
if not _is_hex(operation_id, 32) or not _is_hex(recruit_id, 32):
return {"ok": false, "code": "DISMISS_OPERATION_ID_INVALID"}
var store := _store()
var loaded: Dictionary = store.load_company()
if not loaded.ok:
return loaded
var company: Dictionary = loaded.company.duplicate(true)
var contract: Dictionary = company.active_contract
if contract.is_empty() or String(contract.get("status", "")) != "market":
return {"ok": false, "code": "DISMISS_MARKET_REQUIRED"}
if contract.has("formation"):
return {"ok": false, "code": "DISMISS_FORMATION_LOCKED"}
var receipts: Array = company.get("dismissal_receipts", [])
for stored in receipts:
var prior: Dictionary = stored
if String(prior.operation_id) == operation_id:
if String(prior.recruit_id) != recruit_id:
return {"ok": false, "code": "DISMISS_OPERATION_CONFLICT"}
return {"ok": true, "code": "already_dismissed", "company": company, "receipt": prior}
if company.owned_recruits.size() <= 1:
return {"ok": false, "code": "FINAL_LIVE_RECRUIT"}
var removed_index := -1
var removed: Dictionary = {}
for index in range(company.owned_recruits.size()):
var recruit: Dictionary = company.owned_recruits[index]
if String(recruit.recruit_id) == recruit_id:
removed_index = index
removed = recruit
break
if removed_index < 0:
return {"ok": false, "code": "DISMISS_RECRUIT_UNAVAILABLE"}
var inventory: Array = company.get("equipment_inventory", []).duplicate(true)
if not String(removed.get("equipped_item_id", "")).is_empty():
inventory.append({
"catalog_id": String(removed.equipped_item_id),
"instance_id": ("%s|dismissed|%s" % [recruit_id, removed.equipped_item_id]).sha256_text().substr(0, 32),
"source": "dismissed_recruit",
})
var receipt := {
"operation_id": operation_id,
"company_id": String(company.company_id),
"run_id": String(contract.run_id),
"recruit_id": recruit_id,
"removed_index": removed_index,
"returned_equipped_item_id": String(removed.get("equipped_item_id", "")),
"coin_delta": 0,
"company_before": company.owned_recruits.size(),
"company_after": company.owned_recruits.size() - 1,
}
receipt.intent_hash = _hash({"company_id": receipt.company_id, "run_id": receipt.run_id, "recruit_id": recruit_id, "coin_delta": 0})
receipt.receipt_hash = _hash(receipt)
company.owned_recruits.remove_at(removed_index)
company.equipment_inventory = inventory
receipts.append(receipt)
company.dismissal_receipts = receipts
company.company_revision = int(company.company_revision) + 1
contract.dismissal_receipts = receipts.duplicate(true)
contract.erase("contract_hash")
contract.contract_hash = _hash(contract)
company.active_contract = contract
company.erase("company_hash")
company.company_hash = _hash(company)
var saved: Dictionary = store.save_company(company)
if not saved.ok:
return saved
return {"ok": true, "code": "dismissed", "company": saved.company, "receipt": receipt}
func return_contract(operation_id: String) -> Dictionary:
if not _is_hex(operation_id, 32):
return {"ok": false, "code": "CONTRACT_RETURN_OPERATION_ID_INVALID"}
var store := _store()
var loaded: Dictionary = store.load_company()
if not loaded.ok:
return loaded
var company: Dictionary = loaded.company.duplicate(true)
var contract: Dictionary = company.active_contract
if contract.is_empty():
return {"ok": false, "code": "CONTRACT_RETURN_MARKET_REQUIRED"}
var returns: Array = Array(contract.get("contract_return_receipts", [])).duplicate(true)
var intent_hash := _hash({
"company_id": String(company.company_id),
"run_id": String(contract.run_id),
"contract_id": String(contract.contract_id),
})
for stored in returns:
var prior: Dictionary = stored
if String(prior.operation_id) == operation_id:
if String(prior.get("intent_hash", "")) != intent_hash:
return {"ok": false, "code": "CONTRACT_RETURN_OPERATION_CONFLICT"}
return {"ok": true, "code": "contract_return_already_applied", "company": company, "receipt": prior}
if String(contract.get("status", "")) != "market":
return {"ok": false, "code": "CONTRACT_RETURN_ALREADY_APPLIED" if String(contract.get("status", "")) == "returned" else "CONTRACT_RETURN_MARKET_REQUIRED"}
if contract.has("formation"):
return {"ok": false, "code": "CONTRACT_RETURN_FORMATION_LOCKED"}
var receipt := {
"operation_id": operation_id,
"intent_hash": intent_hash,
"company_id": String(company.company_id),
"run_id": String(contract.run_id),
"contract_id": String(contract.contract_id),
"retained_coin": int(contract.coin),
"retained_recruit_ids": _owned_recruit_ids(company),
"destination": "guild",
}
receipt.receipt_hash = _hash(receipt)
returns.append(receipt)
contract.contract_return_receipts = returns
contract.status = "returned"
contract.erase("contract_hash")
contract.contract_hash = _hash(contract)
company.active_contract = contract
company.company_revision = int(company.company_revision) + 1
company.erase("company_hash")
company.company_hash = _hash(company)
var saved: Dictionary = store.save_company(company)
if not saved.ok:
return saved
return {"ok": true, "code": "contract_returned", "company": saved.company, "receipt": receipt}
func _store() -> RefCounted:
var store := DraftService.new()
store.company_path = company_path
return store
func _find_recruit_offer(contract: Dictionary, offer_id: String) -> Dictionary:
for offer in contract.market_board.recruit_offers:
if String(offer.source_offer_id) == offer_id:
return Dictionary(offer).duplicate(true)
return {}
func _materialize_market_recruit(offer: Dictionary) -> 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: Dictionary = professions.get(String(offer.profession_id), {})
var trait_row: Dictionary = traits.get(String(offer.trait_id), {})
var item: Dictionary = equipment.get(String(offer.starting_equipment_id), {})
var name_tokens: Array = Array(offer.display_name_tokens).duplicate()
var display_name := "%s %s" % [_name_token(name_tokens[0]), _name_token(name_tokens[1])]
var recruit := {
"recruit_id": String(offer.recruit_id),
"generation_seed": String(offer.generation_seed),
"name_component_ids": name_tokens,
"rendered_name_snapshot": {"en": display_name, "zh_CN": display_name},
"appearance_seed": String(offer.appearance_seed),
"profession_id": String(offer.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_company_attributes(String(offer.generation_seed)),
"trait_id": String(offer.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": String(offer.starting_equipment_id),
"starting_item_name": {
"en": String(item.get("name_en", item.get("name", ""))),
"zh_CN": String(item.get("name_zh", "")),
},
"starting_item_state": "equipped",
"equipped_item_id": String(offer.starting_equipment_id),
"level": 1,
"xp": 0,
"state": "owned",
"joined_from_market_id": String(offer.market_id),
"market_offer_id": String(offer.source_offer_id),
"market_offer_hash": String(offer.canonical_output_hash),
"battle_history": {"battles_entered": 0, "victories": 0, "knockouts": 0},
}
recruit.recruit_hash = _hash(recruit)
return recruit
func _numeric_company_attributes(seed: String) -> Dictionary:
var result := {}
for attribute_id in COMPANY_ATTRIBUTE_IDS:
result[attribute_id] = 4
for point in range(15):
var candidates := COMPANY_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 _name_token(token) -> String:
var parts := String(token).split(".")
return String(parts[parts.size() - 1])
func _price_first_board(raw: Dictionary) -> Dictionary:
var board := raw.duplicate(true)
for offer in board.recruit_offers:
offer.offer_level = 1
offer.profession_tier = 0
offer.list_price = 14
for offer in board.equipment_offers:
var ordinal := int(String(offer.catalog_id).trim_prefix("EQ-"))
var item_tier := clampi(int((ordinal - 1) / 10), 0, 3)
offer.item_tier = item_tier
offer.region_band = 0
offer.list_price = 12 + 6 * item_tier
for offer in board.battle_offers:
offer.entry_cost = 0
board.erase("board_hash")
board.board_hash = _hash(board)
return board
func _offer_ids(offers: Array, id_field: String) -> Array[String]:
var result: Array[String] = []
for offer in offers:
result.append(String(offer.get(id_field, "")))
return result
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 _hash_without(value: Dictionary, field: String) -> String:
var copy := value.duplicate(true)
copy.erase(field)
return _hash(copy)
func _owned_recruit_ids(company: Dictionary) -> Array[String]:
var ids: Array[String] = []
for recruit in company.owned_recruits:
ids.append(String(recruit.recruit_id))
return ids
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