687 lines
27 KiB
GDScript
687 lines
27 KiB
GDScript
class_name RelationshipService
|
|
extends RefCounted
|
|
|
|
signal relationship_registered(state: RelationshipState)
|
|
signal relationship_value_changed(relationship_id: StringName, metric_id: StringName, old_value: int, new_value: int)
|
|
signal threshold_candidate_registered(candidate: Dictionary)
|
|
signal shield_changed(relationship_id: StringName, old_value: int, new_value: int)
|
|
signal route_lock_changed(relationship_id: StringName, old_lock: int, new_lock: int, route_id: StringName)
|
|
|
|
const RelationshipStateType = preload("res://scripts/relationship/relationship_state.gd")
|
|
const CommandResultType = preload("res://scripts/core/command_result.gd")
|
|
const OriginalStateType = preload("res://scripts/relationship/original_relationship_state.gd")
|
|
|
|
const SNAPSHOT_VERSION := 2
|
|
const DAILY_AUTOMATIC_DELTA_LIMIT := 10
|
|
const GATED_THRESHOLDS: Array[int] = [50, 80]
|
|
const STAGE_THRESHOLDS: Array[int] = [20, 50, 80, 100]
|
|
|
|
var last_error := ""
|
|
|
|
var _relationships: Dictionary = {}
|
|
var _pair_index: Dictionary = {}
|
|
var _threshold_candidates: Array[Dictionary] = []
|
|
var _next_candidate_sequence := 1
|
|
var _candidate_callback := Callable()
|
|
var _original_records: Dictionary = {}
|
|
var _original_identity_validator := Callable()
|
|
var _legacy_raw: Dictionary = {}
|
|
var _legacy_baselines: Dictionary = {}
|
|
var _container_raw: Dictionary = {}
|
|
|
|
|
|
func configure_original_identity_validator(callback: Callable) -> RelationshipService:
|
|
_original_identity_validator = callback
|
|
return self
|
|
|
|
|
|
func register_original_relationship(payload: Variant) -> CommandResult:
|
|
var state := OriginalStateType.from_snapshot(payload)
|
|
if state == null:
|
|
return _invalid("Original relationship requires complete explicit facts.")
|
|
if not _original_identity_validator.is_valid() or not bool(_original_identity_validator.call(state.create_snapshot())):
|
|
return _invalid("Original relationship identities are not registered.")
|
|
if _original_records.has(state.subject_id()):
|
|
return _duplicate("Original relationship subject", state.subject_id())
|
|
_original_records[state.subject_id()] = state.create_snapshot()
|
|
last_error = ""
|
|
return _success({&"subject_id": state.subject_id()})
|
|
|
|
|
|
func get_original_relationship(subject_id: StringName) -> Dictionary:
|
|
if _original_records.has(subject_id):
|
|
var result: Dictionary = _original_records[subject_id].duplicate(true)
|
|
result[&"configured"] = true
|
|
return result
|
|
return {&"configured": false, &"subject_id": subject_id,
|
|
&"protagonist_id": null, &"rival_id": null, &"affection": null,
|
|
&"lust": null, &"netorare": null, &"shield_current": null, &"shield_max": null}
|
|
|
|
|
|
func validate_original_identities() -> bool:
|
|
for payload: Dictionary in _original_records.values():
|
|
if not _original_identity_validator.is_valid() or not bool(_original_identity_validator.call(payload.duplicate(true))):
|
|
return false
|
|
return true
|
|
|
|
|
|
func configure_candidate_callback(callback: Callable = Callable()) -> RelationshipService:
|
|
_candidate_callback = callback
|
|
return self
|
|
|
|
|
|
func register_relationship(value: Variant) -> CommandResult:
|
|
if value == null or not value is RelationshipState:
|
|
return _invalid("RelationshipState is missing or invalid.")
|
|
var state: RelationshipState = value
|
|
if not state.is_valid():
|
|
return _invalid("RelationshipState fails invariant validation.")
|
|
if _relationships.has(state.relationship_id):
|
|
return _duplicate("Relationship", state.relationship_id)
|
|
var key := state.pair_key()
|
|
if _pair_index.has(key):
|
|
return _invalid("The normalized actor pair already has a Relationship.", {
|
|
&"existing_relationship_id": _pair_index[key],
|
|
})
|
|
_relationships[state.relationship_id] = state
|
|
_pair_index[key] = state.relationship_id
|
|
relationship_registered.emit(state)
|
|
last_error = ""
|
|
return _success({&"relationship_id": state.relationship_id})
|
|
|
|
|
|
func create_relationship(
|
|
relationship_id: StringName,
|
|
actor_one_id: StringName,
|
|
actor_two_id: StringName,
|
|
options: Dictionary = {}
|
|
) -> CommandResult:
|
|
return register_relationship(
|
|
RelationshipStateType.new().configure(
|
|
relationship_id,
|
|
actor_one_id,
|
|
actor_two_id,
|
|
options
|
|
)
|
|
)
|
|
|
|
|
|
func has_relationship(relationship_id: StringName) -> bool:
|
|
return _relationships.has(relationship_id)
|
|
|
|
|
|
func get_relationship(relationship_id: StringName) -> RelationshipState:
|
|
return _relationships.get(relationship_id) as RelationshipState
|
|
|
|
|
|
func find_relationship(actor_one_id: StringName, actor_two_id: StringName) -> RelationshipState:
|
|
var ids: Array[String] = [String(actor_one_id), String(actor_two_id)]
|
|
ids.sort()
|
|
var relationship_id: StringName = _pair_index.get("%s|%s" % [ids[0], ids[1]], &"")
|
|
return get_relationship(relationship_id)
|
|
|
|
|
|
func get_relationships() -> Array[RelationshipState]:
|
|
var result: Array[RelationshipState] = []
|
|
for relationship_id: StringName in _sorted_name_keys(_relationships):
|
|
result.append(_relationships[relationship_id])
|
|
return result
|
|
|
|
|
|
func apply_value_change(
|
|
relationship_id: StringName,
|
|
metric_id: StringName,
|
|
delta: int,
|
|
absolute_minute: int,
|
|
options: Dictionary = {}
|
|
) -> CommandResult:
|
|
var state := get_relationship(relationship_id)
|
|
if state == null:
|
|
return _not_found("Relationship", relationship_id)
|
|
if not RelationshipStateType.METRIC_IDS.has(metric_id):
|
|
return _invalid("Unknown relationship metric: %s" % metric_id)
|
|
if absolute_minute < 0:
|
|
return _invalid("Relationship changes require a non-negative minute.")
|
|
var important := bool(_value(options, &"important", false))
|
|
var allow_threshold_crossing := bool(
|
|
_value(options, &"allow_threshold_crossing", false)
|
|
)
|
|
_reset_metric_day_if_needed(state, absolute_minute / 1_440)
|
|
var requested_delta := delta
|
|
var applied_delta := delta
|
|
if not important:
|
|
var used := int(state.daily_metric_deltas.get(metric_id, 0))
|
|
applied_delta = clampi(delta, -DAILY_AUTOMATIC_DELTA_LIMIT - used, DAILY_AUTOMATIC_DELTA_LIMIT - used)
|
|
var old_value := state.get_metric(metric_id)
|
|
var desired_value := clampi(old_value + applied_delta, 0, 100)
|
|
var gated_threshold := 0
|
|
if desired_value > old_value and not allow_threshold_crossing:
|
|
for threshold: int in GATED_THRESHOLDS:
|
|
if old_value < threshold and desired_value >= threshold:
|
|
desired_value = threshold - 1
|
|
gated_threshold = threshold
|
|
break
|
|
var actual_delta := desired_value - old_value
|
|
state.metrics[metric_id] = desired_value
|
|
if not important:
|
|
state.daily_metric_deltas[metric_id] = int(
|
|
state.daily_metric_deltas.get(metric_id, 0)
|
|
) + actual_delta
|
|
var crossed: Array[int] = []
|
|
if desired_value > old_value:
|
|
for threshold: int in STAGE_THRESHOLDS:
|
|
if old_value < threshold and desired_value >= threshold:
|
|
crossed.append(threshold)
|
|
_record_crossed_threshold(state, metric_id, threshold)
|
|
_register_threshold_candidate(state, metric_id, threshold, absolute_minute, false)
|
|
if gated_threshold > 0:
|
|
_register_threshold_candidate(
|
|
state,
|
|
metric_id,
|
|
gated_threshold,
|
|
absolute_minute,
|
|
true
|
|
)
|
|
if old_value != desired_value:
|
|
relationship_value_changed.emit(
|
|
relationship_id,
|
|
metric_id,
|
|
old_value,
|
|
desired_value
|
|
)
|
|
last_error = ""
|
|
return _success({
|
|
&"relationship_id": relationship_id,
|
|
&"metric_id": metric_id,
|
|
&"old_value": old_value,
|
|
&"new_value": desired_value,
|
|
&"requested_delta": requested_delta,
|
|
&"applied_delta": actual_delta,
|
|
&"gated_threshold": gated_threshold,
|
|
&"crossed_thresholds": crossed,
|
|
})
|
|
|
|
|
|
func set_flag(relationship_id: StringName, flag_id: StringName, enabled: bool) -> CommandResult:
|
|
var state := get_relationship(relationship_id)
|
|
if state == null:
|
|
return _not_found("Relationship", relationship_id)
|
|
if flag_id.is_empty():
|
|
return _invalid("Relationship flag ID cannot be empty.")
|
|
state.flags[flag_id] = enabled
|
|
last_error = ""
|
|
return _success({&"relationship_id": relationship_id, &"flag_id": flag_id})
|
|
|
|
|
|
func set_shield_max(
|
|
relationship_id: StringName,
|
|
new_maximum: int,
|
|
preserve_ratio: bool = false
|
|
) -> CommandResult:
|
|
var state := get_relationship(relationship_id)
|
|
if state == null:
|
|
return _not_found("Relationship", relationship_id)
|
|
var old_maximum := state.shield_max
|
|
var old_current := state.shield_current
|
|
state.shield_max = clampi(new_maximum, 0, 100)
|
|
if preserve_ratio and old_maximum > 0:
|
|
state.shield_current = clampi(
|
|
(old_current * state.shield_max) / old_maximum,
|
|
0,
|
|
state.shield_max
|
|
)
|
|
else:
|
|
state.shield_current = mini(old_current, state.shield_max)
|
|
if old_current != state.shield_current:
|
|
shield_changed.emit(relationship_id, old_current, state.shield_current)
|
|
last_error = ""
|
|
return _success({
|
|
&"old_maximum": old_maximum,
|
|
&"new_maximum": state.shield_max,
|
|
&"old_current": old_current,
|
|
&"new_current": state.shield_current,
|
|
})
|
|
|
|
|
|
func restore_shield(relationship_id: StringName, amount: int) -> CommandResult:
|
|
var state := get_relationship(relationship_id)
|
|
if state == null:
|
|
return _not_found("Relationship", relationship_id)
|
|
if amount < 0:
|
|
return _invalid("Shield restoration amount cannot be negative.")
|
|
var old_value := state.shield_current
|
|
state.shield_current = clampi(old_value + amount, 0, state.shield_max)
|
|
if old_value != state.shield_current:
|
|
shield_changed.emit(relationship_id, old_value, state.shield_current)
|
|
last_error = ""
|
|
return _success({
|
|
&"old_value": old_value,
|
|
&"new_value": state.shield_current,
|
|
&"restored": state.shield_current - old_value,
|
|
})
|
|
|
|
|
|
func apply_shield_attack(
|
|
relationship_id: StringName,
|
|
action_id: StringName,
|
|
absolute_minute: int,
|
|
terms: Dictionary
|
|
) -> CommandResult:
|
|
var state := get_relationship(relationship_id)
|
|
if state == null:
|
|
return _not_found("Relationship", relationship_id)
|
|
if action_id.is_empty() or absolute_minute < 0:
|
|
return _invalid("ShieldAttack requires an Action ID and non-negative minute.")
|
|
var day := absolute_minute / 1_440
|
|
_reset_attack_day_if_needed(state, day)
|
|
var uses_before := int(state.action_counts.get(action_id, 0))
|
|
var daily_limit := maxi(int(_value(terms, &"daily_limit", 4)), 1)
|
|
if uses_before >= daily_limit:
|
|
return _invalid("ShieldAction daily use limit reached.", {
|
|
&"action_id": action_id,
|
|
&"uses": uses_before,
|
|
&"daily_limit": daily_limit,
|
|
})
|
|
var penalty_percent := _repetition_penalty_percent(uses_before)
|
|
var raw_damage := (
|
|
int(_value(terms, &"base_damage", 0))
|
|
+ int(_value(terms, &"owner_stat_bonus", 0))
|
|
+ int(_value(terms, &"intel_bonus", 0))
|
|
+ int(_value(terms, &"event_bonus", 0))
|
|
- int(_value(terms, &"target_resistance", 0))
|
|
)
|
|
var max_damage := maxi(int(_value(terms, &"max_damage", raw_damage)), 0)
|
|
var damage := clampi((maxi(raw_damage, 0) * (100 - penalty_percent)) / 100, 0, max_damage)
|
|
var old_value := state.shield_current
|
|
state.shield_current = maxi(old_value - damage, 0)
|
|
state.action_counts[action_id] = uses_before + 1
|
|
state.daily_attack_count += 1
|
|
if old_value != state.shield_current:
|
|
shield_changed.emit(relationship_id, old_value, state.shield_current)
|
|
last_error = ""
|
|
return _success({
|
|
&"relationship_id": relationship_id,
|
|
&"action_id": action_id,
|
|
&"uses_before": uses_before,
|
|
&"penalty_percent": penalty_percent,
|
|
&"raw_damage": raw_damage,
|
|
&"damage": damage,
|
|
&"old_shield": old_value,
|
|
&"new_shield": state.shield_current,
|
|
&"shield_broken": old_value > 0 and state.shield_current == 0,
|
|
})
|
|
|
|
|
|
func request_route_lock(
|
|
relationship_id: StringName,
|
|
requested_lock: RelationshipState.RouteLock,
|
|
route_id: StringName = &"",
|
|
irreversible_event: bool = false
|
|
) -> CommandResult:
|
|
var state := get_relationship(relationship_id)
|
|
if state == null:
|
|
return _not_found("Relationship", relationship_id)
|
|
if requested_lock < RelationshipStateType.RouteLock.NONE or requested_lock > RelationshipStateType.RouteLock.CLOSED:
|
|
return _invalid("Unknown Route Lock state.")
|
|
if requested_lock in [
|
|
RelationshipStateType.RouteLock.EXCLUSIVE_PENDING,
|
|
RelationshipStateType.RouteLock.EXCLUSIVE_LOCKED,
|
|
] and route_id.is_empty():
|
|
return _invalid("Exclusive Route Lock requires a route ID.")
|
|
if requested_lock == RelationshipStateType.RouteLock.EXCLUSIVE_LOCKED and not irreversible_event:
|
|
return _invalid("Exclusive Route cannot lock before explicit confirmation.", {
|
|
&"required_state": &"EXCLUSIVE_PENDING",
|
|
})
|
|
var old_lock := state.route_lock
|
|
state.route_lock = requested_lock
|
|
state.route_id = route_id if requested_lock != RelationshipStateType.RouteLock.NONE else &""
|
|
route_lock_changed.emit(relationship_id, old_lock, state.route_lock, state.route_id)
|
|
last_error = ""
|
|
return _success({&"old_lock": old_lock, &"new_lock": state.route_lock})
|
|
|
|
|
|
func confirm_route_lock(relationship_id: StringName, route_id: StringName) -> CommandResult:
|
|
var state := get_relationship(relationship_id)
|
|
if state == null:
|
|
return _not_found("Relationship", relationship_id)
|
|
if state.route_lock != RelationshipStateType.RouteLock.EXCLUSIVE_PENDING:
|
|
return _invalid("Route is not awaiting exclusive confirmation.")
|
|
if route_id.is_empty() or state.route_id != route_id:
|
|
return _invalid("Route confirmation does not match the pending route.")
|
|
var old_lock := state.route_lock
|
|
state.route_lock = RelationshipStateType.RouteLock.EXCLUSIVE_LOCKED
|
|
route_lock_changed.emit(relationship_id, old_lock, state.route_lock, state.route_id)
|
|
last_error = ""
|
|
return _success({&"route_id": route_id, &"new_lock": state.route_lock})
|
|
|
|
|
|
func get_threshold_candidates() -> Array[Dictionary]:
|
|
return _threshold_candidates.duplicate(true)
|
|
|
|
|
|
func consume_threshold_candidate(candidate_id: StringName) -> Dictionary:
|
|
for index: int in range(_threshold_candidates.size()):
|
|
if StringName(_threshold_candidates[index].get(&"candidate_id", &"")) == candidate_id:
|
|
var candidate: Dictionary = _threshold_candidates[index].duplicate(true)
|
|
_threshold_candidates.remove_at(index)
|
|
return candidate
|
|
return {}
|
|
|
|
|
|
func validate_invariants() -> Dictionary:
|
|
var errors: Array[String] = []
|
|
if not validate_original_identities():
|
|
errors.append("Original relationship identity reference is invalid.")
|
|
var seen_pairs: Dictionary = {}
|
|
for relationship_id: StringName in _sorted_name_keys(_relationships):
|
|
var state := get_relationship(relationship_id)
|
|
if state == null or not state.is_valid():
|
|
errors.append("Invalid RelationshipState: %s" % relationship_id)
|
|
continue
|
|
var pair := state.pair_key()
|
|
if seen_pairs.has(pair):
|
|
errors.append("Duplicate actor pair: %s" % pair)
|
|
seen_pairs[pair] = relationship_id
|
|
if _pair_index.get(pair, &"") != relationship_id:
|
|
errors.append("Pair index mismatch: %s" % pair)
|
|
return {&"ok": errors.is_empty(), &"errors": errors}
|
|
|
|
|
|
func create_snapshot() -> Dictionary:
|
|
var relationships: Array[Dictionary] = []
|
|
for state: RelationshipState in get_relationships():
|
|
var current := state.create_snapshot()
|
|
var original: Dictionary = _legacy_baselines.get(state.relationship_id, {})
|
|
var raw: Dictionary = {}
|
|
for entry: Dictionary in _legacy_raw.get(&"relationships", []):
|
|
if StringName(_value(entry, &"relationship_id", "")) == state.relationship_id:
|
|
raw = entry
|
|
break
|
|
relationships.append(_merge_legacy_changes(raw, original, current) if not original.is_empty() else current)
|
|
var candidates := _threshold_candidates.duplicate(true)
|
|
candidates.sort_custom(
|
|
func(a: Dictionary, b: Dictionary) -> bool:
|
|
return String(a.get(&"candidate_id", "")) < String(b.get(&"candidate_id", ""))
|
|
)
|
|
var legacy := _legacy_raw.duplicate(true)
|
|
legacy[&"version"] = 1
|
|
# Preserve input collection order as well as every untouched raw field.
|
|
if _legacy_raw.has(&"relationships"):
|
|
var ordered: Array[Dictionary] = []
|
|
for raw: Dictionary in _legacy_raw[&"relationships"]:
|
|
for entry: Dictionary in relationships:
|
|
if _value(entry, &"relationship_id", "") == _value(raw, &"relationship_id", ""):
|
|
ordered.append(entry)
|
|
for entry: Dictionary in relationships:
|
|
if not ordered.has(entry):
|
|
ordered.append(entry)
|
|
relationships = ordered
|
|
if legacy.get(&"relationships") != relationships:
|
|
legacy[&"relationships"] = relationships
|
|
var current_candidates := _threshold_candidates.duplicate(true) if not _legacy_raw.is_empty() else candidates
|
|
if legacy.get(&"threshold_candidates") != current_candidates:
|
|
legacy[&"threshold_candidates"] = current_candidates
|
|
legacy[&"next_candidate_sequence"] = _next_candidate_sequence
|
|
var result := _container_raw.duplicate(true)
|
|
result[&"version"] = SNAPSHOT_VERSION
|
|
result[&"legacy"] = legacy
|
|
# Compatibility projection for existing snapshot consumers; the nested v1
|
|
# record remains lossless, including extension names reserved by v2.
|
|
if result.get(&"relationships") != relationships:
|
|
result[&"relationships"] = relationships.duplicate(true)
|
|
var originals: Array[Dictionary] = []
|
|
for payload: Dictionary in _original_records.values():
|
|
originals.append(payload.duplicate(true))
|
|
if result.get(&"original_relationships") != originals:
|
|
result[&"original_relationships"] = originals
|
|
return result
|
|
|
|
|
|
static func _merge_legacy_changes(raw: Dictionary, baseline: Dictionary, current: Dictionary) -> Dictionary:
|
|
var result := raw.duplicate(true)
|
|
for key: Variant in baseline:
|
|
if not current.has(key):
|
|
result.erase(key)
|
|
for key: Variant in current:
|
|
if baseline.has(key) and current[key] == baseline[key]:
|
|
continue
|
|
if current[key] is Dictionary and baseline.get(key) is Dictionary and result.get(key) is Dictionary:
|
|
result[key] = _merge_legacy_changes(result[key], baseline[key], current[key])
|
|
else:
|
|
result[key] = current[key]
|
|
return result
|
|
|
|
|
|
func restore_snapshot(snapshot: Dictionary) -> bool:
|
|
var version: Variant = _value(snapshot, &"version", null)
|
|
if not version is int or version not in [1, SNAPSHOT_VERSION]:
|
|
last_error = "Unsupported Relationship snapshot version."
|
|
return false
|
|
var legacy: Variant = snapshot if version == 1 else _value(snapshot, &"legacy", null)
|
|
var originals: Variant = [] if version == 1 else _value(snapshot, &"original_relationships", null)
|
|
if not legacy is Dictionary or not originals is Array:
|
|
last_error = "Malformed Relationship snapshot container."
|
|
return false
|
|
if version == SNAPSHOT_VERSION and _value(snapshot, &"relationships", null) != _value(legacy, &"relationships", null):
|
|
last_error = "Legacy compatibility projection disagrees with its source."
|
|
return false
|
|
var original_records: Dictionary = {}
|
|
for raw: Variant in originals:
|
|
var state := OriginalStateType.from_snapshot(raw)
|
|
if state == null or original_records.has(state.subject_id()):
|
|
last_error = "Invalid or duplicate original relationship."
|
|
return false
|
|
if not _original_identity_validator.is_valid() or not bool(_original_identity_validator.call(state.create_snapshot())):
|
|
last_error = "Invalid original relationship identity reference."
|
|
return false
|
|
original_records[state.subject_id()] = state.create_snapshot()
|
|
var parsed := _parse_snapshot(legacy)
|
|
if not bool(parsed.get(&"ok", false)):
|
|
last_error = String(parsed.get(&"error", "Invalid Relationship snapshot."))
|
|
return false
|
|
_relationships = parsed[&"relationships"]
|
|
_pair_index = parsed[&"pair_index"]
|
|
_threshold_candidates = parsed[&"threshold_candidates"]
|
|
_next_candidate_sequence = int(parsed[&"next_candidate_sequence"])
|
|
_original_records = original_records
|
|
_legacy_raw = legacy.duplicate(true)
|
|
_container_raw = snapshot.duplicate(true) if version == SNAPSHOT_VERSION else {}
|
|
_legacy_baselines = {}
|
|
for state: RelationshipState in get_relationships():
|
|
_legacy_baselines[state.relationship_id] = state.create_snapshot()
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func _parse_snapshot(snapshot: Dictionary) -> Dictionary:
|
|
if not _value(snapshot, &"version", null) is int or _value(snapshot, &"version", null) != 1:
|
|
return {&"ok": false, &"error": "Unsupported Relationship snapshot version."}
|
|
var raw_relationships: Variant = _value(snapshot, &"relationships", null)
|
|
var raw_candidates: Variant = _value(snapshot, &"threshold_candidates", null)
|
|
if not raw_relationships is Array or not raw_candidates is Array:
|
|
return {&"ok": false, &"error": "Relationship snapshot collections are malformed."}
|
|
var relationships: Dictionary = {}
|
|
var pair_index: Dictionary = {}
|
|
for raw_state: Variant in raw_relationships:
|
|
if not raw_state is Dictionary or not _legacy_types_valid(raw_state):
|
|
return {&"ok": false, &"error": "Relationship snapshot entry is malformed."}
|
|
var state := RelationshipStateType.from_snapshot(raw_state)
|
|
if not state.is_valid() or relationships.has(state.relationship_id):
|
|
return {&"ok": false, &"error": "Relationship snapshot contains an invalid or duplicate state."}
|
|
if pair_index.has(state.pair_key()):
|
|
return {&"ok": false, &"error": "Relationship snapshot duplicates an actor pair."}
|
|
relationships[state.relationship_id] = state
|
|
pair_index[state.pair_key()] = state.relationship_id
|
|
var candidates: Array[Dictionary] = []
|
|
var candidate_ids: Dictionary = {}
|
|
for raw_candidate: Variant in raw_candidates:
|
|
if not raw_candidate is Dictionary:
|
|
return {&"ok": false, &"error": "Threshold candidate is malformed."}
|
|
for key: String in ["candidate_id", "relationship_id", "metric_id"]:
|
|
if not (raw_candidate.get(key) is String or raw_candidate.get(key) is StringName):
|
|
return {&"ok": false, &"error": "Threshold candidate identity type is invalid."}
|
|
if not raw_candidate.get("deduplication_key") is String or not raw_candidate.get("threshold") is int or not raw_candidate.get("created_at") is int or not raw_candidate.get("requires_unlock_event") is bool:
|
|
return {&"ok": false, &"error": "Threshold candidate field type is invalid."}
|
|
var actors: Variant = raw_candidate.get("actor_ids")
|
|
if not actors is Array or actors.size() != 2:
|
|
return {&"ok": false, &"error": "Threshold candidate actor collection is invalid."}
|
|
for actor: Variant in actors:
|
|
if not (actor is String or actor is StringName):
|
|
return {&"ok": false, &"error": "Threshold candidate actor type is invalid."}
|
|
var candidate: Dictionary = raw_candidate.duplicate(true)
|
|
var candidate_id := StringName(candidate.get(&"candidate_id", candidate.get("candidate_id", "")))
|
|
var relationship_id := StringName(candidate.get(&"relationship_id", candidate.get("relationship_id", "")))
|
|
if candidate_id.is_empty() or candidate_ids.has(candidate_id) or not relationships.has(relationship_id):
|
|
return {&"ok": false, &"error": "Threshold candidate reference is invalid."}
|
|
candidate_ids[candidate_id] = true
|
|
candidates.append(candidate)
|
|
if not _value(snapshot, &"next_candidate_sequence", null) is int:
|
|
return {&"ok": false, &"error": "Relationship candidate sequence type is invalid."}
|
|
var next_sequence := int(_value(snapshot, &"next_candidate_sequence", 0))
|
|
if next_sequence < 1:
|
|
return {&"ok": false, &"error": "Relationship candidate sequence is invalid."}
|
|
return {
|
|
&"ok": true,
|
|
&"relationships": relationships,
|
|
&"pair_index": pair_index,
|
|
&"threshold_candidates": candidates,
|
|
&"next_candidate_sequence": next_sequence,
|
|
}
|
|
|
|
|
|
static func _legacy_types_valid(raw: Dictionary) -> bool:
|
|
for key: String in ["relationship_id", "actor_a_id", "actor_b_id"]:
|
|
if not raw.get(key) is String:
|
|
return false
|
|
for key: String in ["shield_max", "shield_current", "last_event_at", "daily_attack_count", "attack_day", "metric_delta_day"]:
|
|
if raw.has(key) and not raw[key] is int:
|
|
return false
|
|
for key: String in ["metrics", "action_counts", "daily_metric_deltas", "flags", "crossed_thresholds"]:
|
|
if raw.has(key) and not raw[key] is Dictionary:
|
|
return false
|
|
for key: String in ["trust", "respect", "affection", "fear", "dependence", "affinity", "obligation"]:
|
|
var metrics: Dictionary = raw.get("metrics", raw)
|
|
if metrics.has(key) and not metrics[key] is int:
|
|
return false
|
|
for key: String in ["action_counts", "daily_metric_deltas"]:
|
|
for value: Variant in raw.get(key, {}).values():
|
|
if not value is int:
|
|
return false
|
|
for key: String in ["metrics", "action_counts", "daily_metric_deltas", "flags", "crossed_thresholds"]:
|
|
for identity: Variant in raw.get(key, {}):
|
|
if not identity is String:
|
|
return false
|
|
for value: Variant in raw.get("flags", {}).values():
|
|
if not value is bool:
|
|
return false
|
|
for value: Variant in raw.get("crossed_thresholds", {}).values():
|
|
if not value is Array:
|
|
return false
|
|
for threshold: Variant in value:
|
|
if not threshold is int:
|
|
return false
|
|
if raw.has("route_id") and not raw["route_id"] is String:
|
|
return false
|
|
if raw.has("route_lock"):
|
|
if not raw["route_lock"] is String or not RelationshipStateType.RouteLock.keys().has(raw["route_lock"]):
|
|
return false
|
|
return true
|
|
|
|
|
|
func _register_threshold_candidate(
|
|
state: RelationshipState,
|
|
metric_id: StringName,
|
|
threshold: int,
|
|
absolute_minute: int,
|
|
requires_unlock_event: bool
|
|
) -> void:
|
|
var deduplication_key := "%s:%s:%d" % [state.relationship_id, metric_id, threshold]
|
|
for candidate: Dictionary in _threshold_candidates:
|
|
if String(candidate.get(&"deduplication_key", "")) == deduplication_key:
|
|
return
|
|
var candidate := {
|
|
&"candidate_id": StringName("relationship_candidate_%08d" % _next_candidate_sequence),
|
|
&"deduplication_key": deduplication_key,
|
|
&"relationship_id": state.relationship_id,
|
|
&"actor_ids": [state.actor_a_id, state.actor_b_id],
|
|
&"metric_id": metric_id,
|
|
&"threshold": threshold,
|
|
&"requires_unlock_event": requires_unlock_event,
|
|
&"created_at": absolute_minute,
|
|
}
|
|
_next_candidate_sequence += 1
|
|
_threshold_candidates.append(candidate)
|
|
threshold_candidate_registered.emit(candidate.duplicate(true))
|
|
if _candidate_callback.is_valid():
|
|
_candidate_callback.call(candidate.duplicate(true))
|
|
|
|
|
|
func _record_crossed_threshold(state: RelationshipState, metric_id: StringName, threshold: int) -> void:
|
|
var thresholds: Array = state.crossed_thresholds.get(metric_id, [])
|
|
if not thresholds.has(threshold):
|
|
thresholds.append(threshold)
|
|
thresholds.sort()
|
|
state.crossed_thresholds[metric_id] = thresholds
|
|
|
|
|
|
func _reset_attack_day_if_needed(state: RelationshipState, day: int) -> void:
|
|
if state.attack_day == day:
|
|
return
|
|
state.attack_day = day
|
|
state.daily_attack_count = 0
|
|
state.action_counts.clear()
|
|
|
|
|
|
func _reset_metric_day_if_needed(state: RelationshipState, day: int) -> void:
|
|
if state.metric_delta_day == day:
|
|
return
|
|
state.metric_delta_day = day
|
|
for metric_id: StringName in RelationshipStateType.METRIC_IDS:
|
|
state.daily_metric_deltas[metric_id] = 0
|
|
|
|
|
|
static func _repetition_penalty_percent(previous_uses: int) -> int:
|
|
if previous_uses <= 0:
|
|
return 0
|
|
if previous_uses == 1:
|
|
return 25
|
|
if previous_uses == 2:
|
|
return 50
|
|
return 75
|
|
|
|
|
|
static func _sorted_name_keys(source: Dictionary) -> Array[StringName]:
|
|
var result: Array[StringName] = []
|
|
for raw_key: Variant in source:
|
|
result.append(StringName(raw_key))
|
|
result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
|
return result
|
|
|
|
|
|
static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant:
|
|
if source.has(key):
|
|
return source[key]
|
|
return source.get(String(key), fallback)
|
|
|
|
|
|
func _success(details: Dictionary = {}) -> CommandResult:
|
|
return CommandResultType.succeeded(0, details)
|
|
|
|
|
|
func _invalid(message: String, details: Dictionary = {}) -> CommandResult:
|
|
last_error = message
|
|
return CommandResultType.failed(
|
|
CommandResultType.Code.VALIDATION_FAILED,
|
|
0,
|
|
message,
|
|
details
|
|
)
|
|
|
|
|
|
func _not_found(kind: String, entity_id: StringName) -> CommandResult:
|
|
return _invalid("%s not found: %s" % [kind, entity_id], {&"entity_id": entity_id})
|
|
|
|
|
|
func _duplicate(kind: String, entity_id: StringName) -> CommandResult:
|
|
return _invalid("Duplicate %s ID: %s" % [kind, entity_id], {&"entity_id": entity_id})
|