Refactor civilization evolution and state management

- Enhanced the `civilization_evolution.gd` script to improve population handling and resource allocation during civilization transitions.
- Introduced new functions in `civilization_state.gd` for profile normalization and merging, ensuring consistent population representation across stages.
- Updated `evolution_coordinator.gd` to incorporate budget phases for better tracking of simulation performance.
- Improved `frame_budget.gd` to monitor maximum slice durations and phases during processing.
- Added a new benchmarking tool in `android_benchmark.gd` to measure performance metrics during simulation runs, including frame processing times and throughput.
- Created associated scene and resource files for the benchmarking tool to facilitate testing and performance evaluation.
This commit is contained in:
Kirill
2026-10-06 23:02:42 +03:00
parent e48d4bbe88
commit 6ee66a627d
23 changed files with 751 additions and 3009 deletions
+11 -3
View File
@@ -6,7 +6,7 @@ const E = preload("res://world/owniverse/math/exact.gd")
const SETTING_SECTION = "Settings"
const SAVE_PATH = "user://settings.cfg"
const FORMAT_VERSION : int = 5
const FORMAT_VERSION : int = 6
const SimulationTime = preload("res://world/owniverse/simulation_time.gd")
const WorldParameters = preload("res://world/owniverse/world_parameters.gd")
const STATE_VERSION : String = SimulationTime.STATE_VERSION
@@ -348,11 +348,19 @@ static func _validate_payload(data: Variant, speed_limit: int, stages: Array, en
if ((not (saved.ethics is Dictionary)) or (not (saved.civilization_homes is Dictionary))):
return "invalid civilization state"
for id in saved.ethics:
if (not E.contains(stages, id) or (not _number_map(saved.ethics[id], true))):
if (not E.contains(stages, id) or (not (saved.ethics[id] is Dictionary))):
return E.add("invalid state.ethics.", str(id))
if E.ne(saved.ethics[id].keys().size(), 6):
return E.add("invalid ethic profile size for ", str(id))
var share_sum: float = 0.0
for ethic in saved.ethics[id]:
if not E.contains(["m", "l", "w", "d", "c", "b"], ethic):
var share: Variant = saved.ethics[id][ethic]
var share_value: float = 0.0 + share if share is int or share is float else -1.0
if not E.contains(["m", "l", "w", "d", "c", "b"], ethic) or not is_finite(share_value) or share_value < 0.0 or share_value > 1.0:
return E.add("invalid ethic ", str(ethic))
share_sum += share_value
if absf(share_sum - 1.0) > 0.000001:
return E.add("invalid ethic profile total for ", str(id))
for id in saved.civilization_homes:
if (not _number_map(saved.civilization_homes[id], true)):
return "invalid civilization homes"
+1
View File
@@ -1,6 +1,7 @@
[runnable_presets]
"Windows Desktop"="Windows Desktop"
Android="Android"
[preset.0]
+110
View File
@@ -0,0 +1,110 @@
extends Control
const E = preload("res://world/owniverse/math/exact.gd")
const RESULT_PATH := "user://android_benchmark_result.txt"
const FRAME_COUNT := 120
var status_label: Label
func _ready() -> void:
status_label = Label.new()
status_label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
status_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
status_label.add_theme_font_size_override("font_size", 32)
add_child(status_label)
call_deferred("_run")
func _set_status(message: String) -> void:
status_label.text = message
var file := FileAccess.open(RESULT_PATH, FileAccess.WRITE)
if file != null:
file.store_line(message)
file.flush()
func _restore(value):
if value is float and value == floorf(value) and absf(value) <= 9007199254740991:
return int(value)
if value is Dictionary:
for key in value:
value[key] = _restore(value[key])
elif value is Array:
for index in value.size():
value[index] = _restore(value[index])
return value
func _load_payload_threaded() -> Dictionary:
var file := FileAccess.open("res://tests/artifacts/all_objects_speed6.adventure.json", FileAccess.READ)
if file == null:
return {"error": "Cannot open benchmark save: %s" % FileAccess.get_open_error()}
var parser := JSON.new()
var error := parser.parse(file.get_as_text())
if error != OK:
return {"error": "JSON parse error at line %d: %s" % [parser.get_error_line(), parser.get_error_message()]}
var data = E.decode(_restore(parser.data))
if not data is Dictionary or not data.has("state"):
return {"error": "Benchmark save has no state"}
return {"payload": data}
func _run() -> void:
_set_status("Reading and decoding benchmark save on a worker thread…")
var worker := Thread.new()
var start_error := worker.start(Callable(self, "_load_payload_threaded"))
if start_error != OK:
_set_status("ERROR: could not start loader thread (%s)" % start_error)
return
while worker.is_alive():
await get_tree().process_frame
var loaded: Dictionary = worker.wait_to_finish()
if loaded.has("error"):
_set_status("ERROR: " + str(loaded.error))
return
_set_status("Save decoded. Initializing UI scene…")
var payload: Dictionary = loaded.payload
var scene: Node = load("res://world/world.tscn").instantiate()
var stage = scene.get_node("Stage")
stage.owniverse.free()
stage.set_script(load("res://tests/fixtures/benchmark_ui_stage.gd"))
get_tree().root.add_child(scene)
await get_tree().process_frame
stage.owniverse.state = payload.state.duplicate(true)
stage.owniverse.restore_frame_checkpoint(payload.get("simulation", {}))
stage.owniverse.presentation.refresh(stage.owniverse.state, stage.owniverse.catalog)
stage.set_owniverse_item_value()
stage.stage_page_scroller.scroll_horizontal = 4 * stage.PAGE_WIDTH
var initial_requested = stage.owniverse.frame_clock.get("datetime", stage.owniverse.state.time.datetime)
var initial_published = stage.owniverse.state.time.datetime
stage.owniverse.profile_enabled = true
stage.measuring = true
_set_status("Running speed 6: 0/%d frames" % FRAME_COUNT)
for frame in FRAME_COUNT:
await get_tree().process_frame
if frame % 5 == 4:
_set_status("Running speed 6: %d/%d frames; commits=%d" % [frame + 1, FRAME_COUNT, stage.owniverse.frame_commits])
stage.measuring = false
var samples: Array = stage.frame_samples
if samples.is_empty():
_set_status("ERROR: no rendered frame samples; frames=%d" % stage.frame_samples.size())
return
var total: float = 0.0
for sample in samples:
total += sample
samples.sort()
var requested_advance = E.subtract(stage.owniverse.frame_clock.datetime, initial_requested)
var published_advance = E.subtract(stage.owniverse.state.time.datetime, initial_published)
var process_samples: Array = stage.process_samples
process_samples.sort()
var process_total: float = 0.0
for sample in process_samples:
process_total += sample
var process_summary := "no _process samples"
if not process_samples.is_empty():
process_summary = "mean=%.2fms p95=%.2fms max=%.2fms samples=%d" % [process_total / process_samples.size(), process_samples[int(ceil(process_samples.size() * 0.95)) - 1], process_samples.back(), process_samples.size()]
var budget: Variant = stage.owniverse.frame_budget
var max_slice_summary: String = "none"
if budget != null:
max_slice_summary = "%.2fms (%s)" % [budget.max_slice_usec / 1000.0, budget.max_slice_phase]
var result := "DONE\nFPS=%.2f mean=%.2fms p95=%.2fms max=%.2fms frames=%d commits=%d\nStage _process CPU: %s\nMax uninterrupted simulation slice: %s\nRequested=%s published=%s throughput=%s\nFinal adaptive step=%s\nProfile=%s" % [1000.0 / (total / samples.size()), total / samples.size(), samples[int(ceil(samples.size() * 0.95)) - 1], samples.back(), samples.size(), stage.owniverse.frame_commits, process_summary, max_slice_summary, str(requested_advance), str(published_advance), str(E.divide(published_advance, requested_advance)), str(stage.owniverse.frame_step_seconds), str(stage.owniverse.profile)]
_set_status(result)
get_tree().quit()
+1
View File
@@ -0,0 +1 @@
uid://dkbyhj8ib0eyf
+12
View File
@@ -0,0 +1,12 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/android_benchmark.gd" id="1"]
[node name="AndroidBenchmark" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1")
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -61,3 +61,34 @@ godot --headless --path Owniverse.GI --script res://tests/create_full_benchmark_
godot --headless --path Owniverse.GI --script res://tests/benchmark_saved_state.gd -- Owniverse.GI/tests/artifacts/all_objects_speed6.adventure.json 0.5 profile
godot --path Owniverse.GI --script res://tests/benchmark_full_ui.gd
```
## Проверка на Android Note8 (2026-10-06)
Сборка benchmark установлена отдельным пакетом `com.KIDGameProduction.Owniverse.AndroidBench`, сохранение основной игры не использовалось. Подготовка и декодирование сохранения выполнялись в фоновом потоке; ход и результат записывались в файл приложения. Устройство: Samsung SM-N950F, Android 9. Сценарий: все объекты и цивилизации, скорость 6, 120 кадров (119 интервалов).
| Метрика | Результат |
|---|---:|
| FPS UI | 1,76 |
| Средний / p95 / максимальный кадр | 567,28 / 817,54 / 1278,33 мс |
| Завершенные транзакции за прогон | 1 |
| Запрошено / опубликовано игрового времени | 1599938,57 / 10000 лет |
| Доля опубликованного от запрошенного | 0,00625 |
| Итоговый динамический шаг | 0,2 с |
Самый дорогой участок профиля — `generations`: 5,350 с суммарно за единственную завершенную транзакцию; внутри него `civilization_prepare` занял 3,186 с, `wars` — 2,217 с, `civilization_transitions` — 1,034 с. Интервалы вложены, поэтому их нельзя складывать с `generations`. Этот экстремальный сценарий на Note8 не достигает целевых 5 FPS и сильно отстает по игровому времени. Это замер именно полного стресс-сохранения, а не обычного игрового сохранения.
### Покадровая уступка в расчётах цивилизаций
Добавлены контрольные точки внутри обработки этик, проигравших в войне, падения популяции и выходов между стадиями. Математика и порядок переходов не меняются. На том же Note8 повторный прогон дал 1,87 FPS против 1,76 FPS до изменения (прирост около 6%). Средний кадр сократился с 567 до 535 мс, но p95 остался 630 мс, максимум — 1281 мс. Это слишком малое улучшение для цели 5 FPS.
В повторном профиле `generations` заняли 5,166 с, `civilization_prepare` — 3,040 с, войны — 0,985 с, переходы между стадиями — 0,640 с. Профиль не показывает явного трёхкратного ускорения: контрольные точки лучше дробят отдельные подциклы, но главный синхронный участок остается слишком дорогим. Диагностическая сборка для измерения максимального непрерывного участка не завершила прогон на телефоне; при следующем проходе сначала нужно локализовать этот участок, а затем оптимизировать его отдельно.
Контрольный запуск UI-бенчмарка на Apple M4 Pro после изменения: 21,51 FPS, средний / p95 / максимальный кадр — 46,49 / 89,62 / 107,19 мс. Этот результат подтверждает отсутствие регрессии на компьютере, но не заменяет Android-замер.
### После перехода этик на доли (2026-10-06)
Этическое состояние теперь хранит шесть долей `float`, а точное целое население берётся из реестра объектов. Исходы одной стадии сначала собираются по назначениям; доли состояния обновляются после агрегации потоков. Формат сохранения повышен до версии 6; миграции прежних сохранений нет.
Финальная APK повторно проверена на Samsung SM-N950F, Android 9, тем же полным сценарием и 120 кадрами. Два последовательных прогона дали **14,57 и 15,02 FPS**, средний кадр — **68,61 и 66,58 мс**, p95 — **113,03 и 123,03 мс**, по одной завершённой транзакции из 119 интервалов. Опубликовано 10 000 лет из 647 331 и 634 134 запрошенных; пропускная способность — 0,01545 и 0,01577. В последнем профиле `generations` заняли 5,120 с, `civilization_prepare` — 3,162 с, войны — 1,026 с, переходы — 0,728 с, дрейф этик — 0,474 с. Самый длинный непрерывный участок составил **458,14 мс** в `deep_copy_and_coarsen`, максимальный кадр — 976 мс. Предыдущий одиночный запуск старой benchmark-сборки показал 1,83 FPS, поэтому сравнивать его с этой парой напрямую нельзя; оба финальных прогона выполнены на одной APK и подтверждают результат около 15 FPS.
На компьютере прошли тесты состояния (70), эволюции (555), оптимизаций симуляции (364), индикаторов (63) и жизни (25). Полный набор всё ещё сообщает о двух отказах в `test_descriptions.gd` при обновлении выбранного описания и смене языка; оба относятся к UI-описаниям, а не к модели этик. В логах Android ошибок GDScript/Godot за прогон не найдено.
+7 -8
View File
@@ -42,18 +42,17 @@ func seed_population(world, id: String, count: Variant, time: Variant = 1.0) ->
world.state.batches[id] = [{"amount": count, "start_year": time, "end_year": time}]
world.evolution.initialize_life_batches()
func sum_ethics(world, id: String) -> Variant:
var result: Variant = 0
for n in world.state.ethics.get(id, {}).values(): result = E.add(result, n)
return result
func invariants(world) -> void:
var physical: Dictionary = world.ledger.physical_amounts(world.state)
for id in world.state.ethics:
check(E.eq(sum_ethics(world, id), physical.get(id, 0)), E.add("Ethics equal physical population for ", id))
var total: float = 0.0
for n in world.state.ethics[id].values():
if (E.lt(n, 0) or E.ne(n, E.floor_value(n))): check(false, "Invalid ethic amount")
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Civilization state validates")
var share: float = 0.0 + n
if not is_finite(share) or share < 0.0 or share > 1.0: check(false, "Invalid ethic share")
total += share
check(absf(total - 1.0) < 0.000001, E.add("Ethic shares sum to one for ", id))
check(E.ge(physical.get(id, 0), 0), E.add("Exact physical population remains available for ", id))
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 6, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Civilization state validates")
func run() -> void:
var global: Node = root.get_node("Global")
+12 -7
View File
@@ -48,18 +48,23 @@ func run() -> void:
world.evolution.initialize_life_batches()
check((E.eq(world.state.amount["C2.0"], 12) and E.eq(world.state.binds.RD["C2.0"], 0)), "Old space civilizations detach without loss")
check(E.eq(world.state.binds.RD["C1.9"], 3), "Planetary civilizations retain their host")
check(E.eq(world.state.ethics["C0.2"], {"m": 2, "l": 2, "w": 1, "d": 1, "c": 1, "b": 1}), "Migration uses equal ethics and deterministic remainder")
check(world.state.ethics["C2.0"].values().all(func ( n ) : return E.eq(n, 2)), "Bound civilization ethics count physical population")
check(world.state.ethics["C0.2"].values().all(func ( n ) : return E.near(n, 1.0 / 6.0)), "Migration initializes equal ethic shares")
check(world.state.ethics["C2.0"].values().all(func ( n ) : return E.near(n, 1.0 / 6.0)), "Bound civilization ethics are population-independent shares")
var before: Dictionary = world.state.duplicate(true)
world.evolution.initialize_life_batches()
check(E.eq(world.state, before), "Repeated initialization preserves ethics")
var profiles_stable: bool = true
for ethic_id in before.ethics:
for ethic in before.ethics[ethic_id]:
profiles_stable = profiles_stable and E.near(world.state.ethics[ethic_id][ethic], before.ethics[ethic_id][ethic])
check(profiles_stable, "Repeated initialization preserves ethics")
world.state.ethics["C0.2"] = {"m": 8, "l": 0, "w": 0, "d": 0, "c": 0, "b": 0}
world.evolution.initialize_life_batches()
check(E.eq(world.state.ethics["C0.2"].m, 8), "Existing unequal distribution is retained")
check(E.eq(world.state.ethics["C0.2"].m, 1.0), "Existing unequal distribution is retained")
world.state.amount["C0.2"] = 3
world.evolution.initialize_life_batches()
check(E.eq(world.state.ethics["C0.2"].m, 3), "Losses reduce ethics without inventing population")
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Ethics state validates")
check(E.eq(world.state.ethics["C0.2"].m, 1.0), "Losses retain normalized ethical composition")
check(E.eq(world.ledger.physical_amounts(world.state)["C0.2"], 3), "Exact population remains in the object ledger")
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 6, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Ethics state validates")
var restored = fixture()
restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state))))
restored.evolution.initialize_life_batches()
@@ -117,7 +122,7 @@ func run() -> void:
world.evolution.initialize_life_batches()
check(E.eq(world.state.amount["C4.2"], 2), "SMBH capacity uses stellar mass equivalent")
check(E.eq(world.ledger.physical_amounts(world.state)["C1.9"], 1), "Unsupported civilization degrades through missing habitat families")
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Capacity-limited state validates")
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 6, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Capacity-limited state validates")
dispose(world)
world = fixture()
+1 -1
View File
@@ -57,7 +57,7 @@ func run():
var before: Dictionary = world.state.duplicate(true)
world.evolution.evolute_to_datetime(10000)
check(E.eq(world.state, before), "Repeating completed clock does not repeat transitions")
var validation: String = root.get_node("SaveManager").validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state})
var validation: String = root.get_node("SaveManager").validate_data({"format_version": 6, "app_version": "", "ui": {"current_page": 0}, "state": world.state})
check(validation.is_empty(), "Coarse state validates: " + validation)
world.evolution.evolute_to_datetime(20000)
if id in ["C0.0", "C0.1"]:
+1 -1
View File
@@ -94,7 +94,7 @@ func run():
check(str(world.state.auto_star_deficits).length() < 500, "true", "fraction storage stays bounded")
print("100 changing-composition cycles: %d ms" % (Time.get_ticks_msec() - started))
var saves = root.get_node("SaveManager")
check(saves.validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), "", "long-running state saves")
check(saves.validate_data({"format_version": 6, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), "", "long-running state saves")
for entity in world.catalog.entities.values(): entity.free()
world.free()
print("Exact math: %d checks, %d failures" % [checks, failures])
+2 -2
View File
@@ -54,7 +54,7 @@ func run() -> void:
check(E.gt(world.state.batches.HP.size(), 0), "Survivors get next attempt")
var physical: Dictionary = world.ledger.physical_amounts(world.state)
check(E.eq(E.add(E.add(physical.HP, physical.EON1), physical.RP), 40000), "Life conserves planets")
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Life save validates")
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 6, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Life save validates")
var restored = fixture()
restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state))))
world.evolution.evolve_batches(world.state, 11000000)
@@ -120,7 +120,7 @@ func run() -> void:
world.evolution.evolve_batches(world.state, 2050)
check(E.eq(world.state.binds.RD["C0.2"], 700), "C0.1 reaches ethical civilization without losing host")
check(E.eq(world.state.binds.RD["C0.0"], 1000), "C0.1 degradation follows Unity")
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Extended life save validates")
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 6, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Extended life save validates")
dispose(world)
print(("Life: %d checks, %d failures" % [checks, failures]))
quit((0 if E.eq(failures, 0) else 1))
+21 -10
View File
@@ -78,30 +78,41 @@ func run():
changed = Civ.coefficients(world, local_rules, id, modifiers)
check(E.eq(changed.m.pro, E.divide(E.of("0.375"), E.maximum(1, E.add(E.of("0.375"), d)))), "Coefficient cache follows rule edits")
var target: Dictionary = {"ethics": {}}
target.amount = {}
target.binds = {}
target.stats = {"total_amount": {}}
target.amount[id] = 60
target.stats.total_amount[id] = 60
Civ.State.reconcile(world, target, rules, {id: 60})
target.ethics[id].m = 100
target.ethics[id] = {"m": 1.0, "l": 0.0, "w": 0.0, "d": 0.0, "c": 0.0, "b": 0.0}
Civ.State.reconcile(world, target, rules, {id: 60})
check(E.eq(Civ.total(target.ethics[id]), 60), "Reconcile cache detects changed ethics")
check(E.eq(target.ethics[id].m, 1.0), "Reconcile cache detects changed ethics")
Civ.State.reconcile(world, target, rules, {id: 120})
check(E.eq(Civ.total(target.ethics[id]), 120), "Reconcile cache detects changed population")
check(E.eq(target.ethics[id].m, 1.0), "Reconcile cache detects changed population")
target.amount[id] = 120
Civ.worship(world, target, rules)
target.ethics[id].m = E.add(target.ethics[id].m, 5)
var expected: Variant = 0
target.ethics[id].m = 0.5
target.ethics[id].l = 0.5
var expected: float = 0.0
for stage in rules:
if rules[stage].worships:
for ethic in target.ethics[stage]:
expected = E.add(expected, E.multiply(target.ethics[stage][ethic], E.of(rules[stage].ethics[ethic].worship)))
check(E.eq(Civ.worship(world, target, rules), expected), "Worship cache detects changed ethics")
var n: float = (0.0 + target.ethics[stage][ethic]) * E.approx(E.of(rules[stage].ethics[ethic].worship))
n *= E.approx(target.amount.get(stage, 0))
expected += n
check(E.near(Civ.worship(world, target, rules), expected), "Worship cache detects changed ethics")
var worship_rules: Dictionary = rules.duplicate(true)
for stage in worship_rules:
if worship_rules[stage].worships:
worship_rules[stage].ethics.m.worship = "0.375"
expected = 0
expected = 0.0
for stage in worship_rules:
if worship_rules[stage].worships:
for ethic in target.ethics[stage]:
expected = E.add(expected, E.multiply(target.ethics[stage][ethic], E.of(worship_rules[stage].ethics[ethic].worship)))
check(E.eq(Civ.worship(world, target, worship_rules), expected), "Worship cache detects changed rules")
var n: float = (0.0 + target.ethics[stage][ethic]) * E.approx(E.of(worship_rules[stage].ethics[ethic].worship))
n *= E.approx(target.amount.get(stage, 0))
expected += n
check(E.near(Civ.worship(world, target, worship_rules), expected), "Worship cache detects changed rules")
world.ledger = load("res://tests/fixtures/audit_rounding_ledger.gd").new(world)
world.speed = speed
for i in 3: world.evolute(1.0 if speed == 1 else 0.1)
+3 -3
View File
@@ -96,7 +96,7 @@ func run() -> void:
check((E.eq(world.state.binds.RD.GG, 16) and E.eq(world.state.binds.RD.AB, 120)), "Planet multiplicity applied")
check((E.eq(world.state.amount.HP, 0) and E.eq(world.state.stats.total_amount.HP, 1)), "Created planets are bound, counted once")
var saves: Node = root.get_node("SaveManager")
check(E.eq(saves.validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Planet carry save validates")
check(E.eq(saves.validate_data({"format_version": 6, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Planet carry save validates")
restored = fixture()
restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state))))
world.stellar.create_planets(world.state, "RD", 37, 0, 0, E.of("0.7"))
@@ -118,7 +118,7 @@ func run() -> void:
check((E.eq(world.state.amount.RP, 0) and E.eq(world.state.amount["C1.3"], 0)), "Inherited planets never become free")
world.evolution.evolve_batches(world.state, E.add(lifespan, width))
check((E.eq(world.state.binds.RD["C1.3"], 0) and E.eq(world.state.binds.TWD["C1.3"], 4)), "No planets left at extinct parent type")
check(E.eq(saves.validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Inherited state validates")
check(E.eq(saves.validate_data({"format_version": 6, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Inherited state validates")
dispose(world)
world = fixture()
@@ -158,7 +158,7 @@ func run() -> void:
check((E.eq(world.state.amount.SN, 0) and E.eq(world.state.amount.LNS, 1)), "Supernova produces a remnant")
check(((E.eq(world.state.amount.H, 8) and E.eq(world.state.amount.He, 50)) and E.eq(world.state.amount.Met, 11)), "Supernova returns table resources")
check((not world.state.amount.has("blast")), "Supernova dispatches blast output")
check(E.eq(saves.validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Blast state validates")
check(E.eq(saves.validate_data({"format_version": 6, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Blast state validates")
dispose(world)
world = fixture()
+2 -2
View File
@@ -51,10 +51,10 @@ func run() -> void:
for invalid in [0, E.neg(1), NAN, INF, 1e308]:
world.evolute(invalid)
check(E.eq(world.state, before), "Invalid/overflow interval rejected")
check(E.eq(saves.validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Fractional save is valid")
check(E.eq(saves.validate_data({"format_version": 6, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Fractional save is valid")
var invalid_save: Dictionary = world.state.duplicate(true)
invalid_save.time.year = E.of("0.25")
check(E.ne(saves.validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": invalid_save}), ""), "Reject inconsistent fractional clock")
check(E.ne(saves.validate_data({"format_version": 6, "app_version": "", "ui": {"current_page": 0}, "state": invalid_save}), ""), "Reject inconsistent fractional clock")
dispose(world)
for fps in [30, 60, 144]:
+13 -4
View File
@@ -91,7 +91,7 @@ func run() -> void:
world = fixture()
world.state.amount["C0.2"] = 1000000
world.state.ethics["C0.2"] = {"m": 1000000}
world.state.ethics["C0.2"] = {"m": 1.0, "l": 0.0, "w": 0.0, "d": 0.0, "c": 0.0, "b": 0.0}
var baseline: Dictionary = world.state.duplicate(true)
var advanced: Dictionary = baseline.duplicate(true)
advanced.world_parameters.Agg = 19
@@ -108,7 +108,9 @@ func run() -> void:
# Trends use ethical proportions and stage weights, not population magnitude.
world = fixture()
var rules: Dictionary = {"C2.0": {"home": "DwStar"}, "C2.1": {"home": "DwStar"}}
world.state.ethics = {"C2.0": {"d": 100}, "C2.1": {"d": 100}}
world.state.ethics = {"C2.0": {"m": 0.0, "l": 0.0, "w": 0.0, "d": 1.0, "c": 0.0, "b": 0.0}, "C2.1": {"m": 0.0, "l": 0.0, "w": 0.0, "d": 1.0, "c": 0.0, "b": 0.0}}
world.state.amount["C2.1"] = 10
world.state.binds["RD"] = {"C2.0": 10}
var source: Dictionary = world.state.duplicate(true)
var single: Dictionary = source.duplicate(true)
var split: Dictionary = source.duplicate(true)
@@ -117,10 +119,17 @@ func run() -> void:
check(E.near(single.trends.Integration, E.of("0.018")), "Trend integrates elapsed years and doubled stage weights")
for key in Parameters.TRENDS:
check(E.near(single.trends[key], split.trends[key]), E.add("Trend is independent of frame partition: ", key))
source.ethics["C2.0"].d = 1000000
source.ethics["C2.0"].d = 1.0
var larger: Dictionary = world.state.duplicate(true)
larger.amount["C2.1"] = 1000000
larger.binds["RD"]["C2.0"] = 1000000
Parameters.advance(source, larger, rules, 1000000)
check(E.near(larger.trends.Integration, single.trends.Integration), "Population proportions determine trend influence")
var vacant: Dictionary = world.state.duplicate(true)
vacant.amount["C2.1"] = 0
vacant.binds["RD"]["C2.0"] = 0
Parameters.advance(vacant, vacant, rules, 1000000)
check(vacant.trends.values().all(func (value): return E.eq(value, 0)), "Empty saved ethic profiles do not generate trends")
world.state.trends.Integration = E.of("0.99")
Parameters.advance(source, world.state, rules, 100000000)
check(E.eq(world.state.civilization_stats["trend:Integration"], 2), "Long steps process every crossed threshold")
@@ -133,7 +142,7 @@ func run() -> void:
Parameters.advance(source, world.state, rules, E.of("1.0e12"))
check((E.ge(world.state.trends.Integration, 0) and E.lt(world.state.trends.Integration, 1)), "Very large intervals finish with a valid trend fraction")
check(E.ge(world.state.world_parameters.Agg, 1), "Repeated integration events cannot reduce aggression below the scale")
var payload: Dictionary = {"format_version": 5, "app_version": "test", "ui": {"current_page": 1}, "state": world.state}
var payload: Dictionary = {"format_version": 6, "app_version": "test", "ui": {"current_page": 1}, "state": world.state}
var manager: Node = root.get_node("SaveManager")
check(E.eq(manager.validate_data(payload), ""), "New parameters and trends pass save validation")
var old: Dictionary = payload.duplicate(true)
+91 -33
View File
@@ -58,7 +58,7 @@ static func drift(target: Dictionary, key: String, pop: Dictionary, rows: Dictio
weights.append(E.maximum(0, E.subtract(E.add(common, E.multiply(contributions[ETHICS.find(ethic)], 951 if colony else 195)), E.multiply(contributions[ETHICS.find(OPPOSITE[ethic])], 5 if colony else 1))))
if E.le(QuantityMath.sum_array(weights), 0): return pop.duplicate()
if not target.has("civilization_remainders"): target.civilization_remainders = {}
return QuantityMath.split_arrays(target.civilization_remainders, key, population, ETHICS, weights, 0)
return QuantityMath.split_arrays(target.civilization_remainders, key, population, ETHICS, weights, 0, true)
static func diplomacy_rates(rows: Dictionary) -> Array:
var signature: String = ""
@@ -188,18 +188,20 @@ static func consume_untriggered(world, target: Dictionary, plan: Dictionary, pop
# Keeping the summation order preserves float fractional rounding exactly.
static func worship(world, target: Dictionary, rules: Dictionary) -> Variant:
var result: Variant = 0
var physical: Dictionary = world.ledger.physical_amounts(target)
for id in rules:
if not rules[id].worships: continue
var pop: Dictionary = target.ethics[id]
var pop: Dictionary = State.normalize_profile(target.ethics.get(id, {}))
var coefficients: Dictionary = {}
for ethic in pop: coefficients[ethic] = rules[id].ethics[ethic].worship
var cached: Dictionary = world.ledger.worship_products.get(id, {})
if cached.is_empty() or not E.eq(cached.population, pop) or not E.eq(cached.coefficients, coefficients):
var products: Dictionary = {}
for ethic in pop: products[ethic] = E.multiply(pop[ethic], E.of(coefficients[ethic]))
cached = {"population": pop.duplicate(), "coefficients": coefficients, "products": products}
var count: Variant = physical.get(id, 0)
if cached.is_empty() or not E.eq(cached.population, pop) or not E.eq(cached.coefficients, coefficients) or not E.eq(cached.count, count):
var worship_rate: float = 0.0
for ethic in pop: worship_rate += (0.0 + pop[ethic]) * E.approx(E.of(coefficients[ethic]))
cached = {"population": pop.duplicate(), "coefficients": coefficients, "count": count, "product": E.multiply(count, worship_rate)}
world.ledger.worship_products[id] = cached
for ethic in pop: result = E.add(result, cached.products[ethic])
result = E.add(result, cached.product)
return result
static func coefficients(world, rules: Dictionary, id: String, modifiers: Dictionary) -> Dictionary:
@@ -236,8 +238,13 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: Varian
for id in ids:
reconcile_ids.append(id)
if rules[id].fights and not reconcile_ids.has(rules[id].previous): reconcile_ids.append(rules[id].previous)
if budget_timer != null: budget_timer.call("set_phase", "civilization_reconcile")
State.reconcile(world, target, rules, physical, reconcile_ids)
var remaining: Dictionary = target.ethics.duplicate()
if physical.is_empty(): physical = world.ledger.physical_amounts(target)
var remaining: Dictionary = {}
for id in reconcile_ids:
if rules.has(id):
remaining[id] = State.materialize_profile(target, id, target.ethics.get(id, {}), physical.get(id, 0))
var copied: Dictionary = {}
var plans: Dictionary = {}
var colonies: Array = []
@@ -247,6 +254,7 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: Varian
if (not target.has("civilization_stats")): target.civilization_stats = {}
ids.sort()
for id in ids:
if budget_timer != null: budget_timer.call("set_phase", "civilization_stage:" + id)
if budget_timer != null and not await budget_timer.pause(): return {}
if ((not rules.has(id)) or expired[id].is_empty()): continue
var plan: Dictionary = new_plan(id, year)
@@ -265,6 +273,8 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: Varian
var settlers: Dictionary = {}
var stage_rates: Dictionary = coefficients(world, rules, id, modifiers)
for ethic in triggered:
if budget_timer != null: budget_timer.call("set_phase", "civilization_ethic:" + id)
if budget_timer != null and not await budget_timer.pause(): return {}
var probability: Dictionary = stage_rates[ethic]
var prefix: String = id + ":" + ethic
var pro: Variant = rate(target, prefix + ":pro", triggered[ethic], probability.pro)
@@ -277,11 +287,15 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: Varian
triggered[ethic] = E.subtract(triggered[ethic], E.add(bounded.pro, bounded.deg))
var profile_transitions: int = world.profile_start()
add(triggered, progress(world, target, rules, budget, plan, successes))
if budget_timer != null and not await budget_timer.pause(): return {}
fall(target, plan, rules, failures)
world.profile_end("civilization_transitions", profile_transitions)
if rules[id].fights:
var profile_wars: int = world.profile_start()
for ethic in warriors: warriors[ethic] = E.minimum(warriors[ethic], triggered[ethic])
for ethic in warriors:
if budget_timer != null: budget_timer.call("set_phase", "civilization_war:" + id)
if budget_timer != null and not await budget_timer.pause(): return {}
warriors[ethic] = E.minimum(warriors[ethic], triggered[ethic])
var warriors_total: Variant = total(warriors)
var triggered_total: Variant = total(triggered)
var remaining_total: Variant = total(remaining[id])
@@ -289,30 +303,36 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: Varian
warriors = QuantityMath.allocate_whole_with_total(warriors, wars, warriors_total)
for ethic in warriors: triggered[ethic] = E.subtract(triggered[ethic], warriors[ethic])
triggered_total = E.subtract(triggered_total, wars)
var outcomes: Dictionary = split(target, E.add(id, ":wars"), wars, {"deg": 1, "pro25": 2, "pro50": 3, "pro75": 3})
var win_expected: Variant = E.add(E.add(E.multiply(outcomes.get("pro25", 0), E.of("0.25")), E.multiply(outcomes.get("pro50", 0), E.of("0.5"))), E.multiply(outcomes.get("pro75", 0), E.of("0.75")))
var loss_expected: Variant = E.add(E.add(E.add(E.multiply(outcomes.get("deg", 0), 2), outcomes.get("pro25", 0)), E.multiply(outcomes.get("pro50", 0), E.of("1.5"))), E.multiply(outcomes.get("pro75", 0), 2))
var wins_requested: Variant = rate(target, E.add(id, ":wins"), win_expected, 1)
# Integrate the four equally weighted battle outcomes directly.
# Their expected coefficients are wins=17/36, deaths=1/9,
# total losses=29/18, and collateral=40/3 per battle. Rate
# remainders preserve fractional results between generations.
var wins_requested: Variant = rate(target, E.add(id, ":wins"), wars, E.of("17/36"))
var winners: Dictionary = take(world, warriors, wins_requested, wars)
var winner_total: Variant = E.floor_value(E.minimum(E.maximum(0, wins_requested), wars))
warriors_total = E.subtract(wars, winner_total)
var losses_requested: Variant = rate(target, E.add(id, ":attackers_lost"), E.divide(outcomes.get("deg", 0), order), 1)
var deaths_expected: Variant = E.divide(wars, 9)
var losses_requested: Variant = rate(target, E.add(id, ":attackers_lost"), E.divide(deaths_expected, order), 1)
var attackers_lost: Dictionary = take(world, warriors, losses_requested, warriors_total)
var attackers_lost_total: Variant = E.floor_value(E.minimum(E.maximum(0, losses_requested), warriors_total))
warriors_total = E.subtract(warriors_total, attackers_lost_total)
add(triggered, progress(world, target, rules, budget, plan, winners, winner_total))
triggered_total = total(triggered)
if budget_timer != null and not await budget_timer.pause(): return {}
fall(target, plan, rules, attackers_lost)
remaining_total = total(remaining[id])
var losses: Variant = E.minimum(rate(target, E.add(id, ":loss"), E.divide(loss_expected, order), 1), E.add(triggered_total, remaining_total))
var losses_expected: Variant = E.multiply(wars, E.of("29/18"))
var losses: Variant = E.minimum(rate(target, E.add(id, ":loss"), E.divide(losses_expected, order), 1), E.add(triggered_total, remaining_total))
var sides: Dictionary = QuantityMath.allocate_whole_with_total({"due": triggered_total, "later": remaining_total}, losses, E.add(triggered_total, remaining_total))
var victims: Dictionary = take(world, triggered, sides.due, triggered_total)
triggered_total = E.subtract(triggered_total, sides.due)
add(victims, consume_untriggered(world, target, plan, remaining[id], sides.later, year, remaining_total))
if budget_timer != null and not await budget_timer.pause(): return {}
fall(target, plan, rules, victims)
add(triggered, warriors)
triggered_total = E.add(triggered_total, warriors_total)
var damage: Variant = rate(target, E.add(id, ":collateral"), E.divide((E.add(E.multiply(outcomes.get("pro50", 0), 10), E.multiply(outcomes.get("pro75", 0), 30))), order), 1)
var damage_expected: Variant = E.multiply(wars, E.of("40/3"))
var damage: Variant = rate(target, E.add(id, ":collateral"), E.divide(damage_expected, order), 1)
collateral[rules[id].previous] = E.add(collateral.get(rules[id].previous, 0), damage)
target.civilization_stats.wars = E.add(target.civilization_stats.get("wars", 0), wars)
world.profile_end("wars", profile_wars)
@@ -333,15 +353,19 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: Varian
var sides: Dictionary = QuantityMath.allocate_whole_with_total({"due": retry_total, "later": remaining_total}, amount, E.add(retry_total, remaining_total))
var victims: Dictionary = take(world, retry, sides.due, retry_total)
add(victims, consume_untriggered(world, target, plan, remaining[id], sides.later, year, remaining_total))
if budget_timer != null and not await budget_timer.pause(): return {}
fall(target, plan, rules, victims)
target.civilization_stats.collateral = E.add(target.civilization_stats.get("collateral", 0), total(victims))
var incoming: Dictionary = {}
var planetary: Dictionary = {}
for id in plans:
if budget_timer != null: budget_timer.call("set_phase", "civilization_products:" + id)
if budget_timer != null and not await budget_timer.pause(): return {}
var plan: Dictionary = plans[id]
var products: Dictionary = {}
for destination in plan.outputs:
if budget_timer != null: budget_timer.call("set_phase", "civilization_drift:" + id)
if budget_timer != null and not await budget_timer.pause(): return {}
var pop: Dictionary = plan.outputs[destination]
var count: Variant = total(pop)
if rules.has(destination):
@@ -356,7 +380,7 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: Varian
planetary[destination] = E.add(planetary.get(destination, 0), count)
expired[id] = [{"amount": plan.amount, "from": plan.from, "to": E.maximum(plan.from, plan.to), "products": products}]
world.profile_end("civilization_prepare", profile_started)
return {"active": true, "remaining": remaining, "incoming": incoming, "planetary": planetary, "colonies": colonies}
return {"active": true, "remaining": remaining, "incoming": incoming, "planetary": planetary, "colonies": colonies, "base_physical": physical.duplicate()}
static func finish(world, target: Dictionary, transaction: Dictionary, year: Variant, rules: Dictionary, budget_timer: RefCounted = null) -> void:
var profile_started: int = world.profile_start()
@@ -366,10 +390,15 @@ static func finish(world, target: Dictionary, transaction: Dictionary, year: Var
target.civilization_stats.worship = worship(world, target, rules)
world.profile_end("civilization_finish", profile_started)
return
target.ethics = transaction.remaining
for id in transaction.incoming:
if (not target.ethics.has(id)): target.ethics[id] = {}
add(target.ethics[id], transaction.incoming[id])
var remaining: Dictionary = transaction.remaining
var incoming: Dictionary = transaction.incoming
for id in remaining:
var counts: Dictionary = remaining[id].duplicate()
add(counts, incoming.get(id, {}))
target.ethics[id] = State.profile_from_counts(counts)
for id in incoming:
if not remaining.has(id):
target.ethics[id] = State.merge_profile(target.ethics.get(id, {}), transaction.base_physical.get(id, 0), incoming[id])
# Space civilizations that fall back to planetary stages need a stellar host.
var physical: Dictionary = world.ledger.physical_amounts(target)
var hosts: Dictionary = {}
@@ -399,6 +428,16 @@ static func colonize(world, target: Dictionary, colonies: Array, year: Variant,
var requests: Dictionary = {}
for i in colonies.size(): requests[str(i)] = total(colonies[i].population)
var granted: Dictionary = QuantityMath.allocate_whole(requests, total(stock))
var additions: Dictionary = {}
var credit_events: Array = []
var batch_additions: Dictionary = {}
var debits: Dictionary = {}
var slots_by_resource: Dictionary = {}
for id in stock:
var slots: Dictionary = {"": target.amount.get(id, 0)}
for owner in target.binds: slots[owner] = target.binds[owner].get(id, 0)
slots_by_resource[id] = slots
debits[id] = {}
for i in colonies.size():
if budget_timer != null and not await budget_timer.pause(): return
var colony: Dictionary = colonies[i]
@@ -409,21 +448,40 @@ static func colonize(world, target: Dictionary, colonies: Array, year: Variant,
for id in targets:
var amount: Variant = targets[id]
if E.le(amount, 0): continue
var slots: Dictionary = {"": target.amount.get(id, 0)}
for owner in target.binds: slots[owner] = target.binds[owner].get(id, 0)
var slots: Dictionary = slots_by_resource[id]
var taken: Dictionary = QuantityMath.allocate_whole(slots, amount)
world.batches.scale_batches(target, id, E.subtract(1, E.divide(amount, stock[id])))
stock[id] = E.subtract(stock[id], amount)
for owner in taken:
if E.le(taken[owner], 0): continue
if E.eq(owner, ""): target.amount[id] = E.subtract(target.amount[id], taken[owner])
else: target.binds[owner][id] = E.subtract(target.binds[owner][id], taken[owner])
var created: Variant = world.ledger.credit(target, colony.stage, taken[owner])
if E.ne(owner, ""):
target.amount[colony.stage] = E.subtract(target.amount[colony.stage], created)
target.binds[owner][colony.stage] = E.add(target.binds[owner].get(colony.stage, 0), created)
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)
slots[owner] = E.subtract(slots.get(owner, 0), taken[owner])
debits[id][owner] = E.add(debits[id].get(owner, 0), taken[owner])
credit_events.append({"stage": colony.stage, "owner": owner, "amount": taken[owner]})
batch_additions[colony.stage] = E.add(batch_additions.get(colony.stage, 0), taken[owner])
if not additions.has(colony.stage): additions[colony.stage] = {}
add(additions[colony.stage], population)
target.civilization_stats.colonies = E.add(target.civilization_stats.get("colonies", 0), count)
# Commit each resource and owner once after all colonies have consumed their
# sequentially allocated shares. This keeps allocation order exact while
# avoiding repeated batch scaling and ledger mutations in the inner loop.
for id in debits:
var removed: Variant = E.subtract(physical.get(id, 0), stock[id])
if E.gt(physical.get(id, 0), 0):
world.batches.scale_batches(target, id, E.subtract(1, E.divide(removed, physical[id])))
for owner in debits[id]:
if E.eq(owner, ""):
target.amount[id] = E.subtract(target.amount.get(id, 0), debits[id][owner])
elif target.binds.has(owner):
target.binds[owner][id] = E.subtract(target.binds[owner].get(id, 0), debits[id][owner])
for event: Dictionary in credit_events:
var stage: String = event.stage
var owner: String = event.owner
var created: Variant = world.ledger.credit(target, stage, event.amount)
if E.ne(owner, ""):
target.amount[stage] = E.subtract(target.amount[stage], created)
if not target.binds.has(owner): target.binds[owner] = {}
target.binds[owner][stage] = E.add(target.binds[owner].get(stage, 0), created)
for stage in batch_additions:
world.batches.append_batch(target, stage, batch_additions[stage], year, year)
for id in additions:
target.ethics[id] = State.merge_profile(target.ethics.get(id, {}), physical.get(id, 0), additions[id])
world.profile_end("colonization", profile_colonies)
+68 -21
View File
@@ -5,11 +5,68 @@ const QuantityMath = preload("res://world/owniverse/quantity_math.gd")
const ETHICS = ["m", "l", "w", "d", "c", "b"]
static func empty_profile() -> Dictionary:
var result: Dictionary = {}
for ethic in ETHICS: result[ethic] = 1.0 / ETHICS.size()
return result
static func normalize_profile(profile: Dictionary) -> Dictionary:
var result: Dictionary = {}
var total: float = 0.0
for ethic in ETHICS:
var value: float = maxf(0.0, E.approx(profile.get(ethic, 0.0)))
result[ethic] = value
total += value
if not is_finite(total) or total <= 0.0: return empty_profile()
if profile.size() == ETHICS.size() and absf(total - 1.0) <= 0.000000000001:
return result
var assigned: float = 0.0
for i in ETHICS.size() - 1:
var ethic: String = ETHICS[i]
result[ethic] = (0.0 + result[ethic]) / total
assigned += result[ethic]
result[ETHICS.back()] = maxf(0.0, 1.0 - assigned)
return result
static func profile_from_counts(population: Dictionary) -> Dictionary:
var total: Variant = QuantityMath.sum(population)
if E.le(total, 0): return empty_profile()
var profile: Dictionary = {}
for ethic in ETHICS:
profile[ethic] = E.approx(E.divide(population.get(ethic, 0), total))
return normalize_profile(profile)
static func materialize_profile(target: Dictionary, id: String, profile: Dictionary, count: Variant) -> Dictionary:
var normalized: Dictionary = normalize_profile(profile)
if not target.has("civilization_remainders"): target.civilization_remainders = {}
var shares: Array = []
for ethic in ETHICS: shares.append(normalized[ethic])
var result: Dictionary = QuantityMath.split_profile(target.civilization_remainders, "profile:" + id, count, ETHICS, shares)
for ethic in ETHICS:
if not result.has(ethic): result[ethic] = 0
return result
static func merge_profile(base: Dictionary, base_count: Variant, incoming: Dictionary) -> Dictionary:
var incoming_count: Variant = QuantityMath.sum(incoming)
return merge_profiles(base, base_count, profile_from_counts(incoming), incoming_count)
static func merge_profiles(base: Dictionary, base_count: Variant, incoming_profile: Dictionary, incoming_count: Variant) -> Dictionary:
var total: Variant = E.add(base_count, incoming_count)
if E.le(total, 0): return empty_profile()
var base_ratio: float = E.approx(E.divide(base_count, total)) if E.gt(base_count, 0) else 0.0
var incoming_ratio: float = E.approx(E.divide(incoming_count, total)) if E.gt(incoming_count, 0) else 0.0
var normalized_base: Dictionary = normalize_profile(base)
var result: Dictionary = {}
for ethic in ETHICS:
var incoming_share: float = E.approx(incoming_profile.get(ethic, 0.0)) if E.gt(incoming_count, 0) else 0.0
result[ethic] = (0.0 + normalized_base[ethic]) * base_ratio + incoming_share * incoming_ratio
return normalize_profile(result)
static func is_space_civilization(id: String, rules: Dictionary) -> bool:
return (rules.has(id) and E.ne(str(rules[id].home), ""))
# Preserve any existing ethics. New populations start equally distributed;
# reductions (including blasts) are proportional and never leave negative counts.
# Keep one six-value float profile per stage. Absolute population remains in
# the exact physical object ledger and is materialized only when a transition needs it.
static func reconcile(world, target: Dictionary, rules: Dictionary, physical: Dictionary = {}, only_ids: Array = []) -> void:
if (not target.has("ethics")):
target.ethics = {}
@@ -19,22 +76,10 @@ static func reconcile(world, target: Dictionary, rules: Dictionary, physical: Di
for id in ids:
if not rules.has(id): continue
var count: Variant = physical.get(id, 0)
var current: Dictionary = target.ethics.get(id, {})
var current: Dictionary = normalize_profile(target.ethics.get(id, {}))
var cached: Dictionary = world.ledger.reconciled_ethics.get(id, {})
if not cached.is_empty() and E.eq(cached.count, count) and E.eq(cached.population, current):
continue
var total: Variant = 0
for ethic in ETHICS:
current[ethic] = E.maximum(0, current.get(ethic, 0))
total = E.add(total, current[ethic])
if E.gt(total, count):
current = QuantityMath.allocate_whole(current, count)
elif E.lt(total, count):
var missing: Variant = E.subtract(count, total)
var base: Variant = E.floor_value(E.divide(missing, ETHICS.size()))
var rest: int = E.integer(E.subtract(missing, E.multiply(base, ETHICS.size())))
for i in ETHICS.size():
current[ETHICS[i]] = E.add(current[ETHICS[i]], E.add(base, ((1 if E.lt(i, rest) else 0))))
target.ethics[id] = current
world.ledger.reconciled_ethics[id] = {"count": count, "population": current.duplicate()}
@@ -115,6 +160,7 @@ static func enforce_capacity(world, target: Dictionary, rules: Dictionary, year:
detach_space_civilizations(target, rules)
var physical: Dictionary = world.ledger.physical_amounts(target)
reconcile(world, target, rules, physical)
var ethical_totals: Dictionary = physical.duplicate()
var budget: Dictionary = stellar_budget(world, target, rules, physical)
var remaining: Dictionary = budget.capacity.duplicate()
var remaining_homes: Dictionary = budget.habitats.capacity.duplicate()
@@ -141,12 +187,13 @@ static func enforce_capacity(world, target: Dictionary, rules: Dictionary, year:
target.amount[id] = supported
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] = {}
for ethic in ETHICS:
target.ethics[id][ethic] = E.subtract(target.ethics[id][ethic], removed[ethic])
target.ethics[previous][ethic] = E.add(target.ethics[previous].get(ethic, 0), removed[ethic])
var previous_count: Variant = ethical_totals.get(previous, physical.get(previous, target.amount.get(previous, 0)))
var previous_profile: Dictionary = target.ethics.get(previous, empty_profile())
var source_profile: Dictionary = target.ethics.get(id, empty_profile())
target.ethics[previous] = merge_profiles(previous_profile, previous_count, source_profile, excess)
ethical_totals[id] = supported
ethical_totals[previous] = E.add(previous_count, excess)
# Capacity losses are proportional, so the surviving source keeps its profile.
if E.eq(previous, "C1.9"):
var stars: Dictionary = {}
var current_physical: Dictionary = world.ledger.physical_amounts(target)
+8
View File
@@ -30,10 +30,12 @@ func evolute_to_datetime(datetime: Variant, clock: Dictionary = {}, budget: RefC
# Fractional frame updates cannot change capacity or populations.
var processed_year: Variant = SimulationTime.processed_year(datetime)
if E.gt(processed_year, world.state.time.year):
if budget != null: budget.call("set_phase", "initialize_life_batches")
var profile_initialize: int = world.profile_start()
initialize_life_batches()
world.profile_end("initialize", profile_initialize)
var previous: Dictionary = world.state
if budget != null: budget.call("set_phase", "deep_copy_and_coarsen")
var next: Dictionary = previous.duplicate_deep()
next.version = SimulationTime.STATE_VERSION
next.speed = clampi(E.integer(previous.speed), 1, Global.SPEED_LIMIT)
@@ -87,6 +89,7 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
# Apply direct completions at the original next-generation boundary, so
# binding transfers, the blast environment and capacity checks keep order.
if budget != null and not await budget.pause(): return false
if budget != null: budget.call("set_phase", "generation_setup_and_withdrawal")
var completed_now: Dictionary = completed.duplicate()
completed.clear()
if context.is_empty():
@@ -132,7 +135,9 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
var fractions: Dictionary = {}
var outputs: Dictionary = {}
var current_quantities: Dictionary = quantities
if budget != null: budget.call("set_phase", "civilization_stellar_budget")
var star_budget: Dictionary = CivilizationState.stellar_budget(world, target, Global.CivilizationRules, current_quantities)
if budget != null: budget.call("set_phase", "civilization_prepare")
var civilization_transaction: Dictionary = await CivilizationEvolution.prepare(world, target, expired, to_year, star_budget, Global.CivilizationRules, current_quantities, budget, expired_totals)
if budget != null and budget.cancelled: return false
var profile_products: int = world.profile_start()
@@ -166,6 +171,7 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
affected[id] = true
for held in target.binds.get(id, {}): affected[held] = true
for successor in outputs[id]: affected[successor] = true
if budget != null: budget.call("set_phase", "transfer_evolution")
var incoming: Dictionary = world.ledger.transfer_evolution(target, fractions, outputs)
var new_owners: Dictionary = {}
@@ -177,6 +183,7 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
# blast retain the original snapshot before crediting newborn populations.
var environment: Dictionary = world.environment.stellar_environment(target) if E.gt(blast_power, 0) else {}
var resolution: Variant = world.batches.temporal_resolution(target)
if budget != null: budget.call("set_phase", "transfer_and_newborns")
for id in expired:
for part : Dictionary in expired[id]:
for successor in part.products:
@@ -214,6 +221,7 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
world.profile_end("newborn_batches", profile_newborn)
new_owners[successor] = E.add(new_owners.get(successor, 0), amount)
world.ledger.accept_transfers(target, incoming)
if budget != null: budget.call("set_phase", "finish_and_capacity")
world.ledger.bind_new_owners(target, new_owners)
for owner in new_owners:
for resource in world.catalog.entities[owner].bind:
+14
View File
@@ -5,18 +5,32 @@ var slice_usec: int = 40000
var deadline: int
var cancelled: bool = false
var waiting_usec: int = 0
var checkpoint_usec: int = 0
var max_slice_usec: int = 0
var max_slice_phase: String = "startup"
var phase: String = "startup"
func _init(scene_tree: SceneTree, milliseconds: int = 40):
tree = scene_tree
slice_usec = milliseconds * 1000
deadline = Time.get_ticks_usec() + slice_usec
checkpoint_usec = deadline - slice_usec
func set_phase(value: String) -> void:
phase = value
func pause() -> bool:
var keep_alive: RefCounted = self
if cancelled: return false
var now: int = Time.get_ticks_usec()
var work_usec: int = now - checkpoint_usec
if work_usec > max_slice_usec:
max_slice_usec = work_usec
max_slice_phase = phase
if Time.get_ticks_usec() >= deadline:
var waiting_from: int = Time.get_ticks_usec()
await tree.process_frame
keep_alive.waiting_usec += Time.get_ticks_usec() - waiting_from
deadline = Time.get_ticks_usec() + slice_usec
checkpoint_usec = Time.get_ticks_usec()
return not keep_alive.cancelled
+60 -2
View File
@@ -123,11 +123,69 @@ static func sum(pop: Dictionary) -> Variant:
static var _split_keys: Dictionary = {}
## Split a whole population using normalized float shares. Quantizing the six
## shares to 15 decimal places matches their stored float precision, while the
## integer denominator avoids a second expensive exact normalization pass.
static func split_profile(remainders: Dictionary, key: String, count: Variant, ids: Array, shares: Array) -> Dictionary:
var result: Dictionary = {}
if E.le(count, 0): return result
assert(E.eq(count, E.floor_value(count)) and ids.size() == shares.size() and not ids.is_empty())
const SCALE: int = 1000000000000000
var weights: Array = []
var assigned_weight: int = 0
for i in ids.size() - 1:
var weight: int = clampi(roundi(clampf(float(shares[i]), 0.0, 1.0) * SCALE), 0, SCALE)
weights.append(weight)
assigned_weight += weight
var last_weight: int = SCALE - assigned_weight
if last_weight < 0:
var excess: int = -last_weight
for i in range(weights.size() - 1, -1, -1):
var reduction: int = mini(excess, weights[i])
weights[i] -= reduction
excess -= reduction
if excess == 0: break
last_weight = 0
weights.append(last_weight)
var cached: Dictionary = _split_keys.get(key, {})
if cached.is_empty() or cached.ids != ids:
var keys: Array = []
for id in ids: keys.append(key + ":" + str(id))
if _split_keys.size() >= 512: _split_keys.clear()
cached = {"ids": ids.duplicate(), "keys": keys}
_split_keys[key] = cached
var expected: Array = E.mul_div_many(count, weights, SCALE)
var rests: Array = []
var order: Array = []
var total: Variant = 0
for i in ids.size():
expected[i] = E.add(expected[i], remainders.get(cached.keys[i], 0))
result[ids[i]] = E.floor_value(E.maximum(0, expected[i]))
total = E.add(total, result[ids[i]])
rests.append(E.subtract(expected[i], result[ids[i]]))
order.append(i)
order.sort_custom(func(a: int, b: int) -> bool: return E.gt(rests[a], rests[b]) if E.ne(rests[a], rests[b]) else str(ids[a]) < str(ids[b]))
if E.lt(total, count):
for i in order:
if E.ge(total, count): break
result[ids[i]] = E.add(result[ids[i]], 1)
total = E.add(total, 1)
elif E.gt(total, count):
order.reverse()
for i in order:
if E.le(total, count): break
if E.le(result[ids[i]], 0): continue
result[ids[i]] = E.subtract(result[ids[i]], 1)
total = E.subtract(total, 1)
for i in ids.size():
remainders[cached.keys[i]] = E.clamp_value(E.subtract(expected[i], result[ids[i]]), E.neg(1), 1)
return result
static func split(remainders: Dictionary, key: String, count: Variant, weights: Dictionary) -> Dictionary:
if E.le(count, 0): return {}
return split_arrays(remainders, key, count, weights.keys(), weights.values())
static func split_arrays(remainders: Dictionary, key: String, count: Variant, ids: Array, weights: Array, population_factor: int = 0) -> Dictionary:
static func split_arrays(remainders: Dictionary, key: String, count: Variant, ids: Array, weights: Array, population_factor: int = 0, skip_conserved_identity: bool = false) -> Dictionary:
var result: Dictionary = {}
if E.le(count, 0): return result
var sum_weights: Variant = 0
@@ -177,7 +235,7 @@ static func split_arrays(remainders: Dictionary, key: String, count: Variant, id
# Fractional identity ratios still round-trip through parts/make in the
# original algorithm. Retain them to preserve ties and saved errors exactly.
var normalized: Array = positives
if E.gt(positive_sum, 0) and (E.ne(positive_sum, count) or not whole_quotas):
if E.gt(positive_sum, 0) and (E.ne(positive_sum, count) or (not whole_quotas and not (skip_conserved_identity and E.eq(positive_sum, count)))):
normalized = E.mul_div_many(count, positives, positive_sum)
var order: Array = []
var rests: Array = []
+17 -7
View File
@@ -62,16 +62,26 @@ static func advance(previous: Dictionary, target: Dictionary, rules: Dictionary,
for id in rules:
if E.ne(str(rules[id].home), ""): stages.append(id)
stages.sort()
# Profiles remain in the save after a stage becomes empty. Only living
# populations (including populations bound to a host object) contribute.
var populated: Dictionary = {}
var amounts: Dictionary = previous.get("amount", {})
for id in stages:
if E.gt(amounts.get(id, 0), 0): populated[id] = true
for owner in previous.get("binds", {}):
for id in previous.binds[owner]:
if rules.has(id) and E.ne(str(rules[id].home), "") and E.gt(previous.binds[owner][id], 0):
populated[id] = true
var weight: Variant = 1
for id in stages:
if not populated.has(id):
weight = E.multiply(weight, 2)
continue
var population: Dictionary = previous.get("ethics", {}).get(id, {})
var count: Variant = 0
for n in population.values(): count = E.add(count, n)
if E.gt(count, 0):
for ethic in population:
var proportion: Variant = E.divide(population[ethic], count)
for i in TRENDS.size():
rates[i] = E.add(rates[i], E.multiply(E.multiply(proportion, WEIGHTS[ethic][i]), weight))
for ethic in population:
var proportion: float = maxf(0.0, float(population[ethic]))
for i in TRENDS.size():
rates[i] = E.add(rates[i], E.multiply(E.multiply(proportion, WEIGHTS[ethic][i]), weight))
weight = E.multiply(weight, 2)
for i in TRENDS.size():
var name: String = TRENDS[i]