diff --git a/stage/stage.gd b/stage/stage.gd index 795cb2e..ab9db78 100644 --- a/stage/stage.gd +++ b/stage/stage.gd @@ -106,10 +106,10 @@ func set_datetime(value: float): func set_owniverse_item_value() -> void: - for item_id in owniverse.items_to_update: + for item_id in owniverse.presentation.pending_amounts: if owniverse_items.has(item_id): - owniverse_items[item_id].set_label(owniverse.items_to_update[item_id]) - owniverse.items_to_update.clear() + owniverse_items[item_id].set_label(owniverse.presentation.pending_amounts[item_id]) + owniverse.presentation.pending_amounts.clear() #endregion @@ -127,7 +127,7 @@ func _on_indicator_pressed(indicator_name: String, page: int = 0): func _set_item_description(item: OwniverseItem) -> void: - var category: String = "Entity" if owniverse.entities.has(item.item_id) else "Force" + var category: String = "Entity" if owniverse.catalog.entities.has(item.item_id) else "Force" var id: String = item.item_id if category == "Entity" else item.item_name.capitalize() var page: int = clampi(item.item_page, 0, 4) if description_targets.get(page, {}).get("id", "") != id: @@ -215,7 +215,7 @@ func item_activated(item: OwniverseItem) -> void: if item.item_id == "S" or item.item_id == "H": var _value = owniverse.state.amount[item.item_id] _value = 9 * _value if _value > 0 else 1 - owniverse.produce_hs(item.item_id, _value) + owniverse.production.produce_hs(item.item_id, _value) func _check_counters(delta: float) -> Array: @@ -276,16 +276,16 @@ func _set_active_page_actions() -> void: if !g_active_items.has(pa): continue var item_id: String = g_active_items[pa].item_id - var entity: OwniverseEntity = owniverse.entities[item_id] + var entity: OwniverseEntity = owniverse.catalog.entities[item_id] #var amount = owniverse.get_available_to_produce(item_id) #var amount = owniverse.get_available_to_produce(entity) - var amount = owniverse.calculate_max_producible(entity) + var amount = owniverse.production.calculate_max_producible(entity) #print(item_id, " : ", amount) if item_id == "SMBH": var preview: Callable = func(requested: float) -> float: - return owniverse.calculate_max_producible(entity, requested) + return owniverse.production.calculate_max_producible(entity, requested) page_actions[pa].set_actions(item_id, amount, preview) else: page_actions[pa].set_actions(item_id, amount) @@ -298,7 +298,7 @@ func _page_action_pressed(page_id: int, action_amount: float) -> void: var item_id: String = g_active_items[page_id].item_id if action_amount > 0: - owniverse.produce(item_id, action_amount) + owniverse.production.produce(item_id, action_amount) #endregion @@ -325,8 +325,8 @@ func create_new_universe() -> void: owniverse = Owniverse.new() var inited_result: Dictionary = init_enitities() locked_items = inited_result.locked_items - owniverse.new_state = owniverse.state - for entity in previous.entities.values(): + + for entity in previous.catalog.entities.values(): entity.free() previous.free() @@ -354,7 +354,7 @@ func create_new_universe() -> void: func _refresh_universe_ui() -> void: - owniverse.refresh_item_amounts() + owniverse.presentation.refresh(owniverse.state, owniverse.catalog) _change_speed(0) set_datetime(owniverse.state.time.year) set_owniverse_item_value() @@ -437,9 +437,9 @@ func load_data() -> bool: var complete: Dictionary = owniverse.state.duplicate(true) complete.merge(restored, true) owniverse.state = complete - owniverse.new_state = complete - owniverse.initialize_life_batches() - owniverse.refresh_item_amounts() + + owniverse.evolution.initialize_life_batches() + owniverse.presentation.refresh(owniverse.state, owniverse.catalog) pending_page = clampi(int(game_data.get("ui", {}).get("current_page", 0)), 0, PAGE_COUNT - 1) _restore_page.call_deferred() return true @@ -491,14 +491,14 @@ func init_enitities() -> Dictionary: 'locked_items': [], } owniverse.catalog.load_definitions(Global.OwniverseEntities) - for entity_id in owniverse.entities: - var entity: OwniverseEntity = owniverse.entities[entity_id] + for entity_id in owniverse.catalog.entities: + var entity: OwniverseEntity = owniverse.catalog.entities[entity_id] owniverse.state.stats.total_amount[entity_id] = 0 owniverse.state.amount[entity_id] = 0 if entity.lifespan > 0: owniverse.state.batches[entity.id] = [] - owniverse.add_entity_to_batch(entity_id, 0, 0) + owniverse.batches.add_entity_to_batch(entity_id, 0, 0) if entity.group_as != "": owniverse.state.amount[entity.group_as] = 0 diff --git a/tests/README.md b/tests/README.md index 69d6605..ad5a39f 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,5 +1,11 @@ # Проверки поэтапного переноса из Owniverse.U +## Модули симуляции + +`godot --headless --path Owniverse.GI --script res://tests/test_module_lifecycle.gd` + +Проверяются отдельные экземпляры модулей и состояния для каждой вселенной, слабая обратная ссылка, освобождение модулей вместе с миром и защита от некорректного delta. Остальные тесты используют прямые API batches, ledger, production, automatic, stellar, life, environment и evolution; временные методы-переадресации Owniverse удалены. Описание границ модулей и порядка фаз: `../world/owniverse/README.md`. + ## Математика распределений и каталог `godot --headless --path Owniverse.GI --script res://tests/test_quantity_catalog.gd` @@ -43,7 +49,7 @@ godot --headless --path Owniverse.GI --script res://tests/test_ui_updates.gd Тест сохранений пишет только в уникальную временную папку `/tmp`, затем удаляет собственные файлы. Проверяются независимость снимка, работа кадров при записи, объединение запросов, диагностическая копия, сохранность предыдущего файла при ошибках и завершение очереди при уничтожении менеджера. Сообщения `Invalid save`, `Save blocked` и `Cannot write` в этом тесте ожидаемы. -Счётчики, время и кнопки производства обновляются раз в 0,1 секунды. Изменения симуляции помечают суммы для пересчёта; сами суммы вычисляются при обновлении интерфейса. Текст счётчиков применяется только к видимым элементам и только при изменении строки. Переход между страницами, выбор элемента и производство вызывают немедленное обновление. Описания сохраняют прежний интервал 0,25 секунды. Тест интерфейса использует игровую сцену с отключёнными загрузкой, сохранением и автоматической симуляцией, не обращаясь к сохранению игрока. +Тест интерфейса проверяет передачу количеств из presentation в контролы, очистку очереди после публикации, отложенное и принудительное обновление, производство по нажатию и формат времени. Stage в текущей реализации применяет очередь счётчиков и время каждый кадр; интервал 0,1 секунды используется для индикаторов. Рефакторинг этот порядок не меняет. Тест использует игровую сцену с отключёнными загрузкой, сохранением и автоматической симуляцией, не обращаясь к сохранению игрока. ## Время: шаг 0,1 игрового года diff --git a/tests/fixtures/legacy_stellar_events.gd b/tests/fixtures/legacy_stellar_events.gd index 842043a..369b03a 100644 --- a/tests/fixtures/legacy_stellar_events.gd +++ b/tests/fixtures/legacy_stellar_events.gd @@ -1,5 +1,5 @@ extends "res://world/owniverse/owniverse.gd" -# Keep the old materialized-batch path as an independent comparison mode. -func _uses_short_lived_fast_path(_id: String) -> bool: - return false +# Compare with the materialized-batch path, without overriding the coordinator. +func _init() -> void: + stellar.direct_completions = false diff --git a/tests/test_batch_boundaries.gd b/tests/test_batch_boundaries.gd index b61dfb2..15ca4ae 100644 --- a/tests/test_batch_boundaries.gd +++ b/tests/test_batch_boundaries.gd @@ -17,21 +17,21 @@ func fixture(): 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.catalog.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.catalog.entities["N"].life_product = {} + world.catalog.entities["N"].product_per_year_type = "H" + world.catalog.entities["N"].product_per_year_amount = 10.0 + world.catalog.entities["N"].auto = 0.0 world.state.amount.N = 1.0 return world func dispose(world) -> void: - for entity in world.entities.values(): + for entity in world.catalog.entities.values(): entity.free() world.free() @@ -41,7 +41,7 @@ func run() -> void: var boundary: float = 41215289.79999999701976776 for interval in [[boundary - 2.5, boundary + 2.25], [boundary, boundary + 2.0]]: world.state.batches.LGRB = [] - world._append_batch(world.state, "LGRB", 100.0, interval[0], interval[1]) + world.batches.append_batch(world.state, "LGRB", 100.0, interval[0], interval[1]) var total: float = 0.0 var last: float = -INF for batch: Dictionary in world.state.batches.LGRB: diff --git a/tests/test_civilization_evolution.gd b/tests/test_civilization_evolution.gd index 2819149..02d26bf 100644 --- a/tests/test_civilization_evolution.gd +++ b/tests/test_civilization_evolution.gd @@ -17,21 +17,21 @@ func fixture(): 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.catalog.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.catalog.entities["N"].life_product = {} + world.catalog.entities["N"].product_per_year_type = "H" + world.catalog.entities["N"].product_per_year_amount = 10.0 + world.catalog.entities["N"].auto = 0.0 world.state.amount.N = 1.0 return world func dispose(world) -> void: - for entity in world.entities.values(): + for entity in world.catalog.entities.values(): entity.free() world.free() @@ -39,7 +39,7 @@ func seed_population(world, id: String, count: float, time: float = 1.0) -> void world.state.amount.N = 0.0 world.state.amount[id] = count world.state.batches[id] = [{"amount": count, "start_year": time, "end_year": time}] - world.initialize_life_batches() + world.evolution.initialize_life_batches() func sum_ethics(world, id: String) -> float: var result: float = 0.0 @@ -47,7 +47,7 @@ func sum_ethics(world, id: String) -> float: return result func invariants(world) -> void: - var physical: Dictionary = world._physical_amounts(world.state) + var physical: Dictionary = world.ledger.physical_amounts(world.state) for id in world.state.ethics: check(sum_ethics(world, id) == physical.get(id, 0.0), "Ethics equal physical population for " + id) for n in world.state.ethics[id].values(): @@ -59,7 +59,7 @@ func run() -> void: var baseline: Dictionary = global.CivilizationRules.duplicate(true) var world = fixture() seed_population(world, "C0.2", 60000.0) - world._evolve_batches(world.state, 1.0) + world.evolution.evolve_batches(world.state, 1.0) check(world.state.amount["C0.3"] > 0.0, "C0.2 actually progresses") check(world.state.amount["C0.1"] > 0.0, "C0.2 actually degrades") check(world.state.civilization_stats.get("wars", 0.0) == 0.0, "Table disables early wars") @@ -69,8 +69,8 @@ func run() -> void: invariants(world) var restored = fixture() restored.state = bytes_to_var(var_to_bytes(world.state)) - world._evolve_batches(world.state, 2100.0) - restored._evolve_batches(restored.state, 2100.0) + world.evolution.evolve_batches(world.state, 2100.0) + restored.evolution.evolve_batches(restored.state, 2100.0) check(world.state == restored.state, "Save resumes deterministic ethical evolution") invariants(world) dispose(restored) @@ -80,8 +80,8 @@ func run() -> void: world.state.amount.RD = 1e8 seed_population(world, "C2.0", 60000.0) world.state.amount["C1.9"] = 10000.0 - world.initialize_life_batches() - world._evolve_batches(world.state, 1.0) + world.evolution.initialize_life_batches() + world.evolution.evolve_batches(world.state, 1.0) check(world.state.civilization_stats.wars > 0.0, "Wars execute when enabled") check(world.state.civilization_stats.collateral > 0.0, "Wars affect previous-stage populations") check(world.state.amount["C2.1"] > 0.0, "Winners and peaceful progress reach next stage") @@ -93,14 +93,14 @@ func run() -> void: world.state.binds.RD = {"HP": 5.0, "GG": 5.0, "AB": 5.0, "RP": 5.0} seed_population(world, "C1.6", 60000.0) for row: Dictionary in global.CivilizationRules["C1.6"].ethics.values(): row.degradation = "0" - world._evolve_batches(world.state, 1.0) - var after: Dictionary = world._physical_amounts(world.state) + world.evolution.evolve_batches(world.state, 1.0) + var after: Dictionary = world.ledger.physical_amounts(world.state) check(world.state.civilization_stats.colonies == 20.0, "Colonization limited to real targets") check(after.HP + after.GG + after.AB + after.RP == 0.0, "Colonized objects consumed once") check(world.state.binds.RD.get("C1.4", 0.0) == 20.0, "Colonies retain target host and use two-stages-earlier level") var totals: float = 0.0 for id in after: - if world.entities[id].group_as == "Planet": totals += after[id] + if world.catalog.entities[id].group_as == "Planet": totals += after[id] check(totals == 60020.0, "Colonization replaces planets, does not create extra worlds") invariants(world) dispose(world) @@ -114,8 +114,8 @@ func run() -> void: world.state.amount.MGT = 1e25 world.state.amount.BH25 = 1e25 world.state.amount.SMBH = 1e26 - for id in world.entities: - if world.entities[id].claim != "": world.state.amount[id] = 1e12 + for id in world.catalog.entities: + if world.catalog.entities[id].claim != "": world.state.amount[id] = 1e12 # Accelerated deterministic fixture traverses every enabled stage, including Ct. for id in global.CivilizationRules: if id == "Ct": continue @@ -125,11 +125,11 @@ func run() -> void: for row: Dictionary in global.CivilizationRules[id].ethics.values(): row.progress = "1" row.degradation = "0" - world.entities[id].lifespan = 1.0 - world.entities[id].lifespan_delta = 1.0 + world.catalog.entities[id].lifespan = 1.0 + world.catalog.entities[id].lifespan_delta = 1.0 global.CivilizationRules[id].span = 1.0 seed_population(world, "C0.2", 6.0) - world._evolve_batches(world.state, 1000.0) + world.evolution.evolve_batches(world.state, 1000.0) check(world.state.amount.Ct == 6.0, "Complete C0.2 -> C4.4 -> Ct chain runs") check(not world.state.batches.has("Ct"), "Transcendence is terminal") invariants(world) @@ -140,7 +140,7 @@ func run() -> void: world.state.amount.RD = 10000.0 world.state.amount.SCS = 1.0 world.state.amount["C2.4"] = 1.0 - world.initialize_life_batches() + world.evolution.initialize_life_batches() var span: float = world.state.batches["C2.4"][0].start_year check(is_equal_approx(span, 20000.0 / 3.6), "Structure bonus shortens newly registered lifespan") dispose(world) @@ -156,7 +156,7 @@ func run() -> void: world.state.amount.RD = 1e15 world.state.amount.BG = 1e15 world.state.amount["C3.0"] = 6.0 - world.initialize_life_batches() + world.evolution.initialize_life_batches() check(world.state.amount["C3.0"] == 0.0 and world.state.amount["C2.9"] == 6.0, "Missing mandatory structures degrade existing civilizations") invariants(world) dispose(world) diff --git a/tests/test_civilization_state.gd b/tests/test_civilization_state.gd index a37654c..3408476 100644 --- a/tests/test_civilization_state.gd +++ b/tests/test_civilization_state.gd @@ -17,21 +17,21 @@ func fixture(): 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.catalog.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.catalog.entities["N"].life_product = {} + world.catalog.entities["N"].product_per_year_type = "H" + world.catalog.entities["N"].product_per_year_amount = 10.0 + world.catalog.entities["N"].auto = 0.0 world.state.amount.N = 1.0 return world func dispose(world) -> void: - for entity in world.entities.values(): + for entity in world.catalog.entities.values(): entity.free() world.free() @@ -44,24 +44,24 @@ func run() -> void: world.state.amount.RD = 12.0 world.state.amount["C0.2"] = 8.0 world.state.binds.RD = {"C2.0": 12.0, "C1.9": 3.0} - world.initialize_life_batches() + world.evolution.initialize_life_batches() check(world.state.amount["C2.0"] == 12.0 and world.state.binds.RD["C2.0"] == 0.0, "Old space civilizations detach without loss") check(world.state.binds.RD["C1.9"] == 3.0, "Planetary civilizations retain their host") check(world.state.ethics["C0.2"] == {"m": 2.0, "l": 2.0, "w": 1.0, "d": 1.0, "c": 1.0, "b": 1.0}, "Migration uses equal ethics and deterministic remainder") check(world.state.ethics["C2.0"].values().all(func(n): return n == 2.0), "Bound civilization ethics count physical population") var before: Dictionary = world.state.duplicate(true) - world.initialize_life_batches() + world.evolution.initialize_life_batches() check(world.state == before, "Repeated initialization preserves ethics") world.state.ethics["C0.2"] = {"m": 8.0, "l": 0.0, "w": 0.0, "d": 0.0, "c": 0.0, "b": 0.0} - world.initialize_life_batches() + world.evolution.initialize_life_batches() check(world.state.ethics["C0.2"].m == 8.0, "Existing unequal distribution is retained") world.state.amount["C0.2"] = 3.0 - world.initialize_life_batches() + world.evolution.initialize_life_batches() check(world.state.ethics["C0.2"].m == 3.0, "Losses reduce ethics without inventing population") check(root.get_node("SaveManager").validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Ethics state validates") var restored = fixture() restored.state = bytes_to_var(var_to_bytes(world.state)) - restored.initialize_life_batches() + restored.evolution.initialize_life_batches() check(restored.state == world.state, "Save roundtrip preserves ethics and ownership") dispose(restored) dispose(world) @@ -73,7 +73,7 @@ func run() -> void: row.colonization = "0" world.state.binds.RD = {"C1.9": 10.0} world.state.batches["C1.9"] = [{"amount": 10.0, "start_year": 1.0, "end_year": 1.0}] - world._evolve_batches(world.state, 1.0) + world.evolution.evolve_batches(world.state, 1.0) check(world.state.amount["C2.0"] == 10.0 and world.state.binds.RD.get("C2.0", 0.0) == 0.0, "Space successor does not inherit planetary binding") dispose(world) var capacity = load("res://world/owniverse/civilization_state.gd") @@ -97,25 +97,25 @@ func run() -> void: world.state.amount.RD = 12.0 world.state.amount["C2.1"] = 2.0 world.state.amount["C2.0"] = 5.0 - world.initialize_life_batches() + world.evolution.initialize_life_batches() check(world.state.amount["C2.1"] == 1.0 and world.state.amount["C2.0"] == 2.0, "Shared budget supports advanced civilizations first") check(world.state.binds.RD["C1.9"] == 4.0, "Excess cascades to supported planetary stage") var snapshot: Dictionary = world.state.duplicate(true) - world.initialize_life_batches() + world.evolution.initialize_life_batches() check(world.state == snapshot, "Capacity enforcement is idempotent") world.state.amount.RD = 0.0 - world.initialize_life_batches() + world.evolution.initialize_life_batches() check(world.state.amount["C2.1"] == 0.0 and world.state.amount["C2.0"] == 0.0, "Vanished stars force all unsupported space civilizations down") - check(world._physical_amounts(world.state)["C1.9"] == 7.0, "Forced degradation conserves civilizations") + check(world.ledger.physical_amounts(world.state)["C1.9"] == 7.0, "Forced degradation conserves civilizations") dispose(world) world = fixture() world.state.amount.LGW = 20.0 world.state.amount.SMBH = 2.0e22 world.state.amount["C4.2"] = 3.0 - world.initialize_life_batches() + world.evolution.initialize_life_batches() check(world.state.amount["C4.2"] == 2.0, "SMBH capacity uses stellar mass equivalent") - check(world._physical_amounts(world.state)["C1.9"] == 1.0, "Unsupported civilization degrades through missing habitat families") + check(world.ledger.physical_amounts(world.state)["C1.9"] == 1.0, "Unsupported civilization degrades through missing habitat families") check(root.get_node("SaveManager").validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Capacity-limited state validates") dispose(world) @@ -127,7 +127,7 @@ func run() -> void: row.colonization = "0" world.state.binds.RD = {"C1.9": 5.0} world.state.batches["C1.9"] = [{"amount": 5.0, "start_year": 1.0, "end_year": 1.0}] - world._evolve_batches(world.state, 1.0) + world.evolution.evolve_batches(world.state, 1.0) check(world.state.amount["C2.0"] == 2.0 and world.state.binds.RD["C1.9"] == 3.0, "Unfunded transition retains the original planetary civilization") dispose(world) print("Civilization state: %d checks, %d failures" % [checks, failures]) diff --git a/tests/test_descriptions.gd b/tests/test_descriptions.gd index 254eda6..c6ab58e 100644 --- a/tests/test_descriptions.gd +++ b/tests/test_descriptions.gd @@ -17,21 +17,21 @@ func fixture(): 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.catalog.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.catalog.entities["N"].life_product = {} + world.catalog.entities["N"].product_per_year_type = "H" + world.catalog.entities["N"].product_per_year_amount = 10.0 + world.catalog.entities["N"].auto = 0.0 world.state.amount.N = 1.0 return world func dispose(world) -> void: - for entity in world.entities.values(): + for entity in world.catalog.entities.values(): entity.free() world.free() @@ -65,11 +65,11 @@ func run() -> void: world.state.amount.EON1 = 2.0 world.state.binds.G.EON2 = 3.0 var saved_state: Dictionary = world.state.duplicate(true) - var counters: Dictionary = world.get_item_amounts() + var counters: Dictionary = world.presentation.amounts(world.state, world.catalog) check(counters.HP == 7.0 and counters.LP == 5.0 and counters.Planet == 12.0, "Planet counters include bound objects and aggregate eons") check(counters.RD == 2.0, "Star counters retain available quantity") check(world.state == saved_state, "Reading counters does not mutate simulation") - check(world.get_item_amounts(true).HP == 0.0, "Produced counters remain independent of current population") + check(world.presentation.amounts(world.state, world.catalog, true).HP == 0.0, "Produced counters remain independent of current population") values = description.variables(world, global, "RD", 2.0) check(values.amount.contains("#40cc40") and values.amount.contains("+2.0"), "Increase green") var stage_script = load("res://stage/stage.gd") diff --git a/tests/test_group_index.gd b/tests/test_group_index.gd index deab3f1..ebca385 100644 --- a/tests/test_group_index.gd +++ b/tests/test_group_index.gd @@ -17,42 +17,42 @@ func fixture(): 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.catalog.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.catalog.entities["N"].life_product = {} + world.catalog.entities["N"].product_per_year_type = "H" + world.catalog.entities["N"].product_per_year_amount = 10.0 + world.catalog.entities["N"].auto = 0.0 world.state.amount.N = 1.0 return world func dispose(world) -> void: - for entity in world.entities.values(): + for entity in world.catalog.entities.values(): entity.free() world.free() func run() -> void: var world = fixture() - for id in world.entities: + for id in world.catalog.entities: var expected: Array = [] - for key in world.entities: - if world.entities[key].group_as == id: + for key in world.catalog.entities: + if world.catalog.entities[key].group_as == id: expected.append(key) - check(world._is_group(id) == (not expected.is_empty()), "Group classification: " + id) - check(world._members(id) == (expected if not expected.is_empty() else [id]), "Group members: " + id) - var members: Array = world._members("Star") + check(world.catalog.is_group(id) == (not expected.is_empty()), "Group classification: " + id) + check(world.catalog.members(id) == (expected if not expected.is_empty() else [id]), "Group members: " + id) + var members: Array = world.catalog.members("Star") members.clear() - check(not world._members("Star").is_empty(), "Returned members are independent") - world.entities.N.group_as = "TestGroup" - world.rebuild_group_index() - check(world._members("TestGroup") == ["N"], "Explicit definition reload") - world.entities.N.free() - world.entities.erase("N") - check(not world._is_group("TestGroup"), "Removed definition invalidates index") + check(not world.catalog.members("Star").is_empty(), "Returned members are independent") + world.catalog.entities.N.group_as = "TestGroup" + world.catalog.rebuild_group_index() + check(world.catalog.members("TestGroup") == ["N"], "Explicit definition reload") + world.catalog.entities.N.free() + world.catalog.entities.erase("N") + check(not world.catalog.is_group("TestGroup"), "Removed definition invalidates index") print("Group index: %d checks, %d failures" % [checks, failures]) dispose(world) quit(1 if failures else 0) diff --git a/tests/test_life.gd b/tests/test_life.gd index cb5d057..4144a33 100644 --- a/tests/test_life.gd +++ b/tests/test_life.gd @@ -17,21 +17,21 @@ func fixture(): 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.catalog.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.catalog.entities["N"].life_product = {} + world.catalog.entities["N"].product_per_year_type = "H" + world.catalog.entities["N"].product_per_year_amount = 10.0 + world.catalog.entities["N"].auto = 0.0 world.state.amount.N = 1.0 return world func dispose(world) -> void: - for entity in world.entities.values(): + for entity in world.catalog.entities.values(): entity.free() world.free() @@ -39,25 +39,25 @@ func run() -> void: var world = fixture() world.state.amount.N = 0.0 world.state.amount.HP = 40000.0 - world.initialize_life_batches() + world.evolution.initialize_life_batches() check(world.state.batches.HP.size() == 1, "Old HP receives life cohort") check(world.state.batches.HP[0].start_year == 5e6, "HP uses source span") var before: Dictionary = world.state.duplicate(true) - world.initialize_life_batches() + world.evolution.initialize_life_batches() check(world.state == before, "Initialization is idempotent") - world._evolve_batches(world.state, 5e6 + 5e6 / 40.0) + world.evolution.evolve_batches(world.state, 5e6 + 5e6 / 40.0) check(world.state.amount.EON1 == 10.0, "HP abiogenesis follows source probability") check(world.state.amount.RP == 100.0, "HP degradation produces RP") check(world.state.amount.HP == 39890.0, "Unchanged planets retry, not disappear") check(world.state.stats.total_amount.HP == 0.0, "Retry does not inflate creation statistics") check(world.state.batches.HP.size() > 0, "Survivors get next attempt") - var physical: Dictionary = world._physical_amounts(world.state) + var physical: Dictionary = world.ledger.physical_amounts(world.state) check(physical.HP + physical.EON1 + physical.RP == 40000.0, "Life conserves planets") check(root.get_node("SaveManager").validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Life save validates") var restored = fixture() restored.state = bytes_to_var(var_to_bytes(world.state)) - world._evolve_batches(world.state, 11e6) - restored._evolve_batches(restored.state, 11e6) + world.evolution.evolve_batches(world.state, 11e6) + restored.evolution.evolve_batches(restored.state, 11e6) check(world.state == restored.state, "Life continuation preserves cohorts and carries") dispose(restored) dispose(world) @@ -65,8 +65,8 @@ func run() -> void: world = fixture() world.state.amount.RD = 1.0 world.state.binds.RD = {"EON5": 40000.0} - world.initialize_life_batches() - world._evolve_batches(world.state, 5e5 + 5e5 / 40.0) + world.evolution.initialize_life_batches() + world.evolution.evolve_batches(world.state, 5e5 + 5e5 / 40.0) check(world.state.binds.RD["C0.0"] == 10.0, "Intelligence emerges at the same host star") check(world.state.amount["C0.0"] == 0.0, "Life transition keeps planet bound") check(world.state.batches.has("C0.0"), "New C0.0 starts civilization evolution") @@ -77,7 +77,7 @@ func run() -> void: var degraded: float = 0.0 var valid_outcomes: bool = true for i in 4001: - var products: Dictionary = world._life_products(world.state, world.entities.HP, 1.0) + var products: Dictionary = world.life.life_products(world.state, world.catalog.entities.HP, 1.0) var total: float = 0.0 for value in products.values(): total += value @@ -92,29 +92,29 @@ func run() -> void: world = fixture() world.state.time = {"datetime": 123.4, "year": 123.4} world.state.binds.RD = {"HP": 2.0} - world.initialize_life_batches() + world.evolution.initialize_life_batches() check(world.state.batches.HP[0].created_from == 123.4, "Old bound worlds begin life at load time") - world._destroy_planets(world.state, "RD", 1.0, 1) - world._evolve_batches(world.state, 1e7) - check(world._physical_amounts(world.state).HP == 0.0 and world.state.binds.RD.RP == 2.0, "Destroyed life does not resurrect from cohort") + world.stellar.destroy_planets(world.state, "RD", 1.0, 1) + world.evolution.evolve_batches(world.state, 1e7) + check(world.ledger.physical_amounts(world.state).HP == 0.0 and world.state.binds.RD.RP == 2.0, "Destroyed life does not resurrect from cohort") dispose(world) world = fixture() world.state.time = {"datetime": 123.4, "year": 123.4} world.state.amount["C0.0"] = 40000.0 - world.initialize_life_batches() + world.evolution.initialize_life_batches() check(world.state.batches["C0.0"][0].created_from == 123.4, "Civilization cohorts begin at the current time") before = world.state.duplicate(true) - world.initialize_life_batches() + world.evolution.initialize_life_batches() check(world.state == before, "Life batch initialization is idempotent") - world._evolve_batches(world.state, 2173.4) + world.evolution.evolve_batches(world.state, 2173.4) check(world.state.amount["C0.1"] == 700.0 and world.state.amount.EON5 == 1000.0, "C0.0 progresses and degrades by Unity rates") check(world.state.amount["C0.0"] == 38300.0, "C0.0 remainder stays alive") dispose(world) world = fixture() world.state.amount.RD = 1.0 world.state.binds.RD = {"C0.1": 40000.0} - world.initialize_life_batches() - world._evolve_batches(world.state, 2050.0) + world.evolution.initialize_life_batches() + world.evolution.evolve_batches(world.state, 2050.0) check(world.state.binds.RD["C0.2"] == 700.0, "C0.1 reaches ethical civilization without losing host") check(world.state.binds.RD["C0.0"] == 1000.0, "C0.1 degradation follows Unity") check(root.get_node("SaveManager").validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Extended life save validates") diff --git a/tests/test_life_icons.gd b/tests/test_life_icons.gd index 1df4e9a..6948e5c 100644 --- a/tests/test_life_icons.gd +++ b/tests/test_life_icons.gd @@ -28,10 +28,10 @@ func run() -> void: world.state.stats.total_amount["C0.0"] = 3.0 var before: Dictionary = world.state.duplicate(true) stage._refresh_universe_ui() - var counts: Dictionary = world.get_item_amounts() + var counts: Dictionary = world.presentation.amounts(world.state, world.catalog) check(counts.EON1 == 12.0 and counts.EON2 == 4.0 and counts.LP == 16.0, "Individual eons and their total include bound planets") check(counts["C0.0"] == 3.0 and counts.C0 == 3.0 and counts.Planet == 19.0, "Civilization detail does not double the planetary total") - check(world.get_item_amounts(true).EON1 == 12.0 and world.get_item_amounts(true).LP == 16.0, "Historical counts retain individual and aggregate values") + check(world.presentation.amounts(world.state, world.catalog, true).EON1 == 12.0 and world.presentation.amounts(world.state, world.catalog, true).LP == 16.0, "Historical counts retain individual and aggregate values") check(world.state == before, "Presentation does not modify simulation state") var pending: Array = ["EON1", "EON2", "EON3", "C0.0"] var pending_before: Array = pending.duplicate() @@ -40,16 +40,16 @@ func run() -> void: for id in ["EON1", "EON2", "LP", "C0.0", "C0"]: check(decision.visible.has(id), "Unlock projection includes detail and alias: " + id) check(pending == pending_before and world.state == before, "Unlock evaluation leaves inputs untouched") - var snapshot: Dictionary = world.items_to_update.duplicate() - world.defer_item_updates = true + var snapshot: Dictionary = world.presentation.pending_amounts.duplicate() + world.presentation.deferred_updates = true world.state.amount.EON1 += 1.0 - world.refresh_item_amounts() - check(world.item_amounts_dirty and world.items_to_update == snapshot, "Deferred refresh retains pending snapshot") - world.refresh_item_amounts(true) - check(not world.item_amounts_dirty and world.items_to_update.EON1 == 13.0, "Forced refresh publishes latest amounts") + world.presentation.refresh(world.state, world.catalog) + check(world.presentation.dirty and world.presentation.pending_amounts == snapshot, "Deferred refresh retains pending snapshot") + world.presentation.refresh(world.state, world.catalog, true) + check(not world.presentation.dirty and world.presentation.pending_amounts.EON1 == 13.0, "Forced refresh publishes latest amounts") world.state.amount.EON1 -= 1.0 - world.defer_item_updates = false - world.refresh_item_amounts() + world.presentation.deferred_updates = false + world.presentation.refresh(world.state, world.catalog) for id in ["EON1", "EON2", "LP", "C0.0", "C0"]: check(stage.owniverse_items[id].visible, "Unlock both levels: " + id) check(stage.owniverse_items[id].description.text != "", "Populate the icon counter: " + id) @@ -61,7 +61,7 @@ func run() -> void: world.state.binds.RD.EON2 = 0.0 stage._refresh_universe_ui() check(stage.owniverse_items.EON2.visible and stage.owniverse_items.EON2.description.text == "", "Previously reached stage stays unlocked after extinction with no stale count") - for entity in world.entities.values(): entity.free() + for entity in world.catalog.entities.values(): entity.free() world.free() scene.free() print("Life icons: %d checks, %d failures" % [checks, failures]) diff --git a/tests/test_module_lifecycle.gd b/tests/test_module_lifecycle.gd new file mode 100644 index 0000000..0de7b1b --- /dev/null +++ b/tests/test_module_lifecycle.gd @@ -0,0 +1,38 @@ +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 run() -> void: + var World = load("res://world/owniverse/owniverse.gd") + var first = World.new() + var second = World.new() + var names: Array = ["batches", "ledger", "production", "automatic", "stellar", "life", "environment", "evolution"] + var references: Array = [] + for name in names: + check(first.get(name) != second.get(name), "World has its own " + name) + check(first.get(name).world == first, "Module refers to its owner: " + name) + references.append(weakref(first.get(name))) + first.state.amount.H = 123.0 + first.presentation.pending_amounts.H = 123.0 + check(not second.state.amount.has("H") and second.presentation.pending_amounts.is_empty(), "State and pending presentation are isolated") + first.free() + for reference in references: + check(reference.get_ref() == null, "Module is released with its world") + check(second.evolution.world == second, "Other world remains usable") + var snapshot: Dictionary = second.state.duplicate(true) + second.evolute(NAN) + second.evolute(-1.0) + check(second.state == snapshot, "Entry point rejects invalid time deltas") + second.free() + print("Module lifecycle: %d checks, %d failures" % [checks, failures]) + quit(1 if failures else 0) diff --git a/tests/test_module_lifecycle.gd.uid b/tests/test_module_lifecycle.gd.uid new file mode 100644 index 0000000..8a8ddaa --- /dev/null +++ b/tests/test_module_lifecycle.gd.uid @@ -0,0 +1 @@ +uid://d2xk4ldldsgo3 diff --git a/tests/test_production_transaction.gd b/tests/test_production_transaction.gd index ae1576a..cbd42b1 100644 --- a/tests/test_production_transaction.gd +++ b/tests/test_production_transaction.gd @@ -17,39 +17,39 @@ func fixture(): 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.catalog.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.catalog.entities["N"].life_product = {} + world.catalog.entities["N"].product_per_year_type = "H" + world.catalog.entities["N"].product_per_year_amount = 10.0 + world.catalog.entities["N"].auto = 0.0 world.state.amount.N = 1.0 return world func dispose(world) -> void: - for entity in world.entities.values(): + for entity in world.catalog.entities.values(): entity.free() world.free() func run() -> void: var world = fixture() - world.initialize_life_batches() - world.entities.N.cost = {"H": 2.0} - world.entities.N.bind = {"S": 3.0} + world.evolution.initialize_life_batches() + world.catalog.entities.N.cost = {"H": 2.0} + world.catalog.entities.N.bind = {"S": 3.0} world.state.amount.H = 10.0 world.state.amount.S = 0.0 world.state.auto_resources = {"H": 10.0} world.state.batches.H = [{"amount": 10.0, "start_year": 1.0, "end_year": 2.0}] var before: Dictionary = world.state.duplicate(true) - check(not world._try_production(world.state, world.entities.N, 1.0, true), "Binding failure rejects production") + check(not world.production.try_production(world.state, world.catalog.entities.N, 1.0, true), "Binding failure rejects production") check(world.state == before, "Failure preserves costs, batches and budget") world.state.amount.S = 10.0 var old_batches: Array = world.state.batches.H - check(world._try_production(world.state, world.entities.N, 2.0, true), "Funded production succeeds") + check(world.production.try_production(world.state, world.catalog.entities.N, 2.0, true), "Funded production succeeds") check(world.state.amount.H == 6.0 and world.state.amount.S == 4.0, "Exact cost and binding") check(world.state.auto_resources.H == 6.0, "Exact automatic budget") check(old_batches[0].amount == 10.0 and world.state.batches.H[0].amount == 6.0, "Consumed batches isolated") diff --git a/tests/test_short_lived_events.gd b/tests/test_short_lived_events.gd index 09df2fc..e0bfcb5 100644 --- a/tests/test_short_lived_events.gd +++ b/tests/test_short_lived_events.gd @@ -14,16 +14,16 @@ func check(condition: bool, message: String) -> void: func fixture(legacy: bool): var world = load("res://tests/fixtures/legacy_stellar_events.gd" if legacy else "res://world/owniverse/owniverse.gd").new() for id in root.get_node("Global").OwniverseEntities: - world.entities[id] = load("res://world/owniverse/owniverse_entity.gd").new(root.get_node("Global").OwniverseEntities[id]) + world.catalog.entities[id] = load("res://world/owniverse/owniverse_entity.gd").new(root.get_node("Global").OwniverseEntities[id]) world.state.amount[id] = 0.0 world.state.stats.total_amount[id] = 0.0 - if world.entities[id].group_as != "": - world.state.amount[world.entities[id].group_as] = 0.0 - world.state.stats.total_amount[world.entities[id].group_as] = 0.0 + if world.catalog.entities[id].group_as != "": + world.state.amount[world.catalog.entities[id].group_as] = 0.0 + world.state.stats.total_amount[world.catalog.entities[id].group_as] = 0.0 return world func dispose(world) -> void: - for entity in world.entities.values(): entity.free() + for entity in world.catalog.entities.values(): entity.free() world.free() func equivalent(a: Variant, b: Variant) -> bool: @@ -55,11 +55,11 @@ func run() -> void: fast.state.batches[parent] = [{"amount": 20000.0, "start_year": 100.3, "end_year": 3000.7}] # Existing short-lived populations test integration with new arrivals. fast.state.amount.SN = 3.0 - fast._append_batch(fast.state, "SN", 3.0, 100.5, 100.5) + fast.batches.append_batch(fast.state, "SN", 3.0, 100.5, 100.5) if year == 104.75: fast.state.energy = 7.0 legacy.state = fast.state.duplicate(true) - fast._evolve_batches(fast.state, year) - legacy._evolve_batches(legacy.state, year) + fast.evolution.evolve_batches(fast.state, year) + legacy.evolution.evolve_batches(legacy.state, year) for field in fast.state: check(equivalent(fast.state[field], legacy.state[field]), "%s at %s: %s matches materialized events" % [parent, year, field]) for id in ["SN", "DSN", "BSN", "HN", "GRB", "SGRB", "LGRB", "HGRB"]: @@ -68,7 +68,7 @@ func run() -> void: dispose(legacy) var fast = fixture(false) var pending: Dictionary = {} - fast._append_batch(fast.state, "SN", 10000.0, 0.25, 1667.25, pending, 1667.25) + fast.batches.append_batch(fast.state, "SN", 10000.0, 0.25, 1667.25, pending, 1667.25) check(fast.state.batches.SN.size() <= 2 and pending.SN.size() == 1, "Completed windows aggregate without transient batch allocation") var total: float = pending.SN[0].amount for batch in fast.state.batches.SN: total += batch.amount diff --git a/tests/test_stellar_rules.gd b/tests/test_stellar_rules.gd index cff73ae..874fc7c 100644 --- a/tests/test_stellar_rules.gd +++ b/tests/test_stellar_rules.gd @@ -16,72 +16,72 @@ func fixture(): 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.catalog.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.catalog.entities["N"].life_product = {} + world.catalog.entities["N"].product_per_year_type = "H" + world.catalog.entities["N"].product_per_year_amount = 10.0 + world.catalog.entities["N"].auto = 0.0 world.state.amount.N = 1.0 return world func dispose(world) -> void: - for entity in world.entities.values(): + for entity in world.catalog.entities.values(): entity.free() world.free() func run() -> void: var world = fixture() for id in ["DSN", "SN", "BSN", "HN", "SGRB", "GRB", "LGRB", "HGRB"]: - check(world._evolution_products(world.state, world.entities[id], 3.0).blast == 3.0 * world.entities[id].blast, "Entity blast determines power: " + id) - world.entities.SN.output_and.blast = 999.0 - world.entities.SN.blast = 4 - check(world._evolution_products(world.state, world.entities.SN, 2.0).blast == 8.0, "Imported output cannot override or double blast power") - world.entities.SN.blast = 0 - check(not world._evolution_products(world.state, world.entities.SN, 2.0).has("blast"), "Zero entity blast disables external event") - world.entities.SN.output_or = {"positive": "blast:999", "negative": "blast:999", "chance": 50.0} - check(not world._evolution_products(world.state, world.entities.SN, 2.0).has("blast"), "Alternative output cannot restore disabled blast") + check(world.stellar.evolution_products(world.state, world.catalog.entities[id], 3.0).blast == 3.0 * world.catalog.entities[id].blast, "Entity blast determines power: " + id) + world.catalog.entities.SN.output_and.blast = 999.0 + world.catalog.entities.SN.blast = 4 + check(world.stellar.evolution_products(world.state, world.catalog.entities.SN, 2.0).blast == 8.0, "Imported output cannot override or double blast power") + world.catalog.entities.SN.blast = 0 + check(not world.stellar.evolution_products(world.state, world.catalog.entities.SN, 2.0).has("blast"), "Zero entity blast disables external event") + world.catalog.entities.SN.output_or = {"positive": "blast:999", "negative": "blast:999", "chance": 50.0} + check(not world.stellar.evolution_products(world.state, world.catalog.entities.SN, 2.0).has("blast"), "Alternative output cannot restore disabled blast") dispose(world) world = fixture() - var rd = world.entities.RD + var rd = world.catalog.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) + check(world.catalog.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) + check(not world.catalog.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) + var products: Dictionary = world.stellar.evolution_products(world.state, world.catalog.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") + check(world.stellar.evolution_products(world.state, world.catalog.entities.HRG, 37.0) == restored.stellar.evolution_products(restored.state, restored.catalog.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) + world.batches.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) + world.evolution.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) + world.evolution.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) + world.evolution.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) + world.evolution.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) @@ -89,7 +89,7 @@ func run() -> void: 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) + world.stellar.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") @@ -98,8 +98,8 @@ func run() -> void: check(saves.validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Planet carry save validates") restored = fixture() restored.state = bytes_to_var(var_to_bytes(world.state)) - world._create_planets(world.state, "RD", 37.0, 0.0, 0.0, 0.7) - restored._create_planets(restored.state, "RD", 37.0, 0.0, 0.0, 0.7) + world.stellar.create_planets(world.state, "RD", 37.0, 0.0, 0.0, 0.7) + restored.stellar.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) @@ -108,14 +108,14 @@ func run() -> void: 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) + world.batches.append_batch(world.state, "RD", 4.0, 0.0, 0.0) + world.evolution.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) + world.evolution.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({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Inherited state validates") dispose(world) @@ -123,9 +123,9 @@ func run() -> void: 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) + world.batches.append_batch(world.state, "WS", 2.0, 0.0, 0.0) + world.evolution.evolve_batches(world.state, world.catalog.entities.WS.lifespan + world.catalog.entities.WS.lifespan_delta) + var physical: Dictionary = world.ledger.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) @@ -133,10 +133,10 @@ func run() -> void: 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}) + world.stellar.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}) + world.stellar.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) @@ -145,15 +145,15 @@ func run() -> void: 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}) + world.stellar.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) + world.batches.append_batch(world.state, "SN", 1.0, 0.0, 0.0) + world.evolution.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") @@ -163,7 +163,7 @@ func run() -> void: 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.production.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) @@ -180,25 +180,25 @@ func run() -> void: dispose(world) world = fixture() - world.entities.N.auto = 1.0 + world.catalog.entities.N.auto = 1.0 # 1000 newly produced automatic H reserve 1 H, enough for 10 HBD. world.evolute(100.0) check(world.state.amount.HBD == 10.0, "Brown dwarfs form without existing ordinary stars") check(world.state.amount.H == 999.0 and world.state.auto_resources.H == 999.0, "Brown dwarfs consume exactly their reserved hydrogen") - check(world.get_item_amounts().BD == 10.0 and world.state.batches.HBD.size() > 0, "Brown dwarfs appear in counter and evolution batches") - world.entities.N.product_per_year_amount = 0.0 + check(world.presentation.amounts(world.state, world.catalog).BD == 10.0 and world.state.batches.HBD.size() > 0, "Brown dwarfs appear in counter and evolution batches") + world.catalog.entities.N.product_per_year_amount = 0.0 world.evolute(100.0) check(world.state.amount.HBD == 10.0, "Stored automatic budget is not taxed repeatedly") dispose(world) world = fixture() - world.entities.N.auto = 1.0 + world.catalog.entities.N.auto = 1.0 world.state.amount.RD = 1.0 world.evolute(50.0) check(world.state.amount.HBD == 5.0 and is_equal_approx(world.state.auto_star_deficits.HBD_fuel, 0.5), "Individual dwarfs retain prepaid fractional fuel") check(world.state.amount.H == 0.0, "Prepaid fuel is not available to ordinary stars") var resumed = fixture() - resumed.entities.N.auto = 1.0 + resumed.catalog.entities.N.auto = 1.0 resumed.state = bytes_to_var(var_to_bytes(world.state)) world.evolute(50.0) resumed.evolute(50.0) @@ -206,8 +206,8 @@ func run() -> void: check(world.state == resumed.state, "Brown dwarf quota survives serialization deterministically") check(world.state.amount.RD == 1000.0 and world.state.amount.H == 0.0, "Shared automatic hydrogen is spent once") world.state.batches.HBD = [{"amount": 10.0, "start_year": 101.0, "end_year": 101.0}] - world._evolve_batches(world.state, 101.0) - check(world.state.amount.HBD == 0.0 and world.state.amount.CBD == 10.0 and world.get_item_amounts().BD == 10.0, "Cooling preserves aggregate brown dwarf count") + world.evolution.evolve_batches(world.state, 101.0) + check(world.state.amount.HBD == 0.0 and world.state.amount.CBD == 10.0 and world.presentation.amounts(world.state, world.catalog).BD == 10.0, "Cooling preserves aggregate brown dwarf count") dispose(resumed) dispose(world) @@ -217,7 +217,7 @@ func run() -> void: world.state.amount.H += 1.0 world.state.auto_resources.H = world.state.auto_resources.get("H", 0.0) + 1.0 var before: float = world.state.amount.HBD - world._produce_brown_dwarfs(world.state, 1.0, float(i), float(i + 1)) + world.automatic.produce_brown_dwarfs(world.state, 1.0, float(i), float(i + 1)) var born: float = world.state.amount.HBD - before check(born == 0.0 or born == 1.0, "Small fuel increments produce individual dwarfs") saw_individual = saw_individual or born == 1.0 @@ -228,23 +228,23 @@ func run() -> void: for simulation in [world, resumed]: simulation.state.amount.H += 1.0 simulation.state.auto_resources.H += 1.0 - simulation._produce_brown_dwarfs(simulation.state, 1.0, float(i), float(i + 1)) + simulation.automatic.produce_brown_dwarfs(simulation.state, 1.0, float(i), float(i + 1)) check(world.state == resumed.state, "Random sequence and prepaid fuel survive saving") check(is_equal_approx(world.state.amount.H + world.state.amount.HBD * 0.1 + world.state.auto_star_deficits.HBD_fuel, 500.0), "Individual production conserves fuel including prepaid reserve") check(is_equal_approx(world.state.amount.HBD + world.state.auto_star_deficits.HBD, 5.0), "Signed remainder preserves the 0.1 percent allocation") var paused_count: float = world.state.amount.HBD - for i in 10: world._produce_brown_dwarfs(world.state, 0.0, 500.0, 501.0) + for i in 10: world.automatic.produce_brown_dwarfs(world.state, 0.0, 500.0, 501.0) check(world.state.amount.HBD == paused_count, "No repeated random attempts without new fuel") dispose(resumed) dispose(world) # Two simultaneous successors share surviving planets in the same 99:1 ratio. world = fixture() - world.entities.HRG.destroy_level = 0 + world.catalog.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) + world.evolution.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) diff --git a/tests/test_time.gd b/tests/test_time.gd index 88358ea..c707129 100644 --- a/tests/test_time.gd +++ b/tests/test_time.gd @@ -17,21 +17,21 @@ func fixture(): 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.catalog.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.catalog.entities["N"].life_product = {} + world.catalog.entities["N"].product_per_year_type = "H" + world.catalog.entities["N"].product_per_year_amount = 10.0 + world.catalog.entities["N"].auto = 0.0 world.state.amount.N = 1.0 return world func dispose(world) -> void: - for entity in world.entities.values(): + for entity in world.catalog.entities.values(): entity.free() world.free() @@ -80,8 +80,8 @@ func run() -> void: dispose(world) world = fixture() - world.entities.N.output_and = {"He": 1.0} - world.entities.N.output_or = {} + world.catalog.entities.N.output_and = {"He": 1.0} + world.catalog.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") diff --git a/tests/test_ui_updates.gd b/tests/test_ui_updates.gd index de5c88f..6288cec 100644 --- a/tests/test_ui_updates.gd +++ b/tests/test_ui_updates.gd @@ -24,31 +24,36 @@ func run() -> void: await process_frame stage.owniverse.state.amount.H = 1000.0 stage.owniverse.state.amount.RD = 2.0 - stage.owniverse.refresh_item_amounts() - stage._update_ui(0.0) + stage.owniverse.presentation.refresh(stage.owniverse.state, stage.owniverse.catalog) + stage.set_owniverse_item_value() var hydrogen = stage.owniverse_items.H var dwarf = stage.owniverse_items.RD - check(hydrogen.description.text == "1 000", "Visible page updates immediately on navigation") - check(dwarf.description.text == "", "Offscreen counters are not rendered") + check(hydrogen.description.text == "1 000", "Pending quantities reach controls") + check(dwarf.description.text == "2", "Other page receives its current quantity") + check(stage.owniverse.presentation.pending_amounts.is_empty(), "Published queue is consumed by Stage") + stage.owniverse.presentation.deferred_updates = true stage.owniverse.state.amount.H = 2000.0 - stage.owniverse.refresh_item_amounts() - check(stage.owniverse.item_amounts_dirty and stage.owniverse.items_to_update.H == 1000.0, "Simulation invalidates without recalculating display sums") - stage._update_ui(0.05) - check(hydrogen.description.text == "1 000", "No refresh before interval") - stage._update_ui(0.051) - check(hydrogen.description.text == "2 000" and not stage.owniverse.item_amounts_dirty, "Latest state appears at interval") + stage.owniverse.presentation.refresh(stage.owniverse.state, stage.owniverse.catalog) + check(stage.owniverse.presentation.dirty and stage.owniverse.presentation.pending_amounts.is_empty(), "Deferred refresh marks dirty without republishing") + stage.set_owniverse_item_value() + check(hydrogen.description.text == "1 000", "Labels retain last published quantity") + stage.owniverse.presentation.refresh(stage.owniverse.state, stage.owniverse.catalog, true) + stage.set_owniverse_item_value() + check(hydrogen.description.text == "2 000" and not stage.owniverse.presentation.dirty, "Forced refresh publishes latest state") + stage.owniverse.presentation.deferred_updates = false stage.item_activated(hydrogen) - check(hydrogen.description.text == root.get_node("Global").format_number(stage.owniverse.state.amount.H), "Player production updates counter immediately") + stage.set_owniverse_item_value() + check(hydrogen.description.text == root.get_node("Global").format_number(stage.owniverse.state.amount.H), "Player production publishes the new quantity") check(stage.owniverse.state.amount.H > 2000.0, "Production action actually executed") dwarf.visible = true stage.stage_page_scroller.scroll_horizontal = 2 * stage.PAGE_WIDTH await process_frame - stage._update_ui(0.0) + stage._refresh_universe_ui() check(dwarf.description.text == "2", "Newly visible page receives cached latest values") stage.owniverse.state.time.year = 1234.5 - stage._update_ui(0.1) - check(stage.date_time.text == "1 234.5", "Time uses throttled display with tenths") - for entity in stage.owniverse.entities.values(): entity.free() + stage._refresh_universe_ui() + check(stage.date_time.text == "1 234.5", "Time displays tenths with thousands separator") + for entity in stage.owniverse.catalog.entities.values(): entity.free() stage.owniverse.free() scene.free() print("UI updates: %d checks, %d failures" % [checks, failures]) diff --git a/tests/test_world_indicators.gd b/tests/test_world_indicators.gd index 18fc33a..6224861 100644 --- a/tests/test_world_indicators.gd +++ b/tests/test_world_indicators.gd @@ -1,4 +1,6 @@ extends SceneTree +const CivilizationState = preload("res://world/owniverse/civilization_state.gd") +const CivilizationEvolution = preload("res://world/owniverse/civilization_evolution.gd") const Parameters = preload("res://world/owniverse/world_parameters.gd") const Descriptions = preload("res://world/owniverse/item_description.gd") @@ -18,7 +20,7 @@ func fixture(): var world = load("res://world/owniverse/owniverse.gd").new() for id in root.get_node("Global").OwniverseEntities: var entity = load("res://world/owniverse/owniverse_entity.gd").new(root.get_node("Global").OwniverseEntities[id]) - world.entities[id] = entity + world.catalog.entities[id] = entity world.state.amount[id] = 0.0 world.state.stats.total_amount[id] = 0.0 if entity.group_as != "": @@ -27,49 +29,49 @@ func fixture(): return world func dispose(world) -> void: - for entity in world.entities.values(): entity.free() + for entity in world.catalog.entities.values(): entity.free() world.free() func run() -> void: var global: Node = root.get_node("Global") var world = fixture() world.state.amount.RD = 400.0 - world._append_batch(world.state, "RD", 400.0, 0.0, 0.0) + world.batches.append_batch(world.state, "RD", 400.0, 0.0, 0.0) var end: float = world.state.batches.RD[0].end_year - world.modify_world_parameter("Bur", 9.0) + world.environment.modify_world_parameter("Bur", 9.0) check(is_equal_approx(world.state.batches.RD[0].end_year, end / 10.0), "Burnability accelerates existing cohorts") var count: float = world.state.batches.RD[0].amount - world.modify_world_parameter("Bur", -9.0) + world.environment.modify_world_parameter("Bur", -9.0) check(is_equal_approx(world.state.batches.RD[0].end_year, end) and world.state.batches.RD[0].amount == count, "Changing burnability back preserves population and lifetime") - world.modify_world_parameter("Bur", 9.0) - world._append_batch(world.state, "YD", 10.0, 0.0, 0.0) - check(is_equal_approx(world.state.batches.YD[0].start_year, world.entities.YD.lifespan / 10.0), "New stars use the same burnability") + world.environment.modify_world_parameter("Bur", 9.0) + world.batches.append_batch(world.state, "YD", 10.0, 0.0, 0.0) + check(is_equal_approx(world.state.batches.YD[0].start_year, world.catalog.entities.YD.lifespan / 10.0), "New stars use the same burnability") world.state.time = {"datetime": end / 10.0 - 0.1, "year": end / 10.0 - 0.1} - world._withdraw_batches(world.state, "RD", world.state.time.year) - world.modify_world_parameter("Bur", -18.0) + world.batches.withdraw_batches(world.state, "RD", world.state.time.year) + world.environment.modify_world_parameter("Bur", -18.0) check(world.state.batches.RD[0].end_year >= world.state.time.year, "Slowing a partially evolved cohort keeps its remaining window in the future") - world.modify_world_parameter("Exp", 9.0) - check(world._evolution_products(world.state, world.entities.SN, 2.0).blast == 180.0, "Explosion power includes world explosiveness") - var products: Dictionary = world._evolution_products(world.state, world.entities.HRG, 1000.0) + world.environment.modify_world_parameter("Exp", 9.0) + check(world.stellar.evolution_products(world.state, world.catalog.entities.SN, 2.0).blast == 180.0, "Explosion power includes world explosiveness") + var products: Dictionary = world.stellar.evolution_products(world.state, world.catalog.entities.HRG, 1000.0) check(products.get("GRB", 0.0) == 100.0 and products.get("SN", 0.0) == 900.0, "Explosiveness raises rare-event probability without duplicating stars") - world.entities.HRG.output_or.chance = 50.0 - products = world._evolution_products(world.state, world.entities.HRG, 100.0) + world.catalog.entities.HRG.output_or.chance = 50.0 + products = world.stellar.evolution_products(world.state, world.catalog.entities.HRG, 100.0) check(products.get("GRB", 0.0) == 100.0 and products.get("SN", 0.0) == 0.0, "Rare-event probability is bounded at one") dispose(world) world = fixture() - var ordinary: Dictionary = world._life_products(world.state, world.entities.HP, 1000000.0) + var ordinary: Dictionary = world.life.life_products(world.state, world.catalog.entities.HP, 1000000.0) world.state.life_remainders.clear() - world.modify_world_parameter("Agg", 9.0) - var aggressive: Dictionary = world._life_products(world.state, world.entities.HP, 1000000.0) + world.environment.modify_world_parameter("Agg", 9.0) + var aggressive: Dictionary = world.life.life_products(world.state, world.catalog.entities.HP, 1000000.0) check(aggressive.EON1 > ordinary.EON1, "Aggression accelerates primitive evolution") world.state.life_remainders.clear() world.state.world_parameters.Order = 10.0 - var orderly: Dictionary = world._life_products(world.state, world.entities.HP, 1000000.0) + var orderly: Dictionary = world.life.life_products(world.state, world.catalog.entities.HP, 1000000.0) check(orderly.RP < aggressive.RP, "Order reduces degradation") world.state.life_remainders.clear() world.state.world_parameters.Enlightenment = 10.0 - var enlightened: Dictionary = world._life_products(world.state, world.entities.HP, 1000000.0) + var enlightened: Dictionary = world.life.life_products(world.state, world.catalog.entities.HP, 1000000.0) check(enlightened.EON1 > orderly.EON1, "Enlightenment increases primitive progress") dispose(world) @@ -77,8 +79,8 @@ func run() -> void: var hotter = fixture() for simulation in [world, hotter]: simulation.state.amount.RD = 1000000.0 - simulation._append_batch(simulation.state, "RD", 1000000.0, 0.0, 0.0) - hotter.modify_world_parameter("Bur", 9.0) + simulation.batches.append_batch(simulation.state, "RD", 1000000.0, 0.0, 0.0) + hotter.environment.modify_world_parameter("Bur", 9.0) world.evolute(1000.0) hotter.evolute(1000.0) check(hotter.state.amount.S > world.state.amount.S, "Burnability changes actual space production") @@ -96,8 +98,8 @@ func run() -> void: var outcomes: Array = [] for target in [baseline, advanced]: var expired: Dictionary = {"C0.2": [{"amount": 1000000.0, "from": 0.0, "to": 1.0}]} - var budget: Dictionary = world.CivilizationState.stellar_budget(world, target, global.CivilizationRules) - world.CivilizationEvolution.prepare(world, target, expired, 1.0, budget, global.CivilizationRules) + var budget: Dictionary = CivilizationState.stellar_budget(world, target, global.CivilizationRules) + CivilizationEvolution.prepare(world, target, expired, 1.0, budget, global.CivilizationRules) outcomes.append(expired["C0.2"][0].products.get(next_stage, 0.0)) check(outcomes[1] > outcomes[0], "Aggression changes actual ethical civilization transitions") dispose(world) diff --git a/ui/indicators/world_indicators.gd b/ui/indicators/world_indicators.gd index 571d048..daa6953 100644 --- a/ui/indicators/world_indicators.gd +++ b/ui/indicators/world_indicators.gd @@ -34,7 +34,7 @@ func update(world) -> void: var source: Dictionary = world.state var energy: float = clampf(source.energy / Parameters.INITIAL_ENERGY, 0.0, 1.0) for bar in energy_bars: bar.scale.x = energy - var density_value: float = world.stellar_environment(source).density + var density_value: float = world.environment.stellar_environment(source).density var density: Dictionary = Parameters.density_display(density_value) warning_border.self_modulate = Parameters.density_warning_color(density_value) for indicator in density_indicators: diff --git a/world/owniverse/README.md b/world/owniverse/README.md index eb62182..44a94c5 100644 --- a/world/owniverse/README.md +++ b/world/owniverse/README.md @@ -11,7 +11,7 @@ Метод `members(id)` возвращает копию списка. После изменения group_as нужно вызвать `rebuild_group_index()`; добавление или удаление определения автоматически обнаруживается по изменению размера словаря. Полная перезагрузка определений выполняется созданием нового каталога. -Owniverse сохраняет совместимые свойства entities/entity_groups/entity_event_groups и методы-обёртки. Они используют те же данные каталога, без дублирования. Stage загружает каталог, затем отдельно инициализирует количество, статистику и UI. Эволюция цивилизаций обращается непосредственно к QuantityMath. +Stage загружает каталог, затем отдельно инициализирует количество, статистику и UI. Доступ к определениям идёт через `world.catalog.entities`, к группам — через `world.catalog.members(id)`. Совместимые свойства и методы-переадресации из Owniverse удалены. Эволюция цивилизаций обращается непосредственно к QuantityMath. Формулы, порядок обхода определений, формат сохранения и правила симуляции не изменены. Проверки модулей: `tests/test_quantity_catalog.gd`; интеграционные проверки перечислены в `tests/README.md`. @@ -23,4 +23,49 @@ Owniverse сохраняет совместимые свойства entities/en - `unlocks(state, catalog, locked)` проверяет условия по историческим количествам и их отображаемым суммам. Возвращает remaining (ещё закрытые сущности) и visible (открываемые сущности и их count_as). Переданный список не изменяется. Уже открытые иконки повторно не блокируются. - `refresh` управляет снимком pending_amounts и флагом dirty, учитывает отложенное обновление и принудительную публикацию. Сам модуль не запускает таймеры и не меняет частоту обновления. -Owniverse делегирует существующие get_item_amounts/refresh_item_amounts и свойства обновления экземпляру presentation. Stage применяет результат разблокировки к контролам и хранит список закрытых иконок. Снимки и флаги принадлежат отдельному миру и не включаются в сохранение. +Stage обращается непосредственно к `world.presentation`, применяет результат разблокировки к контролам и хранит список закрытых иконок. Снимки и флаги принадлежат отдельному миру и не включаются в сохранение. + +## Партии, учёт, производство и эволюция + +| Модуль / свойство мира | Ответственность | +| --- | --- | +| `object_batches.gd` / `batches` | Окна рождения и эволюции, частичное выбытие, остатки округления, масштабирование партий, интеграл времени жизни, прямое завершение короткоживущих партий | +| `object_ledger.gd` / `ledger` | Свободные и связанные количества, списание и зачисление, статистика выпуска, целочисленность, резервирование и передача привязок | +| `manual_production.gd` / `production` | Предпросмотр доступного количества, транзакция оплаты и привязок, ручное производство, объединение чёрных дыр в SMBH | +| `automatic_production.gd` / `automatic` | Выпуск за период и жизнь новорождённых объектов, разделение ресурсов на обычные и автоматические, энергия, квоты звёзд и бурых карликов | +| `stellar_rules.gd` / `stellar` | Продукты звёздной эволюции, создание планет, выбор звёздных преемников, два уровня разрушения и глобальные бласты | +| `life_evolution.gd` / `life` | Прогресс, деградация и повторная попытка планетарной жизни | +| `civilization_evolution.gd`, `civilization_state.gd` | Существующие правила цивилизаций: этики, войны, колонизация, лимиты и места обитания | +| `universe_environment.gd` / `environment` | Плотность, металличность, доля попаданий бластов, изменение параметров мира с сохранением возраста звёзд | +| `evolution_coordinator.gd` / `evolution` | Порядок фаз шага, последовательные поколения и публикация нового состояния | + +В `owniverse.gd` остались создание состояния и модулей, скорость и проверка входного delta перед запуском эволюции. Дублирующее поле new_state и закомментированная старая реализация удалены. Единственный источник текущего состояния — `world.state`. + +### Порядок шага + +1. Определить обработанную десятую года. При продвижении времени восстановить недостающие партии жизни и проверить вместимость. +2. Скопировать состояние, посчитать физические количества и производство за период. +3. Обработать поколения до конца интервала. Для каждого поколения: локальные разрушения → выбытие партий всех типов → план переходов → резервирование передачи привязок → зачисление преемников и их партий → производство новорождённых → применение привязок и округление → завершение переходов цивилизаций → глобальные бласты → проверка вместимости. +4. Обновить групповые количества, ограничить автоматические ресурсы и продвинуть параметры мира. +5. Опубликовать новое состояние и обновить представление количеств. + +Резервирование привязок использует замороженную таблицу, чтобы одновременные переходы не зависели от порядка обработки владельцев. Коммит передач происходит после зачисления преемников. Оплата производства остаётся атомарной: при нехватке ресурсов исходное состояние не меняется. + +### Зависимости и жизненный цикл + +Модули симуляции получают свой Owniverse через `simulation_module.gd`. Они работают с переданным target либо с актуальным `world.state`; ссылка на старое состояние не кэшируется. Обращение к другим подсистемам явное, например `world.ledger.credit(...)` или `world.batches.append_batch(...)`. Внутренние вызовы идут напрямую. + +Ссылка модуля на мир слабая, поэтому цикл владения не возникает. Owniverse владеет модулями, каждый новый мир получает собственные экземпляры. Использовать модуль после освобождения его мира нельзя. В сохранение попадает только состояние; модули, каталог и UI туда не сериализуются. Определения OwniverseEntity по-прежнему освобождает владелец сцены. + +### Примеры нового API + +```gdscript +var available = world.production.calculate_max_producible(world.catalog.entities["YD"]) +world.production.produce("YD", 10.0) +var physical = world.ledger.physical_amounts(world.state) +var displayed = world.presentation.amounts(world.state, world.catalog) +world.environment.modify_world_parameter("Bur", 1.0) +world.evolute(delta) +``` + +Проверки проверяют прежние формулы через новый API; сравнение ускоренного и обычного путей выполняется через `stellar.direct_completions`. Дополнительный тест `test_module_lifecycle.gd` проверяет изоляцию миров и освобождение модулей. diff --git a/world/owniverse/automatic_production.gd b/world/owniverse/automatic_production.gd new file mode 100644 index 0000000..200ca7a --- /dev/null +++ b/world/owniverse/automatic_production.gd @@ -0,0 +1,226 @@ +extends "res://world/owniverse/simulation_module.gd" +const WorldParameters = preload("res://world/owniverse/world_parameters.gd") +const QuantityMath = preload("res://world/owniverse/quantity_math.gd") + +func yearly_products(entity: OwniverseEntity) -> Dictionary: + var products: Dictionary = {} + if not entity.life_product.is_empty() and entity.lifespan > 0.0: + for resource in entity.life_product: + products[resource] = maxf(0.0, entity.life_product[resource]) / entity.lifespan + elif entity.product_per_year_type != "": + products[entity.product_per_year_type] = maxf(0.0, entity.product_per_year_amount) + return products + + +func produce_for_period(previous: Dictionary, target: Dictionary, quantities: Dictionary, + distribution: Dictionary, from_year: float, to_year: float) -> void: + var basic: Dictionary = {} + var automatic: Dictionary = {} + for id in quantities: + var entity: OwniverseEntity = world.catalog.entities[id] + var object_years: float = world.batches.object_years(previous, id, quantities[id], from_year, to_year) + var auto_share: float = clampf(entity.auto, 0.0, 1.0) + var products: Dictionary = yearly_products(entity) + for product in products: + var amount: float = object_years * products[product] * WorldParameters.production(previous, entity) + basic[product] = basic.get(product, 0.0) + amount * (1.0 - auto_share) + automatic[product] = automatic.get(product, 0.0) + amount * auto_share + # Basic and automatic output each retain their unproduced remainder. + if not target.has("resource_remainders"): + target.resource_remainders = {"basic": {}, "automatic": {}} + var pending: Dictionary = target.resource_remainders + var requests: Dictionary = {} + for id in basic: + pending.basic[id] = pending.basic.get(id, 0.0) + basic[id] + pending.automatic[id] = pending.automatic.get(id, 0.0) + automatic[id] + var totals: Dictionary = {} + for channel in ["basic", "automatic"]: + for id in pending[channel]: + totals[id] = maxf(0.0, pending.basic.get(id, 0.0) + pending.automatic.get(id, 0.0)) + for id in totals: + if id == "S" or id == "H": + requests[id] = floorf(totals[id]) + var funded: Dictionary = QuantityMath.allocate_whole(requests, floorf(maxf(0.0, previous.energy))) + automatic = {} + target.energy = previous.energy + for id in totals: + var amount: float = floorf(totals[id]) + if id == "S" or id == "H": + amount = funded.get(id, 0.0) + target.energy -= amount + # Split the actual whole output, retaining the allocation error for next time. + var auto_ratio: float = clampf(pending.automatic.get(id, 0.0) / totals[id], 0.0, 1.0) if totals[id] > 0.0 else 0.0 + var auto_amount: float = floorf(amount * auto_ratio) + pending.automatic[id] = pending.automatic.get(id, 0.0) - auto_amount + pending.basic[id] = pending.basic.get(id, 0.0) - (amount - auto_amount) + automatic[id] = auto_amount + amount = world.ledger.credit(target, id, amount) + world.batches.append_batch(target, id, amount, from_year, to_year) + + # Keep unused automatic resources across cycles; they are part of free amounts. + var budget: Dictionary = previous.get("auto_resources", {}).duplicate(true) + for id in automatic: + budget[id] = budget.get(id, 0.0) + automatic[id] + for id in budget: + budget[id] = minf(maxf(0.0, budget[id]), world.ledger.available(target, id)) + target.auto_resources = budget + produce_brown_dwarfs(target, automatic.get("H", 0.0), from_year, to_year) + budget = ordinary_star_budget(target) + + # One common multiplier preserves the initial count ratios, even under scarcity. + var candidates: Dictionary = {} + for id in distribution: + var entity: OwniverseEntity = world.catalog.entities[id] + if entity.group_as != "Star": + continue + var has_cost: bool = false + for resource in entity.cost: + if entity.cost[resource] > 0.0: + has_cost = true + if has_cost: + candidates[id] = distribution[id] + candidates = QuantityMath.normalize_distribution(candidates) + if candidates.is_empty(): + return + var costs: Dictionary = {} + var needs: Dictionary = {} + for id in candidates: + if auto_capacity(target, world.catalog.entities[id], budget) <= 0.0: + continue + for resource in world.catalog.entities[id].cost: + var cost: float = maxf(0.0, world.catalog.entities[id].cost[resource]) * candidates[id] + costs[resource] = costs.get(resource, 0.0) + cost + needs[resource] = needs.get(resource, 0.0) + cost + for resource in world.catalog.entities[id].bind: + needs[resource] = needs.get(resource, 0.0) + maxf(0.0, world.catalog.entities[id].bind[resource]) * candidates[id] + var scale: float = INF + for resource in costs: + if costs[resource] > 0.0: + scale = minf(scale, budget.get(resource, 0.0) / costs[resource]) + for resource in needs: + if needs[resource] > 0.0: + scale = minf(scale, world.ledger.available(target, resource) / needs[resource]) + var concrete_needs: Dictionary = world.ledger.expand_requirements(target, needs) + for resource in concrete_needs: + if concrete_needs[resource] > 0.0: + scale = minf(scale, world.ledger.available(target, resource) / concrete_needs[resource]) + if not is_finite(scale) or scale <= 0.0: + return + # Rare types are served first; signed count deficits compensate later cycles. + if not target.has("auto_star_deficits"): + target.auto_star_deficits = {} + var deficits: Dictionary = target.auto_star_deficits + var order: Array = candidates.keys() + 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 = world.environment.stellar_environment(target).metallicity + var total_created: float = 0.0 + for id in order: + var entity: OwniverseEntity = world.catalog.entities[id] + var step: float = world.production.production_step(entity) + if not is_finite(step): + continue + var quota: float = maxf(0.0, candidates[id] * scale + deficits.get(id, 0.0)) + var desired: float = floorf(quota / step) * step + var actual: float = minf(desired, auto_capacity(target, entity, budget)) + if actual <= 0.0: + continue + if not world.production.try_production(target, entity, actual, true): + continue + budget = ordinary_star_budget(target) + created[id] = actual + total_created += actual + for id in candidates: + # Credit only actual production, so retained resources aren't counted twice. + deficits[id] = deficits.get(id, 0.0) + candidates[id] * total_created - created.get(id, 0.0) + for id in created: + world.ledger.credit(target, id, created[id]) + world.batches.append_batch(target, id, created[id], from_year, to_year) + world.stellar.create_planets(target, id, created[id], from_year, to_year, metallicity) + + +func produce_brown_dwarfs(target: Dictionary, new_hydrogen: float, from_year: float, to_year: float) -> void: + if not world.catalog.entities.has("HBD"): + return + var entity: OwniverseEntity = world.catalog.entities.HBD + var cost: float = entity.cost.get("H", 0.0) + if cost <= 0.0: + return + var quota: float = target.auto_star_deficits.get("HBD", 0.0) + maxf(0.0, new_hydrogen) * 0.001 / cost + var fuel: float = maxf(0.0, target.auto_star_deficits.get("HBD_fuel", 0.0)) + var available: float = minf(target.auto_resources.get("H", 0.0), world.ledger.available(target, "H")) + quota = minf(quota, (available + fuel) / cost) + var expected: float = maxf(0.0, snappedf(quota, 1e-9)) + var amount: float = floorf(expected) + # Do not retry a fractional chance on frames with no new automatic fuel. + if new_hydrogen > 0.0 and expected > amount: + var random_state: float = target.auto_star_deficits.get("HBD_random", 1234567.0) + random_state = fmod(random_state * 48271.0, 2147483647.0) + target.auto_star_deficits.HBD_random = random_state + if random_state / 2147483647.0 < expected - amount: + amount += 1.0 + amount = minf(amount, floorf(snappedf((available + fuel) / cost, 1e-9))) + var charge: float = ceilf(maxf(0.0, snappedf(amount * cost - fuel, 1e-9))) + if amount > 0.0 and charge <= available: + world.ledger.take_available(target, "H", charge) + target.auto_resources.H = maxf(0.0, target.auto_resources.get("H", 0.0) - charge) + target.auto_star_deficits.HBD_fuel = maxf(0.0, snappedf(fuel + charge - amount * cost, 1e-9)) + quota -= amount + world.ledger.credit(target, "HBD", amount) + world.batches.append_batch(target, "HBD", amount, from_year, to_year) + target.auto_star_deficits.HBD = quota + + +func ordinary_star_budget(target: Dictionary) -> Dictionary: + var budget: Dictionary = target.auto_resources.duplicate() + if world.catalog.entities.has("HBD"): + var reserved: float = maxf(0.0, target.auto_star_deficits.get("HBD", 0.0) * world.catalog.entities.HBD.cost.get("H", 0.0) - target.auto_star_deficits.get("HBD_fuel", 0.0)) + budget.H = maxf(0.0, budget.get("H", 0.0) - reserved) + return budget + + +func auto_capacity(source: Dictionary, entity: OwniverseEntity, budget: Dictionary) -> float: + var capacity: float = INF + var needs: Dictionary = {} + for resource in entity.cost: + var cost: float = maxf(0.0, entity.cost[resource]) + needs[resource] = cost + if cost > 0.0: + capacity = minf(capacity, floorf(budget.get(resource, 0.0) / cost)) + for resource in entity.bind: + needs[resource] = needs.get(resource, 0.0) + maxf(0.0, entity.bind[resource]) + for resource in needs: + if needs[resource] > 0.0: + capacity = minf(capacity, floorf(world.ledger.available(source, resource) / needs[resource])) + var concrete: Dictionary = world.ledger.expand_requirements(source, needs) + for resource in concrete: + if concrete[resource] > 0.0: + capacity = minf(capacity, floorf(world.ledger.available(source, resource) / concrete[resource])) + var step: float = world.production.production_step(entity) + return floorf(capacity / step) * step if is_finite(capacity) and is_finite(step) else 0.0 + + +func produce_newborn_lifetime(target: Dictionary, id: String, count: float, + born_from: float, born_to: float, to_year: float, before_batches: Array, + remaining_object_years: float = NAN) -> void: + var products: Dictionary = yearly_products(world.catalog.entities[id]) + if products.is_empty() or count <= 0.0: + return + var object_years: float = remaining_object_years if is_finite(remaining_object_years) else count * maxf(0.0, to_year - (born_from + born_to) * 0.5) + var previous_amounts: Dictionary = {} + for old: Dictionary in before_batches: + var start: float = old.get("created_from", -INF) + if not previous_amounts.has(start): + previous_amounts[start] = old.amount + for batch: Dictionary in target.batches.get(id, []): + var added: float = batch.amount - previous_amounts.get(batch.get("created_from", INF), 0.0) + object_years -= maxf(0.0, added) * world.batches.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] * WorldParameters.production(target, world.catalog.entities[id]) + if resource in ["H", "S"]: + output = minf(output, target.energy) + var actual: float = world.ledger.credit(target, resource, output) + if resource in ["H", "S"]: + target.energy = maxf(0.0, target.energy - actual) diff --git a/world/owniverse/automatic_production.gd.uid b/world/owniverse/automatic_production.gd.uid new file mode 100644 index 0000000..180b20a --- /dev/null +++ b/world/owniverse/automatic_production.gd.uid @@ -0,0 +1 @@ +uid://cty3i0pb8ihwf diff --git a/world/owniverse/civilization_evolution.gd b/world/owniverse/civilization_evolution.gd index 41d4682..2b4ecd6 100644 --- a/world/owniverse/civilization_evolution.gd +++ b/world/owniverse/civilization_evolution.gd @@ -72,7 +72,7 @@ static func consume_untriggered(world, target: Dictionary, plan: Dictionary, pop var removed: Dictionary = take(world, pop, count) var actual: float = total(removed) if actual > 0.0: - world._scale_batches(target, plan.id, (old_total - actual) / old_total) + world.batches.scale_batches(target, plan.id, (old_total - actual) / old_total) plan.amount += actual plan.to = maxf(plan.to, year) return removed @@ -164,7 +164,7 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: float, if not incoming.has(destination): incoming[destination] = {} add(incoming[destination], pop) products[destination] = total(pop) - if State.is_space_civilization(id, rules) and world.entities[destination].group_as == "Planet": + if State.is_space_civilization(id, rules) and world.catalog.entities[destination].group_as == "Planet": planetary[destination] = planetary.get(destination, 0.0) + total(pop) expired[id] = [{"amount": plan.amount, "from": plan.from, "to": maxf(plan.from, plan.to), "products": products}] return {"remaining": remaining, "incoming": incoming, "planetary": planetary, "colonies": colonies} @@ -175,10 +175,10 @@ static func finish(world, target: Dictionary, transaction: Dictionary, year: flo if not target.ethics.has(id): target.ethics[id] = {} add(target.ethics[id], transaction.incoming[id]) # Space civilizations that fall back to planetary stages need a stellar host. - var physical: Dictionary = world._physical_amounts(target) + var physical: Dictionary = world.ledger.physical_amounts(target) var hosts: Dictionary = {} for id in physical: - if world.entities[id].event_group == "DwStar" and physical[id] > 0.0: hosts[id] = physical[id] + if world.catalog.entities[id].event_group == "DwStar" and physical[id] > 0.0: hosts[id] = physical[id] for id in transaction.planetary: var shares: Dictionary = split(target, "return:" + id, minf(transaction.planetary[id], target.amount.get(id, 0.0)), hosts) for owner in shares: @@ -194,7 +194,7 @@ static func finish(world, target: Dictionary, transaction: Dictionary, year: flo target.civilization_stats.worship = worship static func colonize(world, target: Dictionary, colonies: Array, year: float, rules: Dictionary) -> void: - var physical: Dictionary = world._physical_amounts(target) + var physical: Dictionary = world.ledger.physical_amounts(target) var stock: Dictionary = {} for id in ["GG", "AB", "RP", "HP"]: stock[id] = physical.get(id, 0.0) var requests: Dictionary = {} @@ -212,17 +212,17 @@ static func colonize(world, target: Dictionary, colonies: Array, year: float, ru var slots: Dictionary = {"": target.amount.get(id, 0.0)} for owner in target.binds: slots[owner] = target.binds[owner].get(id, 0.0) var taken: Dictionary = QuantityMath.allocate_whole(slots, amount) - world._scale_batches(target, id, 1.0 - amount / stock[id]) + world.batches.scale_batches(target, id, 1.0 - amount / stock[id]) stock[id] -= amount for owner in taken: if taken[owner] <= 0.0: continue if owner == "": target.amount[id] -= taken[owner] else: target.binds[owner][id] -= taken[owner] - var created: float = world._credit(target, colony.stage, taken[owner]) + var created: float = world.ledger.credit(target, colony.stage, taken[owner]) if owner != "": target.amount[colony.stage] -= created target.binds[owner][colony.stage] = target.binds[owner].get(colony.stage, 0.0) + created - world._append_batch(target, colony.stage, amount, year, year) + world.batches.append_batch(target, colony.stage, amount, year, year) if not target.ethics.has(colony.stage): target.ethics[colony.stage] = {} add(target.ethics[colony.stage], population) target.civilization_stats.colonies = target.civilization_stats.get("colonies", 0.0) + count diff --git a/world/owniverse/civilization_state.gd b/world/owniverse/civilization_state.gd index d430b74..9d1cb4e 100644 --- a/world/owniverse/civilization_state.gd +++ b/world/owniverse/civilization_state.gd @@ -12,7 +12,7 @@ static func is_space_civilization(id: String, rules: Dictionary) -> bool: static func reconcile(world, target: Dictionary, rules: Dictionary) -> void: if not target.has("ethics"): target.ethics = {} - var physical: Dictionary = world._physical_amounts(target) + var physical: Dictionary = world.ledger.physical_amounts(target) for id in rules: var count: float = physical.get(id, 0.0) var current: Dictionary = target.ethics.get(id, {}) @@ -40,10 +40,10 @@ static func detach_space_civilizations(target: Dictionary, rules: Dictionary) -> # A shared budget per stellar family, including stars bound into structures. # Reservations affect capacity, not the physical stock or its production. static func stellar_budget(world, target: Dictionary, rules: Dictionary) -> Dictionary: - var physical: Dictionary = world._physical_amounts(target) + var physical: Dictionary = world.ledger.physical_amounts(target) var capacity: Dictionary = {} for id in physical: - var entity = world.entities[id] + var entity = world.catalog.entities[id] for home in ["DwStar", "GiStar", "WD", "NS", "MGT", "BH"]: if entity.event_group == home or entity.count_as == home or id == home: capacity[home] = capacity.get(home, 0.0) + physical[id] @@ -129,10 +129,10 @@ static func enforce_capacity(world, target: Dictionary, rules: Dictionary, year: if excess <= 0.0: continue var previous: String = stages[index - 1] if index > 0 else "C1.9" - world._scale_batches(target, id, supported / count) + world.batches.scale_batches(target, id, supported / count) target.amount[id] = supported - world._credit(target, previous, excess) - world._append_batch(target, previous, excess, year, year) + world.ledger.credit(target, previous, excess) + world.batches.append_batch(target, previous, excess, year, year) var removed: Dictionary = QuantityMath.allocate_whole(target.ethics[id], excess) if not target.ethics.has(previous): target.ethics[previous] = {} @@ -141,9 +141,9 @@ static func enforce_capacity(world, target: Dictionary, rules: Dictionary, year: target.ethics[previous][ethic] = target.ethics[previous].get(ethic, 0.0) + removed[ethic] if previous == "C1.9": var stars: Dictionary = {} - var physical: Dictionary = world._physical_amounts(target) + var physical: Dictionary = world.ledger.physical_amounts(target) for star in physical: - if world.entities[star].event_group == "DwStar" and physical[star] > 0.0: + if world.catalog.entities[star].event_group == "DwStar" and physical[star] > 0.0: stars[star] = physical[star] var weights: Dictionary = QuantityMath.normalize_distribution(stars) # Worlds are aggregates: a star may host several inhabited planets. @@ -164,16 +164,16 @@ static func enforce_capacity(world, target: Dictionary, rules: Dictionary, year: # Unity's structure chain: direct Claim resets the required structure count; # intervening stages require ten times more units of the preceding structure. static func habitat_budget(world, target: Dictionary, rules: Dictionary) -> Dictionary: - var physical: Dictionary = world._physical_amounts(target) + var physical: Dictionary = world.ledger.physical_amounts(target) var structures: Array = [] var capacity: Dictionary = {} - for id in world.entities: - if world.entities[id].claim != "": + for id in world.catalog.entities: + if world.catalog.entities[id].claim != "": structures.append(id) capacity[id] = physical.get(id, 0.0) structures.sort_custom(func(a, b): - var left: float = world.entities[a].civ_bonus - var right: float = world.entities[b].civ_bonus + var left: float = world.catalog.entities[a].civ_bonus + var right: float = world.catalog.entities[b].civ_bonus return left > right if left != right else str(a) < str(b)) var stages: Array = [] for id in rules: @@ -186,7 +186,7 @@ static func habitat_budget(world, target: Dictionary, rules: Dictionary) -> Dict for id in stages: var direct: String = "" for structure in structures: - if world.entities[structure].claim == id: direct = structure + if world.catalog.entities[structure].claim == id: direct = structure if direct != "": anchor = direct units = 1.0 @@ -216,7 +216,7 @@ static func assign_homes(world, target: Dictionary, rules: Dictionary) -> void: if structure == budget.anchors[id]: allowed = true if not allowed or budget.capacity[structure] <= 0.0: continue var amount: float = left - if world.entities[structure].claim == id: + if world.catalog.entities[structure].claim == id: amount = minf(amount, maxf(0.0, budget.capacity[structure] - used.get(structure, 0.0))) used[structure] = used.get(structure, 0.0) + amount if amount > 0.0: homes[structure] = amount @@ -225,7 +225,7 @@ static func assign_homes(world, target: Dictionary, rules: Dictionary) -> void: target.civilization_homes[id] = homes static func effective_span(world, target: Dictionary, id: String, rules: Dictionary) -> float: - if not rules.has(id): return world.entities[id].lifespan + if not rules.has(id): return world.catalog.entities[id].lifespan var base: float = rules[id].span if not is_space_civilization(id, rules): return base assign_homes(world, target, rules) @@ -233,5 +233,5 @@ static func effective_span(world, target: Dictionary, id: String, rules: Diction if population <= 0.0: return base var weighted: float = population for home in target.civilization_homes.get(id, {}): - weighted += target.civilization_homes[id][home] * (maxf(0.0, world.entities[home].civ_bonus) - 1.0) + weighted += target.civilization_homes[id][home] * (maxf(0.0, world.catalog.entities[home].civ_bonus) - 1.0) return base * population / weighted if weighted > 0.0 else base diff --git a/world/owniverse/evolution_coordinator.gd b/world/owniverse/evolution_coordinator.gd new file mode 100644 index 0000000..763964b --- /dev/null +++ b/world/owniverse/evolution_coordinator.gd @@ -0,0 +1,149 @@ +extends "res://world/owniverse/simulation_module.gd" +const CivilizationEvolution = preload("res://world/owniverse/civilization_evolution.gd") +const CivilizationState = preload("res://world/owniverse/civilization_state.gd") +const SimulationTime = preload("res://world/owniverse/simulation_time.gd") +const WorldParameters = preload("res://world/owniverse/world_parameters.gd") +const QuantityMath = preload("res://world/owniverse/quantity_math.gd") + +func initialize_life_batches() -> void: + CivilizationState.detach_space_civilizations(world.state, Global.CivilizationRules) + CivilizationState.enforce_capacity(world, world.state, Global.CivilizationRules, world.state.time.datetime) + var physical: Dictionary = world.ledger.physical_amounts(world.state) + for id in world.catalog.entities: + if world.catalog.entities[id].life_rule.is_empty(): + continue + var tracked: float = 0.0 + for batch: Dictionary in world.state.batches.get(id, []): + tracked += batch.amount + world.batches.append_batch(world.state, id, maxf(0.0, physical.get(id, 0.0) - tracked), world.state.time.datetime, world.state.time.datetime) + + +func evolute_to_datetime(datetime: float) -> void: + # Reconcile only when simulation advances. + # Fractional frame updates cannot change capacity or populations. + var processed_year: float = SimulationTime.processed_year(datetime) + if processed_year > world.state.time.year: + initialize_life_batches() + var previous: Dictionary = world.state + var next: Dictionary = previous.duplicate_deep() + next.version = SimulationTime.STATE_VERSION + next.speed = clampi(int(previous.speed), 1, Global.SPEED_LIMIT) + next.speed_in_year = pow(10.0, next.speed - 1) + var from_year: float = previous.time.year + var to_year: float = processed_year + if to_year > from_year: + var quantities: Dictionary = world.ledger.physical_amounts(previous) + var distribution: Dictionary = QuantityMath.normalize_distribution(quantities) + next.distribution = distribution + + world.automatic.produce_for_period(previous, next, quantities, distribution, from_year, to_year) + evolve_batches(next, to_year) + world.ledger.refresh_groups(next) + world.ledger.clamp_auto_resources(next) + WorldParameters.advance(previous, next, Global.CivilizationRules, to_year - from_year) + next.time = {"datetime": datetime, "year": to_year} + + # Publish only after every phase has finished; previous stays untouched. + world.state = next + if to_year > from_year: + world.presentation.refresh(world.state, world.catalog) + + +func evolve_batches(target: Dictionary, to_year: float) -> void: + # A long cycle may contain several generations; preserve actual birth intervals. + var completed: Dictionary = {} + while evolve_batch_generation(target, to_year, completed): + pass + + +func evolve_batch_generation(target: Dictionary, to_year: float, completed: Dictionary = {}) -> bool: + # Apply direct completions at the original next-generation boundary, so + # binding transfers, the blast environment and capacity checks keep order. + var completed_now: Dictionary = completed.duplicate(true) + completed.clear() + var quantities: Dictionary = world.ledger.physical_amounts(target) + var expired: Dictionary = {} + var preview: Dictionary = {"batches": {}} + for id in target.batches: + if world.catalog.entities.has(id) and world.catalog.entities[id].destroy_level > 0: + preview.batches[id] = target.batches[id].duplicate(true) + for id in target.batches.keys(): + if not world.catalog.entities.has(id) or world.catalog.entities[id].destroy_level <= 0: + continue + var dying: float = 0.0 + for part: Dictionary in completed_now.get(id, []): + dying += part.amount + for part: Dictionary in world.batches.withdraw_batches(preview, id, to_year): + dying += part.amount + if quantities.get(id, 0.0) > 0.0: + world.stellar.destroy_planets(target, id, dying / quantities[id], world.catalog.entities[id].destroy_level) + quantities = world.ledger.physical_amounts(target) + # Withdraw all types before creating successors: iteration order cannot affect them. + for id in target.batches.keys(): + if world.catalog.entities.has(id): + expired[id] = completed_now.get(id, []) + world.batches.withdraw_batches(target, id, to_year) + var has_expired: bool = false + for parts: Array in expired.values(): + if not parts.is_empty(): + has_expired = true + break + if not has_expired: + return false + var fractions: Dictionary = {} + var outputs: Dictionary = {} + var star_budget: Dictionary = CivilizationState.stellar_budget(world, target, Global.CivilizationRules) + var civilization_transaction: Dictionary = CivilizationEvolution.prepare(world, target, expired, to_year, star_budget, Global.CivilizationRules) + for id in expired: + var total: float = 0.0 + for part: Dictionary in expired[id]: + total += part.amount + fractions[id] = clampf(total / quantities[id], 0.0, 1.0) if quantities.get(id, 0.0) > 0.0 else 0.0 + var combined: Dictionary = {} + for part: Dictionary in expired[id]: + if not part.has("products"): + part.products = world.stellar.evolution_products(target, world.catalog.entities[id], part.amount) + for successor in part.products.keys(): + if Global.CivilizationRules.has(id): + continue + var requested: float = part.products[successor] + var admitted: float = CivilizationState.admit_transition(star_budget, Global.CivilizationRules, id, successor, requested) + part.products[successor] = admitted + if admitted < requested: + part.products[id] = part.products.get(id, 0.0) + requested - admitted + for successor in part.products: + combined[successor] = combined.get(successor, 0.0) + part.products[successor] + outputs[id] = {} + if total > 0.0: + for successor in combined: + outputs[id][successor] = combined[successor] / total + + var incoming: Dictionary = world.ledger.transfer_evolution(target, fractions, outputs) + + var new_owners: Dictionary = {} + var blast_power: float = 0.0 + var environment: Dictionary = world.environment.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 world.catalog.entities.has(successor): + continue + if successor == id and (not world.catalog.entities[id].life_rule.is_empty() or Global.CivilizationRules.has(id)): + target.amount[id] = target.amount.get(id, 0.0) + amount + else: + amount = world.ledger.credit(target, successor, amount) + var before_batches: Array = target.batches.get(successor, []).duplicate(true) + var cutoff: float = to_year if world.stellar.uses_short_lived_fast_path(successor) else -INF + var integral: float = world.batches.append_batch(target, successor, amount, part.from, part.to, completed, cutoff) + world.automatic.produce_newborn_lifetime(target, successor, amount, part.from, part.to, to_year, before_batches, integral) + new_owners[successor] = new_owners.get(successor, 0.0) + amount + world.ledger.accept_transfers(target, incoming) + world.ledger.bind_new_owners(target, new_owners) + world.ledger.round_object_allocations(target) + CivilizationEvolution.finish(world, target, civilization_transaction, to_year, Global.CivilizationRules) + world.stellar.apply_blast(target, blast_power, environment) + CivilizationState.enforce_capacity(world, target, Global.CivilizationRules, to_year) + return true diff --git a/world/owniverse/evolution_coordinator.gd.uid b/world/owniverse/evolution_coordinator.gd.uid new file mode 100644 index 0000000..163cdf3 --- /dev/null +++ b/world/owniverse/evolution_coordinator.gd.uid @@ -0,0 +1 @@ +uid://bok6yra83r83c diff --git a/world/owniverse/item_description.gd b/world/owniverse/item_description.gd index faf27ba..563833d 100644 --- a/world/owniverse/item_description.gd +++ b/world/owniverse/item_description.gd @@ -18,8 +18,8 @@ static func colored(text: String, color: String) -> String: static func matches(world, id: String) -> Array: var result: Array = [] - for key in world.entities: - var entity = world.entities[key] + for key in world.catalog.entities: + var entity = world.catalog.entities[key] if key == id or entity.count_as == id or entity.group_as == id: if not world.catalog.is_group(key): result.append(key) @@ -28,7 +28,7 @@ static func matches(world, id: String) -> Array: static func quantities(world, id: String) -> Dictionary: var free: float = 0.0 var total: float = 0.0 - var physical: Dictionary = world._physical_amounts(world.state) + var physical: Dictionary = world.ledger.physical_amounts(world.state) for key in matches(world, id): free += world.state.amount.get(key, 0.0) total += physical.get(key, 0.0) @@ -38,15 +38,15 @@ static func quantities(world, id: String) -> Dictionary: return {"free": free, "total": total} static func costs(world, global: Node, id: String) -> String: - if not world.entities.has(id): + if not world.catalog.entities.has(id): return "" var lines: PackedStringArray = [] - for requirements in [world.entities[id].cost, world.entities[id].bind]: + for requirements in [world.catalog.entities[id].cost, world.catalog.entities[id].bind]: for resource in requirements: var required: float = requirements[resource] if required < 0.0: continue - var available: float = floorf(world._available(world.state, resource)) + var available: float = floorf(world.ledger.available(world.state, resource)) var name: String = str(global.get_template(resource, "Entity", "Name").get("text", resource)) lines.append(colored(name + ": " + number(required) + " (" + number(available) + ")", GREEN if available >= required else RED)) return "\n".join(lines) @@ -55,25 +55,25 @@ static func variables(world, global: Node, id: String, rate: float = 0.0) -> Dic var counts: Dictionary = quantities(world, id) var show_total: bool = false for key in matches(world, id): - show_total = show_total or world.entities[key].group_as == "Planet" - var amount: String = number(counts.total if show_total else counts.free) if world.entities.has(id) else "—" + show_total = show_total or world.catalog.entities[key].group_as == "Planet" + var amount: String = number(counts.total if show_total else counts.free) if world.catalog.entities.has(id) else "—" if not show_total and counts.total > counts.free: amount += " " + colored("(" + number(counts.total) + ")", GREY) if rate != 0.0: amount += " | " + colored(("−" if rate < 0.0 else "+") + number(absf(rate), 1), RED if rate < 0.0 else GREEN) - var physical: Dictionary = world._physical_amounts(world.state) + var physical: Dictionary = world.ledger.physical_amounts(world.state) var voids: float = 0.0 for key in physical: - voids += physical[key] * world.entities[key].voids + voids += physical[key] * world.catalog.entities[key].voids var he: float = world.state.amount.get("He", 0.0) var h: float = world.state.amount.get("H", 0.0) var mixture: String = "—" if he + h > 0.0: mixture = "He: " + number(100.0 * he / (he + h), 2) + "% / H: " + number(100.0 * h / (he + h), 2) + "%" - var environment: Dictionary = world.stellar_environment(world.state) + var environment: Dictionary = world.environment.stellar_environment(world.state) return { "amount": amount, "colored costs": costs(world, global, id), - "lifespan": number(world.entities[id].lifespan) if world.entities.has(id) else "—", + "lifespan": number(world.catalog.entities[id].lifespan) if world.catalog.entities.has(id) else "—", "voids": number(voids), "he / h": "\n" + mixture, "metallicity": number(environment.metallicity * 100.0, 2) + "%", "энергия": number(world.state.energy), "плотность": number(environment.density, 6), @@ -85,7 +85,7 @@ static func render(world, global: Node, id: String, category: String = "Entity", var values: Dictionary = variables(world, global, id, rate) if category == "Indicator": if id == "Density": - id = Parameters.density_display(world.stellar_environment(world.state).density).mode + id = Parameters.density_display(world.environment.stellar_environment(world.state).density).mode var scales: Dictionary = {"Burnability": "Bur", "Explosiveness": "Exp", "Aggression": "Agg"} if scales.has(id): var level: float = Parameters.value(world.state, scales[id]) diff --git a/world/owniverse/life_evolution.gd b/world/owniverse/life_evolution.gd new file mode 100644 index 0000000..336949d --- /dev/null +++ b/world/owniverse/life_evolution.gd @@ -0,0 +1,58 @@ +extends "res://world/owniverse/simulation_module.gd" +const WorldParameters = preload("res://world/owniverse/world_parameters.gd") +const QuantityMath = preload("res://world/owniverse/quantity_math.gd") + +func life_products(target: Dictionary, entity: OwniverseEntity, count: float) -> Dictionary: + var rule: Dictionary = entity.life_rule + var pending: Dictionary = {} + var modifiers: Dictionary = WorldParameters.life_modifiers(target) + var probabilities: Dictionary = { + "progress": float(rule.progress) * modifiers.progress * WorldParameters.value(target, "Enlightenment"), + "degradation": float(rule.degradation) * modifiers.degradation, + } + var probability_sum: float = maxf(1.0, probabilities.progress + probabilities.degradation) + if not target.has("life_remainders"): + target.life_remainders = {} + for channel in ["progress", "degradation"]: + var key: String = entity.id + ":" + channel + var expected: float = count * probabilities[channel] / probability_sum + target.life_remainders.get(key, 0.0) + target.life_remainders[key] = expected + pending[channel] = floorf(snappedf(expected, 1.0e-12)) + var assigned: Dictionary = QuantityMath.allocate_whole(pending, count) + for channel in assigned: + var key: String = entity.id + ":" + channel + target.life_remainders[key] = maxf(0.0, target.life_remainders[key] - assigned[channel]) + var result: Dictionary = {entity.id: count - assigned.progress - assigned.degradation} + result[rule.next] = assigned.progress + var weights: Dictionary = {} + var weight_sum: float = 0.0 + for term in str(rule.fall).split("^"): + var pair: PackedStringArray = term.split(":") + weights[pair[0]] = float(pair[1]) + weight_sum += float(pair[1]) + var requests: Dictionary = {} + for id in weights: + var key: String = entity.id + ":fall:" + id + var expected: float = assigned.degradation * weights[id] / weight_sum + target.life_remainders.get(key, 0.0) + target.life_remainders[key] = expected + requests[id] = maxf(0.0, floorf(snappedf(expected, 1.0e-12))) + var falls: Dictionary = QuantityMath.allocate_whole(requests, assigned.degradation) + var distributed: float = 0.0 + for id in falls: + distributed += falls[id] + result[id] = result.get(id, 0.0) + falls[id] + var key: String = entity.id + ":fall:" + id + target.life_remainders[key] -= falls[id] + # Fill sub-unit fall quotas deterministically, retaining signed allocation errors. + while distributed < assigned.degradation: + var selected: String = "" + var largest: float = -INF + for id in weights: + var rest: float = target.life_remainders[entity.id + ":fall:" + id] + if rest > largest: + largest = rest + selected = id + result[selected] = result.get(selected, 0.0) + 1.0 + target.life_remainders[entity.id + ":fall:" + selected] -= 1.0 + distributed += 1.0 + return result diff --git a/world/owniverse/life_evolution.gd.uid b/world/owniverse/life_evolution.gd.uid new file mode 100644 index 0000000..9e77b1d --- /dev/null +++ b/world/owniverse/life_evolution.gd.uid @@ -0,0 +1 @@ +uid://chr76vnysheee diff --git a/world/owniverse/manual_production.gd b/world/owniverse/manual_production.gd new file mode 100644 index 0000000..5a59915 --- /dev/null +++ b/world/owniverse/manual_production.gd @@ -0,0 +1,202 @@ +extends "res://world/owniverse/simulation_module.gd" +const CivilizationState = preload("res://world/owniverse/civilization_state.gd") +const QuantityMath = preload("res://world/owniverse/quantity_math.gd") + +func try_production(target: Dictionary, entity: OwniverseEntity, amount: float, + automatic: bool = false) -> bool: + if amount <= 0.0 or amount != floorf(amount): + return false + var trial: Dictionary = target.duplicate() + trial.amount = target.amount.duplicate() + trial.binds = target.binds.duplicate(true) + trial.auto_resources = target.auto_resources.duplicate() + trial.batches = target.batches.duplicate() + var copied_batches: Dictionary = {} + for resource in entity.cost: + var cost: float = roundf(maxf(0.0, entity.cost[resource]) * amount) + if automatic and trial.auto_resources.get(resource, 0.0) < cost: + return false + var before: Dictionary = world.ledger.physical_amounts(trial) + var taken: Dictionary = world.ledger.take_available(trial, resource, cost) + var total: float = 0.0 + for quantity in taken.values(): + total += quantity + if total != cost: + return false + if automatic: + trial.auto_resources[resource] = maxf(0.0, trial.auto_resources.get(resource, 0.0) - cost) + for id in taken: + # Isolate only batches actually touched by this transaction. + if trial.batches.has(id) and not copied_batches.has(id): + trial.batches[id] = trial.batches[id].duplicate(true) + copied_batches[id] = true + var factor: float = 1.0 - taken[id] / before[id] if before.get(id, 0.0) > 0.0 else 0.0 + world.batches.scale_batches(trial, id, factor) + if not world.ledger.bind_new_owners(trial, {entity.id: amount}, true): + return false + world.ledger.refresh_groups(trial) + world.ledger.clamp_auto_resources(trial) + for field in ["amount", "binds", "batches", "auto_resources"]: + if trial.has(field): + target[field] = trial[field] + return true + + +func smbh_sources() -> Dictionary: + var masses: Dictionary = {} + for id in world.catalog.entities: + var entity: OwniverseEntity = world.catalog.entities[id] + if entity.count_as == "BH" and entity.mass > 0.0: + masses[id] = floorf(maxf(0.0, world.state.amount.get(id, 0.0))) * entity.mass + return masses + + +func smbh_available() -> float: + var mass: float = 0.0 + for value in smbh_sources().values(): + mass += value + return floorf(mass) + + +func plan_smbh(desired_amount: float) -> Dictionary: + var empty: Dictionary = {"amount": 0.0, "taken": {}} + if desired_amount <= 0.0 or not is_finite(desired_amount): + return empty + var total_mass: float = smbh_available() + if total_mass <= 0.0: + return empty + var requested: float = minf(floorf(desired_amount), total_mass) + if requested <= 0.0: + return empty + var masses: Dictionary = smbh_sources() + var distribution: Dictionary = QuantityMath.normalize_distribution(masses) + var taken: Dictionary = {} + var order: Array = [] + var consumed_mass: float = 0.0 + for id in distribution: + var expected: float = requested * distribution[id] / world.catalog.entities[id].mass + taken[id] = minf(floorf(expected), floorf(world.state.amount[id])) + consumed_mass += taken[id] * world.catalog.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 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: + break + var id: String = candidate.id + if taken[id] < floorf(world.state.amount[id]): + taken[id] += 1.0 + consumed_mass += world.catalog.entities[id].mass + if consumed_mass <= 0.0 or consumed_mass != floorf(consumed_mass): + return empty + return {"amount": consumed_mass, "taken": taken} + + +func produce_smbh(desired_amount: float) -> float: + var plan: Dictionary = plan_smbh(desired_amount) + var consumed_mass: float = plan.amount + var taken: Dictionary = plan.taken + if consumed_mass <= 0.0: + return 0.0 + var next: Dictionary = world.state.duplicate(true) + var physical: Dictionary = world.ledger.physical_amounts(world.state) + for id in taken: + if taken[id] <= 0.0: + continue + next.amount[id] -= taken[id] + var fraction: float = taken[id] / physical[id] + world.batches.scale_batches(next, id, 1.0 - fraction) + # Release the consumed owners' share of any resources they held. + for resource in next.binds.get(id, {}): + var released: float = next.binds[id][resource] * fraction + next.binds[id][resource] -= released + next.amount[resource] = next.amount.get(resource, 0.0) + released + world.ledger.credit(next, "SMBH", consumed_mass) + world.batches.append_batch(next, "SMBH", consumed_mass, world.state.time.datetime, world.state.time.datetime) + world.ledger.round_object_allocations(next) + world.ledger.refresh_groups(next) + world.ledger.clamp_auto_resources(next) + world.state = next + world.presentation.refresh(world.state, world.catalog) + return consumed_mass + + +func produce(item_id: String, desired_amount: float) -> float: + if item_id == "SMBH": + return produce_smbh(desired_amount) + # Use special production for S and H + if item_id == "S" or item_id == "H": + return produce_hs(item_id, desired_amount) + + var entity: OwniverseEntity = world.catalog.entities[item_id] + + if desired_amount <= 0: + return 0.0 + + var max_producible = floorf(calculate_max_producible(entity, desired_amount)) + var budget: Dictionary = CivilizationState.stellar_budget(world, world.state, Global.CivilizationRules) + max_producible = CivilizationState.admit_transition(budget, Global.CivilizationRules, "", item_id, max_producible) + + if max_producible <= 0: + return 0.0 + + var metallicity: float = world.environment.stellar_environment(world.state).metallicity + if not try_production(world.state, entity, max_producible): + return 0.0 + + # Add produced entities + world.ledger.process_entity(item_id, max_producible) + world.batches.add_entity_to_batch(item_id, max_producible, world.state.time.datetime) + world.stellar.create_planets(world.state, item_id, max_producible, world.state.time.datetime, world.state.time.datetime, metallicity) + world.ledger.refresh_groups(world.state) + world.presentation.refresh(world.state, world.catalog) + return max_producible + + +func produce_hs(entity_id: String, value: float) -> float: + if value <= 0: + return 0.0 + + var to_produce: float = floorf(value) + to_produce = minf(to_produce, floorf(world.state.energy)) + + if to_produce <= 0: + return 0.0 + + world.state.energy -= to_produce + world.ledger.process_entity(entity_id, to_produce) + + return to_produce + + +func production_step(entity: OwniverseEntity) -> float: + return QuantityMath.production_step(entity.cost.values() + entity.bind.values()) + + +func calculate_max_producible(entity: OwniverseEntity, desired_amount: float = 1.0E150) -> float: + if entity.id == "SMBH": + return plan_smbh(desired_amount).amount + var requirements: Dictionary = {} + for resource in entity.cost: + requirements[resource] = maxf(0.0, entity.cost[resource]) + for resource in entity.bind: + requirements[resource] = requirements.get(resource, 0.0) + maxf(0.0, entity.bind[resource]) + var has_requirements: bool = false + for quantity in requirements.values(): + if quantity > 0.0: + has_requirements = true + break + # No recipe means unavailable for production, not unlimited free objects. + if not has_requirements: + return 0.0 + var available: float = maxf(0.0, desired_amount) + for resource in requirements: + if requirements[resource] > 0.0: + available = minf(available, floorf(world.ledger.available(world.state, resource) / requirements[resource])) + var concrete: Dictionary = world.ledger.expand_requirements(world.state, requirements) + for resource in concrete: + if concrete[resource] > 0.0: + available = minf(available, floorf(world.ledger.available(world.state, resource) / concrete[resource])) + var step: float = production_step(entity) + return floorf(available / step) * step if is_finite(step) else 0.0 diff --git a/world/owniverse/manual_production.gd.uid b/world/owniverse/manual_production.gd.uid new file mode 100644 index 0000000..545050a --- /dev/null +++ b/world/owniverse/manual_production.gd.uid @@ -0,0 +1 @@ +uid://dbxnbd0g6hrab diff --git a/world/owniverse/object_batches.gd b/world/owniverse/object_batches.gd new file mode 100644 index 0000000..b01933b --- /dev/null +++ b/world/owniverse/object_batches.gd @@ -0,0 +1,181 @@ +extends "res://world/owniverse/simulation_module.gd" +const CivilizationState = preload("res://world/owniverse/civilization_state.gd") +const SimulationTime = preload("res://world/owniverse/simulation_time.gd") +const WorldParameters = preload("res://world/owniverse/world_parameters.gd") + +func scale_batches(target: Dictionary, id: String, factor: float) -> void: + var batches: Array = target.batches.get(id, []) + var total: float = 0.0 + var assigned: float = 0.0 + var shares: Array = [] + for batch: Dictionary in batches: + var expected: float = batch.amount * maxf(0.0, factor) + total += expected + batch.amount = floorf(expected) + assigned += batch.amount + shares.append({"batch": batch, "rest": expected - batch.amount}) + shares.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + return a.rest > b.rest) + for share: Dictionary in shares: + if assigned < roundf(total): + share.batch.amount += 1.0 + assigned += 1.0 + share.batch.withdraw_remainder = minf(share.batch.get("withdraw_remainder", 0.0), share.batch.amount) + + +func append_batch(target: Dictionary, id: String, amount: float, + created_from: float, created_to: float, completed: Dictionary = {}, + completed_before: float = -INF) -> float: + if amount <= 0.0 or not world.catalog.entities.has(id) or world.catalog.entities[id].lifespan <= 0.0: + return NAN + if created_to < created_from: + return NAN + if not target.batches.has(id): + target.batches[id] = [] + if not target.has("batch_origins"): + target.batch_origins = {} + var batches: Array = target.batches[id] + var lifespan: float = CivilizationState.effective_span(world, target, id, Global.CivilizationRules) + if world.catalog.entities[id].group_as == "Star": + lifespan /= WorldParameters.effective(target, "Bur") + var batch_duration: float = lifespan / Global.batch_count if lifespan > 10.0 else lifespan + if batch_duration <= 0.0: + return NAN + if not target.batch_origins.has(id): + target.batch_origins[id] = batches[0].get("created_from", created_from) if not batches.is_empty() else created_from + var origin: float = target.batch_origins[id] + var cursor: float = created_from + var duration: float = created_to - created_from + var remaining_amount: float = amount + var completed_amount: float = 0.0 + var completed_from: float = INF + var completed_to: float = -INF + # Subtract virtual windows in their original order to preserve floating-point + # rounding of lifetime production, rather than subtracting one large sum. + var remaining_object_years: float = amount * maxf(0.0, completed_before - (created_from + created_to) * 0.5) if is_finite(completed_before) else NAN + # Compute the index once. Recomputing floor from a rounded boundary can + # select the preceding window forever (e.g. fractional origins at large ages). + var window_index: float = floorf((cursor - origin) / batch_duration) + if origin + (window_index + 1.0) * batch_duration <= cursor: + window_index += 1.0 + var batches_by_start: Dictionary = {} + for existing: Dictionary in batches: + var start: float = existing.get("created_from", -INF) + if not batches_by_start.has(start): + batches_by_start[start] = [] + batches_by_start[start].append(existing) + while true: + var window_start: float = origin + window_index * batch_duration + var window_end: float = origin + (window_index + 1.0) * batch_duration + if window_end <= cursor: + push_error("Batch window exceeds floating-point time precision: " + id) + return remaining_object_years + var part_end: float = minf(created_to, window_end) + var part_amount: float = remaining_amount + if duration > 0.0 and part_end < created_to: + part_amount = floorf(amount * (part_end - created_from) / duration) - (amount - remaining_amount) + if window_end + lifespan <= completed_before: + # Retain the original integer allocation and lifetime integral, but + # do not allocate thousands of already-dead batch dictionaries. + completed_amount += part_amount + completed_from = minf(completed_from, window_start + lifespan) + completed_to = maxf(completed_to, window_end + lifespan) + remaining_object_years -= part_amount * expired_years(completed_before, window_start + lifespan, window_end + lifespan) + else: + var batch: Dictionary = {} + for existing: Dictionary in batches_by_start.get(window_start, []): + if existing.get("created_from", -INF) == window_start and existing.start_year == window_start + lifespan and existing.end_year == window_end + lifespan: + batch = existing + break + if batch.is_empty(): + batch = { + "created_from": window_start, + "created_to": window_end, + "start_year": window_start + lifespan, + "end_year": window_end + lifespan, + "amount": 0.0, + } + batches.append(batch) + if not batches_by_start.has(window_start): + batches_by_start[window_start] = [] + batches_by_start[window_start].append(batch) + batch.amount += part_amount + remaining_amount -= part_amount + if duration == 0.0 or part_end >= created_to: + break + cursor = window_end + window_index += 1.0 + batches.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + return a.created_from < b.created_from) + if completed_amount > 0.0: + if not completed.has(id): completed[id] = [] + completed[id].append({"amount": completed_amount, "from": completed_from, "to": completed_to}) + return remaining_object_years + + +func withdraw_batches(target: Dictionary, id: String, to_year: float) -> Array: + var expired: Array = [] + var remaining: Array = [] + for batch: Dictionary in target.batches.get(id, []): + var start: float = maxf(batch.start_year, batch.get("processed_until", batch.start_year)) + var end: float = batch.end_year + var amount: float = batch.amount + if amount <= 0.0: + continue + if to_year < start or (to_year == start and end > start): + remaining.append(batch) + continue + var consumed: float = amount + var until: float = minf(to_year, end) + if to_year < end: + var carry: float = batch.get("withdraw_remainder", 0.0) + var expected: float = carry + (amount - carry) * (to_year - start) / (end - start) + consumed = floorf(expected) + batch.withdraw_remainder = expected - consumed + batch.amount = maxf(0.0, amount - consumed) + batch.processed_until = to_year + remaining.append(batch) + if consumed > 0.0: + expired.append({"amount": consumed, "from": start, "to": until}) + target.batches[id] = remaining + return expired + + +func object_years(source: Dictionary, id: String, quantity: float, + from_year: float, to_year: float) -> float: + 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 + result -= batch.amount * (expired_years(to_year, start, end) - expired_years(from_year, start, end)) + return maxf(0.0, result) + + +func expired_years(year: float, start: float, end: float) -> float: + if year <= start: + return 0.0 + if end <= start: + return year - start + if year >= end: + return year - end + (end - start) * 0.5 + return (year - start) * (year - start) / (2.0 * (end - start)) + + +func consume_from_batches(entity: OwniverseEntity, current_year: float) -> float: + var total: float = 0.0 + var physical: Dictionary = world.ledger.physical_amounts(world.state) + for part: Dictionary in withdraw_batches(world.state, entity.id, current_year): + total += part.amount + var fraction: float = clampf(total / physical[entity.id], 0.0, 1.0) if physical.get(entity.id, 0.0) > 0.0 else 0.0 + world.state.amount[entity.id] = world.state.amount.get(entity.id, 0.0) * (1.0 - fraction) + for owner_id in world.state.binds: + if world.state.binds[owner_id].has(entity.id): + world.state.binds[owner_id][entity.id] *= 1.0 - fraction + world.ledger.round_object_allocations(world.state) + world.ledger.refresh_groups(world.state) + world.presentation.refresh(world.state, world.catalog) + return total + + +func add_entity_to_batch(entity_id: String, amount: float, current_year: float) -> void: + append_batch(world.state, entity_id, amount, current_year, current_year) diff --git a/world/owniverse/object_batches.gd.uid b/world/owniverse/object_batches.gd.uid new file mode 100644 index 0000000..056a943 --- /dev/null +++ b/world/owniverse/object_batches.gd.uid @@ -0,0 +1 @@ +uid://wyxpfe2amo5q diff --git a/world/owniverse/object_ledger.gd b/world/owniverse/object_ledger.gd new file mode 100644 index 0000000..069f290 --- /dev/null +++ b/world/owniverse/object_ledger.gd @@ -0,0 +1,287 @@ +extends "res://world/owniverse/simulation_module.gd" +const CivilizationState = preload("res://world/owniverse/civilization_state.gd") +const QuantityMath = preload("res://world/owniverse/quantity_math.gd") + +func physical_amounts(source: Dictionary) -> Dictionary: + var amounts: Dictionary = {} + for id in world.catalog.entities: + if not world.catalog.is_group(id): + amounts[id] = maxf(0.0, source.amount.get(id, 0.0)) + for bindings: Dictionary in source.binds.values(): + for id in bindings: + if amounts.has(id): + amounts[id] += maxf(0.0, bindings[id]) + return amounts + + +func available(source: Dictionary, id: String) -> float: + var amount: float = 0.0 + for member in world.catalog.members(id): + amount += maxf(0.0, source.amount.get(member, 0.0)) + return amount + + +func expand_requirements(source: Dictionary, requirements: Dictionary) -> Dictionary: + var expanded: Dictionary = {} + for id in requirements: + var weights: Dictionary = {} + for member in world.catalog.members(id): + weights[member] = source.amount.get(member, 0.0) + weights = QuantityMath.normalize_distribution(weights) + for member in weights: + expanded[member] = expanded.get(member, 0.0) + maxf(0.0, requirements[id]) * weights[member] + return expanded + + +func take_available(target: Dictionary, id: String, amount: float) -> Dictionary: + var quantities: Dictionary = {} + for member in world.catalog.members(id): + quantities[member] = target.amount.get(member, 0.0) + var distribution: Dictionary = QuantityMath.normalize_distribution(quantities) + var taken: Dictionary = {} + var total: float = minf(amount, available(target, id)) + var discrete: bool = not distribution.is_empty() + for member in distribution: + discrete = discrete and is_discrete(member) + if discrete: + total = floorf(total) + var shares: Array = [] + var assigned: float = 0.0 + for member in distribution: + var expected: float = total * distribution[member] + taken[member] = floorf(expected) if discrete else expected + assigned += taken[member] + 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 str(a.id) < str(b.id)) + for share: Dictionary in shares: + if assigned >= total: + break + taken[share.id] += 1.0 + assigned += 1.0 + for member in taken: + target.amount[member] = maxf(0.0, target.amount.get(member, 0.0) - taken[member]) + return taken + + +func is_discrete(_id: String) -> bool: + return true + + +func credit(target: Dictionary, id: String, amount: float) -> float: + if amount <= 0.0: + return 0.0 + if is_discrete(id): + if not target.has("production_remainders"): + target.production_remainders = {} + var expected: float = amount + target.production_remainders.get(id, 0.0) + amount = floorf(expected) + target.production_remainders[id] = expected - amount + target.amount[id] = target.amount.get(id, 0.0) + amount + target.stats.total_amount[id] = target.stats.total_amount.get(id, 0.0) + amount + if world.catalog.entities.has(id) and world.catalog.entities[id].group_as != "" and world.catalog.entities[id].group_as != id: + var group: String = world.catalog.entities[id].group_as + target.stats.total_amount[group] = target.stats.total_amount.get(group, 0.0) + amount + return amount + + +func round_object_allocations(target: Dictionary) -> void: + var physical: Dictionary = physical_amounts(target) + for id in physical: + if not is_discrete(id): + continue + var slots: Array = [ {"owner": "", "value": target.amount.get(id, 0.0)}] + for owner_id in target.binds: + if target.binds[owner_id].has(id): + slots.append({"owner": owner_id, "value": target.binds[owner_id][id]}) + var assigned: float = 0.0 + for slot: Dictionary in slots: + slot.whole = floorf(slot.value) + 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 str(a.owner) < str(b.owner)) + var left: float = roundf(physical[id]) - assigned + for slot: Dictionary in slots: + if left > 0.0: + slot.whole += 1.0 + left -= 1.0 + if slot.owner == "": + target.amount[id] = slot.whole + else: + target.binds[slot.owner][id] = slot.whole + + +func refresh_groups(target: Dictionary) -> void: + var groups: Dictionary = {} + for id in world.catalog.entities: + var group: String = world.catalog.entities[id].group_as + if group != "": + groups[group] = groups.get(group, 0.0) + target.amount.get(id, 0.0) + for group in groups: + target.amount[group] = groups[group] + + +func clamp_auto_resources(target: Dictionary) -> void: + for id in target.get("auto_resources", {}): + target.auto_resources[id] = minf(target.auto_resources[id], available(target, id)) + + +func bind_full(target: Dictionary, amounts: Dictionary) -> bool: + # Binding only mutates free amounts and owners' holdings. + var trial: Dictionary = { + "amount": target.amount.duplicate(), + "binds": target.binds.duplicate(true), + } + var requests: Array = [] + for owner_id in amounts: + if not world.catalog.entities.has(owner_id): + return false + for id in world.catalog.entities[owner_id].bind: + var quantity: float = amounts[owner_id] * maxf(0.0, world.catalog.entities[owner_id].bind[id]) + if not is_equal_approx(quantity, roundf(quantity)): + return false + if quantity > 0.0: + requests.append({"owner": owner_id, "id": id, "amount": roundf(quantity), "members": world.catalog.members(id).size()}) + requests.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + if a.members != b.members: + return a.members < b.members + 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 + for quantity in taken.values(): + total += quantity + if total != request.amount: + return false + if not trial.binds.has(request.owner): + trial.binds[request.owner] = {} + for id in taken: + trial.binds[request.owner][id] = trial.binds[request.owner].get(id, 0.0) + taken[id] + target.amount = trial.amount + target.binds = trial.binds + return true + + +func bind_new_owners(target: Dictionary, amounts: Dictionary, require_full: bool = false) -> bool: + if require_full: + return bind_full(target, amounts) + var requests: Dictionary = {} + for owner_id in amounts: + if not world.catalog.entities.has(owner_id): + continue + for id in world.catalog.entities[owner_id].bind: + var quantity: float = amounts[owner_id] * maxf(0.0, world.catalog.entities[owner_id].bind[id]) + var weights: Dictionary = {} + for member in world.catalog.members(id): + weights[member] = target.amount.get(member, 0.0) + weights = QuantityMath.normalize_distribution(weights) + for member in weights: + if not requests.has(member): + requests[member] = {} + requests[member][owner_id] = requests[member].get(owner_id, 0.0) + quantity * weights[member] + for id in requests: + var requested: float = 0.0 + for quantity in requests[id].values(): + requested += quantity + var available: float = available(target, id) + var factor: float = minf(1.0, available / requested) if requested > 0.0 else 0.0 + for owner_id in requests[id]: + var quantity: float = requests[id][owner_id] * factor + if not target.binds.has(owner_id): + target.binds[owner_id] = {} + target.binds[owner_id][id] = target.binds[owner_id].get(id, 0.0) + quantity + target.amount[id] = maxf(0.0, target.amount.get(id, 0.0) - quantity) + + return true + + +func apply_costs(entity: OwniverseEntity, amount: float) -> void: + var costs: Dictionary = {} + for resource in entity.cost: + costs[resource] = roundf(maxf(0.0, entity.cost[resource]) * amount) + for resource in costs: + var quantities: Dictionary = physical_amounts(world.state) + var taken: Dictionary = take_available(world.state, resource, costs[resource]) + for id in taken: + var factor: float = 1.0 - taken[id] / quantities[id] if quantities.get(id, 0.0) > 0.0 else 0.0 + world.batches.scale_batches(world.state, id, factor) + refresh_groups(world.state) + clamp_auto_resources(world.state) + world.presentation.refresh(world.state, world.catalog) + + +func apply_binds(item_id: String, _entity: OwniverseEntity, amount: float) -> bool: + if not bind_new_owners(world.state, {item_id: amount}, true): + return false + refresh_groups(world.state) + clamp_auto_resources(world.state) + world.presentation.refresh(world.state, world.catalog) + + return true + + +func process_entity(entity_id: String, amount: float) -> void: + if amount == 0: + return + + # Update state amount + world.state.amount[entity_id] = world.state.amount.get(entity_id, 0.0) + amount + world.state.stats.total_amount[entity_id] = world.state.stats.total_amount.get(entity_id, 0.0) + maxf(0.0, amount) + + + # Handle group updates if entity belongs to a group + var entity = world.catalog.entities[entity_id] + if entity.group_as != "": + world.state.amount[entity.group_as] = world.state.amount.get(entity.group_as, 0.0) + amount + world.state.stats.total_amount[entity.group_as] = world.state.stats.total_amount.get(entity.group_as, 0.0) + maxf(0.0, amount) + world.presentation.refresh(world.state, world.catalog) + + +## Freeze holdings before simultaneous transitions and reserve successors. +func transfer_evolution(target: Dictionary, fractions: Dictionary, outputs: Dictionary) -> Dictionary: + # Use a frozen binding table so simultaneous transitions cannot cascade by type order. + var bindings: Dictionary = target.binds.duplicate(true) + var incoming: Dictionary = {} + for owner_id in bindings: + var owner_survival: float = 1.0 - fractions.get(owner_id, 0.0) + target.binds[owner_id] = {} + for id in bindings[owner_id]: + 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 = world.stellar.stellar_successors(outputs.get(owner_id, {})) if world.catalog.entities.has(id) and world.catalog.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 world.catalog.entities.has(child) and not CivilizationState.is_space_civilization(child, Global.CivilizationRules): + 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 world.catalog.entities.has(successor) or CivilizationState.is_space_civilization(successor, Global.CivilizationRules): + continue + var transferred: float = held * owner_survival * fraction * maxf(0.0, outputs[id][successor]) + if not incoming.has(owner_id): + incoming[owner_id] = {} + incoming[owner_id][successor] = incoming[owner_id].get(successor, 0.0) + transferred + + for id in fractions: + target.amount[id] = maxf(0.0, target.amount.get(id, 0.0) * (1.0 - fractions[id])) + + return incoming + + +## Commit reservations after newly evolved objects have been credited. +func accept_transfers(target: Dictionary, incoming: Dictionary) -> void: + 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) diff --git a/world/owniverse/object_ledger.gd.uid b/world/owniverse/object_ledger.gd.uid new file mode 100644 index 0000000..9fdf16a --- /dev/null +++ b/world/owniverse/object_ledger.gd.uid @@ -0,0 +1 @@ +uid://buauxqyl73vjb diff --git a/world/owniverse/owniverse.gd b/world/owniverse/owniverse.gd index d1b1851..6734d90 100644 --- a/world/owniverse/owniverse.gd +++ b/world/owniverse/owniverse.gd @@ -1,41 +1,18 @@ extends Node class_name Owniverse -const CivilizationEvolution = preload("res://world/owniverse/civilization_evolution.gd") -const CivilizationState = preload("res://world/owniverse/civilization_state.gd") +## Owns the state and modules. Rules live in the modules below. const SimulationTime = preload("res://world/owniverse/simulation_time.gd") const WorldParameters = preload("res://world/owniverse/world_parameters.gd") -const QuantityMath = preload("res://world/owniverse/quantity_math.gd") -const EntityCatalog = preload("res://world/owniverse/entity_catalog.gd") -const ItemPresentation = preload("res://world/owniverse/item_presentation.gd") - -#var state: State = State.new() - -var catalog = EntityCatalog.new() -# Compatibility accessors keep existing callers and fixtures on the same dictionaries. -var entities: Dictionary[String, OwniverseEntity]: - get: return catalog.entities -var entity_groups: Dictionary[String, Array]: - get: return catalog.groups -var entity_event_groups: Dictionary[String, Array]: - get: return catalog.event_groups - -var presentation = ItemPresentation.new() -var items_to_update: Dictionary[String, float]: - get: return presentation.pending_amounts - set(value): presentation.pending_amounts = value -var defer_item_updates: bool: - get: return presentation.deferred_updates - set(value): presentation.deferred_updates = value -var item_amounts_dirty: bool: - get: return presentation.dirty - set(value): presentation.dirty = value - -var auto_production: Array: - get: return catalog.automatic_producers -var locked_entities: Array: - get: return catalog.unlockable_entities - -# ------------------------------------ +var catalog = preload("res://world/owniverse/entity_catalog.gd").new() +var presentation = preload("res://world/owniverse/item_presentation.gd").new() +var batches = preload("res://world/owniverse/object_batches.gd").new(self) +var ledger = preload("res://world/owniverse/object_ledger.gd").new(self) +var production = preload("res://world/owniverse/manual_production.gd").new(self) +var automatic = preload("res://world/owniverse/automatic_production.gd").new(self) +var stellar = preload("res://world/owniverse/stellar_rules.gd").new(self) +var life = preload("res://world/owniverse/life_evolution.gd").new(self) +var environment = preload("res://world/owniverse/universe_environment.gd").new(self) +var evolution = preload("res://world/owniverse/evolution_coordinator.gd").new(self) var state: Dictionary = { 'version': SimulationTime.STATE_VERSION, @@ -71,9 +48,6 @@ var state: Dictionary = { } } -var new_state = state.duplicate_deep() - - var speed: int: get: return state.speed @@ -81,1384 +55,10 @@ var speed: int: state.speed = clampi(value, 1, Global.SPEED_LIMIT) state.speed_in_year = pow(10.0, state.speed - 1) - 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) - - -func initialize_life_batches() -> void: - CivilizationState.detach_space_civilizations(state, Global.CivilizationRules) - CivilizationState.enforce_capacity(self, state, Global.CivilizationRules, state.time.datetime) - var physical: Dictionary = _physical_amounts(state) - for id in entities: - if entities[id].life_rule.is_empty(): - continue - var tracked: float = 0.0 - for batch: Dictionary in state.batches.get(id, []): - tracked += batch.amount - _append_batch(state, id, maxf(0.0, physical.get(id, 0.0) - tracked), state.time.datetime, state.time.datetime) - new_state = state - - -func _evolute_to_datetime(datetime: float) -> void: - # Reconcile only when simulation advances. - # Fractional frame updates cannot change capacity or populations. - var processed_year: float = SimulationTime.processed_year(datetime) - if processed_year > state.time.year: - initialize_life_batches() - var previous: Dictionary = state - var next: Dictionary = previous.duplicate_deep() - next.version = SimulationTime.STATE_VERSION - next.speed = clampi(int(previous.speed), 1, Global.SPEED_LIMIT) - next.speed_in_year = pow(10.0, next.speed - 1) - var from_year: float = previous.time.year - var to_year: float = processed_year - if to_year > from_year: - var quantities: Dictionary = _physical_amounts(previous) - var distribution: Dictionary = _normalize_distribution(quantities) - next.distribution = distribution - - _produce_for_period(previous, next, quantities, distribution, from_year, to_year) - _evolve_batches(next, to_year) - _refresh_groups(next) - _clamp_auto_resources(next) - WorldParameters.advance(previous, next, Global.CivilizationRules, to_year - from_year) - next.time = {"datetime": datetime, "year": to_year} - - # Publish only after every phase has finished; previous stays untouched. - new_state = next - state = next - if to_year > from_year: - refresh_item_amounts() - - -func get_item_amounts(total_produced: bool = false) -> Dictionary[String, float]: - return ItemPresentation.amounts(state, catalog, total_produced) - - -func refresh_item_amounts(force: bool = false) -> void: - presentation.refresh(state, catalog, force) - - -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": SimulationTime.processed_year(new_datetime)} - - -## Public entry point for forces: update a world scale and preserve stellar ages. -func modify_world_parameter(key: String, change: float) -> void: - if key not in ["Bur", "Exp", "Agg"] or not is_finite(change): return - var old_multiplier: float = WorldParameters.effective(state, key) - if not state.has("world_parameters"): state.world_parameters = WorldParameters.defaults() - state.world_parameters[key] = clampf(WorldParameters.value(state, key) + change, 1.0, 19.0) - if key == "Bur": - var ratio: float = old_multiplier / WorldParameters.effective(state, key) - var now: float = state.time.year - for id in state.batches: - if not entities.has(id) or entities[id].group_as != "Star": continue - for batch: Dictionary in state.batches[id]: - # Only future survival time changes. Birth dates and the fractional - # withdrawal carry remain intact; already elapsed time is not replayed. - for field in ["start_year", "end_year", "processed_until"]: - if batch.has(field): batch[field] = now + maxf(0.0, batch[field] - now) * ratio - new_state = state - - -## Rebuild after changing group_as or replacing definitions without changing their count. -## Adding/removing definitions is detected automatically during initialization. -func rebuild_group_index() -> void: - catalog.rebuild_group_index() - - -func _is_group(id: String) -> bool: - return catalog.is_group(id) - - -func _physical_amounts(source: Dictionary) -> Dictionary: - var amounts: Dictionary = {} - for id in entities: - if not _is_group(id): - amounts[id] = maxf(0.0, source.amount.get(id, 0.0)) - for bindings: Dictionary in source.binds.values(): - for id in bindings: - if amounts.has(id): - amounts[id] += maxf(0.0, bindings[id]) - return amounts - - -func _members(id: String) -> Array: - return catalog.members(id) - - -func _available(source: Dictionary, id: String) -> float: - var amount: float = 0.0 - for member in _members(id): - amount += maxf(0.0, source.amount.get(member, 0.0)) - return amount - - -# Resolve group references to concrete types, proportional to free quantities. -func _expand_requirements(source: Dictionary, requirements: Dictionary) -> Dictionary: - var expanded: Dictionary = {} - for id in requirements: - var weights: Dictionary = {} - for member in _members(id): - weights[member] = source.amount.get(member, 0.0) - weights = _normalize_distribution(weights) - for member in weights: - expanded[member] = expanded.get(member, 0.0) + maxf(0.0, requirements[id]) * weights[member] - return expanded - - -func _take_available(target: Dictionary, id: String, amount: float) -> Dictionary: - var quantities: Dictionary = {} - for member in _members(id): - quantities[member] = target.amount.get(member, 0.0) - var distribution: Dictionary = _normalize_distribution(quantities) - var taken: Dictionary = {} - var total: float = minf(amount, _available(target, id)) - var discrete: bool = not distribution.is_empty() - for member in distribution: - discrete = discrete and _is_discrete(member) - if discrete: - total = floorf(total) - var shares: Array = [] - var assigned: float = 0.0 - for member in distribution: - var expected: float = total * distribution[member] - taken[member] = floorf(expected) if discrete else expected - assigned += taken[member] - 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 str(a.id) < str(b.id)) - for share: Dictionary in shares: - if assigned >= total: - break - taken[share.id] += 1.0 - assigned += 1.0 - for member in taken: - target.amount[member] = maxf(0.0, target.amount.get(member, 0.0) - taken[member]) - return taken - - -func _scale_batches(target: Dictionary, id: String, factor: float) -> void: - var batches: Array = target.batches.get(id, []) - var total: float = 0.0 - var assigned: float = 0.0 - var shares: Array = [] - for batch: Dictionary in batches: - var expected: float = batch.amount * maxf(0.0, factor) - total += expected - batch.amount = floorf(expected) - assigned += batch.amount - shares.append({"batch": batch, "rest": expected - batch.amount}) - shares.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: - return a.rest > b.rest) - for share: Dictionary in shares: - if assigned < roundf(total): - share.batch.amount += 1.0 - assigned += 1.0 - share.batch.withdraw_remainder = minf(share.batch.get("withdraw_remainder", 0.0), share.batch.amount) - - -func _is_discrete(_id: String) -> bool: - return true - - -func _credit(target: Dictionary, id: String, amount: float) -> float: - if amount <= 0.0: - return 0.0 - if _is_discrete(id): - if not target.has("production_remainders"): - target.production_remainders = {} - var expected: float = amount + target.production_remainders.get(id, 0.0) - amount = floorf(expected) - target.production_remainders[id] = expected - amount - target.amount[id] = target.amount.get(id, 0.0) + amount - target.stats.total_amount[id] = target.stats.total_amount.get(id, 0.0) + amount - if entities.has(id) and entities[id].group_as != "" and entities[id].group_as != id: - var group: String = entities[id].group_as - target.stats.total_amount[group] = target.stats.total_amount.get(group, 0.0) + amount - return amount - - -# Allocate whole objects between free stock and owners without changing totals. -func _round_object_allocations(target: Dictionary) -> void: - var physical: Dictionary = _physical_amounts(target) - for id in physical: - if not _is_discrete(id): - continue - var slots: Array = [ {"owner": "", "value": target.amount.get(id, 0.0)}] - for owner_id in target.binds: - if target.binds[owner_id].has(id): - slots.append({"owner": owner_id, "value": target.binds[owner_id][id]}) - var assigned: float = 0.0 - for slot: Dictionary in slots: - slot.whole = floorf(slot.value) - 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 str(a.owner) < str(b.owner)) - var left: float = roundf(physical[id]) - assigned - for slot: Dictionary in slots: - if left > 0.0: - slot.whole += 1.0 - left -= 1.0 - if slot.owner == "": - target.amount[id] = slot.whole - else: - target.binds[slot.owner][id] = slot.whole - - -func _refresh_groups(target: Dictionary) -> void: - var groups: Dictionary = {} - for id in entities: - var group: String = entities[id].group_as - if group != "": - groups[group] = groups.get(group, 0.0) + target.amount.get(id, 0.0) - for group in groups: - target.amount[group] = groups[group] - - -func _clamp_auto_resources(target: Dictionary) -> void: - for id in target.get("auto_resources", {}): - target.auto_resources[id] = minf(target.auto_resources[id], _available(target, id)) - - -# Each type collects production into consecutive lifespan_delta-wide creation windows. -# Its evolution window is the creation window shifted forward by one lifespan. -# Optional direct completions return the remaining object-year integral for -# newborn production. NAN means the caller should integrate ordinary batches. -func _append_batch(target: Dictionary, id: String, amount: float, - created_from: float, created_to: float, completed: Dictionary = {}, - completed_before: float = -INF) -> float: - if amount <= 0.0 or not entities.has(id) or entities[id].lifespan <= 0.0: - return NAN - if created_to < created_from: - return NAN - if not target.batches.has(id): - target.batches[id] = [] - if not target.has("batch_origins"): - target.batch_origins = {} - var batches: Array = target.batches[id] - var lifespan: float = CivilizationState.effective_span(self, target, id, Global.CivilizationRules) - if entities[id].group_as == "Star": - lifespan /= WorldParameters.effective(target, "Bur") - var batch_duration: float = lifespan / Global.batch_count if lifespan > 10.0 else lifespan - if batch_duration <= 0.0: - return NAN - if not target.batch_origins.has(id): - target.batch_origins[id] = batches[0].get("created_from", created_from) if not batches.is_empty() else created_from - var origin: float = target.batch_origins[id] - var cursor: float = created_from - var duration: float = created_to - created_from - var remaining_amount: float = amount - var completed_amount: float = 0.0 - var completed_from: float = INF - var completed_to: float = -INF - # Subtract virtual windows in their original order to preserve floating-point - # rounding of lifetime production, rather than subtracting one large sum. - var remaining_object_years: float = amount * maxf(0.0, completed_before - (created_from + created_to) * 0.5) if is_finite(completed_before) else NAN - # Compute the index once. Recomputing floor from a rounded boundary can - # select the preceding window forever (e.g. fractional origins at large ages). - var window_index: float = floorf((cursor - origin) / batch_duration) - if origin + (window_index + 1.0) * batch_duration <= cursor: - window_index += 1.0 - var batches_by_start: Dictionary = {} - for existing: Dictionary in batches: - var start: float = existing.get("created_from", -INF) - if not batches_by_start.has(start): - batches_by_start[start] = [] - batches_by_start[start].append(existing) - while true: - var window_start: float = origin + window_index * batch_duration - var window_end: float = origin + (window_index + 1.0) * batch_duration - if window_end <= cursor: - push_error("Batch window exceeds floating-point time precision: " + id) - return remaining_object_years - var part_end: float = minf(created_to, window_end) - var part_amount: float = remaining_amount - if duration > 0.0 and part_end < created_to: - part_amount = floorf(amount * (part_end - created_from) / duration) - (amount - remaining_amount) - if window_end + lifespan <= completed_before: - # Retain the original integer allocation and lifetime integral, but - # do not allocate thousands of already-dead batch dictionaries. - completed_amount += part_amount - completed_from = minf(completed_from, window_start + lifespan) - completed_to = maxf(completed_to, window_end + lifespan) - remaining_object_years -= part_amount * _expired_years(completed_before, window_start + lifespan, window_end + lifespan) - else: - var batch: Dictionary = {} - for existing: Dictionary in batches_by_start.get(window_start, []): - if existing.get("created_from", -INF) == window_start and existing.start_year == window_start + lifespan and existing.end_year == window_end + lifespan: - batch = existing - break - if batch.is_empty(): - batch = { - "created_from": window_start, - "created_to": window_end, - "start_year": window_start + lifespan, - "end_year": window_end + lifespan, - "amount": 0.0, - } - batches.append(batch) - if not batches_by_start.has(window_start): - batches_by_start[window_start] = [] - batches_by_start[window_start].append(batch) - batch.amount += part_amount - remaining_amount -= part_amount - if duration == 0.0 or part_end >= created_to: - break - cursor = window_end - window_index += 1.0 - batches.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: - return a.created_from < b.created_from) - if completed_amount > 0.0: - if not completed.has(id): completed[id] = [] - completed[id].append({"amount": completed_amount, "from": completed_from, "to": completed_to}) - return remaining_object_years - - -# Move the left boundary after each partial withdrawal, keeping a uniform density. -func _withdraw_batches(target: Dictionary, id: String, to_year: float) -> Array: - var expired: Array = [] - var remaining: Array = [] - for batch: Dictionary in target.batches.get(id, []): - var start: float = maxf(batch.start_year, batch.get("processed_until", batch.start_year)) - var end: float = batch.end_year - var amount: float = batch.amount - if amount <= 0.0: - continue - if to_year < start or (to_year == start and end > start): - remaining.append(batch) - continue - var consumed: float = amount - var until: float = minf(to_year, end) - if to_year < end: - var carry: float = batch.get("withdraw_remainder", 0.0) - var expected: float = carry + (amount - carry) * (to_year - start) / (end - start) - consumed = floorf(expected) - batch.withdraw_remainder = expected - consumed - batch.amount = maxf(0.0, amount - consumed) - batch.processed_until = to_year - remaining.append(batch) - if consumed > 0.0: - expired.append({"amount": consumed, "from": start, "to": until}) - target.batches[id] = remaining - return expired - - -func _add_output_terms(outputs: Dictionary, terms: String, count: float) -> void: - if count <= 0.0: - return - for term in terms.split("&"): - var parts: PackedStringArray = term.strip_edges().split(":") - var id: String = parts[0].strip_edges() - var amount: float = float(parts[1]) if parts.size() == 2 else 1.0 - if id != "" and amount > 0.0: - outputs[id] = outputs.get(id, 0.0) + count * amount - - -# One life attempt: progress, degradation, or retry at the same stage. -# Retried worlds are not new planets and must not increment production statistics. -func _life_products(target: Dictionary, entity: OwniverseEntity, count: float) -> Dictionary: - var rule: Dictionary = entity.life_rule - var pending: Dictionary = {} - var modifiers: Dictionary = WorldParameters.life_modifiers(target) - var probabilities: Dictionary = { - "progress": float(rule.progress) * modifiers.progress * WorldParameters.value(target, "Enlightenment"), - "degradation": float(rule.degradation) * modifiers.degradation, - } - var probability_sum: float = maxf(1.0, probabilities.progress + probabilities.degradation) - if not target.has("life_remainders"): - target.life_remainders = {} - for channel in ["progress", "degradation"]: - var key: String = entity.id + ":" + channel - var expected: float = count * probabilities[channel] / probability_sum + target.life_remainders.get(key, 0.0) - target.life_remainders[key] = expected - pending[channel] = floorf(snappedf(expected, 1.0e-12)) - var assigned: Dictionary = _allocate_whole(pending, count) - for channel in assigned: - var key: String = entity.id + ":" + channel - target.life_remainders[key] = maxf(0.0, target.life_remainders[key] - assigned[channel]) - var result: Dictionary = {entity.id: count - assigned.progress - assigned.degradation} - result[rule.next] = assigned.progress - var weights: Dictionary = {} - var weight_sum: float = 0.0 - for term in str(rule.fall).split("^"): - var pair: PackedStringArray = term.split(":") - weights[pair[0]] = float(pair[1]) - weight_sum += float(pair[1]) - var requests: Dictionary = {} - for id in weights: - var key: String = entity.id + ":fall:" + id - var expected: float = assigned.degradation * weights[id] / weight_sum + target.life_remainders.get(key, 0.0) - target.life_remainders[key] = expected - requests[id] = maxf(0.0, floorf(snappedf(expected, 1.0e-12))) - var falls: Dictionary = _allocate_whole(requests, assigned.degradation) - var distributed: float = 0.0 - for id in falls: - distributed += falls[id] - result[id] = result.get(id, 0.0) + falls[id] - var key: String = entity.id + ":fall:" + id - target.life_remainders[key] -= falls[id] - # Fill sub-unit fall quotas deterministically, retaining signed allocation errors. - while distributed < assigned.degradation: - var selected: String = "" - var largest: float = -INF - for id in weights: - var rest: float = target.life_remainders[entity.id + ":fall:" + id] - if rest > largest: - largest = rest - selected = id - result[selected] = result.get(selected, 0.0) + 1.0 - target.life_remainders[entity.id + ":fall:" + selected] -= 1.0 - distributed += 1.0 - return result - - -func _evolution_products(target: Dictionary, entity: OwniverseEntity, count: float) -> Dictionary: - if not entity.life_rule.is_empty(): - return _life_products(target, entity, count) - var outputs: Dictionary = {} - for id in entity.output_and: - outputs[id] = count * maxf(0.0, entity.output_and[id]) - if not entity.output_or.is_empty(): - # The parser stores the percentage after '%', e.g. 0.1 means 0.1 percent. - var chance: float = clampf(entity.output_or.get("chance", 0.0) / 100.0 * WorldParameters.effective(target, "Exp"), 0.0, 1.0) - if not target.has("evolution_remainders"): - target.evolution_remainders = {} - var expected: float = count * chance + target.evolution_remainders.get(entity.id, 0.0) - var positive: float = minf(count, floorf(snappedf(expected, 1.0e-12))) - target.evolution_remainders[entity.id] = maxf(0.0, expected - positive) - _add_output_terms(outputs, str(entity.output_or.get("positive", "")), positive) - _add_output_terms(outputs, str(entity.output_or.get("negative", "")), count - positive) - # Entity.blast is authoritative even if imported output still contains - # blast:N. Zero disables the event; never add both representations. - outputs.erase("blast") - if entity.blast > 0: - outputs["blast"] = count * entity.blast * WorldParameters.effective(target, "Exp") - return outputs - - -# Derived from current concrete stock, including bound objects, never UI aliases. -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: - return QuantityMath.accumulate_whole(target, field, key, value) - - -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, - remaining_object_years: float = NAN) -> void: - var products: Dictionary = _yearly_products(entities[id]) - if products.is_empty() or count <= 0.0: - return - var object_years: float = remaining_object_years if is_finite(remaining_object_years) else count * maxf(0.0, to_year - (born_from + born_to) * 0.5) - var previous_amounts: Dictionary = {} - for old: Dictionary in before_batches: - var start: float = old.get("created_from", -INF) - if not previous_amounts.has(start): - previous_amounts[start] = old.amount - for batch: Dictionary in target.batches.get(id, []): - var added: float = batch.amount - previous_amounts.get(batch.get("created_from", INF), 0.0) - object_years -= maxf(0.0, added) * _expired_years(to_year, batch.start_year, batch.end_year) - object_years = maxf(0.0, object_years) - for resource in products: - var output: float = object_years * products[resource] * WorldParameters.production(target, entities[id]) - 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. - var completed: Dictionary = {} - while _evolve_batch_generation(target, to_year, completed): - pass - - -func _uses_short_lived_fast_path(id: String) -> bool: - # Only terminal stellar events with linear outputs and no living successors. - var entity: OwniverseEntity = entities[id] - if entity.event_group != "SNGRBB" or not entity.output_or.is_empty() or not entity.life_rule.is_empty(): - return false - for successor in entity.output_and: - if entities.has(successor) and entities[successor].lifespan > 0.0: - return false - return true - - -func _evolve_batch_generation(target: Dictionary, to_year: float, completed: Dictionary = {}) -> bool: - # Apply direct completions at the original next-generation boundary, so - # binding transfers, the blast environment and capacity checks keep order. - var completed_now: Dictionary = completed.duplicate(true) - completed.clear() - var quantities: Dictionary = _physical_amounts(target) - var expired: Dictionary = {} - var preview: Dictionary = {"batches": {}} - for id in target.batches: - if entities.has(id) and entities[id].destroy_level > 0: - preview.batches[id] = target.batches[id].duplicate(true) - for id in target.batches.keys(): - if not entities.has(id) or entities[id].destroy_level <= 0: - continue - var dying: float = 0.0 - for part: Dictionary in completed_now.get(id, []): - dying += part.amount - 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): - expired[id] = completed_now.get(id, []) + _withdraw_batches(target, id, to_year) - var has_expired: bool = false - for parts: Array in expired.values(): - if not parts.is_empty(): - has_expired = true - break - if not has_expired: - return false - var fractions: Dictionary = {} - var outputs: Dictionary = {} - var star_budget: Dictionary = CivilizationState.stellar_budget(self, target, Global.CivilizationRules) - var civilization_transaction: Dictionary = CivilizationEvolution.prepare(self, target, expired, to_year, star_budget, Global.CivilizationRules) - for id in expired: - var total: float = 0.0 - for part: Dictionary in expired[id]: - total += part.amount - fractions[id] = clampf(total / quantities[id], 0.0, 1.0) if quantities.get(id, 0.0) > 0.0 else 0.0 - var combined: Dictionary = {} - for part: Dictionary in expired[id]: - if not part.has("products"): - part.products = _evolution_products(target, entities[id], part.amount) - for successor in part.products.keys(): - if Global.CivilizationRules.has(id): - continue - var requested: float = part.products[successor] - var admitted: float = CivilizationState.admit_transition(star_budget, Global.CivilizationRules, id, successor, requested) - part.products[successor] = admitted - if admitted < requested: - part.products[id] = part.products.get(id, 0.0) + requested - admitted - for successor in part.products: - combined[successor] = combined.get(successor, 0.0) + part.products[successor] - outputs[id] = {} - if total > 0.0: - for successor in combined: - outputs[id][successor] = combined[successor] / total - - # Use a frozen binding table so simultaneous transitions cannot cascade by type order. - var bindings: Dictionary = target.binds.duplicate(true) - var incoming: Dictionary = {} - for owner_id in bindings: - var owner_survival: float = 1.0 - fractions.get(owner_id, 0.0) - target.binds[owner_id] = {} - for id in bindings[owner_id]: - 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) and not CivilizationState.is_space_civilization(child, Global.CivilizationRules): - 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) or CivilizationState.is_space_civilization(successor, Global.CivilizationRules): - continue - var transferred: float = held * owner_survival * fraction * maxf(0.0, outputs[id][successor]) - if not incoming.has(owner_id): - incoming[owner_id] = {} - incoming[owner_id][successor] = incoming[owner_id].get(successor, 0.0) + transferred - - for id in fractions: - 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 - if successor == id and (not entities[id].life_rule.is_empty() or Global.CivilizationRules.has(id)): - target.amount[id] = target.amount.get(id, 0.0) + amount - else: - amount = _credit(target, successor, amount) - var before_batches: Array = target.batches.get(successor, []).duplicate(true) - var cutoff: float = to_year if _uses_short_lived_fast_path(successor) else -INF - var integral: float = _append_batch(target, successor, amount, part.from, part.to, completed, cutoff) - _produce_newborn_lifetime(target, successor, amount, part.from, part.to, to_year, before_batches, integral) - 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) - CivilizationEvolution.finish(self, target, civilization_transaction, to_year, Global.CivilizationRules) - _apply_blast(target, blast_power, environment) - CivilizationState.enforce_capacity(self, target, Global.CivilizationRules, to_year) - return true - - -# Plan all bindings on a copy, reserving specific types before broader groups. -func _bind_full(target: Dictionary, amounts: Dictionary) -> bool: - # Binding only mutates free amounts and owners' holdings. - var trial: Dictionary = { - "amount": target.amount.duplicate(), - "binds": target.binds.duplicate(true), - } - var requests: Array = [] - for owner_id in amounts: - if not entities.has(owner_id): - return false - for id in entities[owner_id].bind: - var quantity: float = amounts[owner_id] * maxf(0.0, entities[owner_id].bind[id]) - if not is_equal_approx(quantity, roundf(quantity)): - return false - if quantity > 0.0: - requests.append({"owner": owner_id, "id": id, "amount": roundf(quantity), "members": _members(id).size()}) - requests.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: - if a.members != b.members: - return a.members < b.members - 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 - for quantity in taken.values(): - total += quantity - if total != request.amount: - return false - if not trial.binds.has(request.owner): - trial.binds[request.owner] = {} - for id in taken: - trial.binds[request.owner][id] = trial.binds[request.owner].get(id, 0.0) + taken[id] - target.amount = trial.amount - target.binds = trial.binds - return true - - -# Neither costs nor bindings are committed unless the whole batch is funded. -func _try_production(target: Dictionary, entity: OwniverseEntity, amount: float, - automatic: bool = false) -> bool: - if amount <= 0.0 or amount != floorf(amount): - return false - var trial: Dictionary = target.duplicate() - trial.amount = target.amount.duplicate() - trial.binds = target.binds.duplicate(true) - trial.auto_resources = target.auto_resources.duplicate() - trial.batches = target.batches.duplicate() - var copied_batches: Dictionary = {} - for resource in entity.cost: - var cost: float = roundf(maxf(0.0, entity.cost[resource]) * amount) - if automatic and trial.auto_resources.get(resource, 0.0) < cost: - return false - var before: Dictionary = _physical_amounts(trial) - var taken: Dictionary = _take_available(trial, resource, cost) - var total: float = 0.0 - for quantity in taken.values(): - total += quantity - if total != cost: - return false - if automatic: - trial.auto_resources[resource] = maxf(0.0, trial.auto_resources.get(resource, 0.0) - cost) - for id in taken: - # Isolate only batches actually touched by this transaction. - if trial.batches.has(id) and not copied_batches.has(id): - trial.batches[id] = trial.batches[id].duplicate(true) - copied_batches[id] = true - var factor: float = 1.0 - taken[id] / before[id] if before.get(id, 0.0) > 0.0 else 0.0 - _scale_batches(trial, id, factor) - if not _bind_new_owners(trial, {entity.id: amount}, true): - return false - _refresh_groups(trial) - _clamp_auto_resources(trial) - for field in ["amount", "binds", "batches", "auto_resources"]: - if trial.has(field): - target[field] = trial[field] - return true - - -func _bind_new_owners(target: Dictionary, amounts: Dictionary, require_full: bool = false) -> bool: - if require_full: - return _bind_full(target, amounts) - var requests: Dictionary = {} - for owner_id in amounts: - if not entities.has(owner_id): - continue - for id in entities[owner_id].bind: - var quantity: float = amounts[owner_id] * maxf(0.0, entities[owner_id].bind[id]) - var weights: Dictionary = {} - for member in _members(id): - weights[member] = target.amount.get(member, 0.0) - weights = _normalize_distribution(weights) - for member in weights: - if not requests.has(member): - requests[member] = {} - requests[member][owner_id] = requests[member].get(owner_id, 0.0) + quantity * weights[member] - for id in requests: - var requested: float = 0.0 - for quantity in requests[id].values(): - requested += quantity - var available: float = _available(target, id) - var factor: float = minf(1.0, available / requested) if requested > 0.0 else 0.0 - for owner_id in requests[id]: - var quantity: float = requests[id][owner_id] * factor - if not target.binds.has(owner_id): - target.binds[owner_id] = {} - target.binds[owner_id][id] = target.binds[owner_id].get(id, 0.0) + quantity - target.amount[id] = maxf(0.0, target.amount.get(id, 0.0) - quantity) - - return true - - -# 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 * 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 - result -= batch.amount * (_expired_years(to_year, start, end) - _expired_years(from_year, start, end)) - return maxf(0.0, result) - - -func _expired_years(year: float, start: float, end: float) -> float: - if year <= start: - return 0.0 - if end <= start: - return year - start - if year >= end: - return year - end + (end - start) * 0.5 - return (year - start) * (year - start) / (2.0 * (end - start)) - - -# Largest-remainder allocation: whole float values, bounded by each request. -func _allocate_whole(requests: Dictionary, limit: float) -> Dictionary: - return QuantityMath.allocate_whole(requests, limit) - - -# The entity currently exposes type/amount fields; life_product can contain -# several resources, each specified as total lifetime output. -func _yearly_products(entity: OwniverseEntity) -> Dictionary: - var products: Dictionary = {} - if not entity.life_product.is_empty() and entity.lifespan > 0.0: - for resource in entity.life_product: - products[resource] = maxf(0.0, entity.life_product[resource]) / entity.lifespan - elif entity.product_per_year_type != "": - products[entity.product_per_year_type] = maxf(0.0, entity.product_per_year_amount) - return products - - -func _produce_for_period(previous: Dictionary, target: Dictionary, quantities: Dictionary, - distribution: Dictionary, from_year: float, to_year: float) -> void: - var basic: Dictionary = {} - var automatic: Dictionary = {} - for id in quantities: - var entity: OwniverseEntity = entities[id] - var object_years: float = _object_years(previous, id, quantities[id], from_year, to_year) - var auto_share: float = clampf(entity.auto, 0.0, 1.0) - var products: Dictionary = _yearly_products(entity) - for product in products: - var amount: float = object_years * products[product] * WorldParameters.production(previous, entity) - basic[product] = basic.get(product, 0.0) + amount * (1.0 - auto_share) - automatic[product] = automatic.get(product, 0.0) + amount * auto_share - # Basic and automatic output each retain their unproduced remainder. - if not target.has("resource_remainders"): - target.resource_remainders = {"basic": {}, "automatic": {}} - var pending: Dictionary = target.resource_remainders - var requests: Dictionary = {} - for id in basic: - pending.basic[id] = pending.basic.get(id, 0.0) + basic[id] - pending.automatic[id] = pending.automatic.get(id, 0.0) + automatic[id] - var totals: Dictionary = {} - for channel in ["basic", "automatic"]: - for id in pending[channel]: - totals[id] = maxf(0.0, pending.basic.get(id, 0.0) + pending.automatic.get(id, 0.0)) - for id in totals: - if id == "S" or id == "H": - requests[id] = floorf(totals[id]) - var funded: Dictionary = _allocate_whole(requests, floorf(maxf(0.0, previous.energy))) - automatic = {} - target.energy = previous.energy - for id in totals: - var amount: float = floorf(totals[id]) - if id == "S" or id == "H": - amount = funded.get(id, 0.0) - target.energy -= amount - # Split the actual whole output, retaining the allocation error for next time. - var auto_ratio: float = clampf(pending.automatic.get(id, 0.0) / totals[id], 0.0, 1.0) if totals[id] > 0.0 else 0.0 - var auto_amount: float = floorf(amount * auto_ratio) - pending.automatic[id] = pending.automatic.get(id, 0.0) - auto_amount - pending.basic[id] = pending.basic.get(id, 0.0) - (amount - auto_amount) - automatic[id] = auto_amount - amount = _credit(target, id, amount) - _append_batch(target, id, amount, from_year, to_year) - - # Keep unused automatic resources across cycles; they are part of free amounts. - var budget: Dictionary = previous.get("auto_resources", {}).duplicate(true) - for id in automatic: - budget[id] = budget.get(id, 0.0) + automatic[id] - for id in budget: - budget[id] = minf(maxf(0.0, budget[id]), _available(target, id)) - target.auto_resources = budget - _produce_brown_dwarfs(target, automatic.get("H", 0.0), from_year, to_year) - budget = _ordinary_star_budget(target) - - # One common multiplier preserves the initial count ratios, even under scarcity. - var candidates: Dictionary = {} - for id in distribution: - var entity: OwniverseEntity = entities[id] - if entity.group_as != "Star": - continue - var has_cost: bool = false - for resource in entity.cost: - if entity.cost[resource] > 0.0: - has_cost = true - if has_cost: - candidates[id] = distribution[id] - candidates = _normalize_distribution(candidates) - if candidates.is_empty(): - return - var costs: Dictionary = {} - var needs: Dictionary = {} - for id in candidates: - if _auto_capacity(target, entities[id], budget) <= 0.0: - continue - for resource in entities[id].cost: - var cost: float = maxf(0.0, entities[id].cost[resource]) * candidates[id] - costs[resource] = costs.get(resource, 0.0) + cost - needs[resource] = needs.get(resource, 0.0) + cost - for resource in entities[id].bind: - needs[resource] = needs.get(resource, 0.0) + maxf(0.0, entities[id].bind[resource]) * candidates[id] - var scale: float = INF - for resource in costs: - if costs[resource] > 0.0: - scale = minf(scale, budget.get(resource, 0.0) / costs[resource]) - for resource in needs: - if needs[resource] > 0.0: - scale = minf(scale, _available(target, resource) / needs[resource]) - var concrete_needs: Dictionary = _expand_requirements(target, needs) - for resource in concrete_needs: - if concrete_needs[resource] > 0.0: - scale = minf(scale, _available(target, resource) / concrete_needs[resource]) - if not is_finite(scale) or scale <= 0.0: - return - # Rare types are served first; signed count deficits compensate later cycles. - if not target.has("auto_star_deficits"): - target.auto_star_deficits = {} - var deficits: Dictionary = target.auto_star_deficits - var order: Array = candidates.keys() - 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] - var step: float = _production_step(entity) - if not is_finite(step): - continue - var quota: float = maxf(0.0, candidates[id] * scale + deficits.get(id, 0.0)) - var desired: float = floorf(quota / step) * step - var actual: float = minf(desired, _auto_capacity(target, entity, budget)) - if actual <= 0.0: - continue - if not _try_production(target, entity, actual, true): - continue - budget = _ordinary_star_budget(target) - created[id] = actual - total_created += actual - for id in candidates: - # Credit only actual production, so retained resources aren't counted twice. - deficits[id] = deficits.get(id, 0.0) + candidates[id] * total_created - created.get(id, 0.0) - 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) - - -# HBD quota is in objects. HBD_fuel is prepaid hydrogen removed from free stock. -# Signed rounding debt and the PRNG state survive saving with this dictionary. -func _produce_brown_dwarfs(target: Dictionary, new_hydrogen: float, from_year: float, to_year: float) -> void: - if not entities.has("HBD"): - return - var entity: OwniverseEntity = entities.HBD - var cost: float = entity.cost.get("H", 0.0) - if cost <= 0.0: - return - var quota: float = target.auto_star_deficits.get("HBD", 0.0) + maxf(0.0, new_hydrogen) * 0.001 / cost - var fuel: float = maxf(0.0, target.auto_star_deficits.get("HBD_fuel", 0.0)) - var available: float = minf(target.auto_resources.get("H", 0.0), _available(target, "H")) - quota = minf(quota, (available + fuel) / cost) - var expected: float = maxf(0.0, snappedf(quota, 1e-9)) - var amount: float = floorf(expected) - # Do not retry a fractional chance on frames with no new automatic fuel. - if new_hydrogen > 0.0 and expected > amount: - var random_state: float = target.auto_star_deficits.get("HBD_random", 1234567.0) - random_state = fmod(random_state * 48271.0, 2147483647.0) - target.auto_star_deficits.HBD_random = random_state - if random_state / 2147483647.0 < expected - amount: - amount += 1.0 - amount = minf(amount, floorf(snappedf((available + fuel) / cost, 1e-9))) - var charge: float = ceilf(maxf(0.0, snappedf(amount * cost - fuel, 1e-9))) - if amount > 0.0 and charge <= available: - _take_available(target, "H", charge) - target.auto_resources.H = maxf(0.0, target.auto_resources.get("H", 0.0) - charge) - target.auto_star_deficits.HBD_fuel = maxf(0.0, snappedf(fuel + charge - amount * cost, 1e-9)) - quota -= amount - _credit(target, "HBD", amount) - _append_batch(target, "HBD", amount, from_year, to_year) - target.auto_star_deficits.HBD = quota - - -func _ordinary_star_budget(target: Dictionary) -> Dictionary: - var budget: Dictionary = target.auto_resources.duplicate() - if entities.has("HBD"): - var reserved: float = maxf(0.0, target.auto_star_deficits.get("HBD", 0.0) * entities.HBD.cost.get("H", 0.0) - target.auto_star_deficits.get("HBD_fuel", 0.0)) - budget.H = maxf(0.0, budget.get("H", 0.0) - reserved) - return budget - - -func _auto_capacity(source: Dictionary, entity: OwniverseEntity, budget: Dictionary) -> float: - var capacity: float = INF - var needs: Dictionary = {} - for resource in entity.cost: - var cost: float = maxf(0.0, entity.cost[resource]) - needs[resource] = cost - if cost > 0.0: - capacity = minf(capacity, floorf(budget.get(resource, 0.0) / cost)) - for resource in entity.bind: - needs[resource] = needs.get(resource, 0.0) + maxf(0.0, entity.bind[resource]) - for resource in needs: - if needs[resource] > 0.0: - capacity = minf(capacity, floorf(_available(source, resource) / needs[resource])) - var concrete: Dictionary = _expand_requirements(source, needs) - for resource in concrete: - if concrete[resource] > 0.0: - capacity = minf(capacity, floorf(_available(source, resource) / concrete[resource])) - var step: float = _production_step(entity) - return floorf(capacity / step) * step if is_finite(capacity) and is_finite(step) else 0.0 - - -# ---------- - - -#region Production - - -## Produces entities with cost and bind constraints -## @param item_id - entity id to produce -## @param desired_amount - desired quantity to produce -## @return actual amount produced based on available resources -func _smbh_sources() -> Dictionary: - var masses: Dictionary = {} - for id in entities: - var entity: OwniverseEntity = entities[id] - if entity.count_as == "BH" and entity.mass > 0.0: - masses[id] = floorf(maxf(0.0, state.amount.get(id, 0.0))) * entity.mass - return masses - - -func _smbh_available() -> float: - var mass: float = 0.0 - for value in _smbh_sources().values(): - mass += value - return floorf(mass) - - -func _plan_smbh(desired_amount: float) -> Dictionary: - var empty: Dictionary = {"amount": 0.0, "taken": {}} - if desired_amount <= 0.0 or not is_finite(desired_amount): - return empty - var total_mass: float = _smbh_available() - if total_mass <= 0.0: - return empty - var requested: float = minf(floorf(desired_amount), total_mass) - if requested <= 0.0: - return empty - var masses: Dictionary = _smbh_sources() - var distribution: Dictionary = _normalize_distribution(masses) - var taken: Dictionary = {} - var order: Array = [] - var consumed_mass: float = 0.0 - for id in distribution: - var expected: float = requested * distribution[id] / entities[id].mass - taken[id] = minf(floorf(expected), floorf(state.amount[id])) - 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 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: - break - var id: String = candidate.id - if taken[id] < floorf(state.amount[id]): - taken[id] += 1.0 - consumed_mass += entities[id].mass - if consumed_mass <= 0.0 or consumed_mass != floorf(consumed_mass): - return empty - return {"amount": consumed_mass, "taken": taken} - - -func _produce_smbh(desired_amount: float) -> float: - var plan: Dictionary = _plan_smbh(desired_amount) - var consumed_mass: float = plan.amount - var taken: Dictionary = plan.taken - if consumed_mass <= 0.0: - return 0.0 - var next: Dictionary = state.duplicate(true) - var physical: Dictionary = _physical_amounts(state) - for id in taken: - if taken[id] <= 0.0: - continue - next.amount[id] -= taken[id] - var fraction: float = taken[id] / physical[id] - _scale_batches(next, id, 1.0 - fraction) - # Release the consumed owners' share of any resources they held. - for resource in next.binds.get(id, {}): - var released: float = next.binds[id][resource] * fraction - next.binds[id][resource] -= released - next.amount[resource] = next.amount.get(resource, 0.0) + released - _credit(next, "SMBH", consumed_mass) - _append_batch(next, "SMBH", consumed_mass, state.time.datetime, state.time.datetime) - _round_object_allocations(next) - _refresh_groups(next) - _clamp_auto_resources(next) - state = next - new_state = next - refresh_item_amounts() - return consumed_mass - - -func produce(item_id: String, desired_amount: float) -> float: - if item_id == "SMBH": - return _produce_smbh(desired_amount) - # Use special production for S and H - if item_id == "S" or item_id == "H": - return produce_hs(item_id, desired_amount) - - var entity: OwniverseEntity = entities[item_id] - - if desired_amount <= 0: - return 0.0 - - var max_producible = floorf(calculate_max_producible(entity, desired_amount)) - var budget: Dictionary = CivilizationState.stellar_budget(self, state, Global.CivilizationRules) - max_producible = CivilizationState.admit_transition(budget, Global.CivilizationRules, "", item_id, max_producible) - - 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 - - -## Produces S (space) or H (hydrogen) using energy from state.energy -## @param entity_id - entity id to produce ("S" or "H") -## @param value - desired amount to produce -## @return actual amount produced (limited by available energy) -func produce_hs(entity_id: String, value: float) -> float: - if value <= 0: - return 0.0 - - var to_produce: float = floorf(value) - to_produce = minf(to_produce, floorf(state.energy)) - - if to_produce <= 0: - return 0.0 - - state.energy -= to_produce - _process_enitity(entity_id, to_produce) - - return to_produce - - -## Calculates maximum producible amount based on cost and bind constraints -## @param entity - entity to produce -## @param desired_amount - desired quantity -## @return maximum producible amount considering all constraints -# Find the smallest whole batch whose cost is whole for every resource. -# Decimal costs are represented as integer fractions; combine their denominators. -func _production_step(entity: OwniverseEntity) -> float: - return QuantityMath.production_step(entity.cost.values() + entity.bind.values()) - - -func _whole_gcd(a: float, b: float) -> float: - return QuantityMath.whole_gcd(a, b) - - -func calculate_max_producible(entity: OwniverseEntity, desired_amount: float = 1.0E150) -> float: - if entity.id == "SMBH": - return _plan_smbh(desired_amount).amount - var requirements: Dictionary = {} - for resource in entity.cost: - requirements[resource] = maxf(0.0, entity.cost[resource]) - for resource in entity.bind: - requirements[resource] = requirements.get(resource, 0.0) + maxf(0.0, entity.bind[resource]) - var has_requirements: bool = false - for quantity in requirements.values(): - if quantity > 0.0: - has_requirements = true - break - # No recipe means unavailable for production, not unlimited free objects. - if not has_requirements: - return 0.0 - var available: float = maxf(0.0, desired_amount) - for resource in requirements: - if requirements[resource] > 0.0: - available = minf(available, floorf(_available(state, resource) / requirements[resource])) - var concrete: Dictionary = _expand_requirements(state, requirements) - for resource in concrete: - if concrete[resource] > 0.0: - available = minf(available, floorf(_available(state, resource) / concrete[resource])) - var step: float = _production_step(entity) - return floorf(available / step) * step if is_finite(step) else 0.0 - - -## Consume resources without erasing cumulative production statistics. -func apply_costs(entity: OwniverseEntity, amount: float) -> void: - var costs: Dictionary = {} - for resource in entity.cost: - costs[resource] = roundf(maxf(0.0, entity.cost[resource]) * amount) - for resource in costs: - var quantities: Dictionary = _physical_amounts(state) - var taken: Dictionary = _take_available(state, resource, costs[resource]) - for id in taken: - var factor: float = 1.0 - taken[id] / quantities[id] if quantities.get(id, 0.0) > 0.0 else 0.0 - _scale_batches(state, id, factor) - _refresh_groups(state) - _clamp_auto_resources(state) - refresh_item_amounts() - - -## Keep concrete bound types, so their ages and transitions remain traceable. -func apply_binds(item_id: String, _entity: OwniverseEntity, amount: float) -> bool: - if not _bind_new_owners(state, {item_id: amount}, true): - return false - _refresh_groups(state) - _clamp_auto_resources(state) - refresh_item_amounts() - - return true - - -## Computes a normalized distribution of dictionary values (sum equals 1.0) -## @param values - dictionary containing numeric values -## @return dictionary with normalized values, each ranging from 0.0 to 1.0 -func _normalize_distribution(values: Dictionary) -> Dictionary: - return QuantityMath.normalize_distribution(values) - -#endregion - -#region Batches - -## Withdraws only the previously unprocessed part of each evolution interval. -func consume_from_batches(entity: OwniverseEntity, current_year: float) -> float: - var total: float = 0.0 - var physical: Dictionary = _physical_amounts(state) - for part: Dictionary in _withdraw_batches(state, entity.id, current_year): - total += part.amount - var fraction: float = clampf(total / physical[entity.id], 0.0, 1.0) if physical.get(entity.id, 0.0) > 0.0 else 0.0 - state.amount[entity.id] = state.amount.get(entity.id, 0.0) * (1.0 - fraction) - for owner_id in state.binds: - if state.binds[owner_id].has(entity.id): - state.binds[owner_id][entity.id] *= 1.0 - fraction - _round_object_allocations(state) - _refresh_groups(state) - refresh_item_amounts() - return total - - -## Add manually created objects to the lifespan_delta window containing current_year. -func add_entity_to_batch(entity_id: String, amount: float, current_year: float) -> void: - _append_batch(state, entity_id, amount, current_year, current_year) - -#endregion - -#region ProductionLegacy - - -#----- - - -#func _produce_automatically(year_delta: float) -> void: - #var cycle_production = {"S": 0.0, "H": 0.0} - #for entity: OwniverseEntity in auto_production: - #if current_amount[entity.id] == 0: - #continue - #cycle_production[entity.product_per_year_type] += \ - #current_amount[entity.id] * entity.product_per_year_amount * year_delta - - #for item in cycle_production: - #_count_amount(item, cycle_production[item]) - # - - -#func automated_production(_delta: float) -> void: - #for entity_id in auto_production: - #var entity = entities[entity_id] - #var _amount_to_produce = int(entity.amount * entity.product_per_year_amount * _delta) - - -#func get_available_to_produce(item_id: String) -> float: - #var entity: OwniverseEntity = entities[item_id] - #if entity.cost == {}: return 0 - #var amount: float = 1E50 - #for elem in entity.cost: - #amount = min(floorf(current_amount[elem] / entity.cost[elem]), amount) - #return amount - -# add to all lists -## Processes entity production/consumption -## @param entity_id - id of entity to process -## @param amount - amount to change (positive for production, negative for consumption) -func _process_enitity(entity_id: String, amount: float) -> void: - if amount == 0: - return - - # Update state amount - state.amount[entity_id] = state.amount.get(entity_id, 0.0) + amount - state.stats.total_amount[entity_id] = state.stats.total_amount.get(entity_id, 0.0) + maxf(0.0, amount) - - - # Handle group updates if entity belongs to a group - var entity = entities[entity_id] - if entity.group_as != "": - state.amount[entity.group_as] = state.amount.get(entity.group_as, 0.0) + amount - state.stats.total_amount[entity.group_as] = state.stats.total_amount.get(entity.group_as, 0.0) + maxf(0.0, amount) - refresh_item_amounts() - - -#endregion - -#region Time - - -#endregion + evolution.evolute_to_datetime(datetime) diff --git a/world/owniverse/simulation_module.gd b/world/owniverse/simulation_module.gd new file mode 100644 index 0000000..0ba9595 --- /dev/null +++ b/world/owniverse/simulation_module.gd @@ -0,0 +1,8 @@ +extends RefCounted +## Modules borrow their world; a weak reference prevents ownership cycles. +var _world_ref: WeakRef +var world: + get: return _world_ref.get_ref() + +func _init(owner_world) -> void: + _world_ref = weakref(owner_world) diff --git a/world/owniverse/simulation_module.gd.uid b/world/owniverse/simulation_module.gd.uid new file mode 100644 index 0000000..275f834 --- /dev/null +++ b/world/owniverse/simulation_module.gd.uid @@ -0,0 +1 @@ +uid://orcojklh0nne diff --git a/world/owniverse/stellar_rules.gd b/world/owniverse/stellar_rules.gd new file mode 100644 index 0000000..fe14bf5 --- /dev/null +++ b/world/owniverse/stellar_rules.gd @@ -0,0 +1,118 @@ +extends "res://world/owniverse/simulation_module.gd" +const WorldParameters = preload("res://world/owniverse/world_parameters.gd") +const QuantityMath = preload("res://world/owniverse/quantity_math.gd") + +## Testable optimization switch; both paths preserve the same event order. +var direct_completions: bool = true + +func add_output_terms(outputs: Dictionary, terms: String, count: float) -> void: + if count <= 0.0: + return + for term in terms.split("&"): + var parts: PackedStringArray = term.strip_edges().split(":") + var id: String = parts[0].strip_edges() + var amount: float = float(parts[1]) if parts.size() == 2 else 1.0 + if id != "" and amount > 0.0: + outputs[id] = outputs.get(id, 0.0) + count * amount + + +func evolution_products(target: Dictionary, entity: OwniverseEntity, count: float) -> Dictionary: + if not entity.life_rule.is_empty(): + return world.life.life_products(target, entity, count) + var outputs: Dictionary = {} + for id in entity.output_and: + outputs[id] = count * maxf(0.0, entity.output_and[id]) + if not entity.output_or.is_empty(): + # The parser stores the percentage after '%', e.g. 0.1 means 0.1 percent. + var chance: float = clampf(entity.output_or.get("chance", 0.0) / 100.0 * WorldParameters.effective(target, "Exp"), 0.0, 1.0) + if not target.has("evolution_remainders"): + target.evolution_remainders = {} + var expected: float = count * chance + target.evolution_remainders.get(entity.id, 0.0) + var positive: float = minf(count, floorf(snappedf(expected, 1.0e-12))) + target.evolution_remainders[entity.id] = maxf(0.0, expected - positive) + add_output_terms(outputs, str(entity.output_or.get("positive", "")), positive) + add_output_terms(outputs, str(entity.output_or.get("negative", "")), count - positive) + # Entity.blast is authoritative even if imported output still contains + # blast:N. Zero disables the event; never add both representations. + outputs.erase("blast") + if entity.blast > 0: + outputs["blast"] = count * entity.blast * WorldParameters.effective(target, "Exp") + return outputs + + +func create_planets(target: Dictionary, star_id: String, count: float, + from_year: float, to_year: float, metallicity: float) -> void: + if count <= 0.0 or world.catalog.entities[star_id].planets.is_empty(): + return + if not target.binds.has(star_id): + target.binds[star_id] = {} + for id in world.catalog.entities[star_id].planets: + if not world.catalog.entities.has(id): + continue + var rule: Dictionary = world.catalog.entities[star_id].planets[id] + var amount: float = QuantityMath.accumulate_whole(target, "planet_remainders", star_id + ":" + id, + count * rule.amount * rule.chance * maxf(0.0, metallicity)) + amount = world.ledger.credit(target, id, amount) + target.amount[id] -= amount + target.binds[star_id][id] = target.binds[star_id].get(id, 0.0) + amount + world.batches.append_batch(target, id, amount, from_year, to_year) + + +func stellar_successors(products: Dictionary) -> Dictionary: + var weights: Dictionary = {} + for id in products: + if world.catalog.entities.has(id) and (world.catalog.entities[id].group_as in ["Star", "BH"] or world.catalog.entities[id].event_group == "SNGRBB" or world.catalog.entities[id].count_as == "BD"): + weights[id] = products[id] + return QuantityMath.normalize_distribution(weights) + + +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 = world.ledger.physical_amounts(target) + var rocky: float = 0.0 + for id in held.keys(): + if not world.catalog.entities.has(id) or world.catalog.entities[id].group_as != "Planet": + continue + if level == 1 and not world.catalog.entities[id].blastability: + continue + var key: String = cause + ":" + str(level) + ":" + owner_id + ":" + id + var destroyed: float = minf(held[id], QuantityMath.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 + world.batches.scale_batches(target, id, 1.0 - destroyed / physical[id]) + if level == 1: + rocky += destroyed + if rocky > 0.0: + rocky = world.ledger.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 = QuantityMath.accumulate_whole(target, "blast_remainders", "hits", power * environment.blast) + var quantities: Dictionary = world.ledger.physical_amounts(target) + var weights: Dictionary = {} + for id in world.catalog.entities: + if not world.catalog.entities[id].planets.is_empty() and quantities.get(id, 0.0) > 0.0: + weights[id] = quantities[id] + var distribution: Dictionary = QuantityMath.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 uses_short_lived_fast_path(id: String) -> bool: + if not direct_completions: return false + # Only terminal stellar events with linear outputs and no living successors. + var entity: OwniverseEntity = world.catalog.entities[id] + if entity.event_group != "SNGRBB" or not entity.output_or.is_empty() or not entity.life_rule.is_empty(): + return false + for successor in entity.output_and: + if world.catalog.entities.has(successor) and world.catalog.entities[successor].lifespan > 0.0: + return false + return true diff --git a/world/owniverse/stellar_rules.gd.uid b/world/owniverse/stellar_rules.gd.uid new file mode 100644 index 0000000..b4e0cdb --- /dev/null +++ b/world/owniverse/stellar_rules.gd.uid @@ -0,0 +1 @@ +uid://cim2wuoas2ojv diff --git a/world/owniverse/universe_environment.gd b/world/owniverse/universe_environment.gd new file mode 100644 index 0000000..cf09891 --- /dev/null +++ b/world/owniverse/universe_environment.gd @@ -0,0 +1,42 @@ +extends "res://world/owniverse/simulation_module.gd" +const WorldParameters = preload("res://world/owniverse/world_parameters.gd") + +func stellar_environment(source: Dictionary) -> Dictionary: + var quantities: Dictionary = world.ledger.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 = world.catalog.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 modify_world_parameter(key: String, change: float) -> void: + if key not in ["Bur", "Exp", "Agg"] or not is_finite(change): return + var old_multiplier: float = WorldParameters.effective(world.state, key) + if not world.state.has("world_parameters"): world.state.world_parameters = WorldParameters.defaults() + world.state.world_parameters[key] = clampf(WorldParameters.value(world.state, key) + change, 1.0, 19.0) + if key == "Bur": + var ratio: float = old_multiplier / WorldParameters.effective(world.state, key) + var now: float = world.state.time.year + for id in world.state.batches: + if not world.catalog.entities.has(id) or world.catalog.entities[id].group_as != "Star": continue + for batch: Dictionary in world.state.batches[id]: + # Only future survival time changes. Birth dates and the fractional + # withdrawal carry remain intact; already elapsed time is not replayed. + for field in ["start_year", "end_year", "processed_until"]: + if batch.has(field): batch[field] = now + maxf(0.0, batch[field] - now) * ratio diff --git a/world/owniverse/universe_environment.gd.uid b/world/owniverse/universe_environment.gd.uid new file mode 100644 index 0000000..f4325b0 --- /dev/null +++ b/world/owniverse/universe_environment.gd.uid @@ -0,0 +1 @@ +uid://cimalpe85ofel