diff --git a/data/owniverse_entities.txt b/data/owniverse_entities.txt index c2309da..8939db3 100644 --- a/data/owniverse_entities.txt +++ b/data/owniverse_entities.txt @@ -21,14 +21,14 @@ LRG|0||||50||50000000||||S:300||DSN^SGRB%0.1|2|||RG|Star||||| HRG|0||||100||10000000||||S:400.||SN^GRB%1|2|||RG|Star||||| RSG|0||||300||1000000||||S:500||BSN^LGRB%3|2|||RG|Star||||| RHG|0||||1000||100000||||S:600||HN^HGRB%10|2|||RG|Star||||| -DSN|0||||||1|||2|S:59.1||SNS:1&H:6.5&He:13.5&Met:11&blast:2|2|mass:15&H:6.5&HE:13.5&MET:11||||SNGRBB|||| -SN|0||||||1|||9|S:99.1||LNS:1&H:8&He:50&Met:11&blast:9|2|mass:18&H:8&HE:50&MET:11||||SNGRBB|||| -BSN|0||||||1||.|305|S:309.1||MGT:1&H:15&He:186&Met:15&blast:305|2|mass:21&H:15&HE:186&MET:15||||SNGRBB|||| -HN|0||||||1|||9990|S:939.1||BH40:1&H:30&He:560&Met:30&blast:9990|2|mass:40&H:30&HE:560&MET:30||||SNGRBB|||| -SGRB|0||||||1||0.001|31|S:149.1||BH25:1&H:0.5&He:10&Met:4&blast:31|2|mass:25&H:0.5&HE:10&MET:4||||SNGRBB|||| -GRB|0||||||1||0.01|136|S:239.1||BH30:1&H:19&He:12&Met:5&blast:136|2|mass:30&H:19&HE:12&MET:5||||SNGRBB|||| -LGRB|0||||||1||0.03|3446|S:669.1||BH40:1&H:68&He:18&Met:9&blast:3446|2|mass:40&H:68&HE:18&MET:9||||SNGRBB|||| -HGRB|0||||||1||0.1|97342|S:1939.1||BH60:1&H:0&He:40&Met:10&blast:97342|2|mass:60&H:0&HE:40&MET:10||||SNGRBB|||| +DSN|0||||||1|||2|S:59.1||SNS:1&H:6.5&He:13.5&Met:11|2|mass:15&H:6.5&HE:13.5&MET:11||||SNGRBB|||| +SN|0||||||1|||9|S:99.1||LNS:1&H:8&He:50&Met:11|2|mass:18&H:8&HE:50&MET:11||||SNGRBB|||| +BSN|0||||||1||.|305|S:309.1||MGT:1&H:15&He:186&Met:15|2|mass:21&H:15&HE:186&MET:15||||SNGRBB|||| +HN|0||||||1|||9990|S:939.1||BH40:1&H:30&He:560&Met:30|2|mass:40&H:30&HE:560&MET:30||||SNGRBB|||| +SGRB|0||||||1||0.001|31|S:149.1||BH25:1&H:0.5&He:10&Met:4|2|mass:25&H:0.5&HE:10&MET:4||||SNGRBB|||| +GRB|0||||||1||0.01|136|S:239.1||BH30:1&H:19&He:12&Met:5|2|mass:30&H:19&HE:12&MET:5||||SNGRBB|||| +LGRB|0||||||1||0.03|3446|S:669.1||BH40:1&H:68&He:18&Met:9|2|mass:40&H:68&HE:18&MET:9||||SNGRBB|||| +HGRB|0||||||1||0.1|97342|S:1939.1||BH60:1&H:0&He:40&Met:10|2|mass:60&H:0&HE:40&MET:10||||SNGRBB|||| CBD|0||||0.1||||||||||||BD|||||| WD|1|WD:1||||||||||||||||||||| TWD|0||||0.8||||||||||||WD|Star|Degen|||| diff --git a/simulation_rules.md b/simulation_rules.md index 7e83f41..a40f3da 100644 --- a/simulation_rules.md +++ b/simulation_rules.md @@ -27,6 +27,18 @@ ## Постепенные партии +### Короткоживущие звёздные события внутри длинного шага + +Для SN, DSN, BSN, HN и GRB-событий полностью завершившиеся окна рождения не создают отдельные словари партий. Их целые количества суммируются в очередь завершений текущего расчёта; обычные партии сохраняются только для окон, срок которых ещё не закончился. Состояние этой очереди не записывается в сохранение: она полностью обрабатывается до публикации нового состояния. + +Окно завершено, если `window_end + lifespan ≤ to_year`. Его доля количества рассчитывается прежним методом разности накопленных целых значений. Для производства пространства интеграл исчезновения вычитается по окнам в прежнем порядке, чтобы сохранить округление. Здесь остаётся лёгкий арифметический проход по окнам, но отсутствуют создание, копирование, сортировка и последующая обработка тысяч временных словарей. + +Ресурсы, остатки звёзд и бласты от завершившейся части применяются на следующем поколении расчёта, как раньше. Поэтому сохраняются порядок передачи привязок, среда бластов и ограничения цивилизаций. Ускорение допускается только для событий группы `SNGRBB` с линейным выходом и без преемников с ненулевым сроком жизни. При изменении таблиц остальные случаи автоматически используют обычный путь. Сроки жизни и ширина окон не увеличиваются, формат сохранения не меняется. + +На сохранении возраста 140,55 млн лет, при шестой скорости и шаге 1/60 секунды: максимальное временное число партий SN/GRB уменьшилось с 1668 до 2; средний расчёт кадра без отрисовки — примерно с 15 до 10 мс. За 120 шагов результаты старого и нового путей совпали по всем полям состояния. Эти значения характеризуют конкретное сохранение, а не гарантированную частоту кадров на всех устройствах. + +### Общие окна рождения и эволюции + Окно рождения шириной `w = lifespan / 40` при `lifespan > 10`, иначе `w = lifespan`. Окно превращения сдвинуто на `lifespan`. При частичном списании: ``` @@ -72,7 +84,7 @@ GI не хранит накопленные счётчики первичной Уровень 1 превращает только `blastability=1` в RP (HP, EON, цивилизации до C1.2). C1.3 и выше защищены от этого уровня. Уровень 2 уничтожает все планеты, включая защищённые цивилизации, без создания RP. Уничтоженные объекты удаляются из жизненных партий, чтобы не возникать заново. -`blast` в выходе — событие, а не предмет. Поле `blast` сущности повторно не суммируется с выходом `blast:N`. +Единственный источник мощности — поле `blast` сущности: `power = expired_count × max(0, entity.blast)`. При `blast = 0` внешнего воздействия нет. Старые вхождения `blast:N` в `output` игнорируются, включая альтернативные выходы; двойного суммирования нет. Внутренний выход `blast` остаётся событием, а не предметом. `destroy_level` независимо задаёт уровень локального разрушения у умирающего объекта; внешние бласты всегда действуют на уровне 1. ## Границы этапа diff --git a/stage/stage.gd b/stage/stage.gd index 0c13b4b..475d573 100644 --- a/stage/stage.gd +++ b/stage/stage.gd @@ -312,6 +312,7 @@ func _page_action_pressed(page_id: int, action_amount: float) -> void: func _ready() -> void: + get_tree().auto_accept_quit = false var inited_result = init_enitities() locked_items = inited_result.locked_items load_data() @@ -420,15 +421,33 @@ func save_data() -> void: var page: int = pending_page if page < 0: page = clampi(roundi(float(stage_page_scroller.scroll_horizontal) / PAGE_WIDTH), 0, PAGE_COUNT - 1) - SaveManager.save_data("owniverse", "qwerty", { + SaveManager.request_save("owniverse", "qwerty", { "state": owniverse.state, "ui": {"current_page": page}, }) +var closing: bool = false + + func _notification(what: int) -> void: - if is_node_ready() and (what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_APPLICATION_PAUSED): + if not is_node_ready() or closing: + return + if what == NOTIFICATION_WM_CLOSE_REQUEST: + _close_after_save() + elif what == NOTIFICATION_APPLICATION_PAUSED: save_data() + # The OS may suspend frames immediately; finish the queued write now. + SaveManager.flush_saves() + + +func _close_after_save() -> void: + closing = true + process_mode = Node.PROCESS_MODE_DISABLED + save_data() + if SaveManager.is_saving(): + await SaveManager.saves_drained + get_tree().quit() func init_enitities() -> Dictionary: diff --git a/tests/README.md b/tests/README.md index 82605fb..f718c45 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,5 +1,13 @@ # Проверки поэтапного переноса из Owniverse.U +## Прямой расчёт завершившихся сверхновых + +```sh +godot --headless --path Owniverse.GI --script res://tests/test_short_lived_events.gd +``` + +Сравниваются ускоренный путь и прежний путь с созданием всех партий, отключаемый только в тестовой фикстуре. Проверяются LRG, HRG, RSG и RHG на границах коротких и длинных интервалов, существующие SN, нехватка энергии, ресурсы, бласты, привязки, остатки округления и живые партии. Дополнительно проверяется, что длинный интервал создаёт не более двух живых окон и одну запись завершений, сохраняя общее количество. Сохранение игрока этот тест не использует. + `test_stellar_rules.gd` также проверяет автоматическое создание HBD: долю 0,1% нового автоматического водорода, появление по одному со случайным округлением, сохранение оплаченного остатка топлива, отсутствие повторного начисления на старый запас, продолжение случайной последовательности после сериализации и переход HBD → CBD с сохранением счётчика BD. Формулы описаны в `../simulation_rules.md`. ## Фоновое сохранение и частота обновления интерфейса diff --git a/tests/fixtures/legacy_stellar_events.gd b/tests/fixtures/legacy_stellar_events.gd new file mode 100644 index 0000000..842043a --- /dev/null +++ b/tests/fixtures/legacy_stellar_events.gd @@ -0,0 +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 diff --git a/tests/fixtures/legacy_stellar_events.gd.uid b/tests/fixtures/legacy_stellar_events.gd.uid new file mode 100644 index 0000000..44402c1 --- /dev/null +++ b/tests/fixtures/legacy_stellar_events.gd.uid @@ -0,0 +1 @@ +uid://cn6hhpjw1sjpb diff --git a/tests/test_async_save.gd b/tests/test_async_save.gd index ad022c4..ab12c34 100644 --- a/tests/test_async_save.gd +++ b/tests/test_async_save.gd @@ -92,6 +92,22 @@ func run() -> void: check(reader.load_data("shutdown", "test-password").state.energy == 6.0, "Teardown drains final pending snapshot") reader.free() world.free() + # Exercise the stage entry point too: manager tests alone do not catch a + # regression back to the synchronous API in stage.gd. + var shared_manager: Node = root.get_node("SaveManager") + var original_directory: String = shared_manager.save_directory + shared_manager.save_directory = directory + var stage = load("res://stage/stage.gd").new() + stage.pending_page = 2 + stage.owniverse.state.energy = 42.0 + stage.save_data() + check(shared_manager.is_saving(), "Stage queues a background save instead of waiting for disk") + stage.owniverse.state.energy = 43.0 + shared_manager.flush_saves() + check(shared_manager.load_data("owniverse", "qwerty").state.energy == 42.0, "Stage save writes an independent snapshot") + stage.owniverse.free() + stage.free() + shared_manager.save_directory = original_directory global.save_plaintext_copy = old_plaintext for filename in DirAccess.get_files_at(directory): DirAccess.remove_absolute(directory.path_join(filename)) diff --git a/tests/test_short_lived_events.gd b/tests/test_short_lived_events.gd new file mode 100644 index 0000000..09df2fc --- /dev/null +++ b/tests/test_short_lived_events.gd @@ -0,0 +1,78 @@ +extends SceneTree +var checks: int = 0 +var failures: int = 0 + +func _initialize() -> void: + call_deferred("run") + +func check(condition: bool, message: String) -> void: + checks += 1 + if not condition: + failures += 1 + push_error(message) + +func fixture(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.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 + return world + +func dispose(world) -> void: + for entity in world.entities.values(): entity.free() + world.free() + +func equivalent(a: Variant, b: Variant) -> bool: + if a is Dictionary and b is Dictionary: + if a.size() != b.size(): return false + for key in a: + if not b.has(key) or not equivalent(a[key], b[key]): return false + return true + if a is Array and b is Array: + if a.size() != b.size(): return false + for i in a.size(): + if not equivalent(a[i], b[i]): return false + return true + if (a is float or a is int) and (b is float or b is int): + return absf(float(a) - float(b)) < 1e-6 + return a == b + +func run() -> void: + for parent in ["LRG", "HRG", "RSG", "RHG"]: + for year in [101.0, 101.5, 104.75, 1767.3, 3005.0]: + var fast = fixture(false) + var legacy = fixture(true) + fast.state.amount[parent] = 20000.0 + fast.state.amount.RD = 100000.0 + fast.state.amount.HP = 50.0 + fast.state.amount.Met = 100.0 + fast.state.binds.RD = {"HP": 2000.0, "C0.1": 100.0} + fast.state.binds[parent] = {"HP": 10.0} + 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) + 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) + 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"]: + check(fast.state.batches.get(id, []).size() <= 3, "Only surviving event windows remain") + dispose(fast) + dispose(legacy) + var fast = fixture(false) + var pending: Dictionary = {} + fast._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 + check(total == 10000.0, "Direct completions plus survivors conserve event count") + dispose(fast) + print("Short-lived events: %d checks, %d failures" % [checks, failures]) + quit(0 if failures == 0 else 1) diff --git a/tests/test_short_lived_events.gd.uid b/tests/test_short_lived_events.gd.uid new file mode 100644 index 0000000..a152c88 --- /dev/null +++ b/tests/test_short_lived_events.gd.uid @@ -0,0 +1 @@ +uid://dp7a88n0mmyb5 diff --git a/tests/test_stellar_rules.gd b/tests/test_stellar_rules.gd index 648ae05..cff73ae 100644 --- a/tests/test_stellar_rules.gd +++ b/tests/test_stellar_rules.gd @@ -36,6 +36,17 @@ func dispose(world) -> void: 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") + dispose(world) + world = fixture() var rd = world.entities.RD check(rd.planets.HP.chance == 0.01, "Preserve fractional planet probability") check(rd.planets.GG.amount == 2.0 and rd.planets.GG.chance == 0.08, "Read count and chance separately") diff --git a/world/owniverse/owniverse.gd b/world/owniverse/owniverse.gd index 1e73feb..ccdb7eb 100644 --- a/world/owniverse/owniverse.gd +++ b/world/owniverse/owniverse.gd @@ -340,12 +340,15 @@ func _clamp_auto_resources(target: Dictionary) -> void: # 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) -> void: + 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 + return NAN if created_to < created_from: - return + return NAN if not target.batches.has(id): target.batches[id] = [] if not target.has("batch_origins"): @@ -354,13 +357,19 @@ func _append_batch(target: Dictionary, id: String, amount: float, var lifespan: float = CivilizationState.effective_span(self, target, id, Global.CivilizationRules) var batch_duration: float = lifespan / Global.batch_count if lifespan > 10.0 else lifespan if batch_duration <= 0.0: - return + 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) @@ -377,29 +386,37 @@ func _append_batch(target: Dictionary, id: String, amount: float, 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 + 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) - 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 + 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 @@ -407,6 +424,10 @@ func _append_batch(target: Dictionary, id: String, amount: float, 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. @@ -517,6 +538,11 @@ func _evolution_products(target: Dictionary, entity: OwniverseEntity, count: flo 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 return outputs @@ -576,11 +602,12 @@ func _create_planets(target: Dictionary, star_id: String, count: float, # Successors born and dying inside one long step must still emit their lifetime S. # Integrate the birth interval and subtract the newly added death-window integral. func _produce_newborn_lifetime(target: Dictionary, id: String, count: float, - born_from: float, born_to: float, to_year: float, before_batches: Array) -> void: + 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 = count * maxf(0.0, to_year - (born_from + born_to) * 0.5) + 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) @@ -650,11 +677,27 @@ func _apply_blast(target: Dictionary, power: float, environment: Dictionary) -> func _evolve_batches(target: Dictionary, to_year: float) -> void: # A long cycle may contain several generations; preserve actual birth intervals. - while _evolve_batch_generation(target, to_year): + var completed: Dictionary = {} + while _evolve_batch_generation(target, to_year, completed): pass -func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool: +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": {}} @@ -665,6 +708,8 @@ func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool: 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: @@ -673,7 +718,7 @@ func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool: # Withdraw all types before creating successors: iteration order cannot affect them. for id in target.batches.keys(): if entities.has(id): - expired[id] = _withdraw_batches(target, id, to_year) + 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(): @@ -758,8 +803,9 @@ func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool: else: amount = _credit(target, successor, amount) var before_batches: Array = target.batches.get(successor, []).duplicate(true) - _append_batch(target, successor, amount, part.from, part.to) - _produce_newborn_lifetime(target, successor, amount, part.from, part.to, to_year, before_batches) + 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):