feat(runtime): integrate settings service boundary
Co-Authored-By: Codex <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user