feat(runtime): integrate settings service boundary

Co-Authored-By: Codex <noreply@anthropic.com>
This commit is contained in:
2026-08-25 10:20:18 +08:00
co-authored by Codex
parent 60614149db
commit 8a8fb33c19
3 changed files with 261 additions and 19 deletions
+95 -19
View File
@@ -2,7 +2,9 @@ class_name ABGEntryShell
extends Control
const BootService = preload("res://entry/boot_service.gd")
const SettingsStore = preload("res://entry/settings_store.gd")
const SettingsServiceScript = preload("res://foundation/settings_service.gd")
const AppServicesScript = preload("res://foundation/app_services.gd")
const ScreenContextScript = preload("res://foundation/screen_context.gd")
const EntryChrome = preload("res://entry/entry_chrome.gd")
const InitialCompanyArt = preload("res://entry/initial_company_art.gd")
const GuildDestinationSticker = preload("res://entry/guild_destination_sticker.gd")
@@ -91,7 +93,14 @@ signal boot_completed
@export var auto_start := true
var boot_service := BootService.new()
var settings_store := SettingsStore.new()
var settings_service := SettingsServiceScript.new()
## Compatibility alias for existing focused fixtures. New page code should use
## `settings_service` or resolve the service through `app_services`.
var settings_store:
get: return settings_service
set(value): settings_service = value
var app_services = AppServicesScript.new({"settings": settings_service})
var settings_context
var initial_company_service := InitialCompanyDraftService.new()
var guild_contract_service := GuildContractService.new()
var formation_risk_service := FormationRiskService.new()
@@ -569,7 +578,7 @@ func _step_text(step: String) -> String:
func _open_after_boot() -> void:
var loaded: Dictionary = settings_store.load_settings()
var loaded: Dictionary = settings_service.load_settings()
if not loaded.ok:
boot_snapshot = {"state": "error", "step": "preferences", "code": String(loaded.code), "completed": 0, "total": 1}
_build_boot_screen()
@@ -589,7 +598,7 @@ func _open_after_boot() -> void:
func show_first_launch() -> void:
setup_draft = settings.duplicate(true) if not settings.is_empty() else settings_store.defaults()
setup_draft = settings.duplicate(true) if not settings.is_empty() else settings_service.defaults()
for child in get_children():
child.free()
first_launch_setting_buttons.clear()
@@ -922,7 +931,7 @@ func toggle_setup_locale() -> void:
func cycle_setup_safe_area() -> void:
var options := SettingsStore.SAFE_AREA_PRESETS
var options := SettingsServiceScript.SAFE_AREA_PRESETS
var next := (options.find(int(setup_draft.safe_area)) + 1) % options.size()
setup_draft.safe_area = options[next]
settings = setup_draft.duplicate(true)
@@ -930,7 +939,7 @@ func cycle_setup_safe_area() -> void:
func cycle_setup_text_scale() -> void:
var options := SettingsStore.TEXT_SCALES
var options := SettingsServiceScript.TEXT_SCALES
var current := 0
for index in range(options.size()):
if is_equal_approx(float(setup_draft.text_scale), float(options[index])):
@@ -975,7 +984,7 @@ func toggle_setup_captions() -> void:
func apply_first_launch() -> void:
setup_draft.setup_completed = true
var saved: Dictionary = settings_store.save_settings(setup_draft)
var saved: Dictionary = settings_service.save_settings(setup_draft)
if not saved.ok:
var error := first_launch_error
if error != null:
@@ -8151,12 +8160,73 @@ func show_credits_placeholder() -> void:
show_credits()
func _settings_return_screen_id() -> String:
match settings_caller:
"guild_handoff": return "guild_handoff"
"readiness": return "readiness_review"
"risk_board": return "risk_board"
"risk_commitment": return "risk_commitment"
"market": return "market"
"battle_opening": return "battle"
"battle_live", "battle_pause_controls": return "battle"
return "title"
func _settings_return_focus_path() -> String:
match settings_caller:
"guild_handoff": return "GuildSettings"
"readiness": return "ReadinessSettings"
"risk_board": return "RiskSettings"
"risk_commitment": return "RiskCommitmentSettings"
"market": return "MarketSettings"
"battle_opening": return "BattleOpeningSettings"
"battle_live", "battle_pause_controls": return "BattleLiveSettings"
return "TitleSettings"
func _open_settings_context() -> void:
settings_context = ScreenContextScript.new({
"screen_id": "settings",
"caller_id": settings_caller,
"return_screen_id": _settings_return_screen_id(),
"return_focus_path": _settings_return_focus_path(),
"presentation_mode": "page",
"metadata": {"category": settings_category},
})
var current = app_services.screen_stack.current()
if current != null and String(current.screen_id) == "settings":
app_services.screen_stack.replace(settings_context)
else:
app_services.screen_stack.push(settings_context)
func _close_settings_context() -> Variant:
var context = settings_context
settings_context = null
var current = app_services.screen_stack.current()
if current != null and String(current.screen_id) == "settings":
app_services.screen_stack.pop()
return context
func _restore_settings_focus(focus_name: String) -> void:
if focus_name.is_empty():
return
var target := find_child(focus_name, true, false) as Control
if target == null or target.focus_mode == Control.FOCUS_NONE:
return
if target is BaseButton and (target as BaseButton).disabled:
return
target.grab_focus()
func show_settings(category := "") -> void:
current_screen = "settings"
if settings_draft.is_empty() or not category.is_empty():
settings_draft = settings.duplicate(true)
if not category.is_empty():
settings_category = category
_open_settings_context()
current_screen = "settings"
_build_world_base()
var safe := MarginContainer.new()
safe.name = "SettingsSafeArea"
@@ -8311,28 +8381,34 @@ func discard_settings() -> void:
func _return_from_settings() -> void:
if settings_caller == "guild_handoff":
var context = _close_settings_context()
var caller := settings_caller
if context != null and not String(context.caller_id).is_empty():
caller = String(context.caller_id)
if caller == "guild_handoff":
show_guild_handoff(guild_company)
elif settings_caller == "readiness":
elif caller == "readiness":
show_readiness_review(guild_company)
elif settings_caller == "risk_board":
elif caller == "risk_board":
show_risk_board(guild_company)
elif settings_caller == "risk_commitment":
elif caller == "risk_commitment":
show_risk_commitment(guild_company)
elif settings_caller == "market":
elif caller == "market":
show_market(guild_company)
elif settings_caller == "battle_opening":
elif caller == "battle_opening":
show_battle_opening(guild_company, false)
elif settings_caller == "battle_live":
elif caller == "battle_live":
_battle_live_return_from_settings()
elif settings_caller == "battle_pause_controls":
elif caller == "battle_pause_controls":
_battle_live_return_from_settings()
else:
back_to_title()
if context != null:
call_deferred("_restore_settings_focus", String(context.return_focus_path))
func apply_settings() -> void:
var saved: Dictionary = settings_store.save_settings(settings_draft)
var saved: Dictionary = settings_service.save_settings(settings_draft)
if not saved.ok:
settings_notice = _tr("设置保存失败,请重试。", "Settings save failed. Retry.") + " [%s]" % String(saved.code)
show_settings()
@@ -8370,12 +8446,12 @@ func _apply_runtime_settings() -> void:
func cycle_setting_safe_area() -> void:
settings_draft.safe_area = _next_value(SettingsStore.SAFE_AREA_PRESETS, int(settings_draft.safe_area))
settings_draft.safe_area = _next_value(SettingsServiceScript.SAFE_AREA_PRESETS, int(settings_draft.safe_area))
show_settings()
func cycle_setting_text_scale() -> void:
settings_draft.text_scale = _next_float(SettingsStore.TEXT_SCALES, float(settings_draft.text_scale))
settings_draft.text_scale = _next_float(SettingsServiceScript.TEXT_SCALES, float(settings_draft.text_scale))
show_settings()
+43
View File
@@ -0,0 +1,43 @@
class_name ABGSettingsService
extends RefCounted
const StoreScript = preload("res://entry/settings_store.gd")
const SETTINGS_VERSION := StoreScript.SETTINGS_VERSION
const TEXT_SCALES := StoreScript.TEXT_SCALES
const SAFE_AREA_PRESETS := StoreScript.SAFE_AREA_PRESETS
## Application-facing Settings boundary. The existing JSON schema, migration,
## validation and atomic file protocol remain owned by the proven backend; new
## pages depend on this boundary rather than opening a settings file directly.
var backend
var path: String:
get:
return String(backend.path)
set(value):
backend.path = value
func _init(existing_backend = null) -> void:
backend = existing_backend if existing_backend != null else StoreScript.new()
func defaults() -> Dictionary:
return backend.defaults()
func load_settings() -> Dictionary:
return backend.load_settings()
func save_settings(value: Dictionary) -> Dictionary:
return backend.save_settings(value)
func validate(value: Dictionary) -> Dictionary:
return backend.validate(value)
func backend_type_name() -> String:
return backend.get_script().resource_path.get_file()
@@ -0,0 +1,123 @@
extends SceneTree
const EntryShell = preload("res://entry/entry_shell.gd")
const SettingsService = preload("res://foundation/settings_service.gd")
const SETTINGS_PATH := "user://abg_m15_settings_service_test.json"
var failures: Array[String] = []
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
_cleanup()
verify_service_boundary()
verify_invalid_file_and_v1_migration()
await verify_screen_context_boundary()
_cleanup()
if failures.is_empty():
print("ABG_M15_SETTINGS_OK defaults=true forwarding=true invalid_file=true migration=v1_to_v2 context=true return=true stack_clear=true")
quit(0)
else:
for failure in failures:
push_error(failure)
quit(1)
func verify_service_boundary() -> void:
var service := SettingsService.new()
service.path = SETTINGS_PATH
var defaults := service.defaults()
expect(defaults.settings_version == SettingsService.SETTINGS_VERSION, "service preserves the validated schema version")
expect(defaults.locale == "zh_CN" and defaults.safe_area == 24, "service exposes the existing defaults")
expect(service.backend_type_name() == "settings_store.gd", "service keeps the proven backend behind the boundary")
var invalid := defaults.duplicate(true)
invalid.locale = "fr"
var validation := service.validate(invalid)
expect(not validation.ok and validation.code == "SETTINGS_LOCALE_INVALID", "service forwards validation errors")
var saved := service.save_settings(defaults)
expect(saved.ok and FileAccess.file_exists(SETTINGS_PATH), "service forwards atomic saves")
var loaded := service.load_settings()
expect(loaded.ok and loaded.code == "loaded", "service forwards load results")
if loaded.ok:
expect(String(loaded.settings.locale) == "zh_CN" and int(loaded.settings.safe_area) == 24, "service load preserves discrete settings")
expect(is_equal_approx(float(loaded.settings.music_volume), 0.8) and bool(loaded.settings.captions), "service load preserves scalar and boolean settings")
func verify_invalid_file_and_v1_migration() -> void:
var service := SettingsService.new()
service.path = SETTINGS_PATH
_write_text(SETTINGS_PATH, "{not-json")
var invalid := service.load_settings()
expect(not invalid.ok and invalid.code == "SETTINGS_FILE_INVALID", "corrupt JSON remains an explicit settings error")
var legacy := service.defaults()
legacy.settings_version = 1
legacy.locale = "en"
legacy.text_scale = 1.15
_write_text(SETTINGS_PATH, JSON.stringify(legacy))
var migrated := service.load_settings()
expect(migrated.ok and migrated.code == "migrated_v1", "v1 settings migrate through the application service")
if migrated.ok:
expect(int(migrated.settings.settings_version) == SettingsService.SETTINGS_VERSION, "migration promotes the schema version")
expect(migrated.settings.locale == "en" and is_equal_approx(float(migrated.settings.text_scale), 1.15), "migration preserves compatible user values")
var persisted := service.load_settings()
expect(persisted.ok and persisted.code == "loaded", "migrated settings are atomically persisted")
func verify_screen_context_boundary() -> void:
var shell = EntryShell.new()
shell.auto_start = false
shell.settings_service.path = SETTINGS_PATH
root.add_child(shell)
await process_frame
shell.settings = shell.settings_service.defaults()
shell.settings.setup_completed = true
shell.settings_caller = "title"
shell.show_title()
await process_frame
shell.show_settings("audio")
await process_frame
var context = shell.app_services.screen_stack.current()
expect(context != null and context.screen_id == "settings", "opening Settings pushes a screen context")
if context != null:
expect(context.caller_id == "title" and context.return_screen_id == "title", "title caller is recorded for return")
expect(context.return_focus_path == "TitleSettings" and context.metadata.category == "audio", "return focus and category are explicit context data")
expect(shell.app_services.resolve_service(&"settings") == shell.settings_service, "Settings is registered in the app service bundle")
shell.settings_draft = shell.settings.duplicate(true)
shell.discard_settings()
await process_frame
expect(shell.current_screen == "title", "discard returns to the originating title route")
expect(shell.app_services.screen_stack.depth() == 0, "return clears the Settings stack entry")
shell.settings_caller = "guild_handoff"
shell.show_settings("credits")
await process_frame
context = shell.app_services.screen_stack.current()
expect(context != null and context.caller_id == "guild_handoff", "non-title callers are preserved")
if context != null:
expect(context.return_screen_id == "guild_handoff" and context.return_focus_path == "GuildSettings", "non-title return route and focus are mapped")
shell._close_settings_context()
shell.queue_free()
await process_frame
func _write_text(path: String, value: String) -> void:
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
expect(false, "test fixture file opens")
return
file.store_string(value)
file.close()
func _cleanup() -> void:
for path in [SETTINGS_PATH, "%s.bak" % SETTINGS_PATH, "%s.candidate" % SETTINGS_PATH]:
if FileAccess.file_exists(path):
DirAccess.remove_absolute(path)
func expect(condition: bool, message: String) -> void:
if not condition:
failures.append("M15 Settings: %s" % message)