Refactor save manager validation and update state structure

- Updated the save manager to enforce a new format version (2) and improved validation checks for the save data structure.
- Enhanced the `validate_data` function to ensure all required fields are present and valid.
- Adjusted the handling of legacy saves and introduced new checks for various state fields.
- Improved number formatting in the global script for better readability of large numbers.
- Updated tests to reflect changes in the save manager and validation logic, ensuring compatibility with the new state structure.
- Added new tests for group indexing and production transactions to ensure correctness of entity management and resource production.
This commit is contained in:
Kirill
2026-09-20 20:56:15 +03:00
parent 26347fca30
commit a09e3df220
15 changed files with 295 additions and 154 deletions
+17 -2
View File
@@ -59,8 +59,23 @@ func get_description(item_id: String, items: Array = []) -> String:
func format_number(value: float, digits: int = 0) -> String: func format_number(value: float, digits: int = 0) -> String:
if value == 0: return "" if value == 0: return ""
if value < 10_000: # Keep ordinary quantities readable, grouping thousands with spaces.
return "%.*f" % [digits, value] # Scientific notation is reserved for values whose fixed form would be
# unwieldy in the UI.
if absf(value) < 1_000_000_000.0:
var fixed: String = "%.*f" % [digits, value]
var parts: PackedStringArray = fixed.split(".", true)
var integer: String = parts[0]
var sign: String = ""
if integer.begins_with("-") or integer.begins_with("+"):
sign = integer.left(1)
integer = integer.substr(1)
var grouped: String = ""
while integer.length() > 3:
grouped = " " + integer.right(3) + grouped
integer = integer.left(integer.length() - 3)
grouped = sign + integer + grouped
return grouped + ("." + parts[1] if parts.size() > 1 else "")
var exp_value = int(floor(log(abs(value)) / log(10.0))) var exp_value = int(floor(log(abs(value)) / log(10.0)))
var mantissa = value / pow(10.0, exp_value) var mantissa = value / pow(10.0, exp_value)
+49 -64
View File
@@ -5,7 +5,7 @@ extends Node
const SETTING_SECTION := "Settings" const SETTING_SECTION := "Settings"
const SAVE_PATH = "user://settings.cfg" const SAVE_PATH = "user://settings.cfg"
const FORMAT_VERSION: int = 1 const FORMAT_VERSION: int = 2
const SimulationTime = preload("res://world/owniverse/simulation_time.gd") const SimulationTime = preload("res://world/owniverse/simulation_time.gd")
const STATE_VERSION: String = SimulationTime.STATE_VERSION const STATE_VERSION: String = SimulationTime.STATE_VERSION
var last_error: String = "" var last_error: String = ""
@@ -113,44 +113,44 @@ func _plain_tree(value: Variant) -> bool:
## Return a diagnostic path on failure; an empty string means the structure is valid. ## Return a diagnostic path on failure; an empty string means the structure is valid.
func validate_data(data: Variant) -> String: func validate_data(data: Variant) -> String:
if not data is Dictionary or not data.get("state") is Dictionary: if not data is Dictionary or not _plain_tree(data):
return "state must be a Dictionary" return "save must contain plain finite values"
if not _plain_tree(data): if data.get("format_version") != FORMAT_VERSION or not data.get("app_version") is String:
return "unsupported value or non-finite number" return "unsupported save format"
# Legacy files contained only {state}; accept their known state schema. if not data.get("ui") is Dictionary or not data.ui.get("current_page") is int or data.ui.current_page < 0:
if data.has("format_version") and (not data.format_version is int or data.format_version != FORMAT_VERSION):
return "unsupported format_version"
if data.has("ui"):
if not data.ui is Dictionary or not data.ui.get("current_page") is int or data.ui.current_page < 0:
return "invalid ui.current_page" return "invalid ui.current_page"
if not data.get("state") is Dictionary:
return "state must be a Dictionary"
var saved: Dictionary = data.state var saved: Dictionary = data.state
if saved.get("version") not in [STATE_VERSION, SimulationTime.LEGACY_VERSION]: var required := [
"version", "time", "speed", "speed_in_year", "energy", "batches", "batch_origins",
"binds", "amount", "distribution", "auto_resources", "auto_star_deficits",
"production_remainders", "resource_remainders", "evolution_remainders", "life_remainders",
"planet_remainders", "blast_remainders", "destruction_remainders", "civilization_remainders",
"civilization_stats", "civilization_homes", "ethics", "stats",
]
if saved.size() != required.size():
return "unsupported state structure"
for field in required:
if not saved.has(field):
return "missing state." + field
if saved.version != STATE_VERSION:
return "unsupported state.version" return "unsupported state.version"
if not saved.get("time") is Dictionary: if not saved.time is Dictionary or not _number(saved.time.get("datetime")) or not _number(saved.time.get("year")):
return "state.time must be a Dictionary"
if not _number(saved.time.get("datetime")) or not _number(saved.time.get("year")):
return "invalid state.time" return "invalid state.time"
if not is_finite(float(saved.time.datetime) * 10.0): if not is_finite(float(saved.time.datetime) * 10.0) or saved.time.year != SimulationTime.processed_year(saved.time.datetime):
return "state.time exceeds supported precision range" return "invalid processed time"
var expected_year: float = SimulationTime.processed_year(saved.time.datetime) if not _number(saved.speed, true) or saved.speed < 1 or saved.speed > Global.SPEED_LIMIT:
if saved.version == SimulationTime.LEGACY_VERSION:
expected_year = floorf(saved.time.datetime)
if saved.time.year != expected_year:
return "state.time.year does not match the processed time for this version"
if not _number(saved.get("speed"), true) or saved.speed < 1 or saved.speed > Global.SPEED_LIMIT:
return "invalid state.speed" return "invalid state.speed"
if not _number(saved.get("speed_in_year")) or saved.speed_in_year != pow(10.0, saved.speed - 1): if not _number(saved.speed_in_year) or saved.speed_in_year != pow(10.0, saved.speed - 1):
return "invalid state.speed_in_year" return "invalid state.speed_in_year"
if not _number(saved.get("energy"), true): if not _number(saved.energy, true) or not _number_map(saved.amount, true):
return "invalid state.energy" return "invalid state resources"
if not _number_map(saved.get("amount"), true): if not saved.stats is Dictionary or not _number_map(saved.stats.get("total_amount"), true):
return "invalid state.amount"
if not saved.get("stats") is Dictionary or not _number_map(saved.stats.get("total_amount"), true):
return "invalid state.stats.total_amount" return "invalid state.stats.total_amount"
for id in saved.amount: if saved.amount.keys() != saved.stats.total_amount.keys():
if not saved.stats.total_amount.has(id): return "amount and total_amount keys differ"
return "missing total_amount for " + id if not saved.binds is Dictionary or not saved.batches is Dictionary:
if not saved.get("binds") is Dictionary or not saved.get("batches") is Dictionary:
return "state.binds and state.batches must be Dictionaries" return "state.binds and state.batches must be Dictionaries"
for owner_id in saved.binds: for owner_id in saved.binds:
if not saved.amount.has(owner_id) or not _number_map(saved.binds[owner_id], true): if not saved.amount.has(owner_id) or not _number_map(saved.binds[owner_id], true):
@@ -162,57 +162,42 @@ func validate_data(data: Variant) -> String:
if not saved.amount.has(id) or not saved.batches[id] is Array: if not saved.amount.has(id) or not saved.batches[id] is Array:
return "invalid state.batches." + id return "invalid state.batches." + id
for batch in saved.batches[id]: for batch in saved.batches[id]:
if not batch is Dictionary: if not batch is Dictionary or not _number(batch.get("amount"), true) or not _number(batch.get("start_year")) or not _number(batch.get("end_year")):
return "batch must be a Dictionary"
if not _number(batch.get("amount"), true) or not _number(batch.get("start_year")) or not _number(batch.get("end_year")):
return "invalid batch in " + id return "invalid batch in " + id
if batch.end_year < batch.start_year: if batch.end_year < batch.start_year:
return "reversed batch interval in " + id return "reversed batch interval in " + id
if batch.has("created_from") or batch.has("created_to"): if batch.has("created_from") != batch.has("created_to"):
if not _number(batch.get("created_from")) or not _number(batch.get("created_to")): return "incomplete batch creation interval in " + id
if batch.has("created_from") and (not _number(batch.created_from) or not _number(batch.created_to) or batch.created_to <= batch.created_from or batch.start_year < batch.created_from):
return "invalid batch creation interval in " + id return "invalid batch creation interval in " + id
if batch.created_to <= batch.created_from or batch.start_year < batch.created_from: if batch.has("processed_until") and (not _number(batch.processed_until) or batch.processed_until < batch.start_year or batch.processed_until > batch.end_year):
return "reversed batch creation interval in " + id
if batch.has("processed_until"):
if not _number(batch.processed_until) or batch.processed_until < batch.start_year or batch.processed_until > batch.end_year:
return "invalid batch.processed_until in " + id return "invalid batch.processed_until in " + id
if batch.has("withdraw_remainder") and (not _number(batch.withdraw_remainder) or batch.withdraw_remainder > batch.amount): if batch.has("withdraw_remainder") and (not _number(batch.withdraw_remainder) or batch.withdraw_remainder > batch.amount):
return "invalid batch.withdraw_remainder in " + id return "invalid batch.withdraw_remainder in " + id
for field in ["batch_origins", "distribution", "auto_resources", "production_remainders", "auto_star_deficits", "evolution_remainders", "planet_remainders", "blast_remainders", "destruction_remainders"]: for field in ["batch_origins", "distribution", "production_remainders", "evolution_remainders", "planet_remainders", "blast_remainders", "destruction_remainders", "life_remainders", "civilization_remainders", "civilization_stats"]:
if saved.has(field) and not _number_map(saved[field], field == "auto_resources", field != "auto_star_deficits"): if not _number_map(saved[field], false, false):
return "invalid state." + field return "invalid state." + field
if saved.has("ethics"): if not _number_map(saved.auto_resources, true) or not _number_map(saved.auto_star_deficits, false, false):
if not saved.ethics is Dictionary: return "invalid automatic production state"
return "invalid state.ethics" if not saved.resource_remainders is Dictionary or saved.resource_remainders.keys().size() != 2 or not saved.resource_remainders.has("basic") or not saved.resource_remainders.has("automatic"):
return "invalid state.resource_remainders"
for channel in ["basic", "automatic"]:
if not _number_map(saved.resource_remainders[channel], false, false):
return "invalid state.resource_remainders." + channel
if not saved.ethics is Dictionary or not saved.civilization_homes is Dictionary:
return "invalid civilization state"
for id in saved.ethics: for id in saved.ethics:
if not Global.CivilizationRules.has(id) or not _number_map(saved.ethics[id], true): if not Global.CivilizationRules.has(id) or not _number_map(saved.ethics[id], true):
return "invalid state.ethics." + str(id) return "invalid state.ethics." + str(id)
for ethic in saved.ethics[id]: for ethic in saved.ethics[id]:
if ethic not in ["m", "l", "w", "d", "c", "b"]: if ethic not in ["m", "l", "w", "d", "c", "b"]:
return "invalid ethic " + str(ethic) return "invalid ethic " + str(ethic)
if saved.has("life_version") and saved.life_version not in [1, 2, 3]:
return "invalid state.life_version"
if saved.has("civilization_remainders") and not _number_map(saved.civilization_remainders, false, false):
return "invalid state.civilization_remainders"
if saved.has("civilization_stats") and not _number_map(saved.civilization_stats):
return "invalid state.civilization_stats"
if saved.has("civilization_homes"):
if not saved.civilization_homes is Dictionary:
return "invalid state.civilization_homes"
for id in saved.civilization_homes: for id in saved.civilization_homes:
if not _number_map(saved.civilization_homes[id], true): if not _number_map(saved.civilization_homes[id], true):
return "invalid civilization homes" return "invalid civilization homes"
if saved.has("life_remainders") and not _number_map(saved.life_remainders, false, false):
return "invalid state.life_remainders"
if saved.has("resource_remainders"):
if not saved.resource_remainders is Dictionary:
return "invalid state.resource_remainders"
for channel in ["basic", "automatic"]:
# Signed allocation errors are valid, even though resource balances are whole.
if not _number_map(saved.resource_remainders.get(channel), false, false):
return "invalid state.resource_remainders." + channel
return "" return ""
#endregion #endregion
+6 -6
View File
@@ -86,7 +86,10 @@ var locked_items: Array = []
func set_datetime(value: float): func set_datetime(value: float):
date_time.text = Global.format_number(value, 5) if value != 0.0 else "0.0" # 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"
#region Item manipulations: update and visibility #region Item manipulations: update and visibility
@@ -403,12 +406,9 @@ func load_data() -> bool:
SaveManager.blocked_saves["owniverse"] = true SaveManager.blocked_saves["owniverse"] = true
push_error("Save is missing the current entity: " + id) push_error("Save is missing the current entity: " + id)
return false return false
var complete: Dictionary = owniverse.state.duplicate(true) owniverse.state = restored
complete.merge(restored, true) owniverse.new_state = restored.duplicate(true)
owniverse.state = complete
owniverse.new_state = complete
owniverse.initialize_life_batches() owniverse.initialize_life_batches()
owniverse.migrate_time_precision()
owniverse.refresh_item_amounts() owniverse.refresh_item_amounts()
pending_page = clampi(int(game_data.get("ui", {}).get("current_page", 0)), 0, PAGE_COUNT - 1) pending_page = clampi(int(game_data.get("ui", {}).get("current_page", 0)), 0, PAGE_COUNT - 1)
_restore_page.call_deferred() _restore_page.call_deferred()
+4 -4
View File
@@ -8,14 +8,14 @@ godot --headless --path Owniverse.GI --script res://tests/test_time.gd
Команда выполняется из общей папки проектов. Тесты используют искусственные состояния; сохранение игрока не читается и не перезаписывается. Команда выполняется из общей папки проектов. Тесты используют искусственные состояния; сохранение игрока не читается и не перезаписывается.
Проверяются границы десятых долей года, производство ресурсов на этих границах, накопление дробного остатка, скорости 1–10 000 лет/сек, продолжение после загрузки, недопустимые интервалы и миграция сохранений. Проверяются границы десятых долей года, производство ресурсов на этих границах, накопление дробного остатка, скорости 1–10 000 лет/сек, продолжение после загрузки и недопустимые интервалы.
Схема состояния `0.1.1` хранит: Схема состояния `0.1.1` хранит:
- `time.datetime` — накопленный возраст, включая необработанную дробную часть; - `time.datetime` — накопленный возраст, включая необработанную дробную часть;
- `time.year` — последний рассчитанный возраст с точностью до десятой доли года. - `time.year` — последний рассчитанный возраст с точностью до десятой доли года.
Сохранения `0.1.0` проходят старую проверку целого года. При загрузке стадией игры оставшийся промежуток до последней десятой рассчитывается ровно один раз, после чего применяется версия `0.1.1`. Оставшаяся часть меньше 0,1 года сохраняется. Сохранения используют только актуальную структуру состояния и не поддерживают прежние форматы.
## Согласованные правила эволюции ## Согласованные правила эволюции
@@ -55,7 +55,7 @@ godot --headless --path Owniverse.GI --script res://tests/test_life.gd
Проверяются абиогенез, деградация, повторные попытки без создания дополнительных планет, появление C0.0 на прежней звезде, остатки вероятностей, сохранение/продолжение, однократная инициализация старых планет и отсутствие воскрешения после бластов. Проверяются абиогенез, деградация, повторные попытки без создания дополнительных планет, появление C0.0 на прежней звезде, остатки вероятностей, сохранение/продолжение, однократная инициализация старых планет и отсутствие воскрешения после бластов.
Проверка загрузки этических правил, миграции распределений и отделения космических цивилизаций: Проверка загрузки этических правил, распределений и отделения космических цивилизаций:
```sh ```sh
godot --headless --path Owniverse.GI --script res://tests/test_civilization_state.gd godot --headless --path Owniverse.GI --script res://tests/test_civilization_state.gd
@@ -70,4 +70,4 @@ godot --headless --path Owniverse.GI --script res://tests/test_civilization_stat
godot --headless --path Owniverse.GI --script res://tests/test_civilization_evolution.gd godot --headless --path Owniverse.GI --script res://tests/test_civilization_evolution.gd
``` ```
Проверяются реальные коэффициенты C0.2, войны и побочный ущерб C2.0, замена планет колониями с сохранением хозяина, дипломатическое влияние, бонус периода от структур и деградация при их отсутствии. Ускоренная модель проходит все стадии C0.2 → C4.4 → Ct. Проверки контролируют сохранение населения, целочисленность этик, валидацию сохранения и детерминированное продолжение после сериализации. Миграция часов цивилизаций использует `life_version = 3`. Проверяются реальные коэффициенты C0.2, войны и побочный ущерб C2.0, замена планет колониями с сохранением хозяина, дипломатическое влияние, бонус периода от структур и деградация при их отсутствии. Ускоренная модель проходит все стадии C0.2 → C4.4 → Ct. Проверки контролируют сохранение населения, целочисленность этик, валидацию сохранения и детерминированное продолжение после сериализации.
+1 -1
View File
@@ -52,7 +52,7 @@ func invariants(world) -> void:
check(sum_ethics(world, id) == physical.get(id, 0.0), "Ethics equal physical population for " + id) check(sum_ethics(world, id) == physical.get(id, 0.0), "Ethics equal physical population for " + id)
for n in world.state.ethics[id].values(): for n in world.state.ethics[id].values():
if n < 0.0 or n != floorf(n): check(false, "Invalid ethic amount") if n < 0.0 or n != floorf(n): check(false, "Invalid ethic amount")
check(root.get_node("SaveManager").validate_data({"state": world.state}) == "", "Civilization state validates") check(root.get_node("SaveManager").validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Civilization state validates")
func run() -> void: func run() -> void:
var global: Node = root.get_node("Global") var global: Node = root.get_node("Global")
+2 -2
View File
@@ -58,7 +58,7 @@ func run() -> void:
world.state.amount["C0.2"] = 3.0 world.state.amount["C0.2"] = 3.0
world.initialize_life_batches() world.initialize_life_batches()
check(world.state.ethics["C0.2"].m == 3.0, "Losses reduce ethics without inventing population") check(world.state.ethics["C0.2"].m == 3.0, "Losses reduce ethics without inventing population")
check(root.get_node("SaveManager").validate_data({"state": world.state}) == "", "Ethics state validates") check(root.get_node("SaveManager").validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Ethics state validates")
var restored = fixture() var restored = fixture()
restored.state = bytes_to_var(var_to_bytes(world.state)) restored.state = bytes_to_var(var_to_bytes(world.state))
restored.initialize_life_batches() restored.initialize_life_batches()
@@ -116,7 +116,7 @@ func run() -> void:
world.initialize_life_batches() world.initialize_life_batches()
check(world.state.amount["C4.2"] == 2.0, "SMBH capacity uses stellar mass equivalent") check(world.state.amount["C4.2"] == 2.0, "SMBH capacity uses stellar mass equivalent")
check(world._physical_amounts(world.state)["C1.9"] == 1.0, "Unsupported civilization degrades through missing habitat families") check(world._physical_amounts(world.state)["C1.9"] == 1.0, "Unsupported civilization degrades through missing habitat families")
check(root.get_node("SaveManager").validate_data({"state": world.state}) == "", "Capacity-limited state validates") check(root.get_node("SaveManager").validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Capacity-limited state validates")
dispose(world) dispose(world)
world = fixture() world = fixture()
+58
View File
@@ -0,0 +1,58 @@
extends SceneTree
const Clock = preload("res://world/owniverse/simulation_time.gd")
var checks: int = 0
var failures: int = 0
func _initialize() -> void:
call_deferred("run")
func check(condition: bool, message: String) -> void:
checks += 1
if not condition:
failures += 1
push_error(message)
func fixture():
var world = load("res://world/owniverse/owniverse.gd").new()
var global: Node = root.get_node("Global")
for id in global.OwniverseEntities:
var entity = load("res://world/owniverse/owniverse_entity.gd").new(global.OwniverseEntities[id])
world.entities[id] = entity
world.state.amount[id] = 0.0
world.state.stats.total_amount[id] = 0.0
if entity.group_as != "":
world.state.amount[entity.group_as] = 0.0
world.state.stats.total_amount[entity.group_as] = 0.0
world.entities["N"].life_product = {}
world.entities["N"].product_per_year_type = "H"
world.entities["N"].product_per_year_amount = 10.0
world.entities["N"].auto = 0.0
world.state.amount.N = 1.0
return world
func dispose(world) -> void:
for entity in world.entities.values():
entity.free()
world.free()
func run() -> void:
var world = fixture()
for id in world.entities:
var expected: Array = []
for key in world.entities:
if world.entities[key].group_as == id:
expected.append(key)
check(world._is_group(id) == (not expected.is_empty()), "Group classification: " + id)
check(world._members(id) == (expected if not expected.is_empty() else [id]), "Group members: " + id)
var members: Array = world._members("Star")
members.clear()
check(not world._members("Star").is_empty(), "Returned members are independent")
world.entities.N.group_as = "TestGroup"
world.rebuild_group_index()
check(world._members("TestGroup") == ["N"], "Explicit definition reload")
world.entities.N.free()
world.entities.erase("N")
check(not world._is_group("TestGroup"), "Removed definition invalidates index")
print("Group index: %d checks, %d failures" % [checks, failures])
dispose(world)
quit(1 if failures else 0)
+1
View File
@@ -0,0 +1 @@
uid://bykrkembtacbw
+4 -5
View File
@@ -53,7 +53,7 @@ func run() -> void:
check(world.state.batches.HP.size() > 0, "Survivors get next attempt") check(world.state.batches.HP.size() > 0, "Survivors get next attempt")
var physical: Dictionary = world._physical_amounts(world.state) var physical: Dictionary = world._physical_amounts(world.state)
check(physical.HP + physical.EON1 + physical.RP == 40000.0, "Life conserves planets") check(physical.HP + physical.EON1 + physical.RP == 40000.0, "Life conserves planets")
check(root.get_node("SaveManager").validate_data({"state": world.state}) == "", "Life save validates") check(root.get_node("SaveManager").validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Life save validates")
var restored = fixture() var restored = fixture()
restored.state = bytes_to_var(var_to_bytes(world.state)) restored.state = bytes_to_var(var_to_bytes(world.state))
world._evolve_batches(world.state, 11e6) world._evolve_batches(world.state, 11e6)
@@ -100,13 +100,12 @@ func run() -> void:
dispose(world) dispose(world)
world = fixture() world = fixture()
world.state.time = {"datetime": 123.4, "year": 123.4} world.state.time = {"datetime": 123.4, "year": 123.4}
world.state.life_version = 1
world.state.amount["C0.0"] = 40000.0 world.state.amount["C0.0"] = 40000.0
world.initialize_life_batches() world.initialize_life_batches()
check(world.state.life_version == 3 and world.state.batches["C0.0"][0].created_from == 123.4, "Previous life saves receive missing civilization cohorts") check(world.state.batches["C0.0"][0].created_from == 123.4, "Civilization cohorts begin at the current time")
before = world.state.duplicate(true) before = world.state.duplicate(true)
world.initialize_life_batches() world.initialize_life_batches()
check(world.state == before, "Second life migration is idempotent") check(world.state == before, "Life batch initialization is idempotent")
world._evolve_batches(world.state, 2173.4) world._evolve_batches(world.state, 2173.4)
check(world.state.amount["C0.1"] == 700.0 and world.state.amount.EON5 == 1000.0, "C0.0 progresses and degrades by Unity rates") check(world.state.amount["C0.1"] == 700.0 and world.state.amount.EON5 == 1000.0, "C0.0 progresses and degrades by Unity rates")
check(world.state.amount["C0.0"] == 38300.0, "C0.0 remainder stays alive") check(world.state.amount["C0.0"] == 38300.0, "C0.0 remainder stays alive")
@@ -118,7 +117,7 @@ func run() -> void:
world._evolve_batches(world.state, 2050.0) world._evolve_batches(world.state, 2050.0)
check(world.state.binds.RD["C0.2"] == 700.0, "C0.1 reaches ethical civilization without losing host") check(world.state.binds.RD["C0.2"] == 700.0, "C0.1 reaches ethical civilization without losing host")
check(world.state.binds.RD["C0.0"] == 1000.0, "C0.1 degradation follows Unity") check(world.state.binds.RD["C0.0"] == 1000.0, "C0.1 degradation follows Unity")
check(root.get_node("SaveManager").validate_data({"state": world.state}) == "", "Extended life save validates") check(root.get_node("SaveManager").validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Extended life save validates")
dispose(world) dispose(world)
print("Life: %d checks, %d failures" % [checks, failures]) print("Life: %d checks, %d failures" % [checks, failures])
quit(0 if failures == 0 else 1) quit(0 if failures == 0 else 1)
+58
View File
@@ -0,0 +1,58 @@
extends SceneTree
const Clock = preload("res://world/owniverse/simulation_time.gd")
var checks: int = 0
var failures: int = 0
func _initialize() -> void:
call_deferred("run")
func check(condition: bool, message: String) -> void:
checks += 1
if not condition:
failures += 1
push_error(message)
func fixture():
var world = load("res://world/owniverse/owniverse.gd").new()
var global: Node = root.get_node("Global")
for id in global.OwniverseEntities:
var entity = load("res://world/owniverse/owniverse_entity.gd").new(global.OwniverseEntities[id])
world.entities[id] = entity
world.state.amount[id] = 0.0
world.state.stats.total_amount[id] = 0.0
if entity.group_as != "":
world.state.amount[entity.group_as] = 0.0
world.state.stats.total_amount[entity.group_as] = 0.0
world.entities["N"].life_product = {}
world.entities["N"].product_per_year_type = "H"
world.entities["N"].product_per_year_amount = 10.0
world.entities["N"].auto = 0.0
world.state.amount.N = 1.0
return world
func dispose(world) -> void:
for entity in world.entities.values():
entity.free()
world.free()
func run() -> void:
var world = fixture()
world.initialize_life_batches()
world.entities.N.cost = {"H": 2.0}
world.entities.N.bind = {"S": 3.0}
world.state.amount.H = 10.0
world.state.amount.S = 0.0
world.state.auto_resources = {"H": 10.0}
world.state.batches.H = [{"amount": 10.0, "start_year": 1.0, "end_year": 2.0}]
var before: Dictionary = world.state.duplicate(true)
check(not world._try_production(world.state, world.entities.N, 1.0, true), "Binding failure rejects production")
check(world.state == before, "Failure preserves costs, batches and budget")
world.state.amount.S = 10.0
var old_batches: Array = world.state.batches.H
check(world._try_production(world.state, world.entities.N, 2.0, true), "Funded production succeeds")
check(world.state.amount.H == 6.0 and world.state.amount.S == 4.0, "Exact cost and binding")
check(world.state.auto_resources.H == 6.0, "Exact automatic budget")
check(old_batches[0].amount == 10.0 and world.state.batches.H[0].amount == 6.0, "Consumed batches isolated")
print("Transaction: %d checks, %d failures" % [checks, failures])
dispose(world)
quit(1 if failures else 0)
+1
View File
@@ -0,0 +1 @@
uid://byjixspismsnh
+3 -3
View File
@@ -84,7 +84,7 @@ func run() -> void:
check(world.state.binds.RD.GG == 16.0 and world.state.binds.RD.AB == 120.0, "Planet multiplicity applied") check(world.state.binds.RD.GG == 16.0 and world.state.binds.RD.AB == 120.0, "Planet multiplicity applied")
check(world.state.amount.HP == 0.0 and world.state.stats.total_amount.HP == 1.0, "Created planets are bound, counted once") check(world.state.amount.HP == 0.0 and world.state.stats.total_amount.HP == 1.0, "Created planets are bound, counted once")
var saves: Node = root.get_node("SaveManager") var saves: Node = root.get_node("SaveManager")
check(saves.validate_data({"state": world.state}) == "", "Planet carry save validates") check(saves.validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Planet carry save validates")
restored = fixture() restored = fixture()
restored.state = bytes_to_var(var_to_bytes(world.state)) restored.state = bytes_to_var(var_to_bytes(world.state))
world._create_planets(world.state, "RD", 37.0, 0.0, 0.0, 0.7) world._create_planets(world.state, "RD", 37.0, 0.0, 0.0, 0.7)
@@ -106,7 +106,7 @@ func run() -> void:
check(world.state.amount.RP == 0.0 and world.state.amount["C1.3"] == 0.0, "Inherited planets never become free") check(world.state.amount.RP == 0.0 and world.state.amount["C1.3"] == 0.0, "Inherited planets never become free")
world._evolve_batches(world.state, lifespan + width) world._evolve_batches(world.state, lifespan + width)
check(world.state.binds.RD["C1.3"] == 0.0 and world.state.binds.TWD["C1.3"] == 4.0, "No planets left at extinct parent type") check(world.state.binds.RD["C1.3"] == 0.0 and world.state.binds.TWD["C1.3"] == 4.0, "No planets left at extinct parent type")
check(saves.validate_data({"state": world.state}) == "", "Inherited state validates") check(saves.validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Inherited state validates")
dispose(world) dispose(world)
world = fixture() world = fixture()
@@ -146,7 +146,7 @@ func run() -> void:
check(world.state.amount.SN == 0.0 and world.state.amount.LNS == 1.0, "Supernova produces a remnant") check(world.state.amount.SN == 0.0 and world.state.amount.LNS == 1.0, "Supernova produces a remnant")
check(world.state.amount.H == 8.0 and world.state.amount.He == 50.0 and world.state.amount.Met == 11.0, "Supernova returns table resources") check(world.state.amount.H == 8.0 and world.state.amount.He == 50.0 and world.state.amount.Met == 11.0, "Supernova returns table resources")
check(not world.state.amount.has("blast"), "Supernova dispatches blast output") check(not world.state.amount.has("blast"), "Supernova dispatches blast output")
check(saves.validate_data({"state": world.state}) == "", "Blast state validates") check(saves.validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Blast state validates")
dispose(world) dispose(world)
world = fixture() world = fixture()
+3 -22
View File
@@ -50,10 +50,10 @@ func run() -> void:
for invalid in [0.0, -1.0, NAN, INF, 1e308]: for invalid in [0.0, -1.0, NAN, INF, 1e308]:
world.evolute(invalid) world.evolute(invalid)
check(world.state == before, "Invalid/overflow interval rejected") check(world.state == before, "Invalid/overflow interval rejected")
check(saves.validate_data({"state": world.state}) == "", "Fractional save is valid") check(saves.validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Fractional save is valid")
var invalid_save: Dictionary = world.state.duplicate(true) var invalid_save: Dictionary = world.state.duplicate(true)
invalid_save.time.year = 0.25 invalid_save.time.year = 0.25
check(saves.validate_data({"state": invalid_save}) != "", "Reject inconsistent fractional clock") check(saves.validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": invalid_save}) != "", "Reject inconsistent fractional clock")
dispose(world) dispose(world)
for fps in [30, 60, 144]: for fps in [30, 60, 144]:
@@ -76,26 +76,7 @@ func run() -> void:
dispose(world) dispose(world)
world = fixture() world = fixture()
world.state.version = "0.1.0"
world.state.time = {"datetime": 0.45, "year": 0.0}
check(saves.validate_data({"state": world.state}) == "", "Accept legacy integer-year save")
world.migrate_time_precision()
check(world.state.version == "0.1.1", "Migrate schema version")
check(world.state.time.year == 0.4 and world.state.time.datetime == 0.45, "Keep unprocessed sub-tenth remainder")
check(world.state.amount.H == 4.0, "Migration processes pending legacy time")
check(saves.validate_data({"state": world.state}) == "", "Migrated state validates")
before = world.state.duplicate(true)
world.migrate_time_precision()
check(world.state == before, "Migration is idempotent")
# Verify in-memory save roundtrip without touching the player's save.
var restored = fixture()
restored.state = bytes_to_var(var_to_bytes(world.state))
world.evolute(0.05)
restored.evolute(0.05)
check(world.state == restored.state, "Save continuation preserves time and production")
check(world.state.time.year == 0.5 and world.state.amount.H == 5.0, "No double-counted migration time")
dispose(world) dispose(world)
dispose(restored)
world = fixture() world = fixture()
world.entities.N.output_and = {"He": 1.0} world.entities.N.output_and = {"He": 1.0}
@@ -106,5 +87,5 @@ func run() -> void:
dispose(world) dispose(world)
check(Clock.processed_year(0.099) == 0.0, "Do not round a genuine incomplete tenth forward") check(Clock.processed_year(0.099) == 0.0, "Do not round a genuine incomplete tenth forward")
check(Clock.processed_year(1e9 + 0.45) == 1e9 + 0.4, "Large ages preserve fractional precision") check(Clock.processed_year(1e9 + 0.45) == 1e9 + 0.4, "Large ages preserve fractional precision")
print("Time migration: %d checks, %d failures" % [checks, failures]) print("Time state: %d checks, %d failures" % [checks, failures])
quit(0 if failures == 0 else 1) quit(0 if failures == 0 else 1)
+76 -32
View File
@@ -10,6 +10,9 @@ var entities: Dictionary[String, OwniverseEntity] = {}
var entity_groups: Dictionary[String, Array] = {} var entity_groups: Dictionary[String, Array] = {}
var entity_event_groups: Dictionary[String, Array] = {} var entity_event_groups: Dictionary[String, Array] = {}
var _group_members: Dictionary = {}
var _indexed_entity_count: int = -1
var items_to_update: Dictionary[String, float] = {} var items_to_update: Dictionary[String, float] = {}
var auto_production: Array = [] var auto_production: Array = []
@@ -32,6 +35,18 @@ var state: Dictionary = {
'amount': {}, 'amount': {},
'distribution': {}, 'distribution': {},
'auto_resources': {}, 'auto_resources': {},
'auto_star_deficits': {},
'production_remainders': {},
'resource_remainders': {'basic': {}, 'automatic': {}},
'evolution_remainders': {},
'life_remainders': {},
'planet_remainders': {},
'blast_remainders': {},
'destruction_remainders': {},
'civilization_remainders': {},
'civilization_stats': {},
'civilization_homes': {},
'ethics': {},
'stats': { 'stats': {
'total_amount': {}, 'total_amount': {},
} }
@@ -57,19 +72,9 @@ func evolute(delta: float) -> void:
_evolute_to_datetime(datetime) _evolute_to_datetime(datetime)
## Legacy saves may have up to 0.9 unprocessed years. Process them exactly once,
## without changing accumulated time or discarding production/evolution.
func migrate_time_precision() -> void:
if state.version == SimulationTime.LEGACY_VERSION:
_evolute_to_datetime(float(state.time.datetime))
# Old saves have counts but no life ages. Start only missing cohorts at load time.
func initialize_life_batches() -> void: func initialize_life_batches() -> void:
CivilizationState.detach_space_civilizations(state, Global.CivilizationRules) CivilizationState.detach_space_civilizations(state, Global.CivilizationRules)
CivilizationState.enforce_capacity(self, state, Global.CivilizationRules, state.time.datetime) CivilizationState.enforce_capacity(self, state, Global.CivilizationRules, state.time.datetime)
if state.get("life_version", 0) >= 3:
return
var physical: Dictionary = _physical_amounts(state) var physical: Dictionary = _physical_amounts(state)
for id in entities: for id in entities:
if entities[id].life_rule.is_empty(): if entities[id].life_rule.is_empty():
@@ -78,11 +83,14 @@ func initialize_life_batches() -> void:
for batch: Dictionary in state.batches.get(id, []): for batch: Dictionary in state.batches.get(id, []):
tracked += batch.amount tracked += batch.amount
_append_batch(state, id, maxf(0.0, physical.get(id, 0.0) - tracked), state.time.datetime, state.time.datetime) _append_batch(state, id, maxf(0.0, physical.get(id, 0.0) - tracked), state.time.datetime, state.time.datetime)
state.life_version = 3
new_state = state new_state = state
func _evolute_to_datetime(datetime: float) -> void: func _evolute_to_datetime(datetime: float) -> void:
# Reconcile only when simulation advances.
# Fractional frame updates cannot change capacity or populations.
var processed_year: float = SimulationTime.processed_year(datetime)
if processed_year > state.time.year:
initialize_life_batches() initialize_life_batches()
var previous: Dictionary = state var previous: Dictionary = state
var next: Dictionary = previous.duplicate_deep() var next: Dictionary = previous.duplicate_deep()
@@ -90,7 +98,7 @@ func _evolute_to_datetime(datetime: float) -> void:
next.speed = clampi(int(previous.speed), 1, Global.SPEED_LIMIT) next.speed = clampi(int(previous.speed), 1, Global.SPEED_LIMIT)
next.speed_in_year = pow(10.0, next.speed - 1) next.speed_in_year = pow(10.0, next.speed - 1)
var from_year: float = previous.time.year var from_year: float = previous.time.year
var to_year: float = SimulationTime.processed_year(datetime) var to_year: float = processed_year
if to_year > from_year: if to_year > from_year:
var quantities: Dictionary = _physical_amounts(previous) var quantities: Dictionary = _physical_amounts(previous)
var distribution: Dictionary = _normalize_distribution(quantities) var distribution: Dictionary = _normalize_distribution(quantities)
@@ -147,13 +155,26 @@ func _add_time_delta(delta: float) -> Dictionary:
return {"datetime": new_datetime, "year": SimulationTime.processed_year(new_datetime)} return {"datetime": new_datetime, "year": SimulationTime.processed_year(new_datetime)}
## Rebuild after changing group_as or replacing definitions without changing their count.
## Adding/removing definitions is detected automatically during initialization.
func rebuild_group_index() -> void:
_group_members.clear()
for id in entities:
var group: String = entities[id].group_as
if not _group_members.has(group):
_group_members[group] = []
_group_members[group].append(id)
_indexed_entity_count = entities.size()
func _ensure_group_index() -> void:
if _indexed_entity_count != entities.size():
rebuild_group_index()
func _is_group(id: String) -> bool: func _is_group(id: String) -> bool:
if entity_groups.has(id): _ensure_group_index()
return true return entity_groups.has(id) or _group_members.has(id)
for entity: OwniverseEntity in entities.values():
if entity.group_as == id:
return true
return false
func _physical_amounts(source: Dictionary) -> Dictionary: func _physical_amounts(source: Dictionary) -> Dictionary:
@@ -171,11 +192,8 @@ func _physical_amounts(source: Dictionary) -> Dictionary:
func _members(id: String) -> Array: func _members(id: String) -> Array:
if not _is_group(id): if not _is_group(id):
return [id] return [id]
var members: Array = [] # Callers may modify the returned array without changing the index.
for key in entities: return _group_members.get(id, []).duplicate()
if entities[key].group_as == id:
members.append(key)
return members
func _available(source: Dictionary, id: String) -> float: func _available(source: Dictionary, id: String) -> float:
@@ -342,6 +360,12 @@ func _append_batch(target: Dictionary, id: String, amount: float,
var window_index: float = floorf((cursor - origin) / batch_duration) var window_index: float = floorf((cursor - origin) / batch_duration)
if origin + (window_index + 1.0) * batch_duration <= cursor: if origin + (window_index + 1.0) * batch_duration <= cursor:
window_index += 1.0 window_index += 1.0
var batches_by_start: Dictionary = {}
for existing: Dictionary in batches:
var start: float = existing.get("created_from", -INF)
if not batches_by_start.has(start):
batches_by_start[start] = []
batches_by_start[start].append(existing)
while true: while true:
var window_start: float = origin + window_index * batch_duration var window_start: float = origin + window_index * batch_duration
var window_end: float = origin + (window_index + 1.0) * batch_duration var window_end: float = origin + (window_index + 1.0) * batch_duration
@@ -353,7 +377,7 @@ func _append_batch(target: Dictionary, id: String, amount: float,
if duration > 0.0 and part_end < created_to: if duration > 0.0 and part_end < created_to:
part_amount = floorf(amount * (part_end - created_from) / duration) - (amount - remaining_amount) part_amount = floorf(amount * (part_end - created_from) / duration) - (amount - remaining_amount)
var batch: Dictionary = {} var batch: Dictionary = {}
for existing: Dictionary in batches: for existing: Dictionary in batches_by_start.get(window_start, []):
if existing.get("created_from", -INF) == window_start and existing.start_year == window_start + lifespan and existing.end_year == window_end + lifespan: if existing.get("created_from", -INF) == window_start and existing.start_year == window_start + lifespan and existing.end_year == window_end + lifespan:
batch = existing batch = existing
break break
@@ -366,6 +390,9 @@ func _append_batch(target: Dictionary, id: String, amount: float,
"amount": 0.0, "amount": 0.0,
} }
batches.append(batch) batches.append(batch)
if not batches_by_start.has(window_start):
batches_by_start[window_start] = []
batches_by_start[window_start].append(batch)
batch.amount += part_amount batch.amount += part_amount
remaining_amount -= part_amount remaining_amount -= part_amount
if duration == 0.0 or part_end >= created_to: if duration == 0.0 or part_end >= created_to:
@@ -548,12 +575,13 @@ func _produce_newborn_lifetime(target: Dictionary, id: String, count: float,
if products.is_empty() or count <= 0.0: if products.is_empty() or count <= 0.0:
return return
var object_years: float = count * maxf(0.0, to_year - (born_from + born_to) * 0.5) var object_years: float = count * maxf(0.0, to_year - (born_from + born_to) * 0.5)
for batch: Dictionary in target.batches.get(id, []): var previous_amounts: Dictionary = {}
var added: float = batch.amount
for old: Dictionary in before_batches: for old: Dictionary in before_batches:
if old.get("created_from", -INF) == batch.get("created_from", INF): var start: float = old.get("created_from", -INF)
added -= old.amount if not previous_amounts.has(start):
break previous_amounts[start] = old.amount
for batch: Dictionary in target.batches.get(id, []):
var added: float = batch.amount - previous_amounts.get(batch.get("created_from", INF), 0.0)
object_years -= maxf(0.0, added) * _expired_years(to_year, batch.start_year, batch.end_year) object_years -= maxf(0.0, added) * _expired_years(to_year, batch.start_year, batch.end_year)
object_years = maxf(0.0, object_years) object_years = maxf(0.0, object_years)
for resource in products: for resource in products:
@@ -623,7 +651,10 @@ func _evolve_batches(target: Dictionary, to_year: float) -> void:
func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool: func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool:
var quantities: Dictionary = _physical_amounts(target) var quantities: Dictionary = _physical_amounts(target)
var expired: Dictionary = {} var expired: Dictionary = {}
var preview: Dictionary = target.duplicate(true) var preview: Dictionary = {"batches": {}}
for id in target.batches:
if entities.has(id) and entities[id].destroy_level > 0:
preview.batches[id] = target.batches[id].duplicate(true)
for id in target.batches.keys(): for id in target.batches.keys():
if not entities.has(id) or entities[id].destroy_level <= 0: if not entities.has(id) or entities[id].destroy_level <= 0:
continue continue
@@ -741,7 +772,11 @@ func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool:
# Plan all bindings on a copy, reserving specific types before broader groups. # Plan all bindings on a copy, reserving specific types before broader groups.
func _bind_full(target: Dictionary, amounts: Dictionary) -> bool: func _bind_full(target: Dictionary, amounts: Dictionary) -> bool:
var trial: Dictionary = target.duplicate(true) # Binding only mutates free amounts and owners' holdings.
var trial: Dictionary = {
"amount": target.amount.duplicate(),
"binds": target.binds.duplicate(true),
}
var requests: Array = [] var requests: Array = []
for owner_id in amounts: for owner_id in amounts:
if not entities.has(owner_id): if not entities.has(owner_id):
@@ -777,7 +812,12 @@ func _try_production(target: Dictionary, entity: OwniverseEntity, amount: float,
automatic: bool = false) -> bool: automatic: bool = false) -> bool:
if amount <= 0.0 or amount != floorf(amount): if amount <= 0.0 or amount != floorf(amount):
return false return false
var trial: Dictionary = target.duplicate(true) var trial: Dictionary = target.duplicate()
trial.amount = target.amount.duplicate()
trial.binds = target.binds.duplicate(true)
trial.auto_resources = target.auto_resources.duplicate()
trial.batches = target.batches.duplicate()
var copied_batches: Dictionary = {}
for resource in entity.cost: for resource in entity.cost:
var cost: float = roundf(maxf(0.0, entity.cost[resource]) * amount) var cost: float = roundf(maxf(0.0, entity.cost[resource]) * amount)
if automatic and trial.auto_resources.get(resource, 0.0) < cost: if automatic and trial.auto_resources.get(resource, 0.0) < cost:
@@ -792,6 +832,10 @@ func _try_production(target: Dictionary, entity: OwniverseEntity, amount: float,
if automatic: if automatic:
trial.auto_resources[resource] = maxf(0.0, trial.auto_resources.get(resource, 0.0) - cost) trial.auto_resources[resource] = maxf(0.0, trial.auto_resources.get(resource, 0.0) - cost)
for id in taken: for id in taken:
# Isolate only batches actually touched by this transaction.
if trial.batches.has(id) and not copied_batches.has(id):
trial.batches[id] = trial.batches[id].duplicate(true)
copied_batches[id] = true
var factor: float = 1.0 - taken[id] / before[id] if before.get(id, 0.0) > 0.0 else 0.0 var factor: float = 1.0 - taken[id] / before[id] if before.get(id, 0.0) > 0.0 else 0.0
_scale_batches(trial, id, factor) _scale_batches(trial, id, factor)
if not _bind_new_owners(trial, {entity.id: amount}, true): if not _bind_new_owners(trial, {entity.id: amount}, true):
-1
View File
@@ -1,7 +1,6 @@
extends RefCounted extends RefCounted
## datetime is accumulated time; year is the last processed tenth of a year. ## datetime is accumulated time; year is the last processed tenth of a year.
const STATE_VERSION: String = "0.1.1" const STATE_VERSION: String = "0.1.1"
const LEGACY_VERSION: String = "0.1.0"
static func processed_year(datetime: float) -> float: static func processed_year(datetime: float) -> float:
# Remove only sub-nanotick accumulation noise at decimal boundaries. # Remove only sub-nanotick accumulation noise at decimal boundaries.