823 lines
33 KiB
GDScript
823 lines
33 KiB
GDScript
class_name ProgressionService
|
|
extends RefCounted
|
|
|
|
## Monotonic World Tree rank progression with explicit eligibility evidence.
|
|
##
|
|
## The service owns only progression facts. World expansion and GameState
|
|
## mutation are supplied as capture/apply/rollback callbacks so a failed rank-up
|
|
## cannot leave the map and recorded rank out of sync.
|
|
|
|
const SNAPSHOT_VERSION := 2
|
|
const LEGACY_SNAPSHOT_VERSION := 1
|
|
const MIN_RANK := 1
|
|
const MAX_RANK := 7
|
|
const MAX_COMPLETED_QUESTS := 256
|
|
const MAX_PROCESSED_REQUESTS := MAX_RANK - MIN_RANK
|
|
const MAX_SAFE_INTEGER := 9_007_199_254_740_991
|
|
const QUARANTINE_REASONS: Array[StringName] = [
|
|
&"RANK_CALLBACK_TAMPERED",
|
|
&"RANK_ROLLBACK_FAILED",
|
|
]
|
|
|
|
const METRIC_IDS: Array[StringName] = [
|
|
&"active_above_ground_facilities",
|
|
&"active_underground_facilities",
|
|
&"asset_gold",
|
|
&"faith_lifetime",
|
|
&"guest_lifetime",
|
|
&"reputation",
|
|
&"underground_influence",
|
|
]
|
|
const PERCENT_METRICS: Array[StringName] = [&"reputation", &"underground_influence"]
|
|
const LIFETIME_METRICS: Array[StringName] = [&"faith_lifetime", &"guest_lifetime"]
|
|
const DEFAULT_METRICS := {
|
|
&"active_above_ground_facilities": 0,
|
|
&"active_underground_facilities": 0,
|
|
&"asset_gold": 0,
|
|
&"faith_lifetime": 0,
|
|
&"guest_lifetime": 0,
|
|
&"reputation": 0,
|
|
&"underground_influence": 0,
|
|
}
|
|
|
|
const RANK_BOUNDS := {
|
|
1: {&"min_x": -12, &"max_x": 12, &"min_y": 0, &"max_y": 6},
|
|
2: {&"min_x": -18, &"max_x": 18, &"min_y": -4, &"max_y": 10},
|
|
3: {&"min_x": -24, &"max_x": 24, &"min_y": -8, &"max_y": 16},
|
|
4: {&"min_x": -32, &"max_x": 32, &"min_y": -14, &"max_y": 24},
|
|
5: {&"min_x": -40, &"max_x": 40, &"min_y": -20, &"max_y": 32},
|
|
6: {&"min_x": -48, &"max_x": 48, &"min_y": -28, &"max_y": 42},
|
|
7: {&"min_x": -60, &"max_x": 60, &"min_y": -40, &"max_y": 56},
|
|
}
|
|
|
|
var current_rank := MIN_RANK
|
|
var metrics: Dictionary = DEFAULT_METRICS.duplicate(true)
|
|
var completed_quest_ids: Array[StringName] = []
|
|
var quarantined := false
|
|
var quarantine_reason: StringName = &""
|
|
var last_error := ""
|
|
|
|
var _processed_requests: Dictionary = {}
|
|
var _transaction_active := false
|
|
|
|
|
|
func configure(
|
|
initial_rank: int = MIN_RANK,
|
|
initial_metrics: Dictionary = {},
|
|
initial_quests: Array = []
|
|
) -> bool:
|
|
if _transaction_active:
|
|
return _fail("Progression cannot be configured during a rank transaction.")
|
|
var candidate := ProgressionService.new()
|
|
candidate.current_rank = initial_rank
|
|
if initial_rank != MIN_RANK:
|
|
return _fail("New progression must start at Rank 1; later ranks require recorded promotions.")
|
|
if initial_quests.size() > MAX_COMPLETED_QUESTS:
|
|
return _fail("Completed quest limit exceeded.")
|
|
if not candidate._replace_metrics(initial_metrics, true):
|
|
return _fail(candidate.last_error)
|
|
for raw_quest_id: Variant in initial_quests:
|
|
if not _is_string_variant(raw_quest_id):
|
|
return _fail("Quest IDs must be strings.")
|
|
var quest_id := StringName(raw_quest_id)
|
|
if not _is_stable_id(String(quest_id)) or candidate.completed_quest_ids.has(quest_id):
|
|
return _fail("Quest IDs must be unique stable IDs.")
|
|
candidate.completed_quest_ids.append(quest_id)
|
|
if candidate.completed_quest_ids.size() > MAX_COMPLETED_QUESTS:
|
|
return _fail("Completed quest limit exceeded.")
|
|
candidate.completed_quest_ids.sort_custom(_name_less)
|
|
_copy_from(candidate)
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func update_metrics(patch: Dictionary) -> bool:
|
|
if _transaction_active:
|
|
return _fail("Progression facts cannot change during a rank transaction.")
|
|
if quarantined:
|
|
return _fail("Progression is quarantined until a valid snapshot is restored.")
|
|
if patch.size() > METRIC_IDS.size():
|
|
return _fail("Metric patch is too large.")
|
|
var candidate := metrics.duplicate(true)
|
|
var seen: Dictionary = {}
|
|
for raw_metric_id: Variant in patch:
|
|
if not _is_string_variant(raw_metric_id):
|
|
return _fail("Metric IDs must be strings.")
|
|
var metric_id := StringName(raw_metric_id)
|
|
if not METRIC_IDS.has(metric_id) or seen.has(metric_id):
|
|
return _fail("Metric patch contains an unknown or duplicate canonical ID.")
|
|
seen[metric_id] = true
|
|
var raw_value: Variant = patch[raw_metric_id]
|
|
if not _is_integer_variant(raw_value):
|
|
return _fail("Progression metrics must be integers.")
|
|
var value := int(raw_value)
|
|
if value < 0 or value > MAX_SAFE_INTEGER \
|
|
or (PERCENT_METRICS.has(metric_id) and value > 100):
|
|
return _fail("Progression metric is outside its valid range.")
|
|
if LIFETIME_METRICS.has(metric_id) and value < int(metrics[metric_id]):
|
|
return _fail("Lifetime progression metrics cannot decrease.")
|
|
candidate[metric_id] = value
|
|
metrics = candidate
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func complete_quest(quest_id: StringName) -> bool:
|
|
if _transaction_active:
|
|
return _fail("Progression facts cannot change during a rank transaction.")
|
|
if quarantined:
|
|
return _fail("Progression is quarantined until a valid snapshot is restored.")
|
|
if not _is_stable_id(String(quest_id)):
|
|
return _fail("Quest ID must be a stable lowercase ID.")
|
|
if completed_quest_ids.has(quest_id):
|
|
last_error = ""
|
|
return true
|
|
if completed_quest_ids.size() >= MAX_COMPLETED_QUESTS:
|
|
return _fail("Completed quest limit exceeded.")
|
|
completed_quest_ids.append(quest_id)
|
|
completed_quest_ids.sort_custom(_name_less)
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func evaluate_rank(target_rank: int = -1) -> Dictionary:
|
|
var target := current_rank + 1 if target_rank < 0 else target_rank
|
|
var requirements: Array[Dictionary] = []
|
|
if target != current_rank + 1 or target < 2 or target > MAX_RANK:
|
|
return {
|
|
&"eligible": false,
|
|
&"current_rank": current_rank,
|
|
&"target_rank": target,
|
|
&"requirements": requirements,
|
|
&"reason": &"TARGET_MUST_BE_NEXT_RANK",
|
|
}
|
|
|
|
match target:
|
|
2:
|
|
_require_metric(requirements, &"reputation", 25)
|
|
_require_metric(requirements, &"asset_gold", 10_000)
|
|
3:
|
|
_require_metric(requirements, &"faith_lifetime", 100)
|
|
_require_metric(requirements, &"guest_lifetime", 300)
|
|
4:
|
|
_require_metric(requirements, &"reputation", 45)
|
|
_require_metric(requirements, &"asset_gold", 50_000)
|
|
5:
|
|
_require_metric(requirements, &"faith_lifetime", 500)
|
|
_require_quest(requirements, &"important_rank_5")
|
|
6:
|
|
_require_either_metric(
|
|
requirements,
|
|
&"reputation_or_underground_influence",
|
|
&"reputation",
|
|
&"underground_influence",
|
|
65
|
|
)
|
|
7:
|
|
_require_metric(requirements, &"faith_lifetime", 1_500)
|
|
_require_metric(requirements, &"reputation", 60)
|
|
_require_metric(requirements, &"underground_influence", 40)
|
|
_require_quest(requirements, &"main_chapter_complete")
|
|
if target >= 4:
|
|
_require_metric(requirements, &"active_above_ground_facilities", 1)
|
|
_require_metric(requirements, &"active_underground_facilities", 1)
|
|
|
|
var eligible := not quarantined
|
|
for requirement: Dictionary in requirements:
|
|
if not bool(requirement[&"met"]):
|
|
eligible = false
|
|
return {
|
|
&"eligible": eligible,
|
|
&"current_rank": current_rank,
|
|
&"target_rank": target,
|
|
&"requirements": requirements,
|
|
&"reason": &"OK" if eligible else (&"QUARANTINED" if quarantined else &"REQUIREMENTS_NOT_MET"),
|
|
&"bounds": get_rank_bounds(target),
|
|
}
|
|
|
|
|
|
func request_rank_up(
|
|
request_id: StringName,
|
|
target_rank: int,
|
|
capture_callback: Callable = Callable(),
|
|
apply_callback: Callable = Callable(),
|
|
rollback_callback: Callable = Callable()
|
|
) -> CommandResult:
|
|
if not _is_stable_id(String(request_id)):
|
|
return _failure(CommandResult.Code.INVALID_PAYLOAD, "Rank request ID must be a stable lowercase ID.")
|
|
var fingerprint := "rank:%d" % target_rank
|
|
if _processed_requests.has(request_id):
|
|
var previous: Dictionary = _processed_requests[request_id]
|
|
if String(previous[&"fingerprint"]) != fingerprint:
|
|
return _failure(
|
|
CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY,
|
|
"Rank request ID was already used for different input."
|
|
)
|
|
var replay := CommandResult.succeeded(0, previous[&"details"])
|
|
replay.is_replay = true
|
|
last_error = ""
|
|
return replay
|
|
if _transaction_active:
|
|
return _failure(CommandResult.Code.TRANSACTION_CLOSED, "Another rank transaction is active.")
|
|
if quarantined:
|
|
return _failure(
|
|
CommandResult.Code.TRANSACTION_CLOSED,
|
|
"Progression is quarantined until a valid snapshot is restored."
|
|
)
|
|
if _processed_requests.size() >= MAX_PROCESSED_REQUESTS:
|
|
return _failure(CommandResult.Code.VALIDATION_FAILED, "Processed rank request limit exceeded.")
|
|
var callback_count := int(capture_callback.is_valid()) + int(apply_callback.is_valid()) + int(rollback_callback.is_valid())
|
|
if callback_count not in [0, 3]:
|
|
return _failure(
|
|
CommandResult.Code.INVALID_PAYLOAD,
|
|
"External rank mutation requires capture, apply, and rollback callbacks together."
|
|
)
|
|
var eligibility := evaluate_rank(target_rank)
|
|
if not bool(eligibility[&"eligible"]):
|
|
return _failure(
|
|
CommandResult.Code.VALIDATION_FAILED,
|
|
"World Tree rank requirements are not met.",
|
|
eligibility
|
|
)
|
|
|
|
if callback_count == 3:
|
|
var internal_before := ProgressionService.new()
|
|
if not internal_before.restore_snapshot(create_snapshot()):
|
|
return _failure(CommandResult.Code.TRANSACTION_CLOSED, "Progression could not capture its internal transaction state.")
|
|
_transaction_active = true
|
|
var capture_result: Variant = capture_callback.call(current_rank, target_rank)
|
|
var capture_ok: Variant = (
|
|
capture_result.get(&"ok", capture_result.get("ok", null))
|
|
if capture_result is Dictionary else null
|
|
)
|
|
if not capture_result is Dictionary \
|
|
or not capture_ok is bool \
|
|
or not bool(capture_ok) \
|
|
or (not capture_result.has(&"snapshot") and not capture_result.has("snapshot")):
|
|
var capture_tampered := create_snapshot() != internal_before.create_snapshot()
|
|
_transaction_active = false
|
|
_copy_from(internal_before)
|
|
if capture_tampered:
|
|
quarantined = true
|
|
quarantine_reason = &"RANK_CALLBACK_TAMPERED"
|
|
return _failure(CommandResult.Code.TRANSACTION_CLOSED, "Rank capture mutated progression and the external state is uncertain.")
|
|
return _failure(CommandResult.Code.TRANSACTION_APPLY_FAILED, "Rank mutation capture failed.")
|
|
if create_snapshot() != internal_before.create_snapshot():
|
|
var capture_rollback_result: Variant = rollback_callback.call(
|
|
capture_result.get(&"snapshot", capture_result.get("snapshot"))
|
|
)
|
|
_transaction_active = false
|
|
_copy_from(internal_before)
|
|
if not _callback_succeeded(capture_rollback_result):
|
|
quarantined = true
|
|
quarantine_reason = &"RANK_ROLLBACK_FAILED"
|
|
return _failure(
|
|
CommandResult.Code.TRANSACTION_CLOSED,
|
|
"Rank capture mutated progression and rollback could not restore external state."
|
|
)
|
|
return _failure(
|
|
CommandResult.Code.TRANSACTION_APPLY_FAILED,
|
|
"Rank capture mutated progression and was rolled back before apply."
|
|
)
|
|
var apply_result: Variant = apply_callback.call(target_rank, get_rank_bounds(target_rank))
|
|
var callback_tampered := create_snapshot() != internal_before.create_snapshot()
|
|
if not _callback_succeeded(apply_result) or callback_tampered:
|
|
var rollback_result: Variant = rollback_callback.call(
|
|
capture_result.get(&"snapshot", capture_result.get("snapshot"))
|
|
)
|
|
_transaction_active = false
|
|
_copy_from(internal_before)
|
|
if not _callback_succeeded(rollback_result):
|
|
quarantined = true
|
|
quarantine_reason = &"RANK_ROLLBACK_FAILED"
|
|
return _failure(
|
|
CommandResult.Code.TRANSACTION_CLOSED,
|
|
"Rank mutation failed and rollback could not restore external state."
|
|
)
|
|
return _failure(
|
|
CommandResult.Code.TRANSACTION_APPLY_FAILED,
|
|
"Rank callback mutated progression and was rolled back." if callback_tampered \
|
|
else "Rank mutation failed and was rolled back."
|
|
)
|
|
_transaction_active = false
|
|
|
|
current_rank = target_rank
|
|
var details := {
|
|
&"rank": current_rank,
|
|
&"bounds": get_rank_bounds(current_rank),
|
|
&"eligibility": eligibility.duplicate(true),
|
|
&"request_id": request_id,
|
|
}
|
|
_processed_requests[request_id] = {
|
|
&"fingerprint": fingerprint,
|
|
&"details": details.duplicate(true),
|
|
}
|
|
last_error = ""
|
|
return CommandResult.succeeded(0, details)
|
|
|
|
|
|
func get_rank_bounds(rank: int = current_rank) -> Dictionary:
|
|
var source: Variant = RANK_BOUNDS.get(rank, {})
|
|
return source.duplicate(true) if source is Dictionary else {}
|
|
|
|
|
|
func create_snapshot() -> Dictionary:
|
|
var requests: Array[Dictionary] = []
|
|
var request_ids: Array[StringName] = []
|
|
for raw_request_id: Variant in _processed_requests:
|
|
request_ids.append(StringName(raw_request_id))
|
|
request_ids.sort_custom(_name_less)
|
|
for request_id: StringName in request_ids:
|
|
var record: Dictionary = _processed_requests[request_id]
|
|
requests.append({
|
|
&"request_id": String(request_id),
|
|
&"fingerprint": String(record[&"fingerprint"]),
|
|
&"details": record[&"details"].duplicate(true),
|
|
})
|
|
return {
|
|
&"version": SNAPSHOT_VERSION,
|
|
&"current_rank": current_rank,
|
|
&"metrics": _string_key_dictionary(metrics),
|
|
&"completed_quest_ids": _names_to_strings(completed_quest_ids),
|
|
&"processed_requests": requests,
|
|
&"quarantined": quarantined,
|
|
&"quarantine_reason": String(quarantine_reason),
|
|
}
|
|
|
|
|
|
func restore_snapshot(snapshot: Dictionary) -> bool:
|
|
if _transaction_active:
|
|
return _fail("Progression cannot restore during a rank transaction.")
|
|
if not _keys_match(snapshot, [
|
|
&"completed_quest_ids", &"current_rank", &"metrics", &"processed_requests",
|
|
&"quarantine_reason", &"quarantined", &"version",
|
|
]):
|
|
return _fail("Progression snapshot root fields are invalid.")
|
|
var version_value: Variant = snapshot.get(&"version", null)
|
|
if not _is_integer_variant(version_value) \
|
|
or int(version_value) not in [LEGACY_SNAPSHOT_VERSION, SNAPSHOT_VERSION]:
|
|
return _fail("Unsupported Progression snapshot version.")
|
|
var snapshot_version := int(version_value)
|
|
if not snapshot.get(&"metrics", null) is Dictionary \
|
|
or not snapshot.get(&"completed_quest_ids", null) is Array \
|
|
or not snapshot.get(&"processed_requests", null) is Array \
|
|
or not snapshot.get(&"quarantined", null) is bool \
|
|
or not _is_string_variant(snapshot.get(&"quarantine_reason", null)):
|
|
return _fail("Progression snapshot shape is invalid.")
|
|
var rank_value: Variant = snapshot.get(&"current_rank", null)
|
|
if not _is_integer_variant(rank_value):
|
|
return _fail("Progression snapshot rank is invalid.")
|
|
var candidate := ProgressionService.new()
|
|
candidate.current_rank = int(rank_value)
|
|
if candidate.current_rank < MIN_RANK or candidate.current_rank > MAX_RANK:
|
|
return _fail("Progression snapshot rank is outside 1 through 7.")
|
|
if not candidate._replace_metrics(snapshot[&"metrics"], false):
|
|
return _fail(candidate.last_error)
|
|
if snapshot[&"completed_quest_ids"].size() > MAX_COMPLETED_QUESTS:
|
|
return _fail("Progression snapshot quest limit exceeded.")
|
|
for raw_quest_id: Variant in snapshot[&"completed_quest_ids"]:
|
|
if not _is_string_variant(raw_quest_id):
|
|
return _fail("Progression snapshot quest ID is invalid.")
|
|
var quest_id := StringName(raw_quest_id)
|
|
if not _is_stable_id(String(quest_id)) or candidate.completed_quest_ids.has(quest_id):
|
|
return _fail("Progression snapshot contains duplicate or invalid quest IDs.")
|
|
candidate.completed_quest_ids.append(quest_id)
|
|
var sorted_quests := candidate.completed_quest_ids.duplicate()
|
|
sorted_quests.sort_custom(_name_less)
|
|
if sorted_quests != candidate.completed_quest_ids:
|
|
return _fail("Progression snapshot quest IDs are not canonical.")
|
|
if snapshot[&"processed_requests"].size() > MAX_PROCESSED_REQUESTS:
|
|
return _fail("Progression snapshot request limit exceeded.")
|
|
if snapshot_version == LEGACY_SNAPSHOT_VERSION:
|
|
if candidate.current_rank != MIN_RANK or not snapshot[&"processed_requests"].is_empty():
|
|
return _fail("Legacy progressed snapshots lack verifiable promotion evidence.")
|
|
candidate.quarantined = bool(snapshot[&"quarantined"])
|
|
candidate.quarantine_reason = StringName(snapshot[&"quarantine_reason"])
|
|
if not _quarantine_state_is_valid(candidate.quarantined, candidate.quarantine_reason):
|
|
return _fail("Progression quarantine state is inconsistent.")
|
|
_copy_from(candidate)
|
|
last_error = ""
|
|
return true
|
|
var previous_request_id := ""
|
|
var recorded_target_ranks: Dictionary = {}
|
|
for raw_record: Variant in snapshot[&"processed_requests"]:
|
|
if not raw_record is Dictionary:
|
|
return _fail("Progression snapshot request record is invalid.")
|
|
var record := raw_record as Dictionary
|
|
if not _keys_match(record, [&"details", &"fingerprint", &"request_id"]):
|
|
return _fail("Progression snapshot request fields are invalid.")
|
|
if not _is_string_variant(record.get(&"request_id", null)) \
|
|
or not _is_string_variant(record.get(&"fingerprint", null)) \
|
|
or not record.get(&"details", null) is Dictionary:
|
|
return _fail("Progression snapshot request record shape is invalid.")
|
|
var request_id := StringName(record[&"request_id"])
|
|
var request_text := String(request_id)
|
|
if not _is_stable_id(request_text) \
|
|
or request_text <= previous_request_id \
|
|
or candidate._processed_requests.has(request_id):
|
|
return _fail("Progression snapshot request IDs are not canonical.")
|
|
var fingerprint := String(record[&"fingerprint"])
|
|
var details: Dictionary = record[&"details"]
|
|
if not _keys_match(details, [&"bounds", &"eligibility", &"rank", &"request_id"]):
|
|
return _fail("Progression snapshot request detail fields are invalid.")
|
|
var detail_rank: Variant = details.get(&"rank", null)
|
|
var detail_request_id: Variant = details.get(&"request_id", null)
|
|
var detail_bounds: Variant = details.get(&"bounds", null)
|
|
var detail_eligibility: Variant = details.get(&"eligibility", null)
|
|
if not _is_integer_variant(detail_rank) \
|
|
or not _is_string_variant(detail_request_id) \
|
|
or not detail_bounds is Dictionary \
|
|
or not detail_eligibility is Dictionary \
|
|
or not _bounds_shape_is_strict(detail_bounds) \
|
|
or fingerprint != "rank:%d" % int(detail_rank) \
|
|
or int(detail_rank) < 2 \
|
|
or int(detail_rank) > candidate.current_rank \
|
|
or StringName(detail_request_id) != request_id \
|
|
or not _bounds_equal_expected(detail_bounds, candidate.get_rank_bounds(int(detail_rank))) \
|
|
or recorded_target_ranks.has(int(detail_rank)):
|
|
return _fail("Progression snapshot request details are inconsistent.")
|
|
if not candidate._eligibility_evidence_is_strict(detail_eligibility, int(detail_rank)):
|
|
return _fail(candidate.last_error)
|
|
recorded_target_ranks[int(detail_rank)] = true
|
|
var canonical_details := candidate._canonical_promotion_details(
|
|
request_id,
|
|
int(detail_rank),
|
|
detail_eligibility
|
|
)
|
|
candidate._processed_requests[request_id] = {
|
|
&"fingerprint": fingerprint,
|
|
&"details": canonical_details,
|
|
}
|
|
previous_request_id = request_text
|
|
if recorded_target_ranks.size() != candidate.current_rank - MIN_RANK:
|
|
return _fail("Progression snapshot promotion history is incomplete.")
|
|
for expected_rank in range(MIN_RANK + 1, candidate.current_rank + 1):
|
|
if not recorded_target_ranks.has(expected_rank):
|
|
return _fail("Progression snapshot promotion history is not contiguous.")
|
|
if not candidate._historical_evidence_matches_monotonic_facts():
|
|
return _fail(candidate.last_error)
|
|
candidate.quarantined = bool(snapshot[&"quarantined"])
|
|
candidate.quarantine_reason = StringName(snapshot[&"quarantine_reason"])
|
|
if not _quarantine_state_is_valid(candidate.quarantined, candidate.quarantine_reason):
|
|
return _fail("Progression quarantine state is inconsistent.")
|
|
_copy_from(candidate)
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func _replace_metrics(source: Dictionary, allow_partial: bool) -> bool:
|
|
if source.size() > METRIC_IDS.size():
|
|
return _fail("Metric set is too large.")
|
|
var candidate := DEFAULT_METRICS.duplicate(true) if allow_partial else {}
|
|
var seen: Dictionary = {}
|
|
for raw_metric_id: Variant in source:
|
|
if not _is_string_variant(raw_metric_id):
|
|
return _fail("Metric IDs must be strings.")
|
|
var metric_id := StringName(raw_metric_id)
|
|
if not METRIC_IDS.has(metric_id) or seen.has(metric_id):
|
|
return _fail("Metrics contain an unknown or duplicate canonical ID.")
|
|
seen[metric_id] = true
|
|
var raw_value: Variant = source[raw_metric_id]
|
|
if not _is_integer_variant(raw_value):
|
|
return _fail("Progression metrics must be integers.")
|
|
var value := int(raw_value)
|
|
if value < 0 or value > MAX_SAFE_INTEGER \
|
|
or (PERCENT_METRICS.has(metric_id) and value > 100):
|
|
return _fail("Progression metric is outside its valid range.")
|
|
candidate[metric_id] = value
|
|
if not allow_partial and seen.size() != METRIC_IDS.size():
|
|
return _fail("Progression snapshot must contain every metric exactly once.")
|
|
metrics = candidate
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func _eligibility_evidence_is_strict(evidence: Dictionary, target_rank: int) -> bool:
|
|
if not _keys_match(evidence, [
|
|
&"bounds", &"current_rank", &"eligible", &"reason", &"requirements", &"target_rank",
|
|
]):
|
|
return _fail("Progression promotion evidence fields are invalid.")
|
|
var evidence_current_rank: Variant = evidence.get(&"current_rank", null)
|
|
var evidence_target_rank: Variant = evidence.get(&"target_rank", null)
|
|
var evidence_eligible: Variant = evidence.get(&"eligible", null)
|
|
var evidence_reason: Variant = evidence.get(&"reason", null)
|
|
var evidence_bounds: Variant = evidence.get(&"bounds", null)
|
|
var raw_requirements: Variant = evidence.get(&"requirements", null)
|
|
if not _is_integer_variant(evidence_current_rank) \
|
|
or not _is_integer_variant(evidence_target_rank) \
|
|
or not evidence_eligible is bool \
|
|
or not _is_string_variant(evidence_reason) \
|
|
or not evidence_bounds is Dictionary \
|
|
or not raw_requirements is Array:
|
|
return _fail("Progression promotion evidence shape is invalid.")
|
|
if int(evidence_current_rank) != target_rank - 1 \
|
|
or int(evidence_target_rank) != target_rank \
|
|
or not bool(evidence_eligible) \
|
|
or StringName(evidence_reason) != &"OK" \
|
|
or not _bounds_shape_is_strict(evidence_bounds) \
|
|
or not _bounds_equal_expected(evidence_bounds, get_rank_bounds(target_rank)):
|
|
return _fail("Progression promotion evidence header is inconsistent.")
|
|
var expected := _expected_requirement_descriptors(target_rank)
|
|
var requirements := raw_requirements as Array
|
|
if requirements.size() != expected.size():
|
|
return _fail("Progression promotion evidence requirements are incomplete.")
|
|
for index in requirements.size():
|
|
var raw_requirement: Variant = requirements[index]
|
|
if not raw_requirement is Dictionary:
|
|
return _fail("Progression promotion evidence requirement is invalid.")
|
|
var requirement := raw_requirement as Dictionary
|
|
var descriptor: Dictionary = expected[index]
|
|
var kind := StringName(descriptor[&"kind"])
|
|
var expected_fields: Array = [&"comparison", &"current", &"id", &"met", &"required"]
|
|
if kind == &"either":
|
|
expected_fields.append(&"values")
|
|
if not _keys_match(requirement, expected_fields):
|
|
return _fail("Progression promotion evidence requirement fields are invalid.")
|
|
var raw_id: Variant = requirement.get(&"id", null)
|
|
var raw_comparison: Variant = requirement.get(&"comparison", null)
|
|
var raw_current: Variant = requirement.get(&"current", null)
|
|
var raw_required: Variant = requirement.get(&"required", null)
|
|
var raw_met: Variant = requirement.get(&"met", null)
|
|
if not _is_string_variant(raw_id) \
|
|
or not _is_string_variant(raw_comparison) \
|
|
or not _is_integer_variant(raw_current) \
|
|
or not _is_integer_variant(raw_required) \
|
|
or not raw_met is bool:
|
|
return _fail("Progression promotion evidence requirement shape is invalid.")
|
|
var current := int(raw_current)
|
|
if StringName(raw_id) != StringName(descriptor[&"id"]) \
|
|
or StringName(raw_comparison) != StringName(descriptor[&"comparison"]) \
|
|
or int(raw_required) != int(descriptor[&"required"]) \
|
|
or not bool(raw_met) \
|
|
or current < 0 \
|
|
or current > MAX_SAFE_INTEGER:
|
|
return _fail("Progression promotion evidence requirement values are inconsistent.")
|
|
match kind:
|
|
&"quest":
|
|
if current != 1:
|
|
return _fail("Progression promotion quest evidence is not complete.")
|
|
&"either":
|
|
var raw_values: Variant = requirement.get(&"values", null)
|
|
if not raw_values is Dictionary \
|
|
or not _keys_match(raw_values, [&"reputation", &"underground_influence"]):
|
|
return _fail("Progression alternative evidence values are invalid.")
|
|
var first: Variant = raw_values.get("reputation", raw_values.get(&"reputation", null))
|
|
var second: Variant = raw_values.get("underground_influence", raw_values.get(&"underground_influence", null))
|
|
if not _is_integer_variant(first) or not _is_integer_variant(second) \
|
|
or int(first) < 0 or int(first) > 100 \
|
|
or int(second) < 0 or int(second) > 100 \
|
|
or current != maxi(int(first), int(second)) \
|
|
or (int(first) < int(raw_required) and int(second) < int(raw_required)):
|
|
return _fail("Progression alternative evidence does not meet its threshold.")
|
|
_:
|
|
if current < int(raw_required):
|
|
return _fail("Progression metric evidence does not meet its threshold.")
|
|
var metric_id := StringName(descriptor[&"id"])
|
|
if PERCENT_METRICS.has(metric_id) and current > 100:
|
|
return _fail("Progression percentage evidence is outside its valid range.")
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func _canonical_promotion_details(
|
|
request_id: StringName,
|
|
target_rank: int,
|
|
evidence: Dictionary
|
|
) -> Dictionary:
|
|
var canonical_requirements: Array[Dictionary] = []
|
|
for raw_requirement: Variant in evidence.get(&"requirements", evidence.get("requirements", [])):
|
|
var requirement := raw_requirement as Dictionary
|
|
var canonical := {
|
|
&"id": StringName(requirement.get(&"id", requirement.get("id", ""))),
|
|
&"comparison": StringName(requirement.get(&"comparison", requirement.get("comparison", ""))),
|
|
&"current": int(requirement.get(&"current", requirement.get("current", 0))),
|
|
&"required": int(requirement.get(&"required", requirement.get("required", 0))),
|
|
&"met": bool(requirement.get(&"met", requirement.get("met", false))),
|
|
}
|
|
var raw_values: Variant = requirement.get(&"values", requirement.get("values", null))
|
|
if raw_values is Dictionary:
|
|
canonical[&"values"] = {
|
|
"reputation": int(raw_values.get(&"reputation", raw_values.get("reputation", 0))),
|
|
"underground_influence": int(raw_values.get(&"underground_influence", raw_values.get("underground_influence", 0))),
|
|
}
|
|
canonical_requirements.append(canonical)
|
|
return {
|
|
&"rank": target_rank,
|
|
&"bounds": get_rank_bounds(target_rank),
|
|
&"eligibility": {
|
|
&"eligible": true,
|
|
&"current_rank": target_rank - 1,
|
|
&"target_rank": target_rank,
|
|
&"requirements": canonical_requirements,
|
|
&"reason": &"OK",
|
|
&"bounds": get_rank_bounds(target_rank),
|
|
},
|
|
&"request_id": request_id,
|
|
}
|
|
|
|
|
|
func _historical_evidence_matches_monotonic_facts() -> bool:
|
|
for raw_record: Variant in _processed_requests.values():
|
|
var record := raw_record as Dictionary
|
|
var details := record[&"details"] as Dictionary
|
|
var evidence := details[&"eligibility"] as Dictionary
|
|
for raw_requirement: Variant in evidence[&"requirements"]:
|
|
var requirement := raw_requirement as Dictionary
|
|
var comparison := StringName(requirement[&"comparison"])
|
|
var requirement_id := StringName(requirement[&"id"])
|
|
if comparison == &"QUEST_COMPLETED" and not completed_quest_ids.has(requirement_id):
|
|
return _fail("Progression snapshot lost a quest used by promotion history.")
|
|
if LIFETIME_METRICS.has(requirement_id) \
|
|
and int(metrics[requirement_id]) < int(requirement[&"current"]):
|
|
return _fail("Progression snapshot lifetime metric is below promotion history.")
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func _expected_requirement_descriptors(target_rank: int) -> Array[Dictionary]:
|
|
var result: Array[Dictionary] = []
|
|
match target_rank:
|
|
2:
|
|
result.append(_metric_descriptor(&"reputation", 25))
|
|
result.append(_metric_descriptor(&"asset_gold", 10_000))
|
|
3:
|
|
result.append(_metric_descriptor(&"faith_lifetime", 100))
|
|
result.append(_metric_descriptor(&"guest_lifetime", 300))
|
|
4:
|
|
result.append(_metric_descriptor(&"reputation", 45))
|
|
result.append(_metric_descriptor(&"asset_gold", 50_000))
|
|
5:
|
|
result.append(_metric_descriptor(&"faith_lifetime", 500))
|
|
result.append({
|
|
&"id": &"important_rank_5", &"comparison": &"QUEST_COMPLETED",
|
|
&"required": 1, &"kind": &"quest",
|
|
})
|
|
6:
|
|
result.append({
|
|
&"id": &"reputation_or_underground_influence",
|
|
&"comparison": &"EITHER_AT_LEAST", &"required": 65, &"kind": &"either",
|
|
})
|
|
7:
|
|
result.append(_metric_descriptor(&"faith_lifetime", 1_500))
|
|
result.append(_metric_descriptor(&"reputation", 60))
|
|
result.append(_metric_descriptor(&"underground_influence", 40))
|
|
result.append({
|
|
&"id": &"main_chapter_complete", &"comparison": &"QUEST_COMPLETED",
|
|
&"required": 1, &"kind": &"quest",
|
|
})
|
|
if target_rank >= 4:
|
|
result.append(_metric_descriptor(&"active_above_ground_facilities", 1))
|
|
result.append(_metric_descriptor(&"active_underground_facilities", 1))
|
|
return result
|
|
|
|
|
|
static func _metric_descriptor(metric_id: StringName, required: int) -> Dictionary:
|
|
return {
|
|
&"id": metric_id,
|
|
&"comparison": &"AT_LEAST",
|
|
&"required": required,
|
|
&"kind": &"metric",
|
|
}
|
|
|
|
|
|
func _require_metric(requirements: Array[Dictionary], metric_id: StringName, required: int) -> void:
|
|
var current := int(metrics[metric_id])
|
|
requirements.append({
|
|
&"id": metric_id,
|
|
&"comparison": &"AT_LEAST",
|
|
&"current": current,
|
|
&"required": required,
|
|
&"met": current >= required,
|
|
})
|
|
|
|
|
|
func _require_quest(requirements: Array[Dictionary], quest_id: StringName) -> void:
|
|
requirements.append({
|
|
&"id": quest_id,
|
|
&"comparison": &"QUEST_COMPLETED",
|
|
&"current": int(completed_quest_ids.has(quest_id)),
|
|
&"required": 1,
|
|
&"met": completed_quest_ids.has(quest_id),
|
|
})
|
|
|
|
|
|
func _require_either_metric(
|
|
requirements: Array[Dictionary],
|
|
requirement_id: StringName,
|
|
first_metric_id: StringName,
|
|
second_metric_id: StringName,
|
|
required: int
|
|
) -> void:
|
|
var first := int(metrics[first_metric_id])
|
|
var second := int(metrics[second_metric_id])
|
|
requirements.append({
|
|
&"id": requirement_id,
|
|
&"comparison": &"EITHER_AT_LEAST",
|
|
&"current": maxi(first, second),
|
|
&"required": required,
|
|
&"met": first >= required or second >= required,
|
|
&"values": {String(first_metric_id): first, String(second_metric_id): second},
|
|
})
|
|
|
|
|
|
func _copy_from(source: ProgressionService) -> void:
|
|
current_rank = source.current_rank
|
|
metrics = source.metrics.duplicate(true)
|
|
completed_quest_ids.assign(source.completed_quest_ids)
|
|
_processed_requests = source._processed_requests.duplicate(true)
|
|
quarantined = source.quarantined
|
|
quarantine_reason = source.quarantine_reason
|
|
|
|
|
|
func _failure(code: CommandResult.Code, message: String, details: Dictionary = {}) -> CommandResult:
|
|
last_error = message
|
|
return CommandResult.failed(code, 0, message, details)
|
|
|
|
|
|
func _fail(message: String) -> bool:
|
|
last_error = message
|
|
return false
|
|
|
|
|
|
static func _callback_succeeded(value: Variant) -> bool:
|
|
if value is CommandResult:
|
|
return (value as CommandResult).is_success()
|
|
if value is Dictionary:
|
|
var ok_value: Variant = value.get(&"ok", value.get("ok", null))
|
|
return ok_value is bool and bool(ok_value)
|
|
return value is bool and bool(value)
|
|
|
|
|
|
static func _is_integer_variant(value: Variant) -> bool:
|
|
return value is int or (value is float and is_finite(value) and value == floor(value))
|
|
|
|
|
|
static func _is_string_variant(value: Variant) -> bool:
|
|
return value is String or value is StringName
|
|
|
|
|
|
static func _is_stable_id(value: String) -> bool:
|
|
if value.is_empty() or value.length() > 128 or value[0] < "a" or value[0] > "z":
|
|
return false
|
|
for index in value.length():
|
|
var character := value[index]
|
|
if not ((character >= "a" and character <= "z") or character == "_" or (character >= "0" and character <= "9")):
|
|
return false
|
|
return not value.ends_with("_") and not "__" in value
|
|
|
|
|
|
static func _name_less(left: StringName, right: StringName) -> bool:
|
|
return String(left) < String(right)
|
|
|
|
|
|
static func _quarantine_state_is_valid(value: bool, reason: StringName) -> bool:
|
|
return (value and QUARANTINE_REASONS.has(reason)) or (not value and reason.is_empty())
|
|
|
|
|
|
static func _keys_match(source: Dictionary, expected: Array) -> bool:
|
|
if source.size() != expected.size():
|
|
return false
|
|
var seen: Dictionary = {}
|
|
for raw_key: Variant in source:
|
|
if not _is_string_variant(raw_key):
|
|
return false
|
|
var key := StringName(raw_key)
|
|
if seen.has(key) or not expected.has(key):
|
|
return false
|
|
seen[key] = true
|
|
return seen.size() == expected.size()
|
|
|
|
|
|
static func _bounds_shape_is_strict(bounds: Dictionary) -> bool:
|
|
if not _keys_match(bounds, [&"max_x", &"max_y", &"min_x", &"min_y"]):
|
|
return false
|
|
for field_id: StringName in [&"max_x", &"max_y", &"min_x", &"min_y"]:
|
|
if not _is_integer_variant(bounds.get(field_id, bounds.get(String(field_id), null))):
|
|
return false
|
|
return true
|
|
|
|
|
|
static func _bounds_equal_expected(bounds: Dictionary, expected: Dictionary) -> bool:
|
|
if not _bounds_shape_is_strict(bounds) or not _bounds_shape_is_strict(expected):
|
|
return false
|
|
for field_id: StringName in [&"max_x", &"max_y", &"min_x", &"min_y"]:
|
|
var actual: Variant = bounds.get(field_id, bounds.get(String(field_id), null))
|
|
var wanted: Variant = expected.get(field_id, expected.get(String(field_id), null))
|
|
if int(actual) != int(wanted):
|
|
return false
|
|
return true
|
|
|
|
|
|
static func _names_to_strings(source: Array[StringName]) -> Array[String]:
|
|
var result: Array[String] = []
|
|
for value: StringName in source:
|
|
result.append(String(value))
|
|
return result
|
|
|
|
|
|
static func _string_key_dictionary(source: Dictionary) -> Dictionary:
|
|
var result: Dictionary = {}
|
|
for key: StringName in METRIC_IDS:
|
|
result[String(key)] = int(source[key])
|
|
return result
|