diff --git a/autoload/save_manager.gd b/autoload/save_manager.gd index ba5b0f7..b502ebe 100644 --- a/autoload/save_manager.gd +++ b/autoload/save_manager.gd @@ -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"]: diff --git a/stage/stage.gd b/stage/stage.gd index 803e294..2e003de 100644 --- a/stage/stage.gd +++ b/stage/stage.gd @@ -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: diff --git a/tests/README.md b/tests/README.md index 4fe941d..50543c2 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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. Прогресс несовместимого файла не переносится, исходные байты сохраняются в архиве. Ошибка архивирования, расшифровки или повреждение данных текущего формата по-прежнему блокируют перезапись. ## Согласованные правила эволюции diff --git a/tests/fixtures/ui_stage.gd b/tests/fixtures/ui_stage.gd new file mode 100644 index 0000000..4348b6e --- /dev/null +++ b/tests/fixtures/ui_stage.gd @@ -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 diff --git a/tests/fixtures/ui_stage.gd.uid b/tests/fixtures/ui_stage.gd.uid new file mode 100644 index 0000000..e08b9b2 --- /dev/null +++ b/tests/fixtures/ui_stage.gd.uid @@ -0,0 +1 @@ +uid://c8ewikdhlvnex diff --git a/tests/test_async_save.gd b/tests/test_async_save.gd new file mode 100644 index 0000000..ad022c4 --- /dev/null +++ b/tests/test_async_save.gd @@ -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) diff --git a/tests/test_async_save.gd.uid b/tests/test_async_save.gd.uid new file mode 100644 index 0000000..87949b2 --- /dev/null +++ b/tests/test_async_save.gd.uid @@ -0,0 +1 @@ +uid://dsn0ih3yfrry2 diff --git a/tests/test_ui_updates.gd b/tests/test_ui_updates.gd new file mode 100644 index 0000000..de5c88f --- /dev/null +++ b/tests/test_ui_updates.gd @@ -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) diff --git a/tests/test_ui_updates.gd.uid b/tests/test_ui_updates.gd.uid new file mode 100644 index 0000000..1003fc4 --- /dev/null +++ b/tests/test_ui_updates.gd.uid @@ -0,0 +1 @@ +uid://c8bt2hxqm4hsf diff --git a/ui/actions/actions.gd b/ui/actions/actions.gd index fc40598..3723bdf 100644 --- a/ui/actions/actions.gd +++ b/ui/actions/actions.gd @@ -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: diff --git a/ui/owniverse_item/owniverse_item.gd b/ui/owniverse_item/owniverse_item.gd index 18b1368..ef2f0b5 100644 --- a/ui/owniverse_item/owniverse_item.gd +++ b/ui/owniverse_item/owniverse_item.gd @@ -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) diff --git a/world/owniverse/owniverse.gd b/world/owniverse/owniverse.gd index 254ad92..c1e7c5b 100644 --- a/world/owniverse/owniverse.gd +++ b/world/owniverse/owniverse.gd @@ -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: