454 lines
16 KiB
GDScript
454 lines
16 KiB
GDScript
class_name ABGFormationRiskService
|
|
extends RefCounted
|
|
|
|
const DraftService = preload("res://entry/initial_company_draft_service.gd")
|
|
|
|
const DEFAULT_COMPANY_PATH := "user://aetherbound_expedition_company.json"
|
|
const FOUNDATION_VERSION := 1
|
|
const FRONT_PRIORITY := {
|
|
"PF-B01": 120,
|
|
"PF-B02": 110,
|
|
"PF-B04": 90,
|
|
"PF-B03": 80,
|
|
"PF-B08": 70,
|
|
"PF-B11": 65,
|
|
"PF-B07": 50,
|
|
"PF-B09": 45,
|
|
"PF-B12": 40,
|
|
"PF-B05": 30,
|
|
"PF-B06": 25,
|
|
"PF-B10": 20,
|
|
}
|
|
const PROTECTORS := ["PF-B01", "PF-B02", "PF-B04"]
|
|
const SUSTAINERS := ["PF-B07", "PF-B08", "PF-B12"]
|
|
const LONG_REACH := ["PF-B05", "PF-B06", "PF-B07", "PF-B09", "PF-B10", "PF-B12"]
|
|
const CONTROL := ["PF-B03", "PF-B04", "PF-B08", "PF-B09", "PF-B10", "PF-B11"]
|
|
|
|
var company_path := DEFAULT_COMPANY_PATH
|
|
|
|
|
|
func load_company() -> Dictionary:
|
|
return _store().load_company()
|
|
|
|
|
|
func begin_formation() -> Dictionary:
|
|
var loaded := load_company()
|
|
if not loaded.ok:
|
|
return loaded
|
|
var company: Dictionary = loaded.company.duplicate(true)
|
|
var eligibility := _validate_eligibility(company)
|
|
if not eligibility.ok:
|
|
return eligibility
|
|
var contract: Dictionary = company.active_contract
|
|
if contract.has("formation") and not Dictionary(contract.formation).is_empty():
|
|
return {"ok": true, "code": "formation_loaded", "company": company, "formation": contract.formation}
|
|
var auto_order := _automatic_front_to_rear(company.owned_recruits)
|
|
var risk_options := _project_risk_options(contract, auto_order)
|
|
var preview := _preview(company, auto_order, risk_options[0].known_threat)
|
|
var formation := {
|
|
"foundation_version": FOUNDATION_VERSION,
|
|
"line_capacity": int(contract.line_capacity),
|
|
"orientation": "rear_left_front_right",
|
|
"front_position": 0,
|
|
"status": "draft",
|
|
"base_front_to_rear_ids": auto_order,
|
|
"draft_front_to_rear_ids": auto_order.duplicate(),
|
|
"saved_front_to_rear_ids": [],
|
|
"order_revision": 0,
|
|
"relations": preview.relations,
|
|
"coverage": preview.coverage,
|
|
"first_threat": risk_options[0].known_threat,
|
|
"risk_options": risk_options,
|
|
"order_receipts": [],
|
|
"readiness_receipts": [],
|
|
"accepted_party_snapshot": {},
|
|
}
|
|
_refresh_hash(formation, "formation_hash")
|
|
contract.formation = formation
|
|
_refresh_hash(contract, "contract_hash")
|
|
company.active_contract = contract
|
|
_refresh_hash(company, "company_hash")
|
|
var saved: Dictionary = _store().save_company(company)
|
|
if not saved.ok:
|
|
return saved
|
|
return {"ok": true, "code": "formation_created", "company": saved.company, "formation": saved.company.active_contract.formation}
|
|
|
|
|
|
func preview_order(front_to_rear_ids: Array) -> Dictionary:
|
|
var begun := begin_formation()
|
|
if not begun.ok:
|
|
return begun
|
|
var company: Dictionary = begun.company
|
|
var validation := _validate_order(front_to_rear_ids, company)
|
|
if not validation.ok:
|
|
return validation
|
|
var formation: Dictionary = company.active_contract.formation
|
|
var preview := _preview(company, validation.ids, formation.first_threat)
|
|
return {
|
|
"ok": true,
|
|
"code": "order_previewed",
|
|
"front_to_rear_ids": validation.ids,
|
|
"order_hash": _hash(validation.ids),
|
|
"relations": preview.relations,
|
|
"coverage": preview.coverage,
|
|
}
|
|
|
|
|
|
func reset_order() -> Dictionary:
|
|
var begun := begin_formation()
|
|
if not begun.ok:
|
|
return begun
|
|
return preview_order(Array(begun.formation.base_front_to_rear_ids))
|
|
|
|
|
|
func save_order(operation_id: String, front_to_rear_ids: Array) -> Dictionary:
|
|
if not _is_hex(operation_id, 32):
|
|
return {"ok": false, "code": "FORMATION_OPERATION_ID_INVALID"}
|
|
var begun := begin_formation()
|
|
if not begun.ok:
|
|
return begun
|
|
var company: Dictionary = begun.company.duplicate(true)
|
|
var validation := _validate_order(front_to_rear_ids, company)
|
|
if not validation.ok:
|
|
return validation
|
|
var contract: Dictionary = company.active_contract
|
|
var formation: Dictionary = contract.formation
|
|
var order_hash := _hash(validation.ids)
|
|
var intent := {
|
|
"company_id": String(company.company_id),
|
|
"run_id": String(contract.run_id),
|
|
"order_hash": order_hash,
|
|
"line_capacity": int(contract.line_capacity),
|
|
}
|
|
var intent_hash := _hash(intent)
|
|
for stored in formation.order_receipts:
|
|
var receipt: Dictionary = stored
|
|
if String(receipt.operation_id) == operation_id:
|
|
if String(receipt.intent_hash) != intent_hash:
|
|
return {"ok": false, "code": "FORMATION_OPERATION_CONFLICT"}
|
|
return {"ok": true, "code": "order_already_saved", "company": company, "receipt": receipt}
|
|
if String(receipt.order_hash) == order_hash:
|
|
return {"ok": true, "code": "order_already_saved", "company": company, "receipt": receipt}
|
|
var preview := _preview(company, validation.ids, formation.first_threat)
|
|
var receipt := {
|
|
"operation_id": operation_id,
|
|
"intent_hash": intent_hash,
|
|
"company_id": String(company.company_id),
|
|
"run_id": String(contract.run_id),
|
|
"order_hash": order_hash,
|
|
"front_to_rear_ids": validation.ids,
|
|
"line_capacity": int(contract.line_capacity),
|
|
"order_revision": int(formation.order_revision) + 1,
|
|
"relations_hash": _hash(preview.relations),
|
|
"coverage_hash": _hash(preview.coverage),
|
|
}
|
|
_refresh_hash(receipt, "receipt_hash")
|
|
formation.status = "ordered"
|
|
formation.draft_front_to_rear_ids = validation.ids
|
|
formation.saved_front_to_rear_ids = validation.ids
|
|
formation.order_revision = int(receipt.order_revision)
|
|
formation.relations = preview.relations
|
|
formation.coverage = preview.coverage
|
|
formation.order_receipts.append(receipt)
|
|
formation.readiness_receipts = []
|
|
formation.accepted_party_snapshot = {}
|
|
_refresh_hash(formation, "formation_hash")
|
|
contract.formation = formation
|
|
_refresh_hash(contract, "contract_hash")
|
|
company.active_contract = contract
|
|
_refresh_hash(company, "company_hash")
|
|
var saved: Dictionary = _store().save_company(company)
|
|
if not saved.ok:
|
|
return saved
|
|
return {"ok": true, "code": "order_saved", "company": saved.company, "receipt": receipt}
|
|
|
|
|
|
func accept_readiness(operation_id: String, expected_order_hash: String) -> Dictionary:
|
|
if not _is_hex(operation_id, 32):
|
|
return {"ok": false, "code": "READINESS_OPERATION_ID_INVALID"}
|
|
if not _is_hex(expected_order_hash, 64):
|
|
return {"ok": false, "code": "READINESS_ORDER_HASH_INVALID"}
|
|
var begun := begin_formation()
|
|
if not begun.ok:
|
|
return begun
|
|
var company: Dictionary = begun.company.duplicate(true)
|
|
var contract: Dictionary = company.active_contract
|
|
var formation: Dictionary = contract.formation
|
|
if formation.saved_front_to_rear_ids.is_empty():
|
|
return {"ok": false, "code": "READINESS_ORDER_REQUIRED"}
|
|
var actual_order_hash := _hash(formation.saved_front_to_rear_ids)
|
|
if actual_order_hash != expected_order_hash:
|
|
return {"ok": false, "code": "READINESS_ORDER_STALE"}
|
|
var intent := {
|
|
"company_id": String(company.company_id),
|
|
"run_id": String(contract.run_id),
|
|
"order_hash": actual_order_hash,
|
|
"order_revision": int(formation.order_revision),
|
|
}
|
|
var intent_hash := _hash(intent)
|
|
for stored in formation.readiness_receipts:
|
|
var receipt: Dictionary = stored
|
|
if String(receipt.operation_id) == operation_id:
|
|
if String(receipt.intent_hash) != intent_hash:
|
|
return {"ok": false, "code": "READINESS_OPERATION_CONFLICT"}
|
|
return {"ok": true, "code": "readiness_already_accepted", "company": company, "receipt": receipt}
|
|
if String(receipt.intent_hash) == intent_hash:
|
|
return {"ok": true, "code": "readiness_already_accepted", "company": company, "receipt": receipt}
|
|
var snapshot := {
|
|
"snapshot_id": _low128(_digest(String(contract.run_id), "prepared-party", [actual_order_hash, formation.order_revision])),
|
|
"company_id": String(company.company_id),
|
|
"run_id": String(contract.run_id),
|
|
"front_to_rear_ids": Array(formation.saved_front_to_rear_ids).duplicate(),
|
|
"order_hash": actual_order_hash,
|
|
"order_revision": int(formation.order_revision),
|
|
"line_capacity": int(contract.line_capacity),
|
|
"orientation": "rear_left_front_right",
|
|
"front_position": 0,
|
|
"relations": Array(formation.relations).duplicate(true),
|
|
"coverage": Dictionary(formation.coverage).duplicate(true),
|
|
"risk_option_ids": _risk_option_ids(formation.risk_options),
|
|
}
|
|
_refresh_hash(snapshot, "snapshot_hash")
|
|
var receipt := {
|
|
"operation_id": operation_id,
|
|
"intent_hash": intent_hash,
|
|
"company_id": String(company.company_id),
|
|
"run_id": String(contract.run_id),
|
|
"order_hash": actual_order_hash,
|
|
"order_revision": int(formation.order_revision),
|
|
"snapshot_id": String(snapshot.snapshot_id),
|
|
"snapshot_hash": String(snapshot.snapshot_hash),
|
|
}
|
|
_refresh_hash(receipt, "receipt_hash")
|
|
formation.status = "ready"
|
|
formation.accepted_party_snapshot = snapshot
|
|
formation.readiness_receipts = [receipt]
|
|
_refresh_hash(formation, "formation_hash")
|
|
contract.formation = formation
|
|
_refresh_hash(contract, "contract_hash")
|
|
company.active_contract = contract
|
|
_refresh_hash(company, "company_hash")
|
|
var saved: Dictionary = _store().save_company(company)
|
|
if not saved.ok:
|
|
return saved
|
|
return {"ok": true, "code": "readiness_accepted", "company": saved.company, "receipt": receipt, "snapshot": snapshot}
|
|
|
|
|
|
func _validate_eligibility(company: Dictionary) -> Dictionary:
|
|
if company.active_contract.is_empty():
|
|
return {"ok": false, "code": "FORMATION_CONTRACT_REQUIRED"}
|
|
var contract: Dictionary = company.active_contract
|
|
if contract.hire_receipts.size() != 2 or company.owned_recruits.size() != int(contract.line_capacity):
|
|
return {"ok": false, "code": "FORMATION_OPENING_HIRES_REQUIRED"}
|
|
if int(contract.line_capacity) != 4:
|
|
return {"ok": false, "code": "FORMATION_CAPACITY_STALE"}
|
|
return {"ok": true, "code": "formation_eligible"}
|
|
|
|
|
|
func _validate_order(values: Array, company: Dictionary) -> Dictionary:
|
|
var capacity: int = int(company.active_contract.line_capacity)
|
|
if values.size() != capacity:
|
|
return {"ok": false, "code": "FORMATION_COUNT_INVALID"}
|
|
var owned_ids: Array[String] = []
|
|
for recruit in company.owned_recruits:
|
|
owned_ids.append(String(recruit.recruit_id))
|
|
var ids: Array[String] = []
|
|
for value in values:
|
|
var recruit_id := String(value)
|
|
if recruit_id not in owned_ids:
|
|
return {"ok": false, "code": "FORMATION_RECRUIT_UNKNOWN"}
|
|
if recruit_id in ids:
|
|
return {"ok": false, "code": "FORMATION_RECRUIT_DUPLICATE"}
|
|
ids.append(recruit_id)
|
|
return {"ok": true, "code": "formation_order_valid", "ids": ids}
|
|
|
|
|
|
func _automatic_front_to_rear(recruits: Array) -> Array[String]:
|
|
var ordered: Array[Dictionary] = []
|
|
for recruit in recruits:
|
|
ordered.append(Dictionary(recruit))
|
|
ordered.sort_custom(func(left: Dictionary, right: Dictionary):
|
|
var left_score: int = int(FRONT_PRIORITY.get(String(left.profession_id), 0))
|
|
var right_score: int = int(FRONT_PRIORITY.get(String(right.profession_id), 0))
|
|
if left_score != right_score:
|
|
return left_score > right_score
|
|
return String(left.recruit_id) < String(right.recruit_id)
|
|
)
|
|
var ids: Array[String] = []
|
|
for recruit in ordered:
|
|
ids.append(String(recruit.recruit_id))
|
|
return ids
|
|
|
|
|
|
func _preview(company: Dictionary, order: Array, first_threat: Dictionary) -> Dictionary:
|
|
var by_id := {}
|
|
for recruit in company.owned_recruits:
|
|
by_id[String(recruit.recruit_id)] = recruit
|
|
var relations: Array[Dictionary] = []
|
|
for position in range(order.size()):
|
|
var recruit: Dictionary = by_id[String(order[position])]
|
|
var protected_by: Array[String] = []
|
|
for ahead in range(position):
|
|
var ahead_recruit: Dictionary = by_id[String(order[ahead])]
|
|
if String(ahead_recruit.profession_id) in PROTECTORS:
|
|
protected_by.append(String(ahead_recruit.recruit_id))
|
|
var protects: Array[String] = []
|
|
if String(recruit.profession_id) in PROTECTORS:
|
|
for behind in range(position + 1, order.size()):
|
|
protects.append(String(order[behind]))
|
|
var relation := {
|
|
"position": position,
|
|
"recruit_id": String(recruit.recruit_id),
|
|
"profession_id": String(recruit.profession_id),
|
|
"exposure_id": ["highest", "high", "guarded", "rear"][mini(position, 3)],
|
|
"expected_target_rank": position + 1,
|
|
"protected_by_recruit_ids": protected_by,
|
|
"protects_recruit_ids": protects,
|
|
"reach_id": _reach_id(String(recruit.profession_id), position),
|
|
"can_reach_first_threat": _can_reach(String(recruit.profession_id), position),
|
|
"first_threat_target": position == int(first_threat.target_position),
|
|
}
|
|
_refresh_hash(relation, "relation_hash")
|
|
relations.append(relation)
|
|
var coverage := _coverage(by_id, order)
|
|
return {"relations": relations, "coverage": coverage}
|
|
|
|
|
|
func _coverage(by_id: Dictionary, order: Array) -> Dictionary:
|
|
var protection := 0
|
|
var long_reach := 0
|
|
var sustain := 0
|
|
var control := 0
|
|
for recruit_id in order:
|
|
var profession_id := String(by_id[String(recruit_id)].profession_id)
|
|
protection += 1 if profession_id in PROTECTORS else 0
|
|
long_reach += 1 if profession_id in LONG_REACH else 0
|
|
sustain += 1 if profession_id in SUSTAINERS else 0
|
|
control += 1 if profession_id in CONTROL else 0
|
|
var result := {
|
|
"protection": protection,
|
|
"long_reach": long_reach,
|
|
"sustain": sustain,
|
|
"control": control,
|
|
}
|
|
_refresh_hash(result, "coverage_hash")
|
|
return result
|
|
|
|
|
|
func _reach_id(profession_id: String, position: int) -> String:
|
|
if profession_id in LONG_REACH:
|
|
return "full_line"
|
|
if profession_id in ["PF-B03", "PF-B04", "PF-B08", "PF-B11"]:
|
|
return "front_three" if position <= 2 else "limited"
|
|
return "front_two" if position <= 1 else "limited"
|
|
|
|
|
|
func _can_reach(profession_id: String, position: int) -> bool:
|
|
return profession_id in LONG_REACH or position <= 1 or (profession_id in ["PF-B03", "PF-B04", "PF-B08", "PF-B11"] and position <= 2)
|
|
|
|
|
|
func _project_risk_options(contract: Dictionary, auto_order: Array) -> Array[Dictionary]:
|
|
var options: Array[Dictionary] = []
|
|
for raw_offer in contract.market_board.battle_offers:
|
|
var offer: Dictionary = raw_offer
|
|
var risk: int = int(offer.risk)
|
|
var pressure_tags: Array = Array(offer.preview_tags).duplicate()
|
|
var threat := {
|
|
"target_position": 0,
|
|
"target_recruit_id": String(auto_order[0]),
|
|
"enemy_count": offer.enemy_ids.size(),
|
|
"wave_count": int(offer.wave_count),
|
|
"pressure_tags": pressure_tags,
|
|
}
|
|
_refresh_hash(threat, "threat_hash")
|
|
var base_reward := 20 + 7 * risk
|
|
var multiplier := _difficulty_reward_percent(String(contract.difficulty))
|
|
var option := {
|
|
"risk_option_version": 1,
|
|
"risk_option_id": String(offer.offer_id),
|
|
"battle_offer_hash": String(offer.offer_hash),
|
|
"risk": risk,
|
|
"tier_id": ["steady", "pressured", "elite"][risk],
|
|
"enemy_ids": Array(offer.enemy_ids).duplicate(),
|
|
"known_threat": threat,
|
|
"uncertainty_id": ["disclosed", "one_intent_unresolved", "elite_response_unresolved"][risk],
|
|
"entry_cost": int(offer.get("entry_cost", 0)),
|
|
"reward_coin": int(floor(float(base_reward * multiplier) / 100.0)),
|
|
"reward_tier": int(offer.reward_tier),
|
|
"reward_class_id": ["standard_supplies", "enhanced_choice", "elite_cache"][risk],
|
|
"recovery_exposure_id": ["low", "moderate", "high"][risk],
|
|
"knockout_rule_id": "returns_next_stage",
|
|
}
|
|
_refresh_hash(option, "option_hash")
|
|
options.append(option)
|
|
return options
|
|
|
|
|
|
func _difficulty_reward_percent(difficulty: String) -> int:
|
|
match difficulty:
|
|
"wayfinder": return 90
|
|
"stormbound": return 110
|
|
"iron_oath": return 115
|
|
return 100
|
|
|
|
|
|
func _risk_option_ids(options: Array) -> Array[String]:
|
|
var ids: Array[String] = []
|
|
for option in options:
|
|
ids.append(String(option.risk_option_id))
|
|
return ids
|
|
|
|
|
|
func _store() -> RefCounted:
|
|
var store := DraftService.new()
|
|
store.company_path = company_path
|
|
return store
|
|
|
|
|
|
func _refresh_hash(value: Dictionary, field: String) -> void:
|
|
value.erase(field)
|
|
value[field] = _hash(value)
|
|
|
|
|
|
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
|