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:
@@ -6,7 +6,8 @@ const SETTING_SECTION := "Settings"
|
|||||||
const SAVE_PATH = "user://settings.cfg"
|
const SAVE_PATH = "user://settings.cfg"
|
||||||
|
|
||||||
const FORMAT_VERSION: int = 1
|
const FORMAT_VERSION: int = 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 last_error: String = ""
|
||||||
var blocked_saves: Dictionary = {}
|
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:
|
if not data.ui is Dictionary or not data.ui.get("current_page") is int or data.ui.current_page < 0:
|
||||||
return "invalid ui.current_page"
|
return "invalid ui.current_page"
|
||||||
var saved: Dictionary = data.state
|
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"
|
return "unsupported state.version"
|
||||||
if not saved.get("time") is Dictionary:
|
if not saved.get("time") is Dictionary:
|
||||||
return "state.time must be a 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"
|
return "invalid state.time"
|
||||||
if saved.time.year != floorf(saved.time.datetime):
|
if not is_finite(float(saved.time.datetime) * 10.0):
|
||||||
return "state.time.year must equal floor(datetime)"
|
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 _number(saved.get("speed"), true) or saved.speed < 1 or saved.speed > Global.SPEED_LIMIT:
|
||||||
return "invalid state.speed"
|
return "invalid state.speed"
|
||||||
if not _number(saved.get("speed_in_year")) or saved.speed_in_year != pow(10.0, saved.speed - 1):
|
if not _number(saved.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
|
return "invalid batch.processed_until in " + id
|
||||||
if batch.has("withdraw_remainder") and (not _number(batch.withdraw_remainder) or batch.withdraw_remainder > batch.amount):
|
if batch.has("withdraw_remainder") and (not _number(batch.withdraw_remainder) or batch.withdraw_remainder > batch.amount):
|
||||||
return "invalid batch.withdraw_remainder in " + id
|
return "invalid batch.withdraw_remainder in " + id
|
||||||
for field in ["batch_origins", "distribution", "auto_resources", "production_remainders", "auto_star_deficits", "evolution_remainders"]:
|
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"):
|
if saved.has(field) and not _number_map(saved[field], field == "auto_resources", field != "auto_star_deficits"):
|
||||||
return "invalid state." + field
|
return "invalid state." + field
|
||||||
if saved.has("resource_remainders"):
|
if saved.has("resource_remainders"):
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ config_version=5
|
|||||||
|
|
||||||
[accessibility]
|
[accessibility]
|
||||||
|
|
||||||
general/updates_per_second=5
|
general/updates_per_second=10
|
||||||
|
|
||||||
[application]
|
[application]
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -80,7 +80,7 @@ var locked_items: Array = []
|
|||||||
|
|
||||||
|
|
||||||
func set_datetime(value: float):
|
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
|
#region Item manipulations: update and visibility
|
||||||
|
|
||||||
@@ -380,6 +380,7 @@ func load_data() -> bool:
|
|||||||
complete.merge(restored, true)
|
complete.merge(restored, true)
|
||||||
owniverse.state = complete
|
owniverse.state = complete
|
||||||
owniverse.new_state = complete
|
owniverse.new_state = complete
|
||||||
|
owniverse.migrate_time_precision()
|
||||||
owniverse.refresh_item_amounts()
|
owniverse.refresh_item_amounts()
|
||||||
pending_page = clampi(int(game_data.get("ui", {}).get("current_page", 0)), 0, PAGE_COUNT - 1)
|
pending_page = clampi(int(game_data.get("ui", {}).get("current_page", 0)), 0, PAGE_COUNT - 1)
|
||||||
_restore_page.call_deferred()
|
_restore_page.call_deferred()
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ccegf5mell4ph
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bwon17tg8v8di
|
||||||
+191
-12
@@ -1,5 +1,6 @@
|
|||||||
extends Node
|
extends Node
|
||||||
class_name Owniverse
|
class_name Owniverse
|
||||||
|
const SimulationTime = preload("res://world/owniverse/simulation_time.gd")
|
||||||
|
|
||||||
#var state: State = State.new()
|
#var state: State = State.new()
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ var locked_entities: Array = []
|
|||||||
# ------------------------------------
|
# ------------------------------------
|
||||||
|
|
||||||
var state: Dictionary = {
|
var state: Dictionary = {
|
||||||
'version': '0.1.0',
|
'version': SimulationTime.STATE_VERSION,
|
||||||
'time': {
|
'time': {
|
||||||
'datetime': 0.0,
|
'datetime': 0.0,
|
||||||
'year': 0.0,
|
'year': 0.0,
|
||||||
@@ -48,14 +49,28 @@ var speed: int:
|
|||||||
func evolute(delta: float) -> void:
|
func evolute(delta: float) -> void:
|
||||||
if delta <= 0.0 or not is_finite(delta):
|
if delta <= 0.0 or not is_finite(delta):
|
||||||
return
|
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 previous: Dictionary = state
|
||||||
var next: Dictionary = previous.duplicate_deep()
|
var next: Dictionary = previous.duplicate_deep()
|
||||||
|
next.version = SimulationTime.STATE_VERSION
|
||||||
next.speed = clampi(int(previous.speed), 1, Global.SPEED_LIMIT)
|
next.speed = clampi(int(previous.speed), 1, Global.SPEED_LIMIT)
|
||||||
next.speed_in_year = pow(10.0, next.speed - 1)
|
next.speed_in_year = pow(10.0, next.speed - 1)
|
||||||
var datetime: float = previous.time.datetime + delta * next.speed_in_year
|
var from_year: float = previous.time.year
|
||||||
var from_year: float = floorf(previous.time.year)
|
var to_year: float = SimulationTime.processed_year(datetime)
|
||||||
var to_year: float = floorf(datetime)
|
|
||||||
if to_year > from_year:
|
if to_year > from_year:
|
||||||
var quantities: Dictionary = _physical_amounts(previous)
|
var quantities: Dictionary = _physical_amounts(previous)
|
||||||
var distribution: Dictionary = _normalize_distribution(quantities)
|
var distribution: Dictionary = _normalize_distribution(quantities)
|
||||||
@@ -100,7 +115,7 @@ func refresh_item_amounts() -> void:
|
|||||||
|
|
||||||
func _add_time_delta(delta: float) -> Dictionary:
|
func _add_time_delta(delta: float) -> Dictionary:
|
||||||
var new_datetime: float = state.time.datetime + delta * pow(10.0, speed - 1)
|
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:
|
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]})
|
shares.append({"id": member, "rest": expected - taken[member]})
|
||||||
if discrete:
|
if discrete:
|
||||||
shares.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
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:
|
for share: Dictionary in shares:
|
||||||
if assigned >= total:
|
if assigned >= total:
|
||||||
break
|
break
|
||||||
@@ -243,7 +258,7 @@ func _round_object_allocations(target: Dictionary) -> void:
|
|||||||
slot.rest = slot.value - slot.whole
|
slot.rest = slot.value - slot.whole
|
||||||
assigned += slot.whole
|
assigned += slot.whole
|
||||||
slots.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
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
|
var left: float = roundf(physical[id]) - assigned
|
||||||
for slot: Dictionary in slots:
|
for slot: Dictionary in slots:
|
||||||
if left > 0.0:
|
if left > 0.0:
|
||||||
@@ -381,6 +396,133 @@ func _evolution_products(target: Dictionary, entity: OwniverseEntity, count: flo
|
|||||||
return outputs
|
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:
|
func _evolve_batches(target: Dictionary, to_year: float) -> void:
|
||||||
# A long cycle may contain several generations; preserve actual birth intervals.
|
# A long cycle may contain several generations; preserve actual birth intervals.
|
||||||
while _evolve_batch_generation(target, to_year):
|
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:
|
func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool:
|
||||||
var quantities: Dictionary = _physical_amounts(target)
|
var quantities: Dictionary = _physical_amounts(target)
|
||||||
var expired: Dictionary = {}
|
var expired: Dictionary = {}
|
||||||
|
var preview: Dictionary = target.duplicate(true)
|
||||||
|
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.
|
# Withdraw all types before creating successors: iteration order cannot affect them.
|
||||||
for id in target.batches.keys():
|
for id in target.batches.keys():
|
||||||
if entities.has(id):
|
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 held: float = bindings[owner_id][id]
|
||||||
var released: float = held * (1.0 - owner_survival)
|
var released: float = held * (1.0 - owner_survival)
|
||||||
target.amount[id] = target.amount.get(id, 0.0) + released
|
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)
|
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)
|
var remaining: float = held * owner_survival * (1.0 - fraction)
|
||||||
target.binds[owner_id][id] = target.binds[owner_id].get(id, 0.0) + remaining
|
target.binds[owner_id][id] = target.binds[owner_id].get(id, 0.0) + remaining
|
||||||
for successor in outputs.get(id, {}):
|
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])
|
var transferred: float = held * owner_survival * fraction * maxf(0.0, outputs[id][successor])
|
||||||
if not incoming.has(owner_id):
|
if not incoming.has(owner_id):
|
||||||
incoming[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]))
|
target.amount[id] = maxf(0.0, target.amount.get(id, 0.0) * (1.0 - fractions[id]))
|
||||||
|
|
||||||
var new_owners: Dictionary = {}
|
var new_owners: Dictionary = {}
|
||||||
|
var blast_power: float = 0.0
|
||||||
|
var environment: Dictionary = stellar_environment(target)
|
||||||
for id in expired:
|
for id in expired:
|
||||||
for part: Dictionary in expired[id]:
|
for part: Dictionary in expired[id]:
|
||||||
for successor in part.products:
|
for successor in part.products:
|
||||||
var amount: float = part.products[successor]
|
var amount: float = part.products[successor]
|
||||||
|
if successor == "blast":
|
||||||
|
blast_power += amount
|
||||||
|
continue
|
||||||
|
if not entities.has(successor):
|
||||||
|
continue
|
||||||
amount = _credit(target, successor, amount)
|
amount = _credit(target, successor, amount)
|
||||||
|
var before_batches: Array = target.batches.get(successor, []).duplicate(true)
|
||||||
_append_batch(target, successor, amount, part.from, part.to)
|
_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
|
new_owners[successor] = new_owners.get(successor, 0.0) + amount
|
||||||
for owner_id in incoming:
|
for owner_id in incoming:
|
||||||
|
if not target.binds.has(owner_id):
|
||||||
|
target.binds[owner_id] = {}
|
||||||
for id in incoming[owner_id]:
|
for id in incoming[owner_id]:
|
||||||
var transferred: float = minf(incoming[owner_id][id], target.amount.get(id, 0.0))
|
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.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)
|
target.amount[id] = maxf(0.0, target.amount.get(id, 0.0) - transferred)
|
||||||
_bind_new_owners(target, new_owners)
|
_bind_new_owners(target, new_owners)
|
||||||
_round_object_allocations(target)
|
_round_object_allocations(target)
|
||||||
|
_apply_blast(target, blast_power, environment)
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
|
||||||
@@ -474,7 +648,7 @@ func _bind_full(target: Dictionary, amounts: Dictionary) -> bool:
|
|||||||
requests.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
requests.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||||
if a.members != b.members:
|
if a.members != b.members:
|
||||||
return 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:
|
for request: Dictionary in requests:
|
||||||
var taken: Dictionary = _take_available(trial, request.id, request.amount)
|
var taken: Dictionary = _take_available(trial, request.id, request.amount)
|
||||||
var total: float = 0.0
|
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.
|
# Integrate survival over the period, including partial batch expiry.
|
||||||
func _object_years(source: Dictionary, id: String, quantity: float,
|
func _object_years(source: Dictionary, id: String, quantity: float,
|
||||||
from_year: float, to_year: float) -> 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, []):
|
for batch: Dictionary in source.batches.get(id, []):
|
||||||
var start: float = maxf(batch.start_year, batch.get("processed_until", batch.start_year))
|
var start: float = maxf(batch.start_year, batch.get("processed_until", batch.start_year))
|
||||||
var end: float = batch.end_year
|
var end: float = batch.end_year
|
||||||
@@ -592,7 +766,7 @@ func _allocate_whole(requests: Dictionary, limit: float) -> Dictionary:
|
|||||||
assigned += result[id]
|
assigned += result[id]
|
||||||
order.append({"id": id, "rest": expected - result[id]})
|
order.append({"id": id, "rest": expected - result[id]})
|
||||||
order.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
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:
|
for item: Dictionary in order:
|
||||||
if assigned >= available:
|
if assigned >= available:
|
||||||
break
|
break
|
||||||
@@ -714,6 +888,7 @@ func _produce_for_period(previous: Dictionary, target: Dictionary, quantities: D
|
|||||||
order.sort_custom(func(a: String, b: String) -> bool:
|
order.sort_custom(func(a: String, b: String) -> bool:
|
||||||
return candidates[a] < candidates[b] if candidates[a] != candidates[b] else a < b)
|
return candidates[a] < candidates[b] if candidates[a] != candidates[b] else a < b)
|
||||||
var created: Dictionary = {}
|
var created: Dictionary = {}
|
||||||
|
var metallicity: float = stellar_environment(target).metallicity
|
||||||
var total_created: float = 0.0
|
var total_created: float = 0.0
|
||||||
for id in order:
|
for id in order:
|
||||||
var entity: OwniverseEntity = entities[id]
|
var entity: OwniverseEntity = entities[id]
|
||||||
@@ -736,6 +911,7 @@ func _produce_for_period(previous: Dictionary, target: Dictionary, quantities: D
|
|||||||
for id in created:
|
for id in created:
|
||||||
_credit(target, id, created[id])
|
_credit(target, id, created[id])
|
||||||
_append_batch(target, id, created[id], from_year, to_year)
|
_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:
|
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
|
consumed_mass += taken[id] * entities[id].mass
|
||||||
order.append({"id": id, "remainder": expected - taken[id]})
|
order.append({"id": id, "remainder": expected - taken[id]})
|
||||||
order.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
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.
|
# Consume whole BH only; all excess mass becomes SMBH, never disappears.
|
||||||
for candidate: Dictionary in order:
|
for candidate: Dictionary in order:
|
||||||
if consumed_mass >= requested:
|
if consumed_mass >= requested:
|
||||||
@@ -867,13 +1043,16 @@ func produce(item_id: String, desired_amount: float) -> float:
|
|||||||
if max_producible <= 0:
|
if max_producible <= 0:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
|
var metallicity: float = stellar_environment(state).metallicity
|
||||||
if not _try_production(state, entity, max_producible):
|
if not _try_production(state, entity, max_producible):
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
# Add produced entities
|
# Add produced entities
|
||||||
_process_enitity(item_id, max_producible)
|
_process_enitity(item_id, max_producible)
|
||||||
add_entity_to_batch(item_id, max_producible, state.time.datetime)
|
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
|
return max_producible
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ var lifespan_delta: float
|
|||||||
var auto: float
|
var auto: float
|
||||||
var gamma: float
|
var gamma: float
|
||||||
var blast: int
|
var blast: int
|
||||||
|
var blastability: bool = false
|
||||||
var life_product: Dictionary = {}
|
var life_product: Dictionary = {}
|
||||||
var product_per_year_type: String = ""
|
var product_per_year_type: String = ""
|
||||||
var product_per_year_amount: float = 0
|
var product_per_year_amount: float = 0
|
||||||
@@ -46,6 +47,7 @@ func _init(values: Dictionary) -> void:
|
|||||||
auto = float(values.auto)
|
auto = float(values.auto)
|
||||||
gamma = float(values.gamma)
|
gamma = float(values.gamma)
|
||||||
blast = int(values.blast)
|
blast = int(values.blast)
|
||||||
|
blastability = str(values.get("blastability", "")) == "1"
|
||||||
|
|
||||||
life_product = _parse_and_string(values.life_product)
|
life_product = _parse_and_string(values.life_product)
|
||||||
for key in life_product.keys():
|
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:
|
func _parse_planet_string(source_line: String) -> Dictionary:
|
||||||
var result = {}
|
var result = {}
|
||||||
if source_line.contains("&"):
|
for elem in source_line.split("&", false):
|
||||||
var first_split = source_line.split("&")
|
var parts: PackedStringArray = elem.strip_edges().split(":")
|
||||||
for elem in first_split:
|
if parts.size() != 3 or parts[0].strip_edges().is_empty():
|
||||||
var second_split = elem.split(":")
|
push_warning("Invalid planet definition for %s: %s" % [id, elem])
|
||||||
result[second_split[0]] = {}
|
continue
|
||||||
result[second_split[0]]["amount"] = int(second_split[1])
|
if not parts[1].is_valid_float() or not parts[2].is_valid_float():
|
||||||
result[second_split[0]]["chance"] = int(second_split[2])
|
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
|
return result
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c463vlrlhvpx8
|
||||||
Reference in New Issue
Block a user