348 lines
13 KiB
GDScript
348 lines
13 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 _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 _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
|