Implement asynchronous save manager with UI update optimizations and add tests for save functionality

This commit is contained in:
Kirill
2026-09-20 22:14:47 +03:00
parent a09e3df220
commit f25b77b605
12 changed files with 385 additions and 34 deletions
+16 -1
View File
@@ -1,5 +1,18 @@
# Проверки поэтапного переноса из Owniverse.U
## Фоновое сохранение и частота обновления интерфейса
```sh
godot --headless --path Owniverse.GI --script res://tests/test_async_save.gd
godot --headless --path Owniverse.GI --script res://tests/test_ui_updates.gd
```
Сохранение: основной поток создаёт глубокий снимок состояния и параметров. Рабочий поток проверяет данные, записывает зашифрованный временный файл, проверяет чтением и заменяет основное сохранение; диагностический JSON создаётся из того же снимка. Одновременно работает один писатель. Для каждого имени файла хранится только последний ожидающий запрос. `request_save()` подтверждает постановку в очередь; результат записи сообщает сигнал `save_completed`. При закрытии игра ждёт `saves_drained`, при приостановке приложения и уничтожении менеджера очередь завершается через `flush_saves()`. Копирование снимка остаётся в основном потоке.
Тест сохранений пишет только в уникальную временную папку `/tmp`, затем удаляет собственные файлы. Проверяются независимость снимка, работа кадров при записи, объединение запросов, диагностическая копия, сохранность предыдущего файла при ошибках и завершение очереди при уничтожении менеджера. Сообщения `Invalid save`, `Save blocked` и `Cannot write` в этом тесте ожидаемы.
Счётчики, время и кнопки производства обновляются раз в 0,1 секунды. Изменения симуляции помечают суммы для пересчёта; сами суммы вычисляются при обновлении интерфейса. Текст счётчиков применяется только к видимым элементам и только при изменении строки. Переход между страницами, выбор элемента и производство вызывают немедленное обновление. Описания сохраняют прежний интервал 0,25 секунды. Тест интерфейса использует игровую сцену с отключёнными загрузкой, сохранением и автоматической симуляцией, не обращаясь к сохранению игрока.
## Время: шаг 0,1 игрового года
```sh
@@ -15,7 +28,9 @@ godot --headless --path Owniverse.GI --script res://tests/test_time.gd
- `time.datetime` — накопленный возраст, включая необработанную дробную часть;
- `time.year` — последний рассчитанный возраст с точностью до десятой доли года.
Сохранения используют только актуальную структуру состояния и не поддерживают прежние форматы.
Сохранения используют формат 2 и актуальную структуру состояния. Проверяются целостность данных, фоновая запись и повторная загрузка; поддержка и миграция прежних форматов отсутствуют.
Файл с несовместимым номером формата при загрузке переносится в `*.adventure.incompatible.bak` (при совпадении имени добавляется числовой суффикс). Игра начинает новую сессию и может сохранять её в формате 2. Прогресс несовместимого файла не переносится, исходные байты сохраняются в архиве. Ошибка архивирования, расшифровки или повреждение данных текущего формата по-прежнему блокируют перезапись.
## Согласованные правила эволюции
+14
View File
@@ -0,0 +1,14 @@
extends "res://stage/stage.gd"
# Keep scene wiring and display logic, without accessing the player's save.
func load_data() -> bool:
return false
func save_data() -> void:
pass
func _process(_delta: float) -> void:
pass
func _notification(_what: int) -> void:
pass
+1
View File
@@ -0,0 +1 @@
uid://c8ewikdhlvnex
+100
View File
@@ -0,0 +1,100 @@
extends SceneTree
var failures: int = 0
var checks: int = 0
var completions: Array = []
func _initialize() -> void:
call_deferred("run")
func check(value: bool, message: String) -> void:
checks += 1
if not value:
failures += 1
push_error(message)
func run() -> void:
var manager = load("res://autoload/save_manager.gd").new()
var directory: String = "/tmp/owniverse-async-%s-%s" % [OS.get_process_id(), Time.get_ticks_usec()]
DirAccess.make_dir_recursive_absolute(directory)
manager.save_directory = directory
root.add_child(manager)
manager.save_completed.connect(func(filename, success, error): completions.append([filename, success, error]))
var world = load("res://world/owniverse/owniverse.gd").new()
var data: Dictionary = {"state": world.state, "ui": {"current_page": 1}}
var global: Node = root.get_node("Global")
var old_plaintext: bool = global.save_plaintext_copy
global.save_plaintext_copy = true
check(manager.request_save("snapshot", "test-password", data), "Snapshot queued")
data.state.energy = 99.0
data.ui.current_page = 2
var frames: int = 0
var deadline: int = Time.get_ticks_msec() + 10000
while manager.is_saving() and Time.get_ticks_msec() < deadline:
frames += 1
await process_frame
check(not manager.is_saving() and frames > 0, "Frames continue while save is pending")
var saved: Dictionary = manager.load_data("snapshot", "test-password")
check(saved.state.energy == 1e14 and saved.ui.current_page == 1, "Worker owns a deep snapshot")
var plain: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(directory.path_join("snapshot.adventure.json")))
check(plain.state.energy == 1e14, "Diagnostic JSON uses same snapshot")
check(completions.size() == 1 and completions[0][1], "Completion reports successful write")
completions.clear()
data.state.energy = 1.0
manager.request_save("latest", "test-password", data)
data.state.energy = 2.0
manager.request_save("latest", "test-password", data)
data.state.energy = 3.0
manager.request_save("latest", "test-password", data)
data.state.energy = 4.0
manager.flush_saves()
check(completions.size() == 2, "Repeated requests coalesce behind active write")
check(manager.load_data("latest", "test-password").state.energy == 3.0, "Last queued snapshot wins")
var incompatible_path: String = directory.path_join("incompatible.adventure")
var incompatible = FileAccess.open_encrypted_with_pass(incompatible_path, FileAccess.WRITE, "test-password")
incompatible.store_var({"format_version": -1, "state": {"unknown": true}}, false)
incompatible.close()
var original: PackedByteArray = FileAccess.get_file_as_bytes(incompatible_path)
var previous_archive = FileAccess.open(incompatible_path + ".incompatible.bak", FileAccess.WRITE)
previous_archive.store_string("keep previous backup")
previous_archive.close()
manager.blocked_saves.incompatible = true
check(manager.load_data("incompatible", "test-password").is_empty() and manager.last_error == "", "Unsupported format permits a fresh session")
check(not manager.blocked_saves.has("incompatible") and not FileAccess.file_exists(incompatible_path), "Incompatible file no longer blocks saving")
check(FileAccess.get_file_as_bytes(incompatible_path + ".incompatible.bak.1") == original, "Unsupported save archived without losing bytes")
check(FileAccess.get_file_as_string(incompatible_path + ".incompatible.bak") == "keep previous backup", "Earlier archive preserved")
check(manager.request_save("incompatible", "test-password", data), "New session can queue a save")
manager.flush_saves()
check(manager.load_data("incompatible", "test-password").state == data.state, "New format saves and reloads after incompatible file")
# Expected failures below must leave the last good encrypted file intact.
data.state.energy = -1.0
manager.request_save("latest", "test-password", data)
manager.flush_saves()
check(not completions.back()[1] and manager.last_error.contains("Invalid save"), "Worker validation error reaches main thread")
check(manager.load_data("latest", "test-password").state.energy == 3.0, "Invalid snapshot preserves previous save")
manager.blocked_saves.latest = true
check(not manager.request_save("latest", "test-password", data), "Unreadable save protection also applies to queue")
# A valid snapshot exercises an actual I/O failure, not validation.
data.state.energy = 5.0
manager.request_save("missing/failure", "test-password", data)
manager.flush_saves()
check(manager.last_error.contains("Cannot write"), "I/O error reported without hanging queue")
manager.request_save("shutdown", "test-password", data)
data.state.energy = 6.0
manager.request_save("shutdown", "test-password", data)
manager.free()
var reader = load("res://autoload/save_manager.gd").new()
reader.save_directory = directory
check(reader.load_data("shutdown", "test-password").state.energy == 6.0, "Teardown drains final pending snapshot")
reader.free()
world.free()
global.save_plaintext_copy = old_plaintext
for filename in DirAccess.get_files_at(directory):
DirAccess.remove_absolute(directory.path_join(filename))
DirAccess.remove_absolute(directory)
print("Async save: %d checks, %d failures (validation/block/I/O errors above are intentional)" % [checks, failures])
quit(0 if failures == 0 else 1)
+1
View File
@@ -0,0 +1 @@
uid://dsn0ih3yfrry2
+55
View File
@@ -0,0 +1,55 @@
extends SceneTree
var checks: int = 0
var failures: int = 0
func _initialize() -> void:
call_deferred("run")
func check(value: bool, message: String) -> void:
checks += 1
if not value:
failures += 1
push_error(message)
func run() -> void:
var scene = load("res://world/world.tscn").instantiate()
var stage = scene.get_node("Stage")
stage.owniverse.free()
stage.set_script(load("res://tests/fixtures/ui_stage.gd"))
root.add_child(scene)
await process_frame
await process_frame
stage.stage_page_scroller.scroll_horizontal = stage.PAGE_WIDTH
await process_frame
stage.owniverse.state.amount.H = 1000.0
stage.owniverse.state.amount.RD = 2.0
stage.owniverse.refresh_item_amounts()
stage._update_ui(0.0)
var hydrogen = stage.owniverse_items.H
var dwarf = stage.owniverse_items.RD
check(hydrogen.description.text == "1 000", "Visible page updates immediately on navigation")
check(dwarf.description.text == "", "Offscreen counters are not rendered")
stage.owniverse.state.amount.H = 2000.0
stage.owniverse.refresh_item_amounts()
check(stage.owniverse.item_amounts_dirty and stage.owniverse.items_to_update.H == 1000.0, "Simulation invalidates without recalculating display sums")
stage._update_ui(0.05)
check(hydrogen.description.text == "1 000", "No refresh before interval")
stage._update_ui(0.051)
check(hydrogen.description.text == "2 000" and not stage.owniverse.item_amounts_dirty, "Latest state appears at interval")
stage.item_activated(hydrogen)
check(hydrogen.description.text == root.get_node("Global").format_number(stage.owniverse.state.amount.H), "Player production updates counter immediately")
check(stage.owniverse.state.amount.H > 2000.0, "Production action actually executed")
dwarf.visible = true
stage.stage_page_scroller.scroll_horizontal = 2 * stage.PAGE_WIDTH
await process_frame
stage._update_ui(0.0)
check(dwarf.description.text == "2", "Newly visible page receives cached latest values")
stage.owniverse.state.time.year = 1234.5
stage._update_ui(0.1)
check(stage.date_time.text == "1 234.5", "Time uses throttled display with tenths")
for entity in stage.owniverse.entities.values(): entity.free()
stage.owniverse.free()
scene.free()
print("UI updates: %d checks, %d failures" % [checks, failures])
quit(0 if failures == 0 else 1)
+1
View File
@@ -0,0 +1 @@
uid://c8bt2hxqm4hsf