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.
This commit is contained in:
Kirill
2026-09-15 08:27:04 +03:00
parent 7de65f6145
commit 5f5cc6f894
13 changed files with 647 additions and 28 deletions
+12 -6
View File
@@ -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"):
+1 -1
View File
@@ -10,7 +10,7 @@ config_version=5
[accessibility]
general/updates_per_second=5
general/updates_per_second=10
[application]
+69
View File
@@ -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<M<1e10`, иначе `10`.
- `density = 1.23456e-7 * sqrt(B + M) / max(1, V)^(1/6)`.
- `blast_factor = clamp(free_S / V, 0, 1)`; при нулевом объёме `1`.
Система сил Unity и планетные бонусы в GI пока не перенесены; здесь используются нейтральные множители 1. Металличность для производства фиксируется до расходования ресурсов на звёзды. Среда бласта фиксируется для поколения до добавления его продуктов.
## Разрушение и бласты
Локальное воздействие: `ratio = dying_stars / physical_stars`, без множителя плотности.
Бласт: `hits = floor(power * blast_factor + saved_remainder)`. Распределение экспозиции между типами звёзд, имеющими планетные системы в таблице, пропорционально их физическому количеству. Для типа: `ratio = hits * star_share / physical_stars * density`.
Для каждого связанного типа планет накапливается `held_planets * min(1, ratio)`. Уничтожается целая часть, не больше существующего количества. Остатки локального воздействия и бластов раздельны. Это позволяет слабым воздействиям работать при постепенной эволюции.
Уровень 1 превращает только `blastability=1` в RP (HP, EON, цивилизации до C1.2). C1.3 и выше защищены от этого уровня. Уровень 2 уничтожает все планеты, включая защищённые цивилизации, без создания RP. Уничтоженные объекты удаляются из жизненных партий, чтобы не возникать заново.
`blast` в выходе — событие, а не предмет. Поле `blast` сущности повторно не суммируется с выходом `blast:N`.
## Границы этапа
Рассчитываются агрегаты по типам, а не отдельные звёздные системы; конкретная принадлежность каждой планеты отдельной звезде не моделируется. Распределения целых объектов детерминированы. Порядок внутри длинного шага — по поколениям, поэтому нелинейное воздействие бластов и среды не обязано совпадать с серией более коротких шагов. Развитие жизни и цивилизаций из Unity, их бонусы и силы остаются отдельными этапами переноса.
Проверки: `tests/test_time.gd`, `tests/test_stellar_rules.gd`. Сохранения игрока тестами не затрагиваются.
+2 -1
View File
@@ -80,7 +80,7 @@ var locked_items: Array = []
func set_datetime(value: float):
date_time.text = str(snapped(value, 0.1))
date_time.text = Global.format_number(value, 5) if value != 0.0 else "0.0"
#region Item manipulations: update and visibility
@@ -380,6 +380,7 @@ func load_data() -> 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()
+36
View File
@@ -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`.
+192
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://ccegf5mell4ph
+110
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://bwon17tg8v8di
+191 -12
View File
@@ -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
+17 -8
View File
@@ -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
+14
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://c463vlrlhvpx8