Implement asynchronous save manager with UI update optimizations and add tests for save functionality
This commit is contained in:
+122
-22
@@ -10,6 +10,80 @@ const SimulationTime = preload("res://world/owniverse/simulation_time.gd")
|
||||
const STATE_VERSION: String = SimulationTime.STATE_VERSION
|
||||
var last_error: String = ""
|
||||
var blocked_saves: Dictionary = {}
|
||||
var save_directory: String = "user://"
|
||||
signal save_completed(filename: String, success: bool, error: String)
|
||||
signal saves_drained
|
||||
|
||||
# Queue ownership stays on the main thread. The worker only sees its snapshot.
|
||||
var _save_thread: Thread
|
||||
var _active_filename: String = ""
|
||||
var _pending_saves: Dictionary = {}
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _save_thread != null and not _save_thread.is_alive():
|
||||
_finish_save()
|
||||
|
||||
|
||||
func is_saving() -> bool:
|
||||
return _save_thread != null or not _pending_saves.is_empty()
|
||||
|
||||
|
||||
func _snapshot(filename: String, password: String, data: Dictionary) -> Dictionary:
|
||||
var payload: Dictionary = data.duplicate(true)
|
||||
payload.format_version = FORMAT_VERSION
|
||||
payload.app_version = str(ProjectSettings.get_setting("application/config/version", ""))
|
||||
return {"filename": filename, "path": save_directory.path_join(filename + ".adventure"), "password": password, "payload": payload,
|
||||
"plaintext": Global.save_plaintext_copy, "speed_limit": Global.SPEED_LIMIT,
|
||||
"stages": Global.CivilizationRules.keys()}
|
||||
|
||||
|
||||
## True means queued, not written. Completion is reported by save_completed.
|
||||
func request_save(filename: String, password: String, data: Dictionary) -> bool:
|
||||
if blocked_saves.has(filename):
|
||||
return _fail("Save blocked to preserve an unreadable save: " + filename)
|
||||
_pending_saves[filename] = _snapshot(filename, password, data)
|
||||
if _save_thread == null:
|
||||
_start_save()
|
||||
return true
|
||||
|
||||
|
||||
func _start_save() -> void:
|
||||
if _pending_saves.is_empty():
|
||||
return
|
||||
_active_filename = _pending_saves.keys()[0]
|
||||
var job: Dictionary = _pending_saves[_active_filename]
|
||||
_pending_saves.erase(_active_filename)
|
||||
_save_thread = Thread.new()
|
||||
var error: Error = _save_thread.start(_write_snapshot.bind(job))
|
||||
if error != OK:
|
||||
_save_thread = null
|
||||
_fail("Cannot start save worker: " + error_string(error))
|
||||
save_completed.emit(_active_filename, false, last_error)
|
||||
_start_save()
|
||||
if not is_saving(): saves_drained.emit()
|
||||
|
||||
|
||||
func _finish_save() -> void:
|
||||
var error: String = _save_thread.wait_to_finish()
|
||||
var filename: String = _active_filename
|
||||
_save_thread = null
|
||||
last_error = error
|
||||
if error != "": push_error(error)
|
||||
save_completed.emit(filename, error == "", error)
|
||||
if _save_thread == null: _start_save()
|
||||
if not is_saving(): saves_drained.emit()
|
||||
|
||||
|
||||
## Used for application suspension and teardown, when frames may stop arriving.
|
||||
func flush_saves() -> void:
|
||||
while is_saving():
|
||||
if _save_thread == null: _start_save()
|
||||
if _save_thread != null: _finish_save()
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
flush_saves()
|
||||
|
||||
#region Save Load routines
|
||||
|
||||
@@ -20,50 +94,56 @@ func _fail(message: String) -> bool:
|
||||
|
||||
|
||||
func save_data(filename: String, password: String, data: Dictionary) -> bool:
|
||||
# Synchronous compatibility API must never race an asynchronous writer.
|
||||
flush_saves()
|
||||
last_error = ""
|
||||
if blocked_saves.has(filename):
|
||||
return _fail("Save blocked to preserve an unreadable save: " + filename)
|
||||
var payload: Dictionary = data.duplicate(true)
|
||||
payload.format_version = FORMAT_VERSION
|
||||
payload.app_version = str(ProjectSettings.get_setting("application/config/version", ""))
|
||||
var problem: String = validate_data(payload)
|
||||
var error: String = _write_snapshot(_snapshot(filename, password, data))
|
||||
return _fail(error) if error != "" else true
|
||||
|
||||
|
||||
static func _write_snapshot(job: Dictionary) -> String:
|
||||
var payload: Dictionary = job.payload
|
||||
var problem: String = _validate_payload(payload, job.speed_limit, job.stages)
|
||||
if problem != "":
|
||||
return _fail("Invalid save: " + problem)
|
||||
var path: String = "user://" + filename + ".adventure"
|
||||
return "Invalid save: " + problem
|
||||
var path: String = job.path
|
||||
var temporary: String = path + ".tmp"
|
||||
var file := FileAccess.open_encrypted_with_pass(temporary, FileAccess.WRITE, password)
|
||||
var file := FileAccess.open_encrypted_with_pass(temporary, FileAccess.WRITE, job.password)
|
||||
if file == null:
|
||||
return _fail("Cannot write encrypted save: " + temporary)
|
||||
return "Cannot write encrypted save: " + temporary
|
||||
file.store_var(payload, false)
|
||||
file.close()
|
||||
# Check the actual encrypted file, including finalization, before replacing the old save.
|
||||
var verification := FileAccess.open_encrypted_with_pass(temporary, FileAccess.READ, password)
|
||||
var verification := FileAccess.open_encrypted_with_pass(temporary, FileAccess.READ, job.password)
|
||||
if verification == null:
|
||||
return _fail("Cannot verify encrypted save: " + temporary)
|
||||
return "Cannot verify encrypted save: " + temporary
|
||||
var restored: Variant = verification.get_var(false)
|
||||
verification.close()
|
||||
if not restored is Dictionary or restored != payload:
|
||||
return _fail("Encrypted save verification failed: " + temporary)
|
||||
return "Encrypted save verification failed: " + temporary
|
||||
if DirAccess.rename_absolute(temporary, path) != OK:
|
||||
return _fail("Cannot replace encrypted save: " + path)
|
||||
if Global.save_plaintext_copy:
|
||||
return "Cannot replace encrypted save: " + path
|
||||
if job.plaintext:
|
||||
var plain_path: String = path + ".json"
|
||||
file = FileAccess.open(plain_path + ".tmp", FileAccess.WRITE)
|
||||
if file == null:
|
||||
return _fail("Encrypted save written, but cannot write diagnostic copy: " + plain_path)
|
||||
return "Encrypted save written, but cannot write diagnostic copy: " + plain_path
|
||||
file.store_string(JSON.stringify(payload, " ", true, true) + "\n")
|
||||
file.flush()
|
||||
var write_error: Error = file.get_error()
|
||||
file.close()
|
||||
if write_error != OK or DirAccess.rename_absolute(plain_path + ".tmp", plain_path) != OK:
|
||||
return _fail("Encrypted save written, but diagnostic copy failed: " + plain_path)
|
||||
return true
|
||||
return "Encrypted save written, but diagnostic copy failed: " + plain_path
|
||||
return ""
|
||||
|
||||
|
||||
func load_data(filename: String, password: String) -> Dictionary:
|
||||
flush_saves()
|
||||
last_error = ""
|
||||
# Plaintext is diagnostic only: never use it as a fallback.
|
||||
var path: String = "user://" + filename + ".adventure"
|
||||
var path: String = save_directory.path_join(filename + ".adventure")
|
||||
if not FileAccess.file_exists(path):
|
||||
return {}
|
||||
var file := FileAccess.open_encrypted_with_pass(path, FileAccess.READ, password)
|
||||
@@ -73,6 +153,22 @@ func load_data(filename: String, password: String) -> Dictionary:
|
||||
return {}
|
||||
var data: Variant = file.get_var(false)
|
||||
file.close()
|
||||
# Unsupported formats are archived, never converted or overwritten.
|
||||
# Returning no data lets the stage start a new, saveable session.
|
||||
if data is Dictionary and data.get("format_version") != FORMAT_VERSION:
|
||||
var archive: String = path + ".incompatible.bak"
|
||||
var index: int = 0
|
||||
while FileAccess.file_exists(archive):
|
||||
index += 1
|
||||
archive = path + ".incompatible.bak." + str(index)
|
||||
var error: Error = DirAccess.rename_absolute(path, archive)
|
||||
if error != OK:
|
||||
blocked_saves[filename] = true
|
||||
_fail("Cannot archive incompatible save: " + error_string(error))
|
||||
return {}
|
||||
blocked_saves.erase(filename)
|
||||
push_warning("Incompatible save preserved at " + archive + "; starting a new game.")
|
||||
return {}
|
||||
var problem: String = validate_data(data)
|
||||
if problem != "":
|
||||
blocked_saves[filename] = true
|
||||
@@ -82,13 +178,13 @@ func load_data(filename: String, password: String) -> Dictionary:
|
||||
return data
|
||||
|
||||
|
||||
func _number(value: Variant, whole: bool = false, nonnegative: bool = true) -> bool:
|
||||
static func _number(value: Variant, whole: bool = false, nonnegative: bool = true) -> bool:
|
||||
if not (value is int or value is float) or not is_finite(float(value)):
|
||||
return false
|
||||
return (not whole or float(value) == floorf(float(value))) and (not nonnegative or value >= 0)
|
||||
|
||||
|
||||
func _number_map(value: Variant, whole: bool = false, nonnegative: bool = true) -> bool:
|
||||
static func _number_map(value: Variant, whole: bool = false, nonnegative: bool = true) -> bool:
|
||||
if not value is Dictionary:
|
||||
return false
|
||||
for key in value:
|
||||
@@ -97,7 +193,7 @@ func _number_map(value: Variant, whole: bool = false, nonnegative: bool = true)
|
||||
return true
|
||||
|
||||
|
||||
func _plain_tree(value: Variant) -> bool:
|
||||
static func _plain_tree(value: Variant) -> bool:
|
||||
if value is Dictionary:
|
||||
for key in value:
|
||||
if not (key is String or key is StringName) or not _plain_tree(value[key]):
|
||||
@@ -113,6 +209,10 @@ func _plain_tree(value: Variant) -> bool:
|
||||
|
||||
## Return a diagnostic path on failure; an empty string means the structure is valid.
|
||||
func validate_data(data: Variant) -> String:
|
||||
return _validate_payload(data, Global.SPEED_LIMIT, Global.CivilizationRules.keys())
|
||||
|
||||
|
||||
static func _validate_payload(data: Variant, speed_limit: int, stages: Array) -> String:
|
||||
if not data is Dictionary or not _plain_tree(data):
|
||||
return "save must contain plain finite values"
|
||||
if data.get("format_version") != FORMAT_VERSION or not data.get("app_version") is String:
|
||||
@@ -140,7 +240,7 @@ func validate_data(data: Variant) -> String:
|
||||
return "invalid state.time"
|
||||
if not is_finite(float(saved.time.datetime) * 10.0) or saved.time.year != SimulationTime.processed_year(saved.time.datetime):
|
||||
return "invalid processed time"
|
||||
if not _number(saved.speed, true) or saved.speed < 1 or saved.speed > Global.SPEED_LIMIT:
|
||||
if not _number(saved.speed, true) or saved.speed < 1 or saved.speed > speed_limit:
|
||||
return "invalid state.speed"
|
||||
if not _number(saved.speed_in_year) or saved.speed_in_year != pow(10.0, saved.speed - 1):
|
||||
return "invalid state.speed_in_year"
|
||||
@@ -187,7 +287,7 @@ func validate_data(data: Variant) -> String:
|
||||
if not saved.ethics is Dictionary or not saved.civilization_homes is Dictionary:
|
||||
return "invalid civilization state"
|
||||
for id in saved.ethics:
|
||||
if not Global.CivilizationRules.has(id) or not _number_map(saved.ethics[id], true):
|
||||
if id not in stages or not _number_map(saved.ethics[id], true):
|
||||
return "invalid state.ethics." + str(id)
|
||||
for ethic in saved.ethics[id]:
|
||||
if ethic not in ["m", "l", "w", "d", "c", "b"]:
|
||||
|
||||
+62
-8
@@ -9,6 +9,10 @@ var description_targets: Dictionary = {}
|
||||
var description_samples: Dictionary = {}
|
||||
var description_rates: Dictionary = {}
|
||||
var description_elapsed: float = 0.0
|
||||
const UI_UPDATE_INTERVAL: float = 0.1
|
||||
var ui_elapsed: float = 0.0
|
||||
var ui_pages: Vector2i = Vector2i(-1, -1)
|
||||
var closing: bool = false
|
||||
|
||||
const PAGE_COUNT = 5
|
||||
const RESOURCE_PAGE = 1
|
||||
@@ -89,7 +93,9 @@ func set_datetime(value: float):
|
||||
# Simulation time advances in tenths of a year; displaying five decimal
|
||||
# places only adds zeroes and suggests precision the simulation does not
|
||||
# have.
|
||||
date_time.text = Global.format_number(value, 1) if value != 0.0 else "0.0"
|
||||
var text: String = Global.format_number(value, 1) if value != 0.0 else "0.0"
|
||||
if date_time.text != text:
|
||||
date_time.text = text
|
||||
|
||||
#region Item manipulations: update and visibility
|
||||
|
||||
@@ -97,8 +103,27 @@ func set_datetime(value: float):
|
||||
func set_owniverse_item_value() -> void:
|
||||
for item_id in owniverse.items_to_update:
|
||||
if owniverse_items.has(item_id):
|
||||
owniverse_items[item_id].set_label(owniverse.items_to_update[item_id])
|
||||
owniverse.items_to_update.clear()
|
||||
var item: OwniverseItem = owniverse_items[item_id]
|
||||
if item.is_visible_in_tree() and item.get_global_rect().intersects(stage_page_scroller.get_global_rect()):
|
||||
item.set_label(owniverse.items_to_update[item_id])
|
||||
|
||||
|
||||
func _refresh_ui(force: bool = false) -> void:
|
||||
if force or owniverse.item_amounts_dirty:
|
||||
owniverse.refresh_item_amounts(true)
|
||||
set_owniverse_item_value()
|
||||
set_datetime(owniverse.state.time.year)
|
||||
_set_active_page_actions()
|
||||
ui_elapsed = 0.0
|
||||
|
||||
|
||||
func _update_ui(delta: float) -> void:
|
||||
ui_elapsed += delta
|
||||
var position_in_pages: float = float(stage_page_scroller.scroll_horizontal) / PAGE_WIDTH
|
||||
var pages := Vector2i(floori(position_in_pages), ceili(position_in_pages))
|
||||
if pages != ui_pages or ui_elapsed >= UI_UPDATE_INTERVAL:
|
||||
ui_pages = pages
|
||||
_refresh_ui()
|
||||
|
||||
|
||||
#endregion
|
||||
@@ -219,6 +244,9 @@ func item_activated(item: OwniverseItem) -> void:
|
||||
|
||||
if item.item_id == "S" or item.item_id == "H":
|
||||
var _value = owniverse.produce_hs(item.item_id, 100_000)
|
||||
_unlock_items()
|
||||
_refresh_ui(true)
|
||||
_refresh_descriptions()
|
||||
|
||||
|
||||
func _check_counters(delta: float) -> Array:
|
||||
@@ -277,6 +305,8 @@ func _input(event: InputEvent) -> void:
|
||||
func _set_active_page_actions() -> void:
|
||||
for pa in page_actions:
|
||||
if !g_active_items.has(pa): continue
|
||||
if not page_actions[pa].is_visible_in_tree() or not page_actions[pa].get_global_rect().intersects(stage_page_scroller.get_global_rect()):
|
||||
continue
|
||||
|
||||
var item_id: String = g_active_items[pa].item_id
|
||||
var entity: OwniverseEntity = owniverse.entities[item_id]
|
||||
@@ -302,12 +332,17 @@ func _page_action_pressed(page_id: int, action_amount: float) -> void:
|
||||
|
||||
if action_amount > 0:
|
||||
owniverse.produce(item_id, action_amount)
|
||||
_unlock_items()
|
||||
_refresh_ui(true)
|
||||
_refresh_descriptions()
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
get_tree().auto_accept_quit = false
|
||||
owniverse.defer_item_updates = true
|
||||
var inited_result = init_enitities()
|
||||
locked_items = inited_result.locked_items
|
||||
load_data()
|
||||
@@ -317,6 +352,7 @@ func _ready() -> void:
|
||||
set_datetime(owniverse.state.time.year)
|
||||
set_owniverse_item_value()
|
||||
_unlock_items()
|
||||
_refresh_ui(true)
|
||||
|
||||
g_counters.swipe.sec = 0.2
|
||||
|
||||
@@ -329,8 +365,7 @@ func _process(delta: float) -> void:
|
||||
|
||||
owniverse.evolute(delta)
|
||||
_update_descriptions(delta)
|
||||
set_owniverse_item_value()
|
||||
set_datetime(owniverse.state.time.year)
|
||||
_update_ui(delta)
|
||||
|
||||
#owniverse.cycle(delta)
|
||||
|
||||
@@ -339,7 +374,6 @@ func _process(delta: float) -> void:
|
||||
var actions = _check_counters(delta)
|
||||
_do_actions(actions)
|
||||
|
||||
_set_active_page_actions()
|
||||
|
||||
|
||||
func _change_speed(delta: int) -> void:
|
||||
@@ -421,21 +455,41 @@ func _restore_page() -> void:
|
||||
if pending_page >= 0:
|
||||
stage_page_scroller.scroll_horizontal = pending_page * PAGE_WIDTH
|
||||
pending_page = -1
|
||||
_refresh_ui(true)
|
||||
|
||||
|
||||
func save_data() -> void:
|
||||
var page: int = pending_page
|
||||
if page < 0:
|
||||
page = clampi(roundi(float(stage_page_scroller.scroll_horizontal) / PAGE_WIDTH), 0, PAGE_COUNT - 1)
|
||||
SaveManager.save_data("owniverse", "qwerty", {
|
||||
SaveManager.request_save("owniverse", "qwerty", {
|
||||
"state": owniverse.state,
|
||||
"ui": {"current_page": page},
|
||||
})
|
||||
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if is_node_ready() and (what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_APPLICATION_PAUSED):
|
||||
if not is_node_ready() or closing:
|
||||
return
|
||||
if what == NOTIFICATION_WM_CLOSE_REQUEST:
|
||||
_close_after_save()
|
||||
elif what == NOTIFICATION_APPLICATION_PAUSED:
|
||||
save_data()
|
||||
# Mobile platforms can suspend all processing immediately after this event.
|
||||
SaveManager.flush_saves()
|
||||
|
||||
|
||||
func _close_after_save() -> void:
|
||||
closing = true
|
||||
set_process(false)
|
||||
set_process_input(false)
|
||||
mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
# Stop GUI actions too while the final snapshot is being written.
|
||||
process_mode = Node.PROCESS_MODE_DISABLED
|
||||
save_data()
|
||||
if SaveManager.is_saving():
|
||||
await SaveManager.saves_drained
|
||||
get_tree().quit()
|
||||
|
||||
|
||||
func init_enitities() -> Dictionary:
|
||||
|
||||
+16
-1
@@ -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. Прогресс несовместимого файла не переносится, исходные байты сохраняются в архиве. Ошибка архивирования, расшифровки или повреждение данных текущего формата по-прежнему блокируют перезапись.
|
||||
|
||||
## Согласованные правила эволюции
|
||||
|
||||
|
||||
Vendored
+14
@@ -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
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
uid://c8ewikdhlvnex
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dsn0ih3yfrry2
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c8bt2hxqm4hsf
|
||||
@@ -47,7 +47,9 @@ func _set_action_values(values: Array, displayed: Array) -> void:
|
||||
for ind in range(0, 5):
|
||||
action_values[ind] = values[ind]
|
||||
# Keep the original request for execution; show the exact planned output.
|
||||
action_labels[ind].text = Global.format_number(displayed[ind]) if displayed[ind] > 0 else ""
|
||||
var text: String = Global.format_number(displayed[ind]) if displayed[ind] > 0 else ""
|
||||
if action_labels[ind].text != text:
|
||||
action_labels[ind].text = text
|
||||
|
||||
|
||||
func _on_action_button_pressed(button_id: int) -> void:
|
||||
|
||||
@@ -20,7 +20,9 @@ var item_type = ""
|
||||
|
||||
|
||||
func set_label(value: float) -> void:
|
||||
description.text = Global.format_number(value)
|
||||
var text: String = Global.format_number(value)
|
||||
if description.text != text:
|
||||
description.text = text
|
||||
#description.text = str(value)
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ var _group_members: Dictionary = {}
|
||||
var _indexed_entity_count: int = -1
|
||||
|
||||
var items_to_update: Dictionary[String, float] = {}
|
||||
var defer_item_updates: bool = false
|
||||
var item_amounts_dirty: bool = true
|
||||
|
||||
var auto_production: Array = []
|
||||
var locked_entities: Array = []
|
||||
@@ -146,8 +148,12 @@ func get_item_amounts(total_produced: bool = false) -> Dictionary[String, float]
|
||||
return result
|
||||
|
||||
|
||||
func refresh_item_amounts() -> void:
|
||||
func refresh_item_amounts(force: bool = false) -> void:
|
||||
item_amounts_dirty = true
|
||||
if defer_item_updates and not force:
|
||||
return
|
||||
items_to_update = get_item_amounts()
|
||||
item_amounts_dirty = false
|
||||
|
||||
|
||||
func _add_time_delta(delta: float) -> Dictionary:
|
||||
|
||||
Reference in New Issue
Block a user