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 SETTING_SECTION = "Settings"
const SAVE_PATH = "user://settings.cfg" 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 SimulationTime = preload("res://world/owniverse/simulation_time.gd")
const WorldParameters = preload("res://world/owniverse/world_parameters.gd") const WorldParameters = preload("res://world/owniverse/world_parameters.gd")
const STATE_VERSION : String = SimulationTime.STATE_VERSION 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))): if ((not (saved.ethics is Dictionary)) or (not (saved.civilization_homes is Dictionary))):
return "invalid civilization state" return "invalid civilization state"
for id in saved.ethics: 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)) 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]: 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)) 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: for id in saved.civilization_homes:
if (not _number_map(saved.civilization_homes[id], true)): if (not _number_map(saved.civilization_homes[id], true)):
return "invalid civilization homes" return "invalid civilization homes"
+1
View File
@@ -1,6 +1,7 @@
[runnable_presets] [runnable_presets]
"Windows Desktop"="Windows Desktop" "Windows Desktop"="Windows Desktop"
Android="Android"
[preset.0] [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 --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 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.state.batches[id] = [{"amount": count, "start_year": time, "end_year": time}]
world.evolution.initialize_life_batches() 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: func invariants(world) -> void:
var physical: Dictionary = world.ledger.physical_amounts(world.state) var physical: Dictionary = world.ledger.physical_amounts(world.state)
for id in world.state.ethics: 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(): 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") var share: float = 0.0 + n
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Civilization state validates") 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: func run() -> void:
var global: Node = root.get_node("Global") var global: Node = root.get_node("Global")
+12 -7
View File
@@ -48,18 +48,23 @@ func run() -> void:
world.evolution.initialize_life_batches() 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.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.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["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.eq(n, 2)), "Bound civilization ethics count physical population") 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) var before: Dictionary = world.state.duplicate(true)
world.evolution.initialize_life_batches() 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.state.ethics["C0.2"] = {"m": 8, "l": 0, "w": 0, "d": 0, "c": 0, "b": 0}
world.evolution.initialize_life_batches() 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.state.amount["C0.2"] = 3
world.evolution.initialize_life_batches() world.evolution.initialize_life_batches()
check(E.eq(world.state.ethics["C0.2"].m, 3), "Losses reduce ethics without inventing population") check(E.eq(world.state.ethics["C0.2"].m, 1.0), "Losses retain normalized ethical composition")
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.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() var restored = fixture()
restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state)))) restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state))))
restored.evolution.initialize_life_batches() restored.evolution.initialize_life_batches()
@@ -117,7 +122,7 @@ func run() -> void:
world.evolution.initialize_life_batches() world.evolution.initialize_life_batches()
check(E.eq(world.state.amount["C4.2"], 2), "SMBH capacity uses stellar mass equivalent") 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(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) dispose(world)
world = fixture() world = fixture()
+1 -1
View File
@@ -57,7 +57,7 @@ func run():
var before: Dictionary = world.state.duplicate(true) var before: Dictionary = world.state.duplicate(true)
world.evolution.evolute_to_datetime(10000) world.evolution.evolute_to_datetime(10000)
check(E.eq(world.state, before), "Repeating completed clock does not repeat transitions") 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) check(validation.is_empty(), "Coarse state validates: " + validation)
world.evolution.evolute_to_datetime(20000) world.evolution.evolute_to_datetime(20000)
if id in ["C0.0", "C0.1"]: 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") 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)) print("100 changing-composition cycles: %d ms" % (Time.get_ticks_msec() - started))
var saves = root.get_node("SaveManager") 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() for entity in world.catalog.entities.values(): entity.free()
world.free() world.free()
print("Exact math: %d checks, %d failures" % [checks, failures]) 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") check(E.gt(world.state.batches.HP.size(), 0), "Survivors get next attempt")
var physical: Dictionary = world.ledger.physical_amounts(world.state) 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(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() var restored = fixture()
restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state)))) restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state))))
world.evolution.evolve_batches(world.state, 11000000) world.evolution.evolve_batches(world.state, 11000000)
@@ -120,7 +120,7 @@ func run() -> void:
world.evolution.evolve_batches(world.state, 2050) 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.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(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) dispose(world)
print(("Life: %d checks, %d failures" % [checks, failures])) print(("Life: %d checks, %d failures" % [checks, failures]))
quit((0 if E.eq(failures, 0) else 1)) 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) 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") 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": {}} 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}) 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}) 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}) 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) Civ.worship(world, target, rules)
target.ethics[id].m = E.add(target.ethics[id].m, 5) target.ethics[id].m = 0.5
var expected: Variant = 0 target.ethics[id].l = 0.5
var expected: float = 0.0
for stage in rules: for stage in rules:
if rules[stage].worships: if rules[stage].worships:
for ethic in target.ethics[stage]: for ethic in target.ethics[stage]:
expected = E.add(expected, E.multiply(target.ethics[stage][ethic], E.of(rules[stage].ethics[ethic].worship))) var n: float = (0.0 + target.ethics[stage][ethic]) * E.approx(E.of(rules[stage].ethics[ethic].worship))
check(E.eq(Civ.worship(world, target, rules), expected), "Worship cache detects changed ethics") 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) var worship_rules: Dictionary = rules.duplicate(true)
for stage in worship_rules: for stage in worship_rules:
if worship_rules[stage].worships: if worship_rules[stage].worships:
worship_rules[stage].ethics.m.worship = "0.375" worship_rules[stage].ethics.m.worship = "0.375"
expected = 0 expected = 0.0
for stage in worship_rules: for stage in worship_rules:
if worship_rules[stage].worships: if worship_rules[stage].worships:
for ethic in target.ethics[stage]: for ethic in target.ethics[stage]:
expected = E.add(expected, E.multiply(target.ethics[stage][ethic], E.of(worship_rules[stage].ethics[ethic].worship))) var n: float = (0.0 + target.ethics[stage][ethic]) * E.approx(E.of(worship_rules[stage].ethics[ethic].worship))
check(E.eq(Civ.worship(world, target, worship_rules), expected), "Worship cache detects changed rules") 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.ledger = load("res://tests/fixtures/audit_rounding_ledger.gd").new(world)
world.speed = speed world.speed = speed
for i in 3: world.evolute(1.0 if speed == 1 else 0.1) 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.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") 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") 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 = fixture()
restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state)))) 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")) 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") 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)) 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(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) dispose(world)
world = fixture() 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.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(((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((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) dispose(world)
world = fixture() world = fixture()
+2 -2
View File
@@ -51,10 +51,10 @@ func run() -> void:
for invalid in [0, E.neg(1), NAN, INF, 1e308]: for invalid in [0, E.neg(1), NAN, INF, 1e308]:
world.evolute(invalid) world.evolute(invalid)
check(E.eq(world.state, before), "Invalid/overflow interval rejected") 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) var invalid_save: Dictionary = world.state.duplicate(true)
invalid_save.time.year = E.of("0.25") 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) dispose(world)
for fps in [30, 60, 144]: for fps in [30, 60, 144]:
+13 -4
View File
@@ -91,7 +91,7 @@ func run() -> void:
world = fixture() world = fixture()
world.state.amount["C0.2"] = 1000000 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 baseline: Dictionary = world.state.duplicate(true)
var advanced: Dictionary = baseline.duplicate(true) var advanced: Dictionary = baseline.duplicate(true)
advanced.world_parameters.Agg = 19 advanced.world_parameters.Agg = 19
@@ -108,7 +108,9 @@ func run() -> void:
# Trends use ethical proportions and stage weights, not population magnitude. # Trends use ethical proportions and stage weights, not population magnitude.
world = fixture() world = fixture()
var rules: Dictionary = {"C2.0": {"home": "DwStar"}, "C2.1": {"home": "DwStar"}} 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 source: Dictionary = world.state.duplicate(true)
var single: Dictionary = source.duplicate(true) var single: Dictionary = source.duplicate(true)
var split: 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") check(E.near(single.trends.Integration, E.of("0.018")), "Trend integrates elapsed years and doubled stage weights")
for key in Parameters.TRENDS: for key in Parameters.TRENDS:
check(E.near(single.trends[key], split.trends[key]), E.add("Trend is independent of frame partition: ", key)) 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) var larger: Dictionary = world.state.duplicate(true)
larger.amount["C2.1"] = 1000000
larger.binds["RD"]["C2.0"] = 1000000
Parameters.advance(source, larger, rules, 1000000) Parameters.advance(source, larger, rules, 1000000)
check(E.near(larger.trends.Integration, single.trends.Integration), "Population proportions determine trend influence") 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") world.state.trends.Integration = E.of("0.99")
Parameters.advance(source, world.state, rules, 100000000) Parameters.advance(source, world.state, rules, 100000000)
check(E.eq(world.state.civilization_stats["trend:Integration"], 2), "Long steps process every crossed threshold") 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")) 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.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") 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") var manager: Node = root.get_node("SaveManager")
check(E.eq(manager.validate_data(payload), ""), "New parameters and trends pass save validation") check(E.eq(manager.validate_data(payload), ""), "New parameters and trends pass save validation")
var old: Dictionary = payload.duplicate(true) 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)))) 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 E.le(QuantityMath.sum_array(weights), 0): return pop.duplicate()
if not target.has("civilization_remainders"): target.civilization_remainders = {} 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: static func diplomacy_rates(rows: Dictionary) -> Array:
var signature: String = "" 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. # Keeping the summation order preserves float fractional rounding exactly.
static func worship(world, target: Dictionary, rules: Dictionary) -> Variant: static func worship(world, target: Dictionary, rules: Dictionary) -> Variant:
var result: Variant = 0 var result: Variant = 0
var physical: Dictionary = world.ledger.physical_amounts(target)
for id in rules: for id in rules:
if not rules[id].worships: continue 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 = {} var coefficients: Dictionary = {}
for ethic in pop: coefficients[ethic] = rules[id].ethics[ethic].worship for ethic in pop: coefficients[ethic] = rules[id].ethics[ethic].worship
var cached: Dictionary = world.ledger.worship_products.get(id, {}) 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 count: Variant = physical.get(id, 0)
var products: Dictionary = {} 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):
for ethic in pop: products[ethic] = E.multiply(pop[ethic], E.of(coefficients[ethic])) var worship_rate: float = 0.0
cached = {"population": pop.duplicate(), "coefficients": coefficients, "products": products} 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 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 return result
static func coefficients(world, rules: Dictionary, id: String, modifiers: Dictionary) -> Dictionary: 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: for id in ids:
reconcile_ids.append(id) reconcile_ids.append(id)
if rules[id].fights and not reconcile_ids.has(rules[id].previous): reconcile_ids.append(rules[id].previous) 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) 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 copied: Dictionary = {}
var plans: Dictionary = {} var plans: Dictionary = {}
var colonies: Array = [] 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 = {} if (not target.has("civilization_stats")): target.civilization_stats = {}
ids.sort() ids.sort()
for id in ids: 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 budget_timer != null and not await budget_timer.pause(): return {}
if ((not rules.has(id)) or expired[id].is_empty()): continue if ((not rules.has(id)) or expired[id].is_empty()): continue
var plan: Dictionary = new_plan(id, year) 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 settlers: Dictionary = {}
var stage_rates: Dictionary = coefficients(world, rules, id, modifiers) var stage_rates: Dictionary = coefficients(world, rules, id, modifiers)
for ethic in triggered: 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 probability: Dictionary = stage_rates[ethic]
var prefix: String = id + ":" + ethic var prefix: String = id + ":" + ethic
var pro: Variant = rate(target, prefix + ":pro", triggered[ethic], probability.pro) 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)) triggered[ethic] = E.subtract(triggered[ethic], E.add(bounded.pro, bounded.deg))
var profile_transitions: int = world.profile_start() var profile_transitions: int = world.profile_start()
add(triggered, progress(world, target, rules, budget, plan, successes)) 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) fall(target, plan, rules, failures)
world.profile_end("civilization_transitions", profile_transitions) world.profile_end("civilization_transitions", profile_transitions)
if rules[id].fights: if rules[id].fights:
var profile_wars: int = world.profile_start() 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 warriors_total: Variant = total(warriors)
var triggered_total: Variant = total(triggered) var triggered_total: Variant = total(triggered)
var remaining_total: Variant = total(remaining[id]) 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) warriors = QuantityMath.allocate_whole_with_total(warriors, wars, warriors_total)
for ethic in warriors: triggered[ethic] = E.subtract(triggered[ethic], warriors[ethic]) for ethic in warriors: triggered[ethic] = E.subtract(triggered[ethic], warriors[ethic])
triggered_total = E.subtract(triggered_total, wars) 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}) # Integrate the four equally weighted battle outcomes directly.
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"))) # Their expected coefficients are wins=17/36, deaths=1/9,
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)) # total losses=29/18, and collateral=40/3 per battle. Rate
var wins_requested: Variant = rate(target, E.add(id, ":wins"), win_expected, 1) # 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 winners: Dictionary = take(world, warriors, wins_requested, wars)
var winner_total: Variant = E.floor_value(E.minimum(E.maximum(0, 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) 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: 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)) 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) warriors_total = E.subtract(warriors_total, attackers_lost_total)
add(triggered, progress(world, target, rules, budget, plan, winners, winner_total)) add(triggered, progress(world, target, rules, budget, plan, winners, winner_total))
triggered_total = total(triggered) triggered_total = total(triggered)
if budget_timer != null and not await budget_timer.pause(): return {}
fall(target, plan, rules, attackers_lost) fall(target, plan, rules, attackers_lost)
remaining_total = total(remaining[id]) 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 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) var victims: Dictionary = take(world, triggered, sides.due, triggered_total)
triggered_total = E.subtract(triggered_total, sides.due) triggered_total = E.subtract(triggered_total, sides.due)
add(victims, consume_untriggered(world, target, plan, remaining[id], sides.later, year, remaining_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) fall(target, plan, rules, victims)
add(triggered, warriors) add(triggered, warriors)
triggered_total = E.add(triggered_total, warriors_total) 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) 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) target.civilization_stats.wars = E.add(target.civilization_stats.get("wars", 0), wars)
world.profile_end("wars", profile_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 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) var victims: Dictionary = take(world, retry, sides.due, retry_total)
add(victims, consume_untriggered(world, target, plan, remaining[id], sides.later, year, remaining_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) fall(target, plan, rules, victims)
target.civilization_stats.collateral = E.add(target.civilization_stats.get("collateral", 0), total(victims)) target.civilization_stats.collateral = E.add(target.civilization_stats.get("collateral", 0), total(victims))
var incoming: Dictionary = {} var incoming: Dictionary = {}
var planetary: Dictionary = {} var planetary: Dictionary = {}
for id in plans: 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 {} if budget_timer != null and not await budget_timer.pause(): return {}
var plan: Dictionary = plans[id] var plan: Dictionary = plans[id]
var products: Dictionary = {} var products: Dictionary = {}
for destination in plan.outputs: 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 pop: Dictionary = plan.outputs[destination]
var count: Variant = total(pop) var count: Variant = total(pop)
if rules.has(destination): 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) 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}] expired[id] = [{"amount": plan.amount, "from": plan.from, "to": E.maximum(plan.from, plan.to), "products": products}]
world.profile_end("civilization_prepare", profile_started) 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: static func finish(world, target: Dictionary, transaction: Dictionary, year: Variant, rules: Dictionary, budget_timer: RefCounted = null) -> void:
var profile_started: int = world.profile_start() 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) target.civilization_stats.worship = worship(world, target, rules)
world.profile_end("civilization_finish", profile_started) world.profile_end("civilization_finish", profile_started)
return return
target.ethics = transaction.remaining var remaining: Dictionary = transaction.remaining
for id in transaction.incoming: var incoming: Dictionary = transaction.incoming
if (not target.ethics.has(id)): target.ethics[id] = {} for id in remaining:
add(target.ethics[id], transaction.incoming[id]) 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. # Space civilizations that fall back to planetary stages need a stellar host.
var physical: Dictionary = world.ledger.physical_amounts(target) var physical: Dictionary = world.ledger.physical_amounts(target)
var hosts: Dictionary = {} var hosts: Dictionary = {}
@@ -399,6 +428,16 @@ static func colonize(world, target: Dictionary, colonies: Array, year: Variant,
var requests: Dictionary = {} var requests: Dictionary = {}
for i in colonies.size(): requests[str(i)] = total(colonies[i].population) for i in colonies.size(): requests[str(i)] = total(colonies[i].population)
var granted: Dictionary = QuantityMath.allocate_whole(requests, total(stock)) 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(): for i in colonies.size():
if budget_timer != null and not await budget_timer.pause(): return if budget_timer != null and not await budget_timer.pause(): return
var colony: Dictionary = colonies[i] var colony: Dictionary = colonies[i]
@@ -409,21 +448,40 @@ static func colonize(world, target: Dictionary, colonies: Array, year: Variant,
for id in targets: for id in targets:
var amount: Variant = targets[id] var amount: Variant = targets[id]
if E.le(amount, 0): continue if E.le(amount, 0): continue
var slots: Dictionary = {"": target.amount.get(id, 0)} var slots: Dictionary = slots_by_resource[id]
for owner in target.binds: slots[owner] = target.binds[owner].get(id, 0)
var taken: Dictionary = QuantityMath.allocate_whole(slots, amount) 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) stock[id] = E.subtract(stock[id], amount)
for owner in taken: for owner in taken:
if E.le(taken[owner], 0): continue if E.le(taken[owner], 0): continue
if E.eq(owner, ""): target.amount[id] = E.subtract(target.amount[id], taken[owner]) slots[owner] = E.subtract(slots.get(owner, 0), taken[owner])
else: target.binds[owner][id] = E.subtract(target.binds[owner][id], taken[owner]) debits[id][owner] = E.add(debits[id].get(owner, 0), taken[owner])
var created: Variant = world.ledger.credit(target, colony.stage, taken[owner]) credit_events.append({"stage": colony.stage, "owner": owner, "amount": taken[owner]})
if E.ne(owner, ""): batch_additions[colony.stage] = E.add(batch_additions.get(colony.stage, 0), taken[owner])
target.amount[colony.stage] = E.subtract(target.amount[colony.stage], created) if not additions.has(colony.stage): additions[colony.stage] = {}
target.binds[owner][colony.stage] = E.add(target.binds[owner].get(colony.stage, 0), created) add(additions[colony.stage], population)
world.batches.append_batch(target, colony.stage, amount, year, year)
if (not target.ethics.has(colony.stage)): target.ethics[colony.stage] = {}
add(target.ethics[colony.stage], population)
target.civilization_stats.colonies = E.add(target.civilization_stats.get("colonies", 0), count) 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) 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"] 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: static func is_space_civilization(id: String, rules: Dictionary) -> bool:
return (rules.has(id) and E.ne(str(rules[id].home), "")) return (rules.has(id) and E.ne(str(rules[id].home), ""))
# Preserve any existing ethics. New populations start equally distributed; # Keep one six-value float profile per stage. Absolute population remains in
# reductions (including blasts) are proportional and never leave negative counts. # 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: static func reconcile(world, target: Dictionary, rules: Dictionary, physical: Dictionary = {}, only_ids: Array = []) -> void:
if (not target.has("ethics")): if (not target.has("ethics")):
target.ethics = {} target.ethics = {}
@@ -19,22 +76,10 @@ static func reconcile(world, target: Dictionary, rules: Dictionary, physical: Di
for id in ids: for id in ids:
if not rules.has(id): continue if not rules.has(id): continue
var count: Variant = physical.get(id, 0) 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, {}) 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): if not cached.is_empty() and E.eq(cached.count, count) and E.eq(cached.population, current):
continue 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 target.ethics[id] = current
world.ledger.reconciled_ethics[id] = {"count": count, "population": current.duplicate()} 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) detach_space_civilizations(target, rules)
var physical: Dictionary = world.ledger.physical_amounts(target) var physical: Dictionary = world.ledger.physical_amounts(target)
reconcile(world, target, rules, physical) reconcile(world, target, rules, physical)
var ethical_totals: Dictionary = physical.duplicate()
var budget: Dictionary = stellar_budget(world, target, rules, physical) var budget: Dictionary = stellar_budget(world, target, rules, physical)
var remaining: Dictionary = budget.capacity.duplicate() var remaining: Dictionary = budget.capacity.duplicate()
var remaining_homes: Dictionary = budget.habitats.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 target.amount[id] = supported
world.ledger.credit(target, previous, excess) world.ledger.credit(target, previous, excess)
world.batches.append_batch(target, previous, excess, year, year) world.batches.append_batch(target, previous, excess, year, year)
var removed: Dictionary = QuantityMath.allocate_whole(target.ethics[id], excess) var previous_count: Variant = ethical_totals.get(previous, physical.get(previous, target.amount.get(previous, 0)))
if (not target.ethics.has(previous)): var previous_profile: Dictionary = target.ethics.get(previous, empty_profile())
target.ethics[previous] = {} var source_profile: Dictionary = target.ethics.get(id, empty_profile())
for ethic in ETHICS: target.ethics[previous] = merge_profiles(previous_profile, previous_count, source_profile, excess)
target.ethics[id][ethic] = E.subtract(target.ethics[id][ethic], removed[ethic]) ethical_totals[id] = supported
target.ethics[previous][ethic] = E.add(target.ethics[previous].get(ethic, 0), removed[ethic]) 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"): if E.eq(previous, "C1.9"):
var stars: Dictionary = {} var stars: Dictionary = {}
var current_physical: Dictionary = world.ledger.physical_amounts(target) 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. # Fractional frame updates cannot change capacity or populations.
var processed_year: Variant = SimulationTime.processed_year(datetime) var processed_year: Variant = SimulationTime.processed_year(datetime)
if E.gt(processed_year, world.state.time.year): 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() var profile_initialize: int = world.profile_start()
initialize_life_batches() initialize_life_batches()
world.profile_end("initialize", profile_initialize) world.profile_end("initialize", profile_initialize)
var previous: Dictionary = world.state var previous: Dictionary = world.state
if budget != null: budget.call("set_phase", "deep_copy_and_coarsen")
var next: Dictionary = previous.duplicate_deep() var next: Dictionary = previous.duplicate_deep()
next.version = SimulationTime.STATE_VERSION next.version = SimulationTime.STATE_VERSION
next.speed = clampi(E.integer(previous.speed), 1, Global.SPEED_LIMIT) 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 # Apply direct completions at the original next-generation boundary, so
# binding transfers, the blast environment and capacity checks keep order. # binding transfers, the blast environment and capacity checks keep order.
if budget != null and not await budget.pause(): return false 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() var completed_now: Dictionary = completed.duplicate()
completed.clear() completed.clear()
if context.is_empty(): if context.is_empty():
@@ -132,7 +135,9 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
var fractions: Dictionary = {} var fractions: Dictionary = {}
var outputs: Dictionary = {} var outputs: Dictionary = {}
var current_quantities: Dictionary = quantities 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) 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) 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 if budget != null and budget.cancelled: return false
var profile_products: int = world.profile_start() 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 affected[id] = true
for held in target.binds.get(id, {}): affected[held] = true for held in target.binds.get(id, {}): affected[held] = true
for successor in outputs[id]: affected[successor] = 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 incoming: Dictionary = world.ledger.transfer_evolution(target, fractions, outputs)
var new_owners: Dictionary = {} 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. # 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 environment: Dictionary = world.environment.stellar_environment(target) if E.gt(blast_power, 0) else {}
var resolution: Variant = world.batches.temporal_resolution(target) var resolution: Variant = world.batches.temporal_resolution(target)
if budget != null: budget.call("set_phase", "transfer_and_newborns")
for id in expired: for id in expired:
for part : Dictionary in expired[id]: for part : Dictionary in expired[id]:
for successor in part.products: for successor in part.products:
@@ -214,6 +221,7 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
world.profile_end("newborn_batches", profile_newborn) world.profile_end("newborn_batches", profile_newborn)
new_owners[successor] = E.add(new_owners.get(successor, 0), amount) new_owners[successor] = E.add(new_owners.get(successor, 0), amount)
world.ledger.accept_transfers(target, incoming) world.ledger.accept_transfers(target, incoming)
if budget != null: budget.call("set_phase", "finish_and_capacity")
world.ledger.bind_new_owners(target, new_owners) world.ledger.bind_new_owners(target, new_owners)
for owner in new_owners: for owner in new_owners:
for resource in world.catalog.entities[owner].bind: for resource in world.catalog.entities[owner].bind:
+14
View File
@@ -5,18 +5,32 @@ var slice_usec: int = 40000
var deadline: int var deadline: int
var cancelled: bool = false var cancelled: bool = false
var waiting_usec: int = 0 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): func _init(scene_tree: SceneTree, milliseconds: int = 40):
tree = scene_tree tree = scene_tree
slice_usec = milliseconds * 1000 slice_usec = milliseconds * 1000
deadline = Time.get_ticks_usec() + slice_usec deadline = Time.get_ticks_usec() + slice_usec
checkpoint_usec = deadline - slice_usec
func set_phase(value: String) -> void:
phase = value
func pause() -> bool: func pause() -> bool:
var keep_alive: RefCounted = self var keep_alive: RefCounted = self
if cancelled: return false 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: if Time.get_ticks_usec() >= deadline:
var waiting_from: int = Time.get_ticks_usec() var waiting_from: int = Time.get_ticks_usec()
await tree.process_frame await tree.process_frame
keep_alive.waiting_usec += Time.get_ticks_usec() - waiting_from keep_alive.waiting_usec += Time.get_ticks_usec() - waiting_from
deadline = Time.get_ticks_usec() + slice_usec deadline = Time.get_ticks_usec() + slice_usec
checkpoint_usec = Time.get_ticks_usec()
return not keep_alive.cancelled return not keep_alive.cancelled
+60 -2
View File
@@ -123,11 +123,69 @@ static func sum(pop: Dictionary) -> Variant:
static var _split_keys: Dictionary = {} 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: static func split(remainders: Dictionary, key: String, count: Variant, weights: Dictionary) -> Dictionary:
if E.le(count, 0): return {} if E.le(count, 0): return {}
return split_arrays(remainders, key, count, weights.keys(), weights.values()) 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 = {} var result: Dictionary = {}
if E.le(count, 0): return result if E.le(count, 0): return result
var sum_weights: Variant = 0 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 # Fractional identity ratios still round-trip through parts/make in the
# original algorithm. Retain them to preserve ties and saved errors exactly. # original algorithm. Retain them to preserve ties and saved errors exactly.
var normalized: Array = positives 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) normalized = E.mul_div_many(count, positives, positive_sum)
var order: Array = [] var order: Array = []
var rests: 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: for id in rules:
if E.ne(str(rules[id].home), ""): stages.append(id) if E.ne(str(rules[id].home), ""): stages.append(id)
stages.sort() 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 var weight: Variant = 1
for id in stages: for id in stages:
if not populated.has(id):
weight = E.multiply(weight, 2)
continue
var population: Dictionary = previous.get("ethics", {}).get(id, {}) var population: Dictionary = previous.get("ethics", {}).get(id, {})
var count: Variant = 0 for ethic in population:
for n in population.values(): count = E.add(count, n) var proportion: float = maxf(0.0, float(population[ethic]))
if E.gt(count, 0): for i in TRENDS.size():
for ethic in population: rates[i] = E.add(rates[i], E.multiply(E.multiply(proportion, WEIGHTS[ethic][i]), weight))
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))
weight = E.multiply(weight, 2) weight = E.multiply(weight, 2)
for i in TRENDS.size(): for i in TRENDS.size():
var name: String = TRENDS[i] var name: String = TRENDS[i]