Preserve approved pixel tower prototype as development baseline
This commit is contained in:
@@ -0,0 +1,515 @@
|
||||
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})
|
||||
Reference in New Issue
Block a user