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