Files

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