Module: Lich::Common::Settings

Defined in:
documented/common/settings.rb

Overview

Centralized settings module providing persistent storage, retrieval, and navigation of per-script configuration data. Settings are persisted to a SQLite database and cached in-memory for performance. Supports nested Hash/Array structures with proxy-based live navigation.

Defined Under Namespace

Classes: CircularReferenceError

Constant Summary collapse

LOG_LEVEL_NONE =

Logging Configuration

0
LOG_LEVEL_ERROR =
1
LOG_LEVEL_INFO =
2
LOG_LEVEL_DEBUG =
3
DEFAULT_SCOPE =
":".freeze
@@log_level =

Default: logging disabled

LOG_LEVEL_NONE
@@log_prefix =
"[SettingsModule]".freeze

Class Method Summary collapse

Class Method Details

.[](name) ⇒ Object

Retrieves a setting by name at the current path. Wraps container values (Hash/Array) in SettingsProxy for live navigation. Activates safe navigation if the key is not found or value is nil.

Examples:

Retrieve a top-level setting

Lich::Common::Settings["my_key"] #=> "my_value"

Retrieve a nested setting after navigation

Lich::Common::Settings.my_key.nested_key #=> proxy to nested value

Parameters:

  • name (String, Symbol)

    the setting name

Returns:

  • (Object)

    the setting value (wrapped in SettingsProxy if a container)



550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
# File 'documented/common/settings.rb', line 550

def self.[](name)
  scope_to_use = DEFAULT_SCOPE
  _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "Settings.[]: name: #{name.inspect}, current_path: #{@path_navigator.path.inspect}, safe_nav: #{@safe_navigation_active}" })

  if @path_navigator.path.empty?
    data_for_scope = current_script_settings(scope_to_use)
    _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "Settings.[] (top-level): data_for_scope (DUP) (object_id: #{data_for_scope.object_id}): #{data_for_scope.inspect}" })
    value = get_value_from_container(data_for_scope, name)
    _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "Settings.[] (top-level): value for '#{name}': #{value.inspect}" })
    if value.nil? && !data_for_scope.is_a?(Array) && (!data_for_scope.is_a?(Hash) || !data_for_scope.key?(name))
      _log(LOG_LEVEL_INFO, @@log_prefix, -> { "Settings.[] (top-level): Key '#{name}' not found or value is nil. Activating safe_navigation." })
      @safe_navigation_active = true
    end
    return reset_path_and_return(wrap_value_if_container(value, scope_to_use, [name]))
  else
    current_target, _ = navigate_to_path(false, scope_to_use)
    _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "Settings.[] (path-based): current_target: #{current_target.inspect}" })
    value = get_value_from_container(current_target, name)
    _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "Settings.[] (path-based): value for '#{name}': #{value.inspect}" })
    new_path = @path_navigator.path + [name]
    if value.nil? && !current_target.is_a?(Array) && (!current_target.is_a?(Hash) || !current_target.key?(name))
      _log(LOG_LEVEL_INFO, @@log_prefix, -> { "Settings.[] (path-based): Key '#{name}' not found or value is nil in path. Activating safe_navigation." })
      @safe_navigation_active = true
    end
    return reset_path_and_return(wrap_value_if_container(value, scope_to_use, new_path))
  end
end

.[]=(name, value) ⇒ Object

Sets a setting by name at the current path, persisting to the database. Disables safe navigation on assignment.

Examples:

Lich::Common::Settings["my_key"] = "my_value"

Parameters:

  • name (String, Symbol)

    the setting name

  • value (Object)

    the value to set

Returns:

  • (Object)

    the value argument



586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
# File 'documented/common/settings.rb', line 586

def self.[]=(name, value)
  scope_to_use = DEFAULT_SCOPE
  _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "Settings.[]=: name: #{name.inspect}, value: #{value.inspect}, current_path: #{@path_navigator.path.inspect}" })
  @safe_navigation_active = false # Reset safe navigation on assignment

  if @path_navigator.path.empty?
    set_script_settings(scope_to_use, name, value)
  else
    target, root_settings = navigate_to_path(true, scope_to_use)
    if target && (target.is_a?(Hash) || target.is_a?(Array))
      actual_value = value.is_a?(SettingsProxy) ? unwrap_proxies(value) : value
      target[name] = actual_value
      save_to_database(root_settings, scope_to_use)
      reset_path_and_return(value)
    else
      _log(LOG_LEVEL_ERROR, @@log_prefix, -> { "Settings.[]=: Cannot assign to non-container or nil target at path #{@path_navigator.path.inspect}" })
      reset_path_and_return(nil)
    end
  end
end

._log(level, prefix, message_proc) ⇒ Object

Internal logging method message_proc is a lambda/proc to delay string construction



74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'documented/common/settings.rb', line 74

def self._log(level, prefix, message_proc)
  return unless Lich.respond_to?(:log)
  return unless level <= @@log_level

  level_str = case level
              when LOG_LEVEL_ERROR then "[ERROR]"
              when LOG_LEVEL_INFO  then "[INFO]"
              when LOG_LEVEL_DEBUG then "[DEBUG]"
              else "[UNKNOWN]"
              end

  begin
    message = message_proc.call
    Lich.log("#{prefix} #{level_str} #{message}")
  rescue => e
    Lich.log("#{prefix} [ERROR] Logging failed: #{e.message} - Original message proc: #{message_proc.source_location if message_proc.respond_to?(:source_location)}")
  end
end

._reattach_live!(proxy) ⇒ Object

Internal non-destructive correction method (issue from Lich 5.12.6) non-destructive methods (.sort) cannot own a proxy that requires update (.push)



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'documented/common/settings.rb', line 95

def self._reattach_live!(proxy)
  script_name = Script.current.name
  scope       = proxy.scope
  path        = proxy.path

  _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "reattach_live!: scope=#{scope.inspect} path=#{path.inspect}" })

  @path_navigator.reset_path
  @path_navigator.set_path(path)
  live, _root = @path_navigator.navigate_to_path(script_name, true, scope)

  if live.nil?
    _log(LOG_LEVEL_ERROR, @@log_prefix, -> { "reattach_live!: failed to resolve live target for path=#{path.inspect} scope=#{scope.inspect}" })
    return false
  end

  # Swap the proxy onto the live object via SettingsProxy API (encapsulated)
  # Centralizes invariants/logging within SettingsProxy itself.
  proxy.rebind_to_live!(live)
  true
end

.autovoid

Deprecated.

This method is not applicable

This method returns an undefined value.

Legacy deprecated no-op method.



722
723
724
725
726
# File 'documented/common/settings.rb', line 722

def self.auto
  Lich.deprecated('Settings.auto', 'not using, not applicable,', caller[0], fe_log: true) if Lich.respond_to?(:deprecated)
  _log(LOG_LEVEL_INFO, @@log_prefix, -> { "Settings.auto called (legacy deprecated no-op)." })
  nil
end

.auto=(_val) ⇒ void

Deprecated.

This method is not applicable

This method returns an undefined value.

Legacy deprecated no-op method.

Parameters:

  • _val (Object)

    unused



713
714
715
716
# File 'documented/common/settings.rb', line 713

def self.auto=(_val)
  Lich.deprecated('Settings.auto=(val)', 'not using, not applicable,', caller[0], fe_log: true) if Lich.respond_to?(:deprecated)
  _log(LOG_LEVEL_INFO, @@log_prefix, -> { "Settings.auto= called (legacy deprecated no-op)." })
end

.autoloadvoid

Deprecated.

This method is not applicable

This method returns an undefined value.

Legacy deprecated no-op method.



732
733
734
735
736
# File 'documented/common/settings.rb', line 732

def self.autoload
  Lich.deprecated('Settings.autoload', 'not using, not applicable,', caller[0], fe_log: true) if Lich.respond_to?(:deprecated)
  _log(LOG_LEVEL_INFO, @@log_prefix, -> { "Settings.autoload called (legacy deprecated no-op)." })
  nil
end

.clearvoid

Deprecated.

This method is not applicable

This method returns an undefined value.

Legacy deprecated no-op method.



702
703
704
705
706
# File 'documented/common/settings.rb', line 702

def self.clear
  Lich.deprecated('Settings.clear', 'not using, not applicable,', caller[0], fe_log: true) if Lich.respond_to?(:deprecated)
  _log(LOG_LEVEL_INFO, @@log_prefix, -> { "Settings.clear called (legacy deprecated no-op)." })
  nil
end

.container?(value) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Checks if a value is a container type (Hash or Array).

Parameters:

  • value (Object)

    the value to check

Returns:

  • (Boolean)

    true if value is a Hash or Array, false otherwise



128
129
130
# File 'documented/common/settings.rb', line 128

def self.container?(value)
  value.is_a?(Hash) || value.is_a?(Array)
end

.current_script_settings(scope = DEFAULT_SCOPE, script_name: nil) ⇒ Hash, Array

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Retrieves a duplicate of the current script's settings for the given scope. Results are cached; this method returns a fresh copy from cache or loads from database if not cached.

Parameters:

  • scope (String) (defaults to: DEFAULT_SCOPE)

    the scope identifier (e.g. "#XMLData.game:#XMLData.name")

  • script_name (String, nil) (defaults to: nil)

    the script namespace; defaults to Script.current.name

Returns:

  • (Hash, Array)

    a duplicate of the cached settings structure for the given scope



396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
# File 'documented/common/settings.rb', line 396

def self.current_script_settings(scope = DEFAULT_SCOPE, script_name: nil)
  # Use provided script_name or fall back to Script.current.name
  script_name = script_name || Script.current.name
  cache_key = "#{script_name || ""}::#{scope}" # Use an empty string if script_name is nil
  _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "current_script_settings: Request for scope: #{scope.inspect}, cache_key: #{cache_key}" })

  cached_data = @settings_cache[cache_key]
  if cached_data
    _log(LOG_LEVEL_INFO, @@log_prefix, -> { "current_script_settings: Cache hit for #{cache_key} (object_id: #{cached_data.object_id}). Returning DUP: #{cached_data.inspect}" })
    return cached_data.dup
  else
    _log(LOG_LEVEL_INFO, @@log_prefix, -> { "current_script_settings: Cache miss for #{cache_key}. Loading from DB." })
    settings = @db_adapter.get_settings(script_name, scope)
    _log(LOG_LEVEL_INFO, @@log_prefix, -> { "current_script_settings: Loaded from DB (object_id: #{settings.object_id}): #{settings.inspect}" })
    @settings_cache[cache_key] = settings
    _log(LOG_LEVEL_INFO, @@log_prefix, -> { "current_script_settings: Stored in cache (object_id: #{@settings_cache[cache_key].object_id}). Returning DUP." })
    return settings.dup
  end
end

.get_log_levelInteger

Returns the current logging level of the Settings module.

Returns:

  • (Integer)

    the current log level (LOG_LEVEL_NONE, LOG_LEVEL_ERROR, LOG_LEVEL_INFO, or LOG_LEVEL_DEBUG)



68
69
70
# File 'documented/common/settings.rb', line 68

def self.get_log_level
  @@log_level
end

.get_scoped_setting(scope_string, key_name, script_name: nil) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Retrieves a single key-value setting from a specific scope without affecting the current path navigator state. Wraps container values in SettingsProxy.

Parameters:

  • scope_string (String)

    the scope identifier

  • key_name (String, Symbol)

    the key to retrieve; may be nil to return the entire scope

  • script_name (String, nil) (defaults to: nil)

    the script namespace; defaults to current script

Returns:

  • (Object)

    the setting value (wrapped in SettingsProxy if a container), or nil if not found



615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
# File 'documented/common/settings.rb', line 615

def self.get_scoped_setting(scope_string, key_name, script_name: nil)
  _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "get_scoped_setting: scope: #{scope_string.inspect}, key: #{key_name.inspect}, script_name: #{script_name.inspect}" })
  data_for_scope = current_script_settings(scope_string, script_name: script_name)
  _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "get_scoped_setting: data_for_scope (DUP) (object_id: #{data_for_scope.object_id}): #{data_for_scope.inspect}" })
  value = get_value_from_container(data_for_scope, key_name)
  _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "get_scoped_setting: value for '#{key_name}': #{value.inspect}" })

  if value.nil? && key_name
    key_absent_in_hash = data_for_scope.is_a?(Hash) && !data_for_scope.key?(key_name)
    key_invalid_for_array = data_for_scope.is_a?(Array) && (!key_name.is_a?(Integer) || key_name < 0 || key_name >= data_for_scope.length)

    if key_absent_in_hash || key_invalid_for_array || (data_for_scope.nil? || (data_for_scope.is_a?(Hash) && data_for_scope.empty?))
      _log(Settings::LOG_LEVEL_INFO, @@log_prefix, -> { "get_scoped_setting: Key '#{key_name}' not found in scope '#{scope_string}'. Value will be nil, supporting '|| default' idiom." })
    end
  end
  wrap_value_if_container(value, scope_string, key_name ? [key_name] : [], script_name: script_name)
end

.get_value_from_container(container, key) ⇒ Object?

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Retrieves a value from a Hash or Array container by key/index. Returns nil if the key does not exist, the index is out of bounds, or the container is not a Hash/Array.

Parameters:

  • container (Object)

    the Hash or Array to access

  • key (String, Symbol, Integer)

    the key (Hash) or index (Array)

Returns:

  • (Object, nil)

    the value at the key/index, or nil if not found



780
781
782
783
784
785
786
787
788
789
790
791
# File 'documented/common/settings.rb', line 780

def self.get_value_from_container(container, key)
  if container.is_a?(Hash)
    container[key]
  elsif container.is_a?(Array) && key.is_a?(Integer)
    container[key]
  elsif container.is_a?(Array) && !key.is_a?(Integer)
    _log(LOG_LEVEL_ERROR, @@log_prefix, -> { "get_value_from_container: Attempted to access Array with non-Integer key: #{key.inspect}" })
    nil
  else
    nil
  end
end

.loadHash, Array

Deprecated.

Use refresh_data instead

Legacy method; equivalent to refresh_data with default scope.

Returns:

  • (Hash, Array)

    the reloaded settings



676
677
678
679
# File 'documented/common/settings.rb', line 676

def self.load
  _log(LOG_LEVEL_INFO, @@log_prefix, -> { "Settings.load called (legacy, aliasing to refresh_data)." })
  refresh_data
end

.method_missing(method, *args, &block) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Delegates method calls to the path navigator, enabling method-chain-based navigation syntax (e.g., Settings.my_key.nested_value). Returns nil if safe navigation is active and a non-empty path exists.

Parameters:

  • method (Symbol)

    the method name

  • args (Array)

    method arguments

  • block (Proc)

    optional block argument

Returns:

  • (Object)

    the result of delegating to path_navigator



748
749
750
751
752
753
754
755
756
757
758
759
760
# File 'documented/common/settings.rb', line 748

def self.method_missing(method, *args, &block)
  _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "method_missing: method: #{method}, args: #{args.inspect}, path: #{@path_navigator.path.inspect}" })
  if @safe_navigation_active && !@path_navigator.path.empty?
    if method.to_s.end_with?("=")
      _log(LOG_LEVEL_ERROR, @@log_prefix, -> { "method_missing: Attempted assignment (#{method}) on a nil path due to safe navigation." })
      return reset_path_and_return(nil)
    end
    return reset_path_and_return(nil)
  end

  _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "method_missing: Delegating to path_navigator: #{method}" })
  @path_navigator.send(method, *args, &block)
end

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Navigates through nested Hash/Array structures using the current path navigator's path, optionally creating missing intermediate nodes.

Parameters:

  • create_missing (Boolean) (defaults to: true)

    if true, creates intermediate nodes of type Hash or Array as needed

  • scope (String) (defaults to: DEFAULT_SCOPE)

    the scope identifier

Returns:

  • (Array)

    [target, root] where target is the resolved nested value (or nil if not found), and root is the root settings object for the scope



479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
# File 'documented/common/settings.rb', line 479

def self.navigate_to_path(create_missing = true, scope = DEFAULT_SCOPE)
  root_for_scope = current_script_settings(scope)
  return [root_for_scope, root_for_scope] if @path_navigator.path.empty?

  target = root_for_scope
  @path_navigator.path.each do |key|
    if target.is_a?(Hash) && target.key?(key)
      target = target[key]
    elsif target.is_a?(Array) && key.is_a?(Integer) && key >= 0 && key < target.length
      target = target[key]
    elsif create_missing && (target.is_a?(Hash) || target.is_a?(Array))
      _log(LOG_LEVEL_INFO, @@log_prefix, -> { "navigate_to_path: Creating missing segment '#{key}' in DUPPED structure for scope #{scope.inspect}" })
      new_node = key.is_a?(Integer) ? [] : {}
      if target.is_a?(Hash)
        target[key] = new_node
      elsif target.is_a?(Array) && key.is_a?(Integer)
        target[key] = new_node
      end
      target = new_node
    else
      return [nil, root_for_scope]
    end
  end
  [target, root_for_scope]
end

.refresh_data(scope = DEFAULT_SCOPE) ⇒ Hash, Array

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Clears the cache for the given scope and reloads settings from the database.

Parameters:

  • scope (String) (defaults to: DEFAULT_SCOPE)

    the scope identifier to refresh

Returns:

  • (Hash, Array)

    the reloaded settings



453
454
455
456
457
458
459
# File 'documented/common/settings.rb', line 453

def self.refresh_data(scope = DEFAULT_SCOPE)
  script_name = Script.current.name
  cache_key = "#{script_name || ""}::#{scope}" # Use an empty string if script_name is nil
  _log(LOG_LEVEL_INFO, @@log_prefix, -> { "refresh_data: Deleting cache for scope: #{scope.inspect}, cache_key: #{cache_key}" })
  @settings_cache.delete(cache_key)
  current_script_settings(scope)
end

.reset_path_and_return(value) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Resets the path navigator's internal path state and returns the given value. Used after navigation operations to reset state for subsequent calls.

Parameters:

  • value (Object)

    the value to return

Returns:

  • (Object)

    the value argument



467
468
469
# File 'documented/common/settings.rb', line 467

def self.reset_path_and_return(value)
  @path_navigator.reset_path_and_return(value)
end

.respond_to_missing?(method_name, include_private = false) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Indicates that this module responds to method names delegated to the path navigator.

Parameters:

  • method_name (Symbol)

    the method name to check

  • include_private (Boolean) (defaults to: false)

    whether to include private methods

Returns:

  • (Boolean)

    true if the path navigator responds to the method



768
769
770
# File 'documented/common/settings.rb', line 768

def self.respond_to_missing?(method_name, include_private = false)
  @path_navigator.respond_to?(method_name, include_private) || super
end

.root_proxy_for(scope, script_name: Script.current.name) ⇒ SettingsProxy

Creates a root-level SettingsProxy for the given scope.

This factory ensures the proxy directly targets the cached root object for the (script_name, scope) pair. By using the cached root, it avoids "identity drift" bugs where the proxy's @target differs from the object being persisted by save_proxy_changes.

Examples:

Create a root proxy for the current character

settings = Lich::Common::Settings.root_proxy_for("#{XMLData.game}:#{XMLData.name}")

Parameters:

  • scope (String)

    A logical scope identifier (for example, "#XMLData.game:#XMLData.name").

  • script_name (String, nil) (defaults to: Script.current.name)

    The script namespace; defaults to Script.current.name. Pass nil to fall back to the empty string.

Returns:

Raises:

  • (ArgumentError)

    if scope is nil or empty.



191
192
193
194
195
196
197
198
199
# File 'documented/common/settings.rb', line 191

def self.root_proxy_for(scope, script_name: Script.current.name)
  raise ArgumentError, "scope must be a non-empty String" if scope.nil? || scope.to_s.strip.empty?

  script_name ||= ""
  cache_key = "#{script_name}::#{scope}"
  root = @settings_cache[cache_key] ||= @db_adapter.get_settings(script_name, scope)

  SettingsProxy.new(self, scope, [], root, script_name: script_name)
end

.saveObject

Legacy Support Methods



667
668
669
670
# File 'documented/common/settings.rb', line 667

def self.save
  _log(LOG_LEVEL_INFO, @@log_prefix, -> { "Settings.save called (legacy no-op)." })
  :noop
end

.save_allObject

Deprecated No-Op Methods from Original



692
693
694
695
696
# File 'documented/common/settings.rb', line 692

def self.save_all
  Lich.deprecated('Settings.save_all', 'not using, not applicable,', caller[0], fe_log: true) if Lich.respond_to?(:deprecated)
  _log(LOG_LEVEL_INFO, @@log_prefix, -> { "Settings.save_all called (legacy deprecated no-op)." })
  nil
end

.save_proxy_changes(proxy) ⇒ void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Persists changes made through a SettingsProxy to the database, handling both root-level and nested saves. Manages cache consistency and refreshes stale roots before save to prevent overwrites. Logs comprehensive diagnostics at each step.

Parameters:

  • proxy (SettingsProxy)

    the proxy whose changes should be persisted



208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
# File 'documented/common/settings.rb', line 208

def self.save_proxy_changes(proxy)
  _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "save_proxy_changes: Initiated for proxy.scope: #{proxy.scope.inspect}, proxy.path: #{proxy.path.inspect}, proxy.target_object_id: #{proxy.target.object_id}" })
  _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "save_proxy_changes: proxy.target data: #{proxy.target.inspect}" })

  path        = proxy.path
  scope       = proxy.scope
  # Use proxy.script_name if available (for InstanceSettings), fall back to Script.current.name
  script_name = proxy.respond_to?(:script_name) && proxy.script_name ? proxy.script_name : Script.current.name
  cache_key   = "#{script_name || ""}::#{scope}"

  # Local helper to keep cache in sync with the just-persisted root
  sync_cache = lambda do |root_obj|
    cached = @settings_cache[cache_key]
    if cached && !cached.equal?(root_obj)
      if cached.is_a?(Hash) && root_obj.is_a?(Hash)
        cached.replace(root_obj)
      elsif cached.is_a?(Array) && root_obj.is_a?(Array)
        cached.clear
        cached.concat(root_obj)
      else
        @settings_cache[cache_key] = root_obj
      end
    elsif cached.nil?
      @settings_cache[cache_key] = root_obj
    end
    _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "save_proxy_changes: Cache synchronized post-save for #{cache_key}" })
  end

  # --- Refresh-before-save to prevent stale-cache overwrites ---
  # IMPORTANT: If proxy.target IS the cached object, we must NOT replace it
  # from the database, as that would wipe out the changes we're trying to save.
  # Only refresh when the proxy is detached or working with a different object.
  fresh_root = @db_adapter.get_settings(script_name, scope)

  cached = @settings_cache[cache_key]
  if cached
    # Skip refresh if proxy.target is the same object as cached - we'd wipe out our changes!
    if cached.equal?(proxy.target)
      _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "save_proxy_changes: Skipping refresh - proxy.target IS the cached object (object_id: #{cached.object_id})" })
      current_root_for_scope = cached
    elsif cached.is_a?(Hash) && fresh_root.is_a?(Hash)
      cached.replace(fresh_root)
      current_root_for_scope = cached
    elsif cached.is_a?(Array) && fresh_root.is_a?(Array)
      cached.clear
      cached.concat(fresh_root)
      current_root_for_scope = cached
    else
      @settings_cache[cache_key] = fresh_root
      current_root_for_scope = fresh_root
    end
    _log(LOG_LEVEL_INFO, @@log_prefix, -> { "save_proxy_changes: Cache state for #{cache_key} (object_id: #{current_root_for_scope.object_id}): #{current_root_for_scope.inspect}" })
  else
    @settings_cache[cache_key] = fresh_root
    current_root_for_scope = fresh_root
    _log(LOG_LEVEL_INFO, @@log_prefix, -> { "save_proxy_changes: Cache populated from DB for #{cache_key} (object_id: #{current_root_for_scope.object_id}): #{current_root_for_scope.inspect}" })
  end
  # -------------------------------------------------------------

  # EMPTY PATH -> Save *current root* (not proxy.target). Also covers detached "view" proxies.
  if path.empty?
    _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "save_proxy_changes: Empty path; saving CURRENT ROOT for scope #{scope.inspect}" })

    unless current_root_for_scope.is_a?(Hash) || current_root_for_scope.is_a?(Array)
      _log(LOG_LEVEL_INFO, @@log_prefix, -> { "save_proxy_changes: Root not a container; initializing {} for scope #{scope.inspect}" })
      current_root_for_scope = {}
      @settings_cache[cache_key] = current_root_for_scope
    end

    if proxy.respond_to?(:detached?) && proxy.detached?
      _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "save_proxy_changes: Proxy is detached (view); persisting current root without copying view target." })
      save_to_database(current_root_for_scope, scope, script_name: script_name)
      sync_cache.call(current_root_for_scope)
      return nil
    end

    # Root identity drift: sync proxy.target into cached root if different objects (same-type containers).
    if !current_root_for_scope.equal?(proxy.target)
      if proxy.target.is_a?(Hash) && current_root_for_scope.is_a?(Hash)
        _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "save_proxy_changes: Root identity mismatch (cache #{current_root_for_scope.object_id} vs proxy #{proxy.target.object_id}); copying via Hash#replace" })
        current_root_for_scope.replace(proxy.target)
      elsif proxy.target.is_a?(Array) && current_root_for_scope.is_a?(Array)
        _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "save_proxy_changes: Root identity mismatch (Array); copying elements" })
        current_root_for_scope.clear
        current_root_for_scope.concat(proxy.target)
      else
        _log(LOG_LEVEL_WARN, @@log_prefix, -> { "save_proxy_changes: Root/target type mismatch; persisting current root only (root=#{current_root_for_scope.class}, target=#{proxy.target.class})." })
      end
    end

    save_to_database(current_root_for_scope, scope, script_name: script_name)
    sync_cache.call(current_root_for_scope)
    return nil
  end

  _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "save_proxy_changes: script_name: #{script_name.inspect}, cache_key: #{cache_key}" })

  # From here on, we're saving into a nested path. Ensure root is a container.
  unless current_root_for_scope.is_a?(Hash) || current_root_for_scope.is_a?(Array)
    _log(LOG_LEVEL_INFO, @@log_prefix, -> { "save_proxy_changes: Root not a container; initializing {} for scope #{scope.inspect}" })
    current_root_for_scope = {}
    @settings_cache[cache_key] = current_root_for_scope
  end

  parent_path = path[0...-1]
  leaf_key    = path.last

  # Pre-navigation diagnostics
  _log(LOG_LEVEL_DEBUG, @@log_prefix, -> {
    "save_proxy_changes: Navigation preflight - parent_path=#{parent_path.inspect} (#{parent_path.map { |s| s.class }.inspect}), leaf_key=#{leaf_key.inspect} (#{leaf_key.class}), root_class=#{current_root_for_scope.class}"
  })

  # Navigate **within the freshly-loaded root** (do NOT re-fetch via PathNavigator here).
  begin
    parent = current_root_for_scope
    parent_path.each_with_index do |seg, idx|
      next_seg = parent_path[idx + 1]

      if parent.is_a?(Hash)
        unless parent.key?(seg)
          parent[seg] = next_seg.is_a?(Integer) ? [] : {}
          _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "save_proxy_changes: Created #{parent[seg].class} at #{parent_path[0..idx].inspect} for scope #{scope.inspect}" })
        end
        parent = parent[seg]
      elsif parent.is_a?(Array)
        unless seg.is_a?(Integer) && seg >= 0
          _log(LOG_LEVEL_ERROR, @@log_prefix, -> { "save_proxy_changes: Non-integer or negative index #{seg.inspect} for Array at #{parent_path[0..idx].inspect} in scope #{scope.inspect}" })
          return nil
        end
        if seg >= parent.length
          (parent.length..seg).each { parent << nil }
          _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "save_proxy_changes: Extended Array to index #{seg} at #{parent_path[0..idx].inspect} for scope #{scope.inspect}" })
        end
        parent[seg] ||= (next_seg.is_a?(Integer) ? [] : {})
        parent = parent[seg]
      else
        _log(LOG_LEVEL_ERROR, @@log_prefix, -> { "save_proxy_changes: Parent is not a container at #{parent_path[0..idx].inspect} (#{parent.class}) for scope #{scope.inspect}" })
        return nil
      end
    end
  rescue => e
    _log(LOG_LEVEL_ERROR, @@log_prefix, -> {
      "save_proxy_changes: Local navigation raised #{e.class}: #{e.message}. "\
      "scope=#{scope.inspect}, script_name=#{script_name.inspect}, cache_key=#{cache_key}, "\
      "parent_path=#{parent_path.inspect}, leaf_key=#{leaf_key.inspect}"
    })
    bt = (e.backtrace || [])[0, 5]
    _log(LOG_LEVEL_ERROR, @@log_prefix, -> { "save_proxy_changes: Backtrace (top 5): #{bt.join(' | ')}" })
    return nil
  end

  _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "save_proxy_changes: Navigated/created parent (object_id: #{parent.object_id}, class=#{parent.class}): #{parent.inspect}" })

  if parent.is_a?(Hash)
    _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "save_proxy_changes: Setting Hash key #{leaf_key.inspect} with proxy.target (object_id: #{proxy.target.object_id})" })
    parent[leaf_key] = proxy.target
  elsif parent.is_a?(Array) && leaf_key.is_a?(Integer)
    if leaf_key < 0
      _log(LOG_LEVEL_ERROR, @@log_prefix, -> { "save_proxy_changes: Negative array index #{leaf_key} not supported at path #{path.inspect} in scope #{scope.inspect}" })
      return nil
    end
    if leaf_key >= parent.length
      (parent.length..leaf_key).each { parent << nil }
      _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "save_proxy_changes: Extended Array to index #{leaf_key} for parent at path #{parent_path.inspect}" })
    end
    parent[leaf_key] = proxy.target
    _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "save_proxy_changes: Set Array index #{leaf_key} with proxy.target (object_id: #{proxy.target.object_id})" })
  else
    _log(LOG_LEVEL_ERROR, @@log_prefix, -> {
      "save_proxy_changes: Cannot set value at path #{path.inspect} in scope #{scope.inspect}; "\
      "parent_class=#{parent.class}, leaf_key_class=#{leaf_key.class}"
    })
    return nil
  end

  _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "save_proxy_changes: root after update (object_id: #{current_root_for_scope.object_id}): #{current_root_for_scope.inspect}" })
  save_to_database(current_root_for_scope, scope, script_name: script_name)
  sync_cache.call(current_root_for_scope)
end

.save_to_database(data_to_save, scope = DEFAULT_SCOPE, script_name: nil) ⇒ void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Note:

Does nothing and returns nil if script_name is nil or empty

This method returns an undefined value.

Persists a settings structure to the database after unwrapping any proxies. Updates the in-memory cache with the unwrapped data.

Parameters:

  • data_to_save (Object)

    the settings structure to persist (Hash, Array, or scalar)

  • scope (String) (defaults to: DEFAULT_SCOPE)

    the scope identifier

  • script_name (String, nil) (defaults to: nil)

    the script namespace; defaults to Script.current.name



425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
# File 'documented/common/settings.rb', line 425

def self.save_to_database(data_to_save, scope = DEFAULT_SCOPE, script_name: nil)
  # Use provided script_name or fall back to Script.current.name
  script_name = script_name || Script.current.name

  if script_name.nil? || script_name.empty?
    _log(LOG_LEVEL_ERROR, @@log_prefix, -> { "save_to_database: Aborting save. script_name is nil or empty. Scope: #{scope.inspect}. Data will NOT be persisted." })
    return nil # Explicitly return nil
  end

  cache_key = "#{script_name}::#{scope}" # script_name is guaranteed to be non-nil/non-empty here
  _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "save_to_database: Saving for script: '#{script_name}', scope: #{scope.inspect}, cache_key: #{cache_key}" })
  _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "save_to_database: Data BEFORE unwrap_proxies (object_id: #{data_to_save.object_id}): #{data_to_save.inspect}" })

  unwrapped_settings = unwrap_proxies(data_to_save)
  _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "save_to_database: Data AFTER unwrap_proxies (object_id: #{unwrapped_settings.object_id}): #{unwrapped_settings.inspect}" })

  @db_adapter.save_settings(script_name, unwrapped_settings, scope)
  _log(LOG_LEVEL_INFO, @@log_prefix, -> { "save_to_database: Data saved to DB for script '#{script_name}'." })

  @settings_cache[cache_key] = unwrapped_settings
  _log(LOG_LEVEL_INFO, @@log_prefix, -> { "save_to_database: Cache updated for #{cache_key} with saved data (object_id: #{@settings_cache[cache_key].object_id})." })
end

.set_log_level(level) ⇒ void

This method returns an undefined value.

Sets the logging level for the Settings module.

Examples:

Lich::Common::Settings.set_log_level(:debug)

Parameters:

  • level (Symbol, Integer)

    the log level; accepted values are :none, :error, :info, :debug (or their corresponding integer constants LOG_LEVEL_NONE, LOG_LEVEL_ERROR, LOG_LEVEL_INFO, LOG_LEVEL_DEBUG). Invalid levels default to LOG_LEVEL_NONE with an error logged.



52
53
54
55
56
57
58
59
60
61
62
63
# File 'documented/common/settings.rb', line 52

def self.set_log_level(level)
  numeric_level = case level
                  when :none, LOG_LEVEL_NONE then LOG_LEVEL_NONE
                  when :error, LOG_LEVEL_ERROR then LOG_LEVEL_ERROR
                  when :info, LOG_LEVEL_INFO then LOG_LEVEL_INFO
                  when :debug, LOG_LEVEL_DEBUG then LOG_LEVEL_DEBUG
                  else
                    _log(LOG_LEVEL_ERROR, @@log_prefix, -> { "Invalid log level specified: #{level.inspect}. Defaulting to NONE." })
                    LOG_LEVEL_NONE
                  end
  @@log_level = numeric_level
end

.set_script_settings(scope = DEFAULT_SCOPE, name, value, script_name: nil) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Sets a key-value pair in the script's settings at the current scope and path, persisting to the database.

Parameters:

  • scope (String) (defaults to: DEFAULT_SCOPE)

    the scope identifier

  • name (String, Symbol)

    the key to set

  • value (Object)

    the value to set (will be unwrapped if a proxy)

  • script_name (String, nil) (defaults to: nil)

    the script namespace; defaults to Script.current.name

Returns:

  • (Object)

    the value argument



514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
# File 'documented/common/settings.rb', line 514

def self.set_script_settings(scope = DEFAULT_SCOPE, name, value, script_name: nil)
  _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "set_script_settings: scope: #{scope.inspect}, name: #{name.inspect}, value: #{value.inspect}, script_name: #{script_name.inspect}, current_path: #{@path_navigator.path.inspect}" })
  unwrapped_value = unwrap_proxies(value)
  _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "set_script_settings: unwrapped_value: #{unwrapped_value.inspect}" })

  current_root = current_script_settings(scope, script_name: script_name)
  _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "set_script_settings: current_root (DUP) for scope #{scope.inspect} (object_id: #{current_root.object_id}): #{current_root.inspect}" })

  if @path_navigator.path.empty?
    _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "set_script_settings: Path is empty. Setting '#{name}' on current_root." })
    current_root[name] = unwrapped_value
    save_to_database(current_root, scope, script_name: script_name)
  else
    if !@path_navigator.path.empty?
      _log(LOG_LEVEL_ERROR, @@log_prefix, -> { "set_script_settings: WARNING: Called with non-empty path_navigator path: #{@path_navigator.path.inspect}. This is unusual for Char/GameSettings direct assignment." })
    end
    if current_root.is_a?(Hash)
      current_root[name] = unwrapped_value
      save_to_database(current_root, scope, script_name: script_name)
    else
      _log(LOG_LEVEL_ERROR, @@log_prefix, -> { "set_script_settings: current_root for scope #{scope.inspect} is not a Hash. Cannot set key '#{name}'. Root class: #{current_root.class}" })
    end
  end
  reset_path_and_return(value)
end

.to_h(scope = DEFAULT_SCOPE) ⇒ Hash, Array

Deprecated.

Use to_hash instead

Legacy alias for to_hash.

Parameters:

  • scope (String) (defaults to: DEFAULT_SCOPE)

    the scope identifier

Returns:

  • (Hash, Array)

    an unwrapped copy of the settings



686
687
688
689
# File 'documented/common/settings.rb', line 686

def self.to_h(scope = DEFAULT_SCOPE) # Added scope to match to_hash for consistency if used directly
  _log(LOG_LEVEL_INFO, @@log_prefix, -> { "Settings.to_h called (legacy, aliasing to to_hash)." })
  self.to_hash(scope)
end

.to_hash(scope = DEFAULT_SCOPE) ⇒ Hash, Array

Returns the current script's settings as an unwrapped Hash or Array (snapshot), suitable for serialization or inspection without proxies.

Parameters:

  • scope (String) (defaults to: DEFAULT_SCOPE)

    the scope identifier

Returns:

  • (Hash, Array)

    an unwrapped copy of the settings for the given scope



658
659
660
661
662
663
664
# File 'documented/common/settings.rb', line 658

def self.to_hash(scope = DEFAULT_SCOPE)
  _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "to_hash: scope: #{scope.inspect}" })
  data = current_script_settings(scope)
  unwrapped_data = unwrap_proxies(data)
  _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "to_hash: Returning unwrapped data (snapshot): #{unwrapped_data.inspect}" })
  return unwrapped_data
end

.wrap_value_if_container(value, scope, path_array, script_name: nil) ⇒ SettingsProxy, Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Wraps a value in a SettingsProxy if it is a container (Hash or Array), otherwise returns the value unchanged.

Parameters:

  • value (Object)

    the value to conditionally wrap

  • scope (String)

    the scope identifier

  • path_array (Array)

    the navigation path to this value

  • script_name (String, nil) (defaults to: nil)

    the script namespace

Returns:

  • (SettingsProxy, Object)

    a proxy if value is a container, or the value itself



642
643
644
645
646
647
648
649
650
651
# File 'documented/common/settings.rb', line 642

def self.wrap_value_if_container(value, scope, path_array, script_name: nil)
  _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "wrap_value_if_container: value_class: #{value.class}, scope: #{scope.inspect}, path: #{path_array.inspect}, script_name: #{script_name.inspect}" })
  if container?(value)
    proxy = SettingsProxy.new(self, scope, path_array, value, script_name: script_name)
    _log(LOG_LEVEL_DEBUG, @@log_prefix, -> { "wrap_value_if_container: Wrapped in proxy: #{proxy.inspect}" })
    return proxy
  else
    return value
  end
end