From 8a8fb33c19db98dfb05f211da85b04462ef579af Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Tue, 25 Aug 2026 10:20:18 +0800 Subject: [PATCH] feat(runtime): integrate settings service boundary Co-Authored-By: Codex --- runtime/entry/entry_shell.gd | 114 +++++++++++++--- runtime/foundation/settings_service.gd | 43 ++++++ .../tests/foundation/settings_service_test.gd | 123 ++++++++++++++++++ 3 files changed, 261 insertions(+), 19 deletions(-) create mode 100644 runtime/foundation/settings_service.gd create mode 100644 runtime/tests/foundation/settings_service_test.gd diff --git a/runtime/entry/entry_shell.gd b/runtime/entry/entry_shell.gd index 1b334e84..ff6c81ab 100644 --- a/runtime/entry/entry_shell.gd +++ b/runtime/entry/entry_shell.gd @@ -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() diff --git a/runtime/foundation/settings_service.gd b/runtime/foundation/settings_service.gd new file mode 100644 index 00000000..532dae73 --- /dev/null +++ b/runtime/foundation/settings_service.gd @@ -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() diff --git a/runtime/tests/foundation/settings_service_test.gd b/runtime/tests/foundation/settings_service_test.gd new file mode 100644 index 00000000..13a3e0e1 --- /dev/null +++ b/runtime/tests/foundation/settings_service_test.gd @@ -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)