diff --git a/autoload/global.gd b/autoload/global.gd index 7ce7a15..5746961 100644 --- a/autoload/global.gd +++ b/autoload/global.gd @@ -59,8 +59,23 @@ func get_description(item_id: String, items: Array = []) -> String: func format_number(value: float, digits: int = 0) -> String: if value == 0: return "" - if value < 10_000: - return "%.*f" % [digits, value] + # Keep ordinary quantities readable, grouping thousands with spaces. + # 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 mantissa = value / pow(10.0, exp_value) diff --git a/autoload/save_manager.gd b/autoload/save_manager.gd index 5cd1666..ba5b0f7 100644 --- a/autoload/save_manager.gd +++ b/autoload/save_manager.gd @@ -5,7 +5,7 @@ extends Node const SETTING_SECTION := "Settings" 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 STATE_VERSION: String = SimulationTime.STATE_VERSION 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. 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 "save must contain plain finite values" + if data.get("format_version") != FORMAT_VERSION or not data.get("app_version") is String: + return "unsupported save format" + if not data.get("ui") is Dictionary or not data.ui.get("current_page") is int or data.ui.current_page < 0: + return "invalid ui.current_page" + if not data.get("state") is Dictionary: return "state must be a Dictionary" - if not _plain_tree(data): - return "unsupported value or non-finite number" - # Legacy files contained only {state}; accept their known state schema. - 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" 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" - if not saved.get("time") is Dictionary: - return "state.time must be a Dictionary" - if not _number(saved.time.get("datetime")) or not _number(saved.time.get("year")): + if not saved.time is Dictionary or not _number(saved.time.get("datetime")) or not _number(saved.time.get("year")): return "invalid state.time" - if not is_finite(float(saved.time.datetime) * 10.0): - return "state.time exceeds supported precision range" - var expected_year: float = SimulationTime.processed_year(saved.time.datetime) - 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: + 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: 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" - if not _number(saved.get("energy"), true): - return "invalid state.energy" - if not _number_map(saved.get("amount"), true): - return "invalid state.amount" - if not saved.get("stats") is Dictionary or not _number_map(saved.stats.get("total_amount"), true): + if not _number(saved.energy, true) or not _number_map(saved.amount, true): + return "invalid state resources" + if not saved.stats is Dictionary or not _number_map(saved.stats.get("total_amount"), true): return "invalid state.stats.total_amount" - for id in saved.amount: - if not saved.stats.total_amount.has(id): - return "missing total_amount for " + id - if not saved.get("binds") is Dictionary or not saved.get("batches") is Dictionary: + if saved.amount.keys() != saved.stats.total_amount.keys(): + return "amount and total_amount keys differ" + if not saved.binds is Dictionary or not saved.batches is Dictionary: return "state.binds and state.batches must be Dictionaries" for owner_id in saved.binds: 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: return "invalid state.batches." + id for batch in saved.batches[id]: - if not batch is Dictionary: - 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")): + 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 "invalid batch in " + id if batch.end_year < batch.start_year: return "reversed batch interval in " + id - if batch.has("created_from") or batch.has("created_to"): - if not _number(batch.get("created_from")) or not _number(batch.get("created_to")): - return "invalid batch creation interval in " + id - if batch.created_to <= batch.created_from or batch.start_year < batch.created_from: - 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 + if batch.has("created_from") != batch.has("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 + 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 "invalid batch.processed_until in " + id if batch.has("withdraw_remainder") and (not _number(batch.withdraw_remainder) or batch.withdraw_remainder > batch.amount): 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"]: - if saved.has(field) and not _number_map(saved[field], field == "auto_resources", field != "auto_star_deficits"): + for field in ["batch_origins", "distribution", "production_remainders", "evolution_remainders", "planet_remainders", "blast_remainders", "destruction_remainders", "life_remainders", "civilization_remainders", "civilization_stats"]: + if not _number_map(saved[field], false, false): return "invalid state." + field - if saved.has("ethics"): - if not saved.ethics is Dictionary: - return "invalid state.ethics" - for id in saved.ethics: - if not Global.CivilizationRules.has(id) 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"]: - 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: - if not _number_map(saved.civilization_homes[id], true): - 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 + if not _number_map(saved.auto_resources, true) or not _number_map(saved.auto_star_deficits, false, false): + return "invalid automatic production state" + 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: + if not Global.CivilizationRules.has(id) 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"]: + return "invalid ethic " + str(ethic) + for id in saved.civilization_homes: + if not _number_map(saved.civilization_homes[id], true): + return "invalid civilization homes" return "" + #endregion diff --git a/stage/stage.gd b/stage/stage.gd index a504eef..803e294 100644 --- a/stage/stage.gd +++ b/stage/stage.gd @@ -86,7 +86,10 @@ var locked_items: Array = [] 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 @@ -403,12 +406,9 @@ func load_data() -> bool: SaveManager.blocked_saves["owniverse"] = true push_error("Save is missing the current entity: " + id) return false - var complete: Dictionary = owniverse.state.duplicate(true) - complete.merge(restored, true) - owniverse.state = complete - owniverse.new_state = complete + owniverse.state = restored + owniverse.new_state = restored.duplicate(true) owniverse.initialize_life_batches() - owniverse.migrate_time_precision() owniverse.refresh_item_amounts() pending_page = clampi(int(game_data.get("ui", {}).get("current_page", 0)), 0, PAGE_COUNT - 1) _restore_page.call_deferred() diff --git a/tests/README.md b/tests/README.md index af7192c..4fe941d 100644 --- a/tests/README.md +++ b/tests/README.md @@ -8,14 +8,14 @@ godot --headless --path Owniverse.GI --script res://tests/test_time.gd Команда выполняется из общей папки проектов. Тесты используют искусственные состояния; сохранение игрока не читается и не перезаписывается. -Проверяются границы десятых долей года, производство ресурсов на этих границах, накопление дробного остатка, скорости 1–10 000 лет/сек, продолжение после загрузки, недопустимые интервалы и миграция сохранений. +Проверяются границы десятых долей года, производство ресурсов на этих границах, накопление дробного остатка, скорости 1–10 000 лет/сек, продолжение после загрузки и недопустимые интервалы. Схема состояния `0.1.1` хранит: - `time.datetime` — накопленный возраст, включая необработанную дробную часть; - `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 на прежней звезде, остатки вероятностей, сохранение/продолжение, однократная инициализация старых планет и отсутствие воскрешения после бластов. -Проверка загрузки этических правил, миграции распределений и отделения космических цивилизаций: +Проверка загрузки этических правил, распределений и отделения космических цивилизаций: ```sh 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 ``` -Проверяются реальные коэффициенты C0.2, войны и побочный ущерб C2.0, замена планет колониями с сохранением хозяина, дипломатическое влияние, бонус периода от структур и деградация при их отсутствии. Ускоренная модель проходит все стадии C0.2 → C4.4 → Ct. Проверки контролируют сохранение населения, целочисленность этик, валидацию сохранения и детерминированное продолжение после сериализации. Миграция часов цивилизаций использует `life_version = 3`. +Проверяются реальные коэффициенты C0.2, войны и побочный ущерб C2.0, замена планет колониями с сохранением хозяина, дипломатическое влияние, бонус периода от структур и деградация при их отсутствии. Ускоренная модель проходит все стадии C0.2 → C4.4 → Ct. Проверки контролируют сохранение населения, целочисленность этик, валидацию сохранения и детерминированное продолжение после сериализации. diff --git a/tests/test_civilization_evolution.gd b/tests/test_civilization_evolution.gd index f96ea5b..2819149 100644 --- a/tests/test_civilization_evolution.gd +++ b/tests/test_civilization_evolution.gd @@ -52,7 +52,7 @@ func invariants(world) -> void: check(sum_ethics(world, id) == physical.get(id, 0.0), "Ethics equal physical population for " + id) for n in world.state.ethics[id].values(): 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: var global: Node = root.get_node("Global") diff --git a/tests/test_civilization_state.gd b/tests/test_civilization_state.gd index 4099645..a37654c 100644 --- a/tests/test_civilization_state.gd +++ b/tests/test_civilization_state.gd @@ -58,7 +58,7 @@ func run() -> void: world.state.amount["C0.2"] = 3.0 world.initialize_life_batches() 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() restored.state = bytes_to_var(var_to_bytes(world.state)) restored.initialize_life_batches() @@ -116,7 +116,7 @@ func run() -> void: world.initialize_life_batches() 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(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) world = fixture() diff --git a/tests/test_group_index.gd b/tests/test_group_index.gd new file mode 100644 index 0000000..deab3f1 --- /dev/null +++ b/tests/test_group_index.gd @@ -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) diff --git a/tests/test_group_index.gd.uid b/tests/test_group_index.gd.uid new file mode 100644 index 0000000..e40e3eb --- /dev/null +++ b/tests/test_group_index.gd.uid @@ -0,0 +1 @@ +uid://bykrkembtacbw diff --git a/tests/test_life.gd b/tests/test_life.gd index d56cd5a..cb5d057 100644 --- a/tests/test_life.gd +++ b/tests/test_life.gd @@ -53,7 +53,7 @@ func run() -> void: check(world.state.batches.HP.size() > 0, "Survivors get next attempt") var physical: Dictionary = world._physical_amounts(world.state) 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() restored.state = bytes_to_var(var_to_bytes(world.state)) world._evolve_batches(world.state, 11e6) @@ -100,13 +100,12 @@ func run() -> void: dispose(world) world = fixture() world.state.time = {"datetime": 123.4, "year": 123.4} - world.state.life_version = 1 world.state.amount["C0.0"] = 40000.0 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) 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) 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") @@ -118,7 +117,7 @@ func run() -> void: 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.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) print("Life: %d checks, %d failures" % [checks, failures]) quit(0 if failures == 0 else 1) diff --git a/tests/test_production_transaction.gd b/tests/test_production_transaction.gd new file mode 100644 index 0000000..ae1576a --- /dev/null +++ b/tests/test_production_transaction.gd @@ -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) diff --git a/tests/test_production_transaction.gd.uid b/tests/test_production_transaction.gd.uid new file mode 100644 index 0000000..013d4ce --- /dev/null +++ b/tests/test_production_transaction.gd.uid @@ -0,0 +1 @@ +uid://byjixspismsnh diff --git a/tests/test_stellar_rules.gd b/tests/test_stellar_rules.gd index 76e9011..7b9f875 100644 --- a/tests/test_stellar_rules.gd +++ b/tests/test_stellar_rules.gd @@ -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.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") - 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.state = bytes_to_var(var_to_bytes(world.state)) 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") 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(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) 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.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(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) world = fixture() diff --git a/tests/test_time.gd b/tests/test_time.gd index b958f81..36e40cc 100644 --- a/tests/test_time.gd +++ b/tests/test_time.gd @@ -50,10 +50,10 @@ func run() -> void: for invalid in [0.0, -1.0, NAN, INF, 1e308]: world.evolute(invalid) 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) 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) for fps in [30, 60, 144]: @@ -76,26 +76,7 @@ func run() -> void: dispose(world) 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(restored) world = fixture() world.entities.N.output_and = {"He": 1.0} @@ -106,5 +87,5 @@ func run() -> void: dispose(world) 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") - 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) diff --git a/world/owniverse/owniverse.gd b/world/owniverse/owniverse.gd index 71e8d18..254ad92 100644 --- a/world/owniverse/owniverse.gd +++ b/world/owniverse/owniverse.gd @@ -10,6 +10,9 @@ var entities: Dictionary[String, OwniverseEntity] = {} var entity_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 auto_production: Array = [] @@ -32,6 +35,18 @@ var state: Dictionary = { 'amount': {}, 'distribution': {}, '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': { 'total_amount': {}, } @@ -57,19 +72,9 @@ func evolute(delta: float) -> void: _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: CivilizationState.detach_space_civilizations(state, Global.CivilizationRules) CivilizationState.enforce_capacity(self, state, Global.CivilizationRules, state.time.datetime) - if state.get("life_version", 0) >= 3: - return var physical: Dictionary = _physical_amounts(state) for id in entities: if entities[id].life_rule.is_empty(): @@ -78,19 +83,22 @@ func initialize_life_batches() -> void: for batch: Dictionary in state.batches.get(id, []): tracked += batch.amount _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 func _evolute_to_datetime(datetime: float) -> void: - initialize_life_batches() + # 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() var previous: Dictionary = state var next: Dictionary = previous.duplicate_deep() next.version = SimulationTime.STATE_VERSION next.speed = clampi(int(previous.speed), 1, Global.SPEED_LIMIT) next.speed_in_year = pow(10.0, next.speed - 1) 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: var quantities: Dictionary = _physical_amounts(previous) 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)} +## 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: - if entity_groups.has(id): - return true - for entity: OwniverseEntity in entities.values(): - if entity.group_as == id: - return true - return false + _ensure_group_index() + return entity_groups.has(id) or _group_members.has(id) func _physical_amounts(source: Dictionary) -> Dictionary: @@ -171,11 +192,8 @@ func _physical_amounts(source: Dictionary) -> Dictionary: func _members(id: String) -> Array: if not _is_group(id): return [id] - var members: Array = [] - for key in entities: - if entities[key].group_as == id: - members.append(key) - return members + # Callers may modify the returned array without changing the index. + return _group_members.get(id, []).duplicate() 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) if origin + (window_index + 1.0) * batch_duration <= cursor: 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: var window_start: float = origin + window_index * 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: part_amount = floorf(amount * (part_end - created_from) / duration) - (amount - remaining_amount) 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: batch = existing break @@ -366,6 +390,9 @@ func _append_batch(target: Dictionary, id: String, amount: float, "amount": 0.0, } 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 remaining_amount -= part_amount 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: return var object_years: float = count * maxf(0.0, to_year - (born_from + born_to) * 0.5) + var previous_amounts: Dictionary = {} + for old: Dictionary in before_batches: + var start: float = old.get("created_from", -INF) + if not previous_amounts.has(start): + previous_amounts[start] = old.amount for batch: Dictionary in target.batches.get(id, []): - var added: float = batch.amount - for old: Dictionary in before_batches: - if old.get("created_from", -INF) == batch.get("created_from", INF): - added -= old.amount - break + 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, object_years) 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: var quantities: Dictionary = _physical_amounts(target) 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(): if not entities.has(id) or entities[id].destroy_level <= 0: 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. 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 = [] for owner_id in amounts: if not entities.has(owner_id): @@ -777,7 +812,12 @@ func _try_production(target: Dictionary, entity: OwniverseEntity, amount: float, automatic: bool = false) -> bool: if amount <= 0.0 or amount != floorf(amount): 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: var cost: float = roundf(maxf(0.0, entity.cost[resource]) * amount) 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: trial.auto_resources[resource] = maxf(0.0, trial.auto_resources.get(resource, 0.0) - cost) 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 _scale_batches(trial, id, factor) if not _bind_new_owners(trial, {entity.id: amount}, true): diff --git a/world/owniverse/simulation_time.gd b/world/owniverse/simulation_time.gd index 1007f21..83773ee 100644 --- a/world/owniverse/simulation_time.gd +++ b/world/owniverse/simulation_time.gd @@ -1,7 +1,6 @@ extends RefCounted ## datetime is accumulated time; year is the last processed tenth of a year. const STATE_VERSION: String = "0.1.1" -const LEGACY_VERSION: String = "0.1.0" static func processed_year(datetime: float) -> float: # Remove only sub-nanotick accumulation noise at decimal boundaries.