516 lines
18 KiB
GDScript
516 lines
18 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 SNAPSHOT_VERSION := 1
|
|
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()
|
|
|
|
|
|
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] = []
|
|
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():
|
|
relationships.append(state.create_snapshot())
|
|
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", ""))
|
|
)
|
|
return {
|
|
&"version": SNAPSHOT_VERSION,
|
|
&"relationships": relationships,
|
|
&"threshold_candidates": candidates,
|
|
&"next_candidate_sequence": _next_candidate_sequence,
|
|
}
|
|
|
|
|
|
func restore_snapshot(snapshot: Dictionary) -> bool:
|
|
var parsed := _parse_snapshot(snapshot)
|
|
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"])
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func _parse_snapshot(snapshot: Dictionary) -> Dictionary:
|
|
if int(_value(snapshot, &"version", 0)) != SNAPSHOT_VERSION:
|
|
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:
|
|
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."}
|
|
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)
|
|
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,
|
|
}
|
|
|
|
|
|
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})
|