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

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