Files
TheTree_mock/scripts/relationship/relationship_service.gd
T

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})