From 5f5cc6f8948b88a03d8ea1e4cb553e1191ae4c12 Mon Sep 17 00:00:00 2001 From: Kirill Date: Tue, 15 Sep 2026 08:27:04 +0300 Subject: [PATCH] Implement gradual evolution mechanics and simulation time handling in Owniverse.GI - Added `blastability` property to entities for vulnerability tracking. - Enhanced planet string parsing with validation and error handling. - Introduced `simulation_rules.md` to document evolution mechanics and rules. - Created tests for time progression and stellar evolution rules, ensuring correct handling of fractional years and resource production. - Implemented `simulation_time.gd` for managing accumulated time and year processing. - Updated tests to validate legacy save compatibility and migration to new time schema. --- autoload/save_manager.gd | 18 ++- project.godot | 2 +- simulation_rules.md | 69 +++++++++ stage/stage.gd | 3 +- tests/README.md | 36 +++++ tests/test_stellar_rules.gd | 192 +++++++++++++++++++++++ tests/test_stellar_rules.gd.uid | 1 + tests/test_time.gd | 110 ++++++++++++++ tests/test_time.gd.uid | 1 + world/owniverse/owniverse.gd | 203 +++++++++++++++++++++++-- world/owniverse/owniverse_entity.gd | 25 ++- world/owniverse/simulation_time.gd | 14 ++ world/owniverse/simulation_time.gd.uid | 1 + 13 files changed, 647 insertions(+), 28 deletions(-) create mode 100644 simulation_rules.md create mode 100644 tests/README.md create mode 100644 tests/test_stellar_rules.gd create mode 100644 tests/test_stellar_rules.gd.uid create mode 100644 tests/test_time.gd create mode 100644 tests/test_time.gd.uid create mode 100644 world/owniverse/simulation_time.gd create mode 100644 world/owniverse/simulation_time.gd.uid diff --git a/autoload/save_manager.gd b/autoload/save_manager.gd index 7c4d03a..6ed87c0 100644 --- a/autoload/save_manager.gd +++ b/autoload/save_manager.gd @@ -6,7 +6,8 @@ const SETTING_SECTION := "Settings" const SAVE_PATH = "user://settings.cfg" const FORMAT_VERSION: int = 1 -const STATE_VERSION: String = "0.1.0" +const SimulationTime = preload("res://world/owniverse/simulation_time.gd") +const STATE_VERSION: String = SimulationTime.STATE_VERSION var last_error: String = "" var blocked_saves: Dictionary = {} @@ -123,14 +124,19 @@ func validate_data(data: Variant) -> String: 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") != STATE_VERSION: + if saved.get("version") not in [STATE_VERSION, SimulationTime.LEGACY_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"), true): + if not _number(saved.time.get("datetime")) or not _number(saved.time.get("year")): return "invalid state.time" - if saved.time.year != floorf(saved.time.datetime): - return "state.time.year must equal floor(datetime)" + 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: return "invalid state.speed" if not _number(saved.get("speed_in_year")) or saved.speed_in_year != pow(10.0, saved.speed - 1): @@ -172,7 +178,7 @@ func validate_data(data: Variant) -> String: 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"]: + 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"): return "invalid state." + field if saved.has("resource_remainders"): diff --git a/project.godot b/project.godot index d5d3f2f..bbe4c85 100644 --- a/project.godot +++ b/project.godot @@ -10,7 +10,7 @@ config_version=5 [accessibility] -general/updates_per_second=5 +general/updates_per_second=10 [application] diff --git a/simulation_rules.md b/simulation_rules.md new file mode 100644 index 0000000..8a72363 --- /dev/null +++ b/simulation_rules.md @@ -0,0 +1,69 @@ +# Эволюция звёзд Owniverse.GI + +Механики перенесены из Owniverse.U с согласованными изменениями GI: постепенные партии, детерминированные дробные остатки, уязвимость цивилизаций до C1.2 и передача выживших планет преемнику. Баланс таблицы GI сохраняется. + +## Порядок расчёта + +1. Накопить игровое время; обработать новые десятые доли года. +2. Рассчитать производство существующих объектов за интервал с учётом их постепенного исчезновения; выполнить автоматическое создание звёзд и их планет. +3. Для очередного поколения определить долю заканчивающих жизнь звёзд. Применить локальное разрушение их планет до списания партий. +4. Снять целые объекты из партий, рассчитать основные и альтернативные выходы с накоплением дробей. +5. Передать выжившие планеты полученным звёздам/остаткам. Если преемников несколько, распределить по их фактическим количествам; если преемника нет — освободить. Остальные связанные объекты используют существующие правила GI. +6. Создать партии преемников. Учесть производство новорождённых за оставшуюся часть длинного шага, включая излучение короткоживущих сверхновых. +7. Применить суммарный бласт поколения; повторять обработку поколений, пока в интервале остаются переходы. +8. Пересчитать группы и опубликовать состояние. + +## Постепенные партии + +Окно рождения шириной `w = lifespan / 40` при `lifespan > 10`, иначе `w = lifespan`. Окно превращения сдвинуто на `lifespan`. При частичном списании: + +``` +expected = remainder + (remaining_count - remainder) * elapsed / remaining_window +converted = floor(expected) +remainder = expected - converted +``` + +Конец окна списывает остаток. Переход не уничтожает всю партию в начале окна. Начала и длительности сохраняют модель GI, включая годовую длительность событий SN/GRB. + +## Планеты + +При ручном и автоматическом создании звёзд: + +``` +expected = stars_created * planet_amount * planet_chance * metallicity + remainder +planets_created = floor(expected) +remainder = expected - planets_created +``` + +Остаток независим для каждой пары звезда/планета и сохраняется. Планеты сразу связаны со звездой. Эволюционный переход сам по себе не создаёт новую планетную систему. Шансы планет в таблице — доли, а проценты альтернативных выходов звёзд делятся на 100. + +## Среда: адаптация формул Unity к состоянию GI + +GI не хранит накопленные счётчики первичной материи Unity. Поэтому `stellar_environment()` вычисляет среду из текущего физического состава, включая связанные объекты, без суммирующих групп интерфейса. Это приближение для баланса GI, а не побитовое воспроизведение Unity. + +- `M` — свободные и связанные H/He/Met плюс табличная масса конкретных объектов и масса SMBH. +- `V` — физическое S плюс табличный объём объектов и `voids * 1e22`. +- `Z = max(0, 0.4 * ln(100 * free_Met / M + 0.11) + 0.88)`; при `M = 0` металличность нулевая. +- `B = 65536 * f(M)`, где `f(0)=1`, `f(M)=M^0.1` при `0 bool: complete.merge(restored, true) owniverse.state = complete owniverse.new_state = complete + 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 new file mode 100644 index 0000000..8096b00 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,36 @@ +# Проверки поэтапного переноса из Owniverse.U + +## Время: шаг 0,1 игрового года + +```sh +godot --headless --path Owniverse.GI --script res://tests/test_time.gd +``` + +Команда выполняется из общей папки проектов. Тесты используют искусственные состояния; сохранение игрока не читается и не перезаписывается. + +Проверяются границы десятых долей года, производство ресурсов на этих границах, накопление дробного остатка, скорости 1–10 000 лет/сек, продолжение после загрузки, недопустимые интервалы и миграция сохранений. + +Схема состояния `0.1.1` хранит: + +- `time.datetime` — накопленный возраст, включая необработанную дробную часть; +- `time.year` — последний рассчитанный возраст с точностью до десятой доли года. + +Сохранения `0.1.0` проходят старую проверку целого года. При загрузке стадией игры оставшийся промежуток до последней десятой рассчитывается ровно один раз, после чего применяется версия `0.1.1`. Оставшаяся часть меньше 0,1 года сохраняется. + +## Согласованные правила эволюции + +Сохраняются постепенные интервальные окна GI: начало преобразования — через `lifespan`, длительность окна — `lifespan / 40` при `lifespan > 10`, иначе `lifespan`. + +Вероятностные исходы используют накопление дробного остатка GI, без случайного округления. Этот же подход выбран для будущего создания планет. Шансы планет записаны долями (`0.01` = 1%), а альтернативные исходы звёзд — процентами (`%0.1` = 0.1%). Парсер планет сохраняет дроби и поддерживает один элемент без `&`. + +Уязвимость к бластам читается из `blastability` таблицы GI: HP, EON и цивилизации до C1.2 включительно; C1.3 и выше неуязвимы. Это ограничение относится к бластам уровня 1; локальное уничтожение планет уровня 2 в Unity охватывает все планеты. + +```sh +godot --headless --path Owniverse.GI --script res://tests/test_stellar_rules.gd +``` + +Проверяются парсер планет, граница уязвимости, накопление редких исходов, сериализация остатков и постепенное преобразование реальной партии RD в TWD. + +Создание планет подключено к ручному и автоматическому производству звёзд. Бласты обрабатываются как события, а не ресурс. Выжившие планеты переходят преемнику звезды пропорционально числу реально полученных преемников; при отсутствии преемника освобождаются. Локальное разрушение выполняется перед передачей. + +Дополнительно проверяются частичная передача, альтернативные преемники, уровни разрушения 1/2, накопление слабых бластов, сохранения, ручное/автоматическое создание планет и несколько поколений за длинный шаг. Правила и формулы описаны в `../simulation_rules.md`. diff --git a/tests/test_stellar_rules.gd b/tests/test_stellar_rules.gd new file mode 100644 index 0000000..76e9011 --- /dev/null +++ b/tests/test_stellar_rules.gd @@ -0,0 +1,192 @@ +extends SceneTree +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() + var rd = world.entities.RD + check(rd.planets.HP.chance == 0.01, "Preserve fractional planet probability") + check(rd.planets.GG.amount == 2.0 and rd.planets.GG.chance == 0.08, "Read count and chance separately") + check(rd._parse_planet_string("HP:1:0.01") == {"HP": {"amount": 1.0, "chance": 0.01}}, "Accept a single planet term") + check(rd._parse_planet_string("").is_empty(), "Empty planet definitions stay empty") + for id in ["HP", "EON1", "C0.9", "C1.2"]: + check(world.entities[id].blastability, "Blast vulnerable: " + id) + for id in ["RP", "GG", "AB", "C1.3", "C1.4", "C1.5"]: + check(not world.entities[id].blastability, "Blast protected: " + id) + + # Actual GI table: HRG -> SN, with 1% GRB; no random rounding. + var totals: Dictionary = {} + for i in 100: + var products: Dictionary = world._evolution_products(world.state, world.entities.HRG, 1.0) + for id in products: + totals[id] = totals.get(id, 0.0) + products[id] + check(totals.get("SN") == 99.0 and totals.get("GRB") == 1.0, "GI rare outcomes accumulate deterministically") + var restored = fixture() + restored.state = bytes_to_var(var_to_bytes(world.state)) + check(world._evolution_products(world.state, world.entities.HRG, 37.0) == restored._evolution_products(restored.state, restored.entities.HRG, 37.0), "Evolution carry survives serialization") + dispose(restored) + + # Real RD lifespan, four stars spread over the existing lifespan/40 window. + world.state.amount.N = 0.0 + world.state.amount.RD = 4.0 + world._append_batch(world.state, "RD", 4.0, 0.0, 0.0) + var lifespan: float = rd.lifespan + var width: float = rd.lifespan_delta + world._evolve_batches(world.state, lifespan) + check(world.state.amount.RD == 4.0, "Window start does not kill the whole batch") + world._evolve_batches(world.state, lifespan + width / 4.0) + check(world.state.amount.RD == 3.0 and world.state.amount.TWD == 1.0, "One quarter of a batch evolves") + world._evolve_batches(world.state, lifespan + width / 2.0) + check(world.state.amount.RD == 2.0 and world.state.amount.TWD == 2.0, "Half the batch evolves") + world._evolve_batches(world.state, lifespan + width) + check(world.state.amount.RD == 0.0 and world.state.amount.TWD == 4.0, "Window end completes evolution") + check(world.state.batches.RD.is_empty(), "Completed batch removed") + dispose(world) + # Creation accumulates independently for each star/planet pair. + world = fixture() + world.state.amount.RD = 100.0 + for i in 100: + world._create_planets(world.state, "RD", 1.0, 0.0, 0.0, 1.0) + check(world.state.binds.RD.HP == 1.0, "100 RD at metallicity 1 produce one HP") + check(world.state.binds.RD.RP == 21.0, "Rocky planet probability retains fractions") + 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") + 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) + restored._create_planets(restored.state, "RD", 37.0, 0.0, 0.0, 0.7) + check(world.state == restored.state, "Planet carry continues after save") + dispose(restored) + dispose(world) + + # A partially dying dwarf sterilizes HP; survivors and RP move to its heir. + world = fixture() + world.state.amount.RD = 4.0 + world.state.binds.RD = {"HP": 8.0, "RP": 4.0, "C1.2": 4.0, "C1.3": 4.0, "GG": 4.0} + world._append_batch(world.state, "RD", 4.0, 0.0, 0.0) + world._evolve_batches(world.state, lifespan + width / 2.0) + check(world.state.amount.RD == 2.0 and world.state.amount.TWD == 2.0, "Partial star transition with planets") + check(world.state.binds.RD.HP + world.state.binds.TWD.HP == 4.0, "Half of HP sterilized") + check(world.state.binds.RD["C1.3"] == 2.0 and world.state.binds.TWD["C1.3"] == 2.0, "Protected civilization splits between parent and heir") + check(world.state.binds.RD.RP + world.state.binds.TWD.RP == 10.0, "Sterilized planets become RP without loss") + 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") + dispose(world) + + world = fixture() + world.state.amount.WS = 2.0 + world.state.binds.WS = {"HP": 4.0, "C1.3": 4.0, "RP": 4.0, "GG": 4.0} + world._append_batch(world.state, "WS", 2.0, 0.0, 0.0) + world._evolve_batches(world.state, world.entities.WS.lifespan + world.entities.WS.lifespan_delta) + var physical: Dictionary = world._physical_amounts(world.state) + check(physical.HP == 0.0 and physical["C1.3"] == 0.0 and physical.RP == 0.0 and physical.GG == 0.0, "Level 2 removes all planets, including protected civilization") + check(world.state.amount.LRG == 2.0, "Giant evolves after local destruction") + dispose(world) + + world = fixture() + world.state.amount.RD = 10.0 + world.state.binds.RD = {"HP": 10.0, "C1.2": 10.0, "C1.3": 10.0, "RP": 0.0} + world._apply_blast(world.state, 5.0, {"blast": 1.0, "density": 1.0}) + check(world.state.binds.RD.HP == 5.0 and world.state.binds.RD["C1.2"] == 5.0, "Blast damages vulnerable worlds proportionally") + check(world.state.binds.RD["C1.3"] == 10.0 and world.state.binds.RD.RP == 10.0, "Blast protects C1.3 and creates RP") + world._apply_blast(world.state, 1e6, {"blast": 1.0, "density": 1.0}) + check(world.state.binds.RD.HP == 0.0 and world.state.binds.RD.RP == 20.0, "Overwhelming blast is capped at available planets") + check(not world.state.amount.has("blast"), "Blast is not a resource") + dispose(world) + + world = fixture() + world.state.amount.RD = 1.0 + world.state.binds.RD = {"HP": 1.0} + for i in 10: + world._apply_blast(world.state, 1.0, {"blast": 1.0, "density": 0.1}) + check(world.state.binds.RD.HP == 0.0 and world.state.binds.RD.RP == 1.0, "Small blast effects accumulate across cycles") + dispose(world) + + # Actual supernova output: one SN gives a neutron star, resources, and an event. + world = fixture() + world.state.amount.SN = 1.0 + world._append_batch(world.state, "SN", 1.0, 0.0, 0.0) + world._evolve_batches(world.state, 2.0) + 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") + dispose(world) + + world = fixture() + world.state.amount.H = 1000.0 + world.state.amount.Met = 100.0 + check(world.produce("RD", 100.0) == 100.0, "Manual star creation succeeds") + check(world.state.binds.RD.HP >= 1.0, "Manual creation dispatches planet formation") + check(world.state.amount.H == 900.0, "Planet creation does not duplicate or spend stellar fuel") + dispose(world) + + world = fixture() + world.state.amount.N = 0.0 + world.state.amount.RD = 1.0 + world.state.amount.H = 100.0 + world.state.amount.Met = 100.0 + world.state.auto_resources.H = 100.0 + world.evolute(0.1) + check(world.state.amount.RD == 101.0 and world.state.amount.H == 0.0, "Automatic formation uses its resource budget") + check(world.state.binds.RD.HP >= 1.0, "Automatic formation also creates planets") + dispose(world) + + # Two simultaneous successors share surviving planets in the same 99:1 ratio. + world = fixture() + world.entities.HRG.destroy_level = 0 + world.state.amount.HRG = 100.0 + world.state.binds.HRG = {"C1.3": 100.0} + world.state.batches.HRG = [{"start_year": 0.1, "end_year": 0.1, "amount": 100.0}] + world._evolve_batches(world.state, 0.1) + check(world.state.binds.SN["C1.3"] == 99.0 and world.state.binds.GRB["C1.3"] == 1.0, "Planets split among actual alternative successors") + check(world.state.binds.HRG["C1.3"] == 0.0, "Alternative evolution leaves no orphan bindings") + dispose(world) + + world = fixture() + world.state.amount.N = 0.0 + world.state.amount.HRG = 1.0 + world.state.batches.HRG = [{"start_year": 0.1, "end_year": 0.1, "amount": 1.0}] + world.evolute(3.0) + check(world.state.amount.LNS == 1.0 and world.state.amount.SN == 0.0, "Long step follows multiple stellar generations") + check(world.state.amount.S >= 99.0, "Short-lived newborn supernova emits S before disappearing") + dispose(world) + + print("Stellar rules: %d checks, %d failures" % [checks, failures]) + quit(0 if failures == 0 else 1) diff --git a/tests/test_stellar_rules.gd.uid b/tests/test_stellar_rules.gd.uid new file mode 100644 index 0000000..1a27314 --- /dev/null +++ b/tests/test_stellar_rules.gd.uid @@ -0,0 +1 @@ +uid://ccegf5mell4ph diff --git a/tests/test_time.gd b/tests/test_time.gd new file mode 100644 index 0000000..b958f81 --- /dev/null +++ b/tests/test_time.gd @@ -0,0 +1,110 @@ +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 saves: Node = root.get_node("SaveManager") + var world = fixture() + world.evolute(0.09) + check(world.state.time.year == 0.0, "Do not process before a tenth boundary") + check(world.state.amount.H == 0.0, "No premature production") + world.evolute(0.01) + check(world.state.time.year == 0.1, "Process the first tenth") + check(world.state.amount.H == 1.0, "Production runs at 0.1 year") + world.evolute(0.2) + check(world.state.time.year == 0.3 and world.state.amount.H == 3.0, "Do not reprocess previous tenths") + var before: Dictionary = world.state.duplicate(true) + 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") + 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") + dispose(world) + + for fps in [30, 60, 144]: + world = fixture() + for frame in fps: + world.evolute(1.0 / fps) + check(world.state.time.year == 1.0, "One second at %d fps" % fps) + check(world.state.amount.H == 10.0, "Production across frames at %d fps" % fps) + dispose(world) + + world = fixture() + world.evolute(0.05) + world.speed = 2 + world.evolute(0.005) + check(world.state.time.year == 0.1, "Speed change preserves accumulated fraction") + world.speed = 100 + check(world.speed == 5 and world.state.speed_in_year == 10000.0, "Upper speed limit unchanged") + world.speed = -10 + check(world.speed == 1 and world.state.speed_in_year == 1.0, "Lower speed limit unchanged") + 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} + world.entities.N.output_or = {} + world.state.batches.N = [{"start_year": 0.1, "end_year": 0.1, "amount": 1.0}] + world.evolute(0.1) + check(world.state.amount.N == 0.0 and world.state.amount.He == 1.0, "Existing evolution executes at fractional boundary") + 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]) + quit(0 if failures == 0 else 1) diff --git a/tests/test_time.gd.uid b/tests/test_time.gd.uid new file mode 100644 index 0000000..3bbea94 --- /dev/null +++ b/tests/test_time.gd.uid @@ -0,0 +1 @@ +uid://bwon17tg8v8di diff --git a/world/owniverse/owniverse.gd b/world/owniverse/owniverse.gd index 6fd8f93..c106bc2 100644 --- a/world/owniverse/owniverse.gd +++ b/world/owniverse/owniverse.gd @@ -1,5 +1,6 @@ extends Node class_name Owniverse +const SimulationTime = preload("res://world/owniverse/simulation_time.gd") #var state: State = State.new() @@ -15,7 +16,7 @@ var locked_entities: Array = [] # ------------------------------------ var state: Dictionary = { - 'version': '0.1.0', + 'version': SimulationTime.STATE_VERSION, 'time': { 'datetime': 0.0, 'year': 0.0, @@ -48,14 +49,28 @@ var speed: int: func evolute(delta: float) -> void: if delta <= 0.0 or not is_finite(delta): return + var datetime: float = state.time.datetime + delta * pow(10.0, speed - 1) + if not is_finite(datetime) or not is_finite(datetime * 10.0): + return + _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)) + + +func _evolute_to_datetime(datetime: float) -> void: 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 datetime: float = previous.time.datetime + delta * next.speed_in_year - var from_year: float = floorf(previous.time.year) - var to_year: float = floorf(datetime) + var from_year: float = previous.time.year + var to_year: float = SimulationTime.processed_year(datetime) if to_year > from_year: var quantities: Dictionary = _physical_amounts(previous) var distribution: Dictionary = _normalize_distribution(quantities) @@ -100,7 +115,7 @@ func refresh_item_amounts() -> void: func _add_time_delta(delta: float) -> Dictionary: var new_datetime: float = state.time.datetime + delta * pow(10.0, speed - 1) - return {"datetime": new_datetime, "year": floorf(new_datetime)} + return {"datetime": new_datetime, "year": SimulationTime.processed_year(new_datetime)} func _is_group(id: String) -> bool: @@ -175,7 +190,7 @@ func _take_available(target: Dictionary, id: String, amount: float) -> Dictionar shares.append({"id": member, "rest": expected - taken[member]}) if discrete: shares.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: - return a.rest > b.rest if a.rest != b.rest else a.id < b.id) + return a.rest > b.rest if a.rest != b.rest else str(a.id) < str(b.id)) for share: Dictionary in shares: if assigned >= total: break @@ -243,7 +258,7 @@ func _round_object_allocations(target: Dictionary) -> void: slot.rest = slot.value - slot.whole assigned += slot.whole slots.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: - return a.rest > b.rest if a.rest != b.rest else a.owner < b.owner) + return a.rest > b.rest if a.rest != b.rest else str(a.owner) < str(b.owner)) var left: float = roundf(physical[id]) - assigned for slot: Dictionary in slots: if left > 0.0: @@ -381,6 +396,133 @@ func _evolution_products(target: Dictionary, entity: OwniverseEntity, count: flo return outputs +# Derived from current concrete stock, including bound objects, never UI aliases. +# GI has no Unity force parameters; stellar effects use their neutral multiplier 1. +func stellar_environment(source: Dictionary) -> Dictionary: + var quantities: Dictionary = _physical_amounts(source) + # SMBH is a self-group in GI; its quantity stores mass, not object count. + var mass: float = source.amount.get("SMBH", 0.0) + var volume: float = quantities.get("S", 0.0) + for id in quantities: + var entity: OwniverseEntity = entities[id] + if id in ["H", "He", "Met", "SMBH"]: + mass += quantities[id] + else: + mass += quantities[id] * maxf(0.0, entity.mass) + volume += quantities[id] * maxf(0.0, entity.space) + volume += quantities[id] * maxf(0.0, entity.voids) * 1.0e22 + var metallicity: float = 0.0 + if mass > 0.0: + metallicity = maxf(0.0, 0.4 * log(100.0 * source.amount.get("Met", 0.0) / mass + 0.11) + 0.88) + var base_mass: float = 65536.0 * (pow(mass, 0.1) if mass > 0.0 and mass < 1.0e10 else (10.0 if mass >= 1.0e10 else 1.0)) + return { + "metallicity": metallicity, + "density": 1.23456e-7 * sqrt(base_mass + mass) / pow(maxf(1.0, volume), 1.0 / 6.0), + "blast": clampf(source.amount.get("S", 0.0) / volume, 0.0, 1.0) if volume > 0.0 else 1.0, + } + + +func _accumulate_whole(target: Dictionary, field: String, key: String, value: float) -> float: + if not target.has(field): + target[field] = {} + var expected: float = maxf(0.0, value) + target[field].get(key, 0.0) + var whole: float = floorf(snappedf(expected, 1.0e-12)) + target[field][key] = maxf(0.0, expected - whole) + return whole + + +func _create_planets(target: Dictionary, star_id: String, count: float, + from_year: float, to_year: float, metallicity: float) -> void: + if count <= 0.0 or entities[star_id].planets.is_empty(): + return + if not target.binds.has(star_id): + target.binds[star_id] = {} + for id in entities[star_id].planets: + if not entities.has(id): + continue + var rule: Dictionary = entities[star_id].planets[id] + var amount: float = _accumulate_whole(target, "planet_remainders", star_id + ":" + id, + count * rule.amount * rule.chance * maxf(0.0, metallicity)) + amount = _credit(target, id, amount) + target.amount[id] -= amount + target.binds[star_id][id] = target.binds[star_id].get(id, 0.0) + amount + _append_batch(target, id, amount, from_year, to_year) + + +# Successors born and dying inside one long step must still emit their lifetime S. +# Integrate the birth interval and subtract the newly added death-window integral. +func _produce_newborn_lifetime(target: Dictionary, id: String, count: float, + born_from: float, born_to: float, to_year: float, before_batches: Array) -> void: + var products: Dictionary = _yearly_products(entities[id]) + 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) + 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 + 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: + var output: float = object_years * products[resource] + if resource in ["H", "S"]: + output = minf(output, target.energy) + var actual: float = _credit(target, resource, output) + if resource in ["H", "S"]: + target.energy = maxf(0.0, target.energy - actual) + + +func _stellar_successors(products: Dictionary) -> Dictionary: + var weights: Dictionary = {} + for id in products: + if entities.has(id) and (entities[id].group_as in ["Star", "BH"] or entities[id].event_group == "SNGRBB" or entities[id].count_as == "BD"): + weights[id] = products[id] + return _normalize_distribution(weights) + + +# Level 1 sterilizes vulnerable worlds to RP; level 2 removes every planet. +func _destroy_planets(target: Dictionary, owner_id: String, ratio: float, level: int, cause: String = "local") -> void: + if ratio <= 0.0 or level not in [1, 2]: + return + var held: Dictionary = target.binds.get(owner_id, {}) + var physical: Dictionary = _physical_amounts(target) + var rocky: float = 0.0 + for id in held.keys(): + if not entities.has(id) or entities[id].group_as != "Planet": + continue + if level == 1 and not entities[id].blastability: + continue + var key: String = cause + ":" + str(level) + ":" + owner_id + ":" + id + var destroyed: float = minf(held[id], _accumulate_whole(target, "destruction_remainders", key, held[id] * minf(1.0, ratio))) + if destroyed == held[id]: + target.destruction_remainders[key] = 0.0 + if destroyed <= 0.0: + continue + held[id] -= destroyed + _scale_batches(target, id, 1.0 - destroyed / physical[id]) + if level == 1: + rocky += destroyed + if rocky > 0.0: + rocky = _credit(target, "RP", rocky) + target.amount.RP -= rocky + held.RP = held.get("RP", 0.0) + rocky + + +func _apply_blast(target: Dictionary, power: float, environment: Dictionary) -> void: + var hits: float = _accumulate_whole(target, "blast_remainders", "hits", power * environment.blast) + var quantities: Dictionary = _physical_amounts(target) + var weights: Dictionary = {} + for id in entities: + if not entities[id].planets.is_empty() and quantities.get(id, 0.0) > 0.0: + weights[id] = quantities[id] + var distribution: Dictionary = _normalize_distribution(weights) + # Fractional exposure is deterministic; destruction itself remains whole. + for id in distribution: + _destroy_planets(target, id, hits * distribution[id] / quantities[id] * environment.density, 1, "blast") + + func _evolve_batches(target: Dictionary, to_year: float) -> void: # A long cycle may contain several generations; preserve actual birth intervals. while _evolve_batch_generation(target, to_year): @@ -390,6 +532,16 @@ 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) + for id in target.batches.keys(): + if not entities.has(id) or entities[id].destroy_level <= 0: + continue + var dying: float = 0.0 + for part: Dictionary in _withdraw_batches(preview, id, to_year): + dying += part.amount + if quantities.get(id, 0.0) > 0.0: + _destroy_planets(target, id, dying / quantities[id], entities[id].destroy_level) + quantities = _physical_amounts(target) # Withdraw all types before creating successors: iteration order cannot affect them. for id in target.batches.keys(): if entities.has(id): @@ -428,10 +580,20 @@ func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool: var held: float = bindings[owner_id][id] var released: float = held * (1.0 - owner_survival) target.amount[id] = target.amount.get(id, 0.0) + released + var heirs: Dictionary = _stellar_successors(outputs.get(owner_id, {})) if entities.has(id) and entities[id].group_as == "Planet" else {} var fraction: float = fractions.get(id, 0.0) + for heir in heirs: + if not incoming.has(heir): + incoming[heir] = {} + incoming[heir][id] = incoming[heir].get(id, 0.0) + released * (1.0 - fraction) * heirs[heir] + for child in outputs.get(id, {}): + if entities.has(child): + incoming[heir][child] = incoming[heir].get(child, 0.0) + released * fraction * heirs[heir] * outputs[id][child] var remaining: float = held * owner_survival * (1.0 - fraction) target.binds[owner_id][id] = target.binds[owner_id].get(id, 0.0) + remaining for successor in outputs.get(id, {}): + if not entities.has(successor): + continue var transferred: float = held * owner_survival * fraction * maxf(0.0, outputs[id][successor]) if not incoming.has(owner_id): incoming[owner_id] = {} @@ -441,20 +603,32 @@ func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool: target.amount[id] = maxf(0.0, target.amount.get(id, 0.0) * (1.0 - fractions[id])) var new_owners: Dictionary = {} + var blast_power: float = 0.0 + var environment: Dictionary = stellar_environment(target) for id in expired: for part: Dictionary in expired[id]: for successor in part.products: var amount: float = part.products[successor] + if successor == "blast": + blast_power += amount + continue + if not entities.has(successor): + continue amount = _credit(target, successor, amount) + var before_batches: Array = target.batches.get(successor, []).duplicate(true) _append_batch(target, successor, amount, part.from, part.to) + _produce_newborn_lifetime(target, successor, amount, part.from, part.to, to_year, before_batches) new_owners[successor] = new_owners.get(successor, 0.0) + amount for owner_id in incoming: + if not target.binds.has(owner_id): + target.binds[owner_id] = {} for id in incoming[owner_id]: var transferred: float = minf(incoming[owner_id][id], target.amount.get(id, 0.0)) target.binds[owner_id][id] = target.binds[owner_id].get(id, 0.0) + transferred target.amount[id] = maxf(0.0, target.amount.get(id, 0.0) - transferred) _bind_new_owners(target, new_owners) _round_object_allocations(target) + _apply_blast(target, blast_power, environment) return true @@ -474,7 +648,7 @@ func _bind_full(target: Dictionary, amounts: Dictionary) -> bool: requests.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: if a.members != b.members: return a.members < b.members - return a.id < b.id if a.id != b.id else a.owner < b.owner) + return str(a.id) < str(b.id) if a.id != b.id else str(a.owner) < str(b.owner)) for request: Dictionary in requests: var taken: Dictionary = _take_available(trial, request.id, request.amount) var total: float = 0.0 @@ -559,7 +733,7 @@ func _bind_new_owners(target: Dictionary, amounts: Dictionary, require_full: boo # Integrate survival over the period, including partial batch expiry. func _object_years(source: Dictionary, id: String, quantity: float, from_year: float, to_year: float) -> float: - var result: float = quantity * (to_year - from_year) + var result: float = quantity * SimulationTime.elapsed_years(from_year, to_year) for batch: Dictionary in source.batches.get(id, []): var start: float = maxf(batch.start_year, batch.get("processed_until", batch.start_year)) var end: float = batch.end_year @@ -592,7 +766,7 @@ func _allocate_whole(requests: Dictionary, limit: float) -> Dictionary: assigned += result[id] order.append({"id": id, "rest": expected - result[id]}) order.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: - return a.rest > b.rest if a.rest != b.rest else a.id < b.id) + return a.rest > b.rest if a.rest != b.rest else str(a.id) < str(b.id)) for item: Dictionary in order: if assigned >= available: break @@ -714,6 +888,7 @@ func _produce_for_period(previous: Dictionary, target: Dictionary, quantities: D order.sort_custom(func(a: String, b: String) -> bool: return candidates[a] < candidates[b] if candidates[a] != candidates[b] else a < b) var created: Dictionary = {} + var metallicity: float = stellar_environment(target).metallicity var total_created: float = 0.0 for id in order: var entity: OwniverseEntity = entities[id] @@ -736,6 +911,7 @@ func _produce_for_period(previous: Dictionary, target: Dictionary, quantities: D for id in created: _credit(target, id, created[id]) _append_batch(target, id, created[id], from_year, to_year) + _create_planets(target, id, created[id], from_year, to_year, metallicity) func _auto_capacity(source: Dictionary, entity: OwniverseEntity, budget: Dictionary) -> float: @@ -806,7 +982,7 @@ func _plan_smbh(desired_amount: float) -> Dictionary: consumed_mass += taken[id] * entities[id].mass order.append({"id": id, "remainder": expected - taken[id]}) order.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: - return a.remainder > b.remainder if a.remainder != b.remainder else a.id < b.id) + return a.remainder > b.remainder if a.remainder != b.remainder else str(a.id) < str(b.id)) # Consume whole BH only; all excess mass becomes SMBH, never disappears. for candidate: Dictionary in order: if consumed_mass >= requested: @@ -867,13 +1043,16 @@ func produce(item_id: String, desired_amount: float) -> float: if max_producible <= 0: return 0.0 + var metallicity: float = stellar_environment(state).metallicity if not _try_production(state, entity, max_producible): return 0.0 # Add produced entities _process_enitity(item_id, max_producible) add_entity_to_batch(item_id, max_producible, state.time.datetime) - + _create_planets(state, item_id, max_producible, state.time.datetime, state.time.datetime, metallicity) + _refresh_groups(state) + refresh_item_amounts() return max_producible diff --git a/world/owniverse/owniverse_entity.gd b/world/owniverse/owniverse_entity.gd index a5ccf67..6a3f998 100644 --- a/world/owniverse/owniverse_entity.gd +++ b/world/owniverse/owniverse_entity.gd @@ -13,6 +13,7 @@ var lifespan_delta: float var auto: float var gamma: float var blast: int +var blastability: bool = false var life_product: Dictionary = {} var product_per_year_type: String = "" var product_per_year_amount: float = 0 @@ -46,6 +47,7 @@ func _init(values: Dictionary) -> void: auto = float(values.auto) gamma = float(values.gamma) blast = int(values.blast) + blastability = str(values.get("blastability", "")) == "1" life_product = _parse_and_string(values.life_product) for key in life_product.keys(): @@ -93,12 +95,19 @@ func _parse_and_string(source_line: String) -> Dictionary: func _parse_planet_string(source_line: String) -> Dictionary: var result = {} - if source_line.contains("&"): - var first_split = source_line.split("&") - for elem in first_split: - var second_split = elem.split(":") - result[second_split[0]] = {} - result[second_split[0]]["amount"] = int(second_split[1]) - result[second_split[0]]["chance"] = int(second_split[2]) - + for elem in source_line.split("&", false): + var parts: PackedStringArray = elem.strip_edges().split(":") + if parts.size() != 3 or parts[0].strip_edges().is_empty(): + push_warning("Invalid planet definition for %s: %s" % [id, elem]) + continue + if not parts[1].is_valid_float() or not parts[2].is_valid_float(): + push_warning("Invalid planet numbers for %s: %s" % [id, elem]) + continue + var count: float = float(parts[1]) + var chance: float = float(parts[2]) + if not is_finite(count) or not is_finite(chance) or count < 0.0 or count != floorf(count) or chance < 0.0 or chance > 1.0: + push_warning("Planet values out of range for %s: %s" % [id, elem]) + continue + # Planet chances are fractions (0.01 = 1%), unlike output_or percentages. + result[parts[0].strip_edges()] = {"amount": count, "chance": chance} return result diff --git a/world/owniverse/simulation_time.gd b/world/owniverse/simulation_time.gd new file mode 100644 index 0000000..1007f21 --- /dev/null +++ b/world/owniverse/simulation_time.gd @@ -0,0 +1,14 @@ +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. + # A relative epsilon would incorrectly advance large ages by whole years. + return floorf(snappedf(datetime * 10.0, 1e-9)) / 10.0 + +static func elapsed_years(from_year: float, to_year: float) -> float: + # Subtract integer tenths before returning to float years. Otherwise 0.3-0.2 + # can fall below 0.1 and postpone a whole unit of resource production. + return (roundf(to_year * 10.0) - roundf(from_year * 10.0)) / 10.0 diff --git a/world/owniverse/simulation_time.gd.uid b/world/owniverse/simulation_time.gd.uid new file mode 100644 index 0000000..cae41de --- /dev/null +++ b/world/owniverse/simulation_time.gd.uid @@ -0,0 +1 @@ +uid://c463vlrlhvpx8