Implement short-lived stellar event handling and optimize batch processing; add tests for new functionality
This commit is contained in:
@@ -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|||||
|
HRG|0||||100||10000000||||S:400.||SN^GRB%1|2|||RG|Star|||||
|
||||||
RSG|0||||300||1000000||||S:500||BSN^LGRB%3|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|||||
|
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||||
|
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&blast:9|2|mass:18&H:8&HE:50&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&blast:305|2|mass:21&H:15&HE:186&MET:15||||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&blast:9990|2|mass:40&H:30&HE:560&MET:30||||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&blast:31|2|mass:25&H:0.5&HE:10&MET:4||||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&blast:136|2|mass:30&H:19&HE:12&MET:5||||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&blast:3446|2|mass:40&H:68&HE:18&MET:9||||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&blast:97342|2|mass:60&H:0&HE:40&MET:10||||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||||||
|
CBD|0||||0.1||||||||||||BD||||||
|
||||||
WD|1|WD:1|||||||||||||||||||||
|
WD|1|WD:1|||||||||||||||||||||
|
||||||
TWD|0||||0.8||||||||||||WD|Star|Degen||||
|
TWD|0||||0.8||||||||||||WD|Star|Degen||||
|
||||||
|
|||||||
+13
-1
@@ -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`. При частичном списании:
|
Окно рождения шириной `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. Уничтоженные объекты удаляются из жизненных партий, чтобы не возникать заново.
|
Уровень 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.
|
||||||
|
|
||||||
## Границы этапа
|
## Границы этапа
|
||||||
|
|
||||||
|
|||||||
+21
-2
@@ -312,6 +312,7 @@ func _page_action_pressed(page_id: int, action_amount: float) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
get_tree().auto_accept_quit = false
|
||||||
var inited_result = init_enitities()
|
var inited_result = init_enitities()
|
||||||
locked_items = inited_result.locked_items
|
locked_items = inited_result.locked_items
|
||||||
load_data()
|
load_data()
|
||||||
@@ -420,15 +421,33 @@ func save_data() -> void:
|
|||||||
var page: int = pending_page
|
var page: int = pending_page
|
||||||
if page < 0:
|
if page < 0:
|
||||||
page = clampi(roundi(float(stage_page_scroller.scroll_horizontal) / PAGE_WIDTH), 0, PAGE_COUNT - 1)
|
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,
|
"state": owniverse.state,
|
||||||
"ui": {"current_page": page},
|
"ui": {"current_page": page},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
var closing: bool = false
|
||||||
|
|
||||||
|
|
||||||
func _notification(what: int) -> void:
|
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()
|
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:
|
func init_enitities() -> Dictionary:
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# Проверки поэтапного переноса из Owniverse.U
|
# Проверки поэтапного переноса из 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`.
|
`test_stellar_rules.gd` также проверяет автоматическое создание HBD: долю 0,1% нового автоматического водорода, появление по одному со случайным округлением, сохранение оплаченного остатка топлива, отсутствие повторного начисления на старый запас, продолжение случайной последовательности после сериализации и переход HBD → CBD с сохранением счётчика BD. Формулы описаны в `../simulation_rules.md`.
|
||||||
|
|
||||||
## Фоновое сохранение и частота обновления интерфейса
|
## Фоновое сохранение и частота обновления интерфейса
|
||||||
|
|||||||
+5
@@ -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
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
uid://cn6hhpjw1sjpb
|
||||||
@@ -92,6 +92,22 @@ func run() -> void:
|
|||||||
check(reader.load_data("shutdown", "test-password").state.energy == 6.0, "Teardown drains final pending snapshot")
|
check(reader.load_data("shutdown", "test-password").state.energy == 6.0, "Teardown drains final pending snapshot")
|
||||||
reader.free()
|
reader.free()
|
||||||
world.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
|
global.save_plaintext_copy = old_plaintext
|
||||||
for filename in DirAccess.get_files_at(directory):
|
for filename in DirAccess.get_files_at(directory):
|
||||||
DirAccess.remove_absolute(directory.path_join(filename))
|
DirAccess.remove_absolute(directory.path_join(filename))
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dp7a88n0mmyb5
|
||||||
@@ -36,6 +36,17 @@ func dispose(world) -> void:
|
|||||||
|
|
||||||
func run() -> void:
|
func run() -> void:
|
||||||
var world = fixture()
|
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
|
var rd = world.entities.RD
|
||||||
check(rd.planets.HP.chance == 0.01, "Preserve fractional planet probability")
|
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.planets.GG.amount == 2.0 and rd.planets.GG.chance == 0.08, "Read count and chance separately")
|
||||||
|
|||||||
@@ -340,12 +340,15 @@ func _clamp_auto_resources(target: Dictionary) -> void:
|
|||||||
|
|
||||||
# Each type collects production into consecutive lifespan_delta-wide creation windows.
|
# Each type collects production into consecutive lifespan_delta-wide creation windows.
|
||||||
# Its evolution window is the creation window shifted forward by one lifespan.
|
# 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,
|
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:
|
if amount <= 0.0 or not entities.has(id) or entities[id].lifespan <= 0.0:
|
||||||
return
|
return NAN
|
||||||
if created_to < created_from:
|
if created_to < created_from:
|
||||||
return
|
return NAN
|
||||||
if not target.batches.has(id):
|
if not target.batches.has(id):
|
||||||
target.batches[id] = []
|
target.batches[id] = []
|
||||||
if not target.has("batch_origins"):
|
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 lifespan: float = CivilizationState.effective_span(self, target, id, Global.CivilizationRules)
|
||||||
var batch_duration: float = lifespan / Global.batch_count if lifespan > 10.0 else lifespan
|
var batch_duration: float = lifespan / Global.batch_count if lifespan > 10.0 else lifespan
|
||||||
if batch_duration <= 0.0:
|
if batch_duration <= 0.0:
|
||||||
return
|
return NAN
|
||||||
if not target.batch_origins.has(id):
|
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
|
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 origin: float = target.batch_origins[id]
|
||||||
var cursor: float = created_from
|
var cursor: float = created_from
|
||||||
var duration: float = created_to - created_from
|
var duration: float = created_to - created_from
|
||||||
var remaining_amount: float = amount
|
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
|
# Compute the index once. Recomputing floor from a rounded boundary can
|
||||||
# select the preceding window forever (e.g. fractional origins at large ages).
|
# select the preceding window forever (e.g. fractional origins at large ages).
|
||||||
var window_index: float = floorf((cursor - origin) / batch_duration)
|
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
|
var window_end: float = origin + (window_index + 1.0) * batch_duration
|
||||||
if window_end <= cursor:
|
if window_end <= cursor:
|
||||||
push_error("Batch window exceeds floating-point time precision: " + id)
|
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_end: float = minf(created_to, window_end)
|
||||||
var part_amount: float = remaining_amount
|
var part_amount: float = remaining_amount
|
||||||
if duration > 0.0 and part_end < created_to:
|
if duration > 0.0 and part_end < created_to:
|
||||||
part_amount = floorf(amount * (part_end - created_from) / duration) - (amount - remaining_amount)
|
part_amount = floorf(amount * (part_end - created_from) / duration) - (amount - remaining_amount)
|
||||||
var batch: Dictionary = {}
|
if window_end + lifespan <= completed_before:
|
||||||
for existing: Dictionary in batches_by_start.get(window_start, []):
|
# Retain the original integer allocation and lifetime integral, but
|
||||||
if existing.get("created_from", -INF) == window_start and existing.start_year == window_start + lifespan and existing.end_year == window_end + lifespan:
|
# do not allocate thousands of already-dead batch dictionaries.
|
||||||
batch = existing
|
completed_amount += part_amount
|
||||||
break
|
completed_from = minf(completed_from, window_start + lifespan)
|
||||||
if batch.is_empty():
|
completed_to = maxf(completed_to, window_end + lifespan)
|
||||||
batch = {
|
remaining_object_years -= part_amount * _expired_years(completed_before, window_start + lifespan, window_end + lifespan)
|
||||||
"created_from": window_start,
|
else:
|
||||||
"created_to": window_end,
|
var batch: Dictionary = {}
|
||||||
"start_year": window_start + lifespan,
|
for existing: Dictionary in batches_by_start.get(window_start, []):
|
||||||
"end_year": window_end + lifespan,
|
if existing.get("created_from", -INF) == window_start and existing.start_year == window_start + lifespan and existing.end_year == window_end + lifespan:
|
||||||
"amount": 0.0,
|
batch = existing
|
||||||
}
|
break
|
||||||
batches.append(batch)
|
if batch.is_empty():
|
||||||
if not batches_by_start.has(window_start):
|
batch = {
|
||||||
batches_by_start[window_start] = []
|
"created_from": window_start,
|
||||||
batches_by_start[window_start].append(batch)
|
"created_to": window_end,
|
||||||
batch.amount += part_amount
|
"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
|
remaining_amount -= part_amount
|
||||||
if duration == 0.0 or part_end >= created_to:
|
if duration == 0.0 or part_end >= created_to:
|
||||||
break
|
break
|
||||||
@@ -407,6 +424,10 @@ func _append_batch(target: Dictionary, id: String, amount: float,
|
|||||||
window_index += 1.0
|
window_index += 1.0
|
||||||
batches.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
batches.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||||
return a.created_from < b.created_from)
|
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.
|
# 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)
|
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("positive", "")), positive)
|
||||||
_add_output_terms(outputs, str(entity.output_or.get("negative", "")), count - 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
|
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.
|
# 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.
|
# Integrate the birth interval and subtract the newly added death-window integral.
|
||||||
func _produce_newborn_lifetime(target: Dictionary, id: String, count: float,
|
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])
|
var products: Dictionary = _yearly_products(entities[id])
|
||||||
if products.is_empty() or count <= 0.0:
|
if products.is_empty() or count <= 0.0:
|
||||||
return
|
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 = {}
|
var previous_amounts: Dictionary = {}
|
||||||
for old: Dictionary in before_batches:
|
for old: Dictionary in before_batches:
|
||||||
var start: float = old.get("created_from", -INF)
|
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:
|
func _evolve_batches(target: Dictionary, to_year: float) -> void:
|
||||||
# A long cycle may contain several generations; preserve actual birth intervals.
|
# A long cycle may contain several generations; preserve actual birth intervals.
|
||||||
while _evolve_batch_generation(target, to_year):
|
var completed: Dictionary = {}
|
||||||
|
while _evolve_batch_generation(target, to_year, completed):
|
||||||
pass
|
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 quantities: Dictionary = _physical_amounts(target)
|
||||||
var expired: Dictionary = {}
|
var expired: Dictionary = {}
|
||||||
var preview: Dictionary = {"batches": {}}
|
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:
|
if not entities.has(id) or entities[id].destroy_level <= 0:
|
||||||
continue
|
continue
|
||||||
var dying: float = 0.0
|
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):
|
for part: Dictionary in _withdraw_batches(preview, id, to_year):
|
||||||
dying += part.amount
|
dying += part.amount
|
||||||
if quantities.get(id, 0.0) > 0.0:
|
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.
|
# Withdraw all types before creating successors: iteration order cannot affect them.
|
||||||
for id in target.batches.keys():
|
for id in target.batches.keys():
|
||||||
if entities.has(id):
|
if entities.has(id):
|
||||||
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
|
var has_expired: bool = false
|
||||||
for parts: Array in expired.values():
|
for parts: Array in expired.values():
|
||||||
if not parts.is_empty():
|
if not parts.is_empty():
|
||||||
@@ -758,8 +803,9 @@ func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool:
|
|||||||
else:
|
else:
|
||||||
amount = _credit(target, successor, amount)
|
amount = _credit(target, successor, amount)
|
||||||
var before_batches: Array = target.batches.get(successor, []).duplicate(true)
|
var before_batches: Array = target.batches.get(successor, []).duplicate(true)
|
||||||
_append_batch(target, successor, amount, part.from, part.to)
|
var cutoff: float = to_year if _uses_short_lived_fast_path(successor) else -INF
|
||||||
_produce_newborn_lifetime(target, successor, amount, part.from, part.to, to_year, before_batches)
|
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
|
new_owners[successor] = new_owners.get(successor, 0.0) + amount
|
||||||
for owner_id in incoming:
|
for owner_id in incoming:
|
||||||
if not target.binds.has(owner_id):
|
if not target.binds.has(owner_id):
|
||||||
|
|||||||
Reference in New Issue
Block a user