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

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