Add comprehensive tests and optimizations for simulation and UI performance
- Introduced a new benchmark save script to create a stress test scenario with extreme values for simulation performance. - Added a full UI benchmark script to measure frame rates and performance metrics during UI interactions. - Implemented tests for coarse generations to ensure population transitions are handled correctly. - Created tests for extra parameters to validate the loading and application of configuration settings. - Developed simulation optimization tests to verify the efficiency of population drift and rounding mechanisms. - Added split optimization tests to ensure accurate allocation and rounding behavior in population management. - Included startup parameter tests to confirm the correct initialization of global parameters before scene readiness. - Enhanced the audit rounding ledger to compare selective and full rounding results for consistency.
This commit is contained in:
+109
@@ -1,5 +1,24 @@
|
||||
# Проверки поэтапного переноса из Owniverse.U
|
||||
|
||||
`test_simulation_optimizations.gd` сравнивает прямые формулы дрейфа с прежней
|
||||
матрицей, включая целые количества больше E27 и накопленные остатки.
|
||||
Тестовый ledger сверяет выборочное округление с полным на каждом поколении
|
||||
нагруженной симуляции. `test_ui_updates.gd` проверяет обновление по публикации,
|
||||
выбору объекта и изменению параметров мира, а также пропуск повторных расчётов
|
||||
для неизменившегося состояния.
|
||||
|
||||
Динамический размер партий проверяется в `test_batch_boundaries.gd`: скорости
|
||||
1–6, сохранение больших целых количеств и дробных остатков, повторное
|
||||
укрупнение и снижение скорости. Высокие скорости допускают приближение
|
||||
времени переходов; сравнение со старым состоянием целиком не является
|
||||
проверкой эквивалентности такого режима.
|
||||
|
||||
`test_coarse_generations.gd` проверяет отсутствие повторного перехода C0.0,
|
||||
C0.1 и C0.2 внутри одного интервала, старое смещение сетки партий,
|
||||
продолжение эволюции в следующем интервале, большие целые количества и
|
||||
дробные остатки. `test_frame_evolution.gd` сравнивает целый и разбитый по
|
||||
кадрам расчёт, включая возобновление сохранённого незавершённого шага.
|
||||
|
||||
## Производительность на сохранении
|
||||
|
||||
```sh
|
||||
@@ -205,3 +224,93 @@ Stage вызывает `evolute_frame`: транзакция получает б
|
||||
15.9 FPS (максимальный кадр 98 мс), большие количества 17.5 FPS (106 мс),
|
||||
много партий 16.3 FPS (97 мс).
|
||||
Это FPS отрисовки, а не число завершённых транзакций симуляции в секунду.
|
||||
|
||||
Индекс партий переиспользуется внутри одной транзакции, отдельно для каждого
|
||||
массива партий. Замена массива при изъятии или переход к другому состоянию
|
||||
сбрасывает индекс. Сортировка выполняется при нарушении порядка новых окон,
|
||||
а не после каждого добавления в уже существующее окно. `test_batch_boundaries`
|
||||
проверяет объединение, раннюю вставку, замену массива и изоляцию состояний.
|
||||
На копии текущего сохранения с 785 партиями шесть шагов на скорости 6:
|
||||
среднее время 167 → 145 мс; полное состояние после шести шагов совпало точно.
|
||||
|
||||
Следующий этап исключает арифметическую работу при прибавлении нуля,
|
||||
временные знаменатели при умножении целых блоков и нулевые вклады этик.
|
||||
На той же копии сохранения среднее время шага скорости 6 снизилось с 145
|
||||
до 138 мс; полный результат после шести шагов совпал с исходным состоянием.
|
||||
Тест появления разума задаёт свой progress явно, поскольку проверяет
|
||||
сохранение хозяина планеты, а не актуальное значение коэффициента баланса.
|
||||
|
||||
Следующие шесть оптимизаций: однократное изъятие умирающих партий;
|
||||
поверхностные снимки неизменяемых таблиц связей; пропуск планирования
|
||||
цивилизаций без переходов; кэш нормализованных этик и произведений поклонения;
|
||||
пропуск укрупнения для нуля/одной партии; кэш вероятностей переходов.
|
||||
Кэши принадлежат миру и не входят в сохранение. Изменение количества,
|
||||
этик, коэффициентов правил или модификаторов обнаруживается по значениям.
|
||||
Суммирование поклонения сохраняет прежний порядок дробных операций.
|
||||
`test_simulation_optimizations` проверяет также обновление этих кэшей.
|
||||
|
||||
Полное состояние совпало с реализацией до этих шести изменений после каждого
|
||||
из восьми шагов на скоростях 1, 4 и 6, отдельно на большом фрагментированном
|
||||
сценарии и на диагностической копии сохранения (48 сравнений).
|
||||
На M4 Pro шесть шагов по 0.1 секунды скорости 6: 103.3 мс первый,
|
||||
70.9–76.0 мс последующие; пропускная способность без UI 1.278.
|
||||
Замер 600 кадров с реальным UI: 12.24 FPS, p95 97.16 мс, максимум 104.86 мс,
|
||||
520 завершённых транзакций, отношение обработанного времени к поступившему
|
||||
1.057. Замер включает сокращение накопленного отставания сохранения;
|
||||
это результат конкретного сценария на Mac, а не гарантия для Android.
|
||||
|
||||
Адаптивный шаг на скоростях 4–6 выбирает 0.1, 0.2 или 0.5 реальной секунды.
|
||||
Долг больше двух шагов повышает ступень, меньше половины шага понижает её.
|
||||
Шаг ограничен доступным запрошенным временем. Скорости 1–3 сохраняют 1/60;
|
||||
смена скорости сбрасывает ступень. Возобновляемая транзакция сохраняет точную
|
||||
границу из checkpoint, адаптация применяется к следующей транзакции.
|
||||
Бюджет кадра остаётся 40 мс; ширина временных партий не меняется.
|
||||
`test_frame_evolution` проверяет ступени, гистерезис, сохранение долга,
|
||||
переключение скорости, возобновление и совпадение с прямым расчётом того же шага.
|
||||
На свежей диагностической копии сохранения с долгом 407.79 млн лет:
|
||||
600 кадров реального UI на M4 Pro дали 18.05 FPS (p95 92.91 мс,
|
||||
максимум 122.45 мс), поступило 3.335 млн лет, обработано 6.480 млн:
|
||||
пропускная способность 1.943 вместо 0.787 при фиксированном шаге 0.1.
|
||||
Разбиение времени влияет на дискретные производство и глобальные параметры;
|
||||
адаптивные шаги не обещают совпадения результата с последовательностью 0.1.
|
||||
|
||||
Дипломатический drift использует шесть взвешенных вкладов вместо матрицы
|
||||
пар этик: вес = сумма вкладов + 195 × свой вклад − противоположный вклад.
|
||||
Для колониальной матрицы коэффициенты 9, 951 и 5 соответственно.
|
||||
Целая численность и механизм сохранения ошибок распределения не меняются,
|
||||
но порядок операций над дробными весами отличается от матричного варианта.
|
||||
Тест сравнивает с матрицей на точно представимых коэффициентах и проверяет
|
||||
целочисленное сохранение популяций до 1e40 на десятичных коэффициентах.
|
||||
Пустые популяции пропускают drift; численность выходов суммируется один раз.
|
||||
На профильной копии при шаге 0.5 число вызовов снизилось 1038 → 849
|
||||
за шесть шагов, но время drift осталось около 303 мс суммарно;
|
||||
заметного ускорения полного расчёта в этом сценарии не зафиксировано.
|
||||
|
||||
`test_split_optimizations.gd` сравнивает распределение и все сохранённые
|
||||
остатки с прежним scalar split в последовательных вызовах: пустые ветви,
|
||||
единственная ветвь, равные веса, десятичные веса, положительные/отрицательные
|
||||
остатки, количества до 1e80. Также проверяет grouped mul_div против scalar.
|
||||
Split переиспользует разложение количества и знаменателя между ветвями,
|
||||
кэширует ключи остатков (не более 512 наборов) и получает веса дрифта массивом.
|
||||
Вторая нормализация пропускается только при целых долях с нужной суммой:
|
||||
при дробях даже единичное отношение может изменить округление, поэтому
|
||||
прежний путь сохраняется. Единственная положительная ветвь обрабатывается
|
||||
напрямую только при целом количестве, неотрицательных весах и нулевых остатках.
|
||||
На профильной копии время drift за шесть шагов 0.5 снизилось примерно
|
||||
303 → 287 мс, но общее время осталось около 1.31 секунды.
|
||||
|
||||
Обычный drift сокращает целое количество из отношения
|
||||
count*weight/(200*count), колониальный — из отношения с 1000.
|
||||
Целая часть делится на малый знаменатель; для дроби используются исходные
|
||||
масштабированные числитель и знаменатель, чтобы сохранить float округление.
|
||||
`test_split_optimizations` проверяет совпадение со scalar mul_div до 1e80.
|
||||
Производство новорождённых сохраняет только created_from → amount вместо
|
||||
глубокой копии партий. Повторное мгновенное рождение с теми же сроками,
|
||||
длительностью и сеткой сразу добавляется в найденную партию.
|
||||
Индекс ближайшей границы позволяет повторным проходам пропускать будущие
|
||||
партии; замена массива, добавление и масштабирование сбрасывают индекс.
|
||||
Тест границ проверяет объединение и сброс обоих индексов.
|
||||
Адаптивный шаг дополнительно учитывает сглаженное отношение реальной
|
||||
длительности транзакции к обработанному времени. Если отношение выше 0.85
|
||||
и долг больше одного шага, ступень увеличивается раньше порога двух шагов.
|
||||
Долг и граница сохраняются как прежде; измерения являются временным кэшем.
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
# Все объекты, скорость 6
|
||||
|
||||
Создано скриптом `tests/create_full_benchmark_save.gd`. Пароль нативного сохранения: `qwerty`.
|
||||
Генератор не читает и не перезаписывает сохранение игрока.
|
||||
|
||||
Начальное состояние: 151 запись каталога (включая группы), все 44 стадии правил цивилизаций, все шесть этик на каждой стадии, 1000 когорт (по десять на сущность с временными партиями). У большинства обычных объектов количество равно 6e60, у стадий цивилизаций — 6e20. Пространство и водород имеют количество 1e90, гелий и металлы — 1e80, энергия — 1e100. Возраст — 1e15 лет. Запасов достаточно для требований продвинутых стадий звезд и структур. Оставшиеся сроки жизни когорт покрывают весь срок жизни сущностей, поэтому переходы происходят во время замера.
|
||||
|
||||
Это искусственный экстремальный сценарий, а не прогноз для обычного сохранения.
|
||||
|
||||
Машина: Apple M4 Pro, Godot 4.7.2, рендерер Metal Mobile.
|
||||
|
||||
| Замер | Результат |
|
||||
|---|---:|
|
||||
| Headless, скорость 6, шесть шагов по 0,5 секунды | 943,936–1221,992 мс на шаг |
|
||||
| Среднее время headless-шага | 1122,287 мс |
|
||||
| Пропускная способность headless | 0,446 секунды симуляции на секунду CPU |
|
||||
| Настоящий UI, страница цивилизаций, 600 кадров | 17,38 FPS |
|
||||
| Средний / p95 / максимальный кадр UI | 57,55 / 99,78 / 116,94 мс |
|
||||
| Завершенные транзакции | 23 |
|
||||
| Запрошенный прирост игрового времени | 3464662,267 лет |
|
||||
| Опубликованный прирост игрового времени | 1080000 лет |
|
||||
| Пропускная способность с UI | 0,3117 |
|
||||
| Накопленный долг за замер | 2384662,267 игровых лет (23,85 реальных секунд на скорости 6) |
|
||||
| Записей каталога осталось в конце | 151 |
|
||||
|
||||
Динамический шаг достигает предела в 0,5 секунды. Рендеринг идет быстрее 5 FPS, но симуляция не успевает за заданным темпом в этом сценарии. На обработку поколений приходится примерно 94% времени CPU. Подготовка цивилизаций занимает в среднем 634 мс на шаг, в том числе дрифт этик — 218 мс, войны — 124 мс; колонизация — 121 мс на шаг. Интервалы профилировщика вложенные и частично перекрываются.
|
||||
|
||||
## Сравнение оптимизации дрифта (2026-10-05)
|
||||
|
||||
Замеры сделаны на одном сгенерированном сохранении и одной машине до и после прямого распределения потоков для обычного и колониального дрифта, а также кеширования ставок дипломатии в фиксированной точке. Каждый headless-запуск выполняет шесть последовательных шагов по 0,5 секунды на каждой скорости.
|
||||
|
||||
| Замер | До | После |
|
||||
|---|---:|---:|
|
||||
| Средний headless-шаг на скорости 6 | 1122,287 мс | 1018,559 мс |
|
||||
| Пропускная способность headless на скорости 6 | 0,446 | 0,491 |
|
||||
| Дрифт этик, сумма интервалов за шесть шагов | 1,338 с | 0,643 с |
|
||||
| UI, FPS за 600 кадров | 17,38 | 17,04 |
|
||||
| Средний / p95 / максимальный кадр UI | 57,55 / 99,78 / 116,94 мс | 58,70 / 102,67 / 114,30 мс |
|
||||
| Завершенные UI-транзакции | 23 | 25 |
|
||||
| Запрошено / опубликовано игровых лет в UI | 3464662 / 1080000 | 3533627 / 1180000 |
|
||||
| Пропускная способность симуляции с UI | 0,3117 | 0,3339 |
|
||||
|
||||
Средний headless-шаг ускорился примерно на 9,2%, а интервал профиля дрифта этик сократился примерно на 52%. Частота кадров UI практически не изменилась (в этом запуске снизилась примерно на 2%), при этом завершилось больше транзакций и выросла доля опубликованного игрового времени. На скорости 6 стресс-сценарий все еще отстает от реального времени: пропускная способность — около 0,49 секунды симуляции на секунду CPU в headless и 0,33 с UI. Самыми затратными остаются планирование поколений и войны.
|
||||
|
||||
Прямые пути сохраняют целое население, но разносят малые целочисленные потоки по нескольким последовательным переходам с помощью остатков для каждого источника. Поэтому распределение в отдельном переходе отличается от независимого округленного распределения по матрице 6×6. Ставки дипломатии округляются до сетки фиксированной точки с шагом 1e-9. Целевая регрессия: 769 проверок эволюции цивилизаций, оптимизаций симуляции, границ партий, укрупненных поколений и эволюции кадров; все пройдены.
|
||||
|
||||
## Последующая оптимизация подготовки поколений и кешей (2026-10-05)
|
||||
|
||||
На той же нагрузке среднее время headless-шага на скорости 6 снизилось с 1018,559 до 1005,138 мс (примерно на 1,3%). Пропускная способность headless изменилась с 0,491 до 0,497. Суммарные интервалы `generations` близки: 5,703 с и 5,629 с за шесть шагов. Подготовка цивилизаций остается самой затратной измеренной подфазой — 3,130 с. UI также почти не изменился: 17,04 → 17,09 FPS, пропускная способность симуляции — 0,3339 → 0,3351. Улучшение небольшое: арифметика населения в поколениях по-прежнему дорога при сотнях активных когорт и множестве правил цивилизаций.
|
||||
|
||||
Профиль теперь отдельно показывает `generation_withdrawal` и `generation_products`. Первый интервал занимает 55,5 мс, второй — 120,2 мс за шесть headless-шагов на скорости 6. Эти значения нельзя складывать с общим временем поколений, поскольку интервалы профилировщика вложенные.
|
||||
|
||||
## Инкрементальная обработка партий новорожденных (2026-10-05)
|
||||
|
||||
Удалены снимки когорт до и после рождения и последующий полный обход когорт. `append_batch` теперь сразу накапливает поправку на возраст новорожденных при добавлении частей когорт. Финальный замер после повторного использования сумм завершившихся партий в пределах поколения дал 1028,991 мс на headless-шаг скорости 6 и пропускную способность 0,486. В повторных прогонах этой же версии получилось 1005–1011 мс, поэтому при выборке всего в шесть шагов разброс велик, и устойчивое ускорение утверждать нельзя. В UI получилось 17,05 FPS, среднее время кадра 58,66 мс и пропускная способность 0,3342. Новый профиль показывает 319 мс на обработку партий новорожденных за шесть шагов. Это изменение пока не демонстрирует снижения общего времени на этом сохранении; его потенциальная польза относится прежде всего к сценариям с гораздо большим числом когорт на сущность, где прежние обходы росли пропорционально числу когорт.
|
||||
|
||||
Повторить замеры из родительской папки проекта:
|
||||
|
||||
```sh
|
||||
godot --headless --path Owniverse.GI --script res://tests/create_full_benchmark_save.gd
|
||||
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
|
||||
```
|
||||
@@ -0,0 +1,48 @@
|
||||
extends SceneTree
|
||||
const E = preload("res://world/owniverse/math/exact.gd")
|
||||
func _initialize(): call_deferred("run")
|
||||
func restore(value):
|
||||
if value is float and value == floorf(value) and absf(value) <= 9007199254740991: return int(value)
|
||||
if value is Dictionary:
|
||||
for k in value: value[k] = restore(value[k])
|
||||
elif value is Array:
|
||||
for i in value.size(): value[i] = restore(value[i])
|
||||
return value
|
||||
func run():
|
||||
var payload = E.decode(restore(JSON.parse_string(FileAccess.get_file_as_string("res://tests/artifacts/all_objects_speed6.adventure.json"))))
|
||||
var scene = 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"))
|
||||
root.add_child(scene)
|
||||
await 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: Variant = stage.owniverse.frame_clock.get("datetime", stage.owniverse.state.time.datetime)
|
||||
var initial_published: Variant = stage.owniverse.state.time.datetime
|
||||
stage.owniverse.profile_enabled = true
|
||||
stage.measuring = true
|
||||
for i in 600: await process_frame
|
||||
stage.measuring = false
|
||||
var samples = stage.frame_samples
|
||||
var total: float = 0
|
||||
for sample in samples: total += sample
|
||||
samples.sort()
|
||||
print("Saved UI: FPS=%.2f mean=%.2fms p95=%.2fms max=%.2fms commits=%d" % [1000.0/(total/samples.size()),total/samples.size(),samples[int(ceil(samples.size()*0.95))-1],samples.back(),stage.owniverse.frame_commits])
|
||||
var requested_advance: Variant = E.subtract(stage.owniverse.frame_clock.datetime, initial_requested)
|
||||
var published_advance: Variant = E.subtract(stage.owniverse.state.time.datetime, initial_published)
|
||||
print("Requested years advanced=", requested_advance, " published=", published_advance, " throughput=", E.divide(published_advance, requested_advance))
|
||||
print("UI CPU profile: ", stage.owniverse.profile)
|
||||
var remaining_objects: int = 0
|
||||
for id in stage.owniverse.catalog.entities:
|
||||
if E.gt(stage.owniverse.ledger.physical_amount(stage.owniverse.state, id), 0): remaining_objects += 1
|
||||
print("Remaining objects: ", remaining_objects)
|
||||
print("Final adaptive step: ", stage.owniverse.frame_step_seconds)
|
||||
for entity in stage.owniverse.catalog.entities.values(): entity.free()
|
||||
stage.owniverse.free()
|
||||
scene.free()
|
||||
for i in 3: await process_frame
|
||||
quit()
|
||||
@@ -0,0 +1 @@
|
||||
uid://3xjh23kg5vrn
|
||||
@@ -27,6 +27,8 @@ func run():
|
||||
quit(1)
|
||||
return
|
||||
var initial: Dictionary = E.decode(restore_json_integers(data)).state
|
||||
var validation: String = root.get_node("SaveManager").validate_data(E.decode(restore_json_integers(data)))
|
||||
print("Save validation: ", "OK" if validation.is_empty() else validation)
|
||||
var delta: float = args[1].to_float() if args.size() > 1 else 1.0 / 60.0
|
||||
if not is_finite(delta) or delta <= 0:
|
||||
quit(1)
|
||||
@@ -36,12 +38,18 @@ func run():
|
||||
world.catalog.load_definitions(root.get_node("Global").OwniverseEntities)
|
||||
world.state = initial.duplicate(true)
|
||||
world.speed = speed
|
||||
world.profile_enabled = args.has("profile")
|
||||
var elapsed: Array = []
|
||||
for cycle in 6:
|
||||
var start: int = Time.get_ticks_usec()
|
||||
world.evolute(delta)
|
||||
elapsed.append((Time.get_ticks_usec() - start) / 1000.0)
|
||||
print("Speed %d, six consecutive %s-second steps, ms: %s" % [speed, delta, elapsed])
|
||||
var total_ms: float = 0.0
|
||||
for sample in elapsed: total_ms += sample
|
||||
var throughput: float = 6.0 * delta / (total_ms / 1000.0)
|
||||
print("Simulation throughput: %.3f real-time seconds processed per CPU second; needs >= 1.0 to keep up" % throughput)
|
||||
if world.profile_enabled: print("CPU profile (usec, calls): ", world.profile)
|
||||
for entity in world.catalog.entities.values(): entity.free()
|
||||
world.free()
|
||||
quit()
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
extends SceneTree
|
||||
## Reproducible stress save; never writes to the player's user:// directory.
|
||||
const E = preload("res://world/owniverse/math/exact.gd")
|
||||
const State = preload("res://world/owniverse/civilization_state.gd")
|
||||
func _initialize(): call_deferred("run")
|
||||
func run():
|
||||
var global: Node = root.get_node("Global")
|
||||
var world = load("res://world/owniverse/owniverse.gd").new()
|
||||
world.catalog.load_definitions(global.OwniverseEntities)
|
||||
world.speed = 6
|
||||
world.state.time = {"datetime": 1000000000000000, "year": 1000000000000000, "tick_remainder": 0}
|
||||
world.state.energy = E.of("1e100")
|
||||
for id in world.catalog.entities:
|
||||
world.state.amount[id] = E.of("6e60")
|
||||
world.state.stats.total_amount[id] = world.state.amount[id]
|
||||
if global.CivilizationRules.has(id): world.state.amount[id] = E.of("6e20")
|
||||
world.state.amount.S = E.of("1e90")
|
||||
world.state.amount.H = E.of("1e90")
|
||||
world.state.amount.He = E.of("1e80")
|
||||
world.state.amount.Met = E.of("1e80")
|
||||
world.ledger.refresh_groups(world.state)
|
||||
State.enforce_capacity(world, world.state, global.CivilizationRules, world.state.time.year)
|
||||
State.reconcile(world, world.state, global.CivilizationRules)
|
||||
var cohorts: int = 0
|
||||
for id in world.catalog.entities:
|
||||
if world.catalog.is_group(id): continue
|
||||
var lifespan: Variant = State.effective_span(world, world.state, id, global.CivilizationRules)
|
||||
if E.le(lifespan, 0): continue
|
||||
world.state.batches[id] = []
|
||||
var width: Variant = E.divide(lifespan, 10)
|
||||
for i in 10:
|
||||
var start: Variant = E.add(world.state.time.year, E.multiply(width, i))
|
||||
var end: Variant = E.add(start, width)
|
||||
var birth: Variant = E.subtract(start, lifespan)
|
||||
world.state.batches[id].append({"amount": E.divide(world.state.amount[id], 10), "created_from": birth, "created_to": E.add(birth, width), "start_year": start, "end_year": end})
|
||||
cohorts += 1
|
||||
world.state.batch_origins[id] = world.state.batches[id][0].created_from
|
||||
world.state.stats.total_amount = world.state.amount.duplicate(true)
|
||||
var manager: Node = root.get_node("SaveManager")
|
||||
var folder: String = ProjectSettings.globalize_path("res://tests/artifacts")
|
||||
DirAccess.make_dir_recursive_absolute(folder)
|
||||
manager.save_directory = folder
|
||||
global.save_plaintext_copy = true
|
||||
var success: bool = manager.save_data("all_objects_speed6", "qwerty", {"state": world.state, "simulation": world.frame_checkpoint(), "ui": {"current_page": 4}})
|
||||
var empty: Array = []
|
||||
for id in world.catalog.entities:
|
||||
if E.le(world.state.amount.get(id, 0), 0): empty.append(id)
|
||||
print("Full save: objects=%d civilization stages=%d cohorts=%d empty=%s success=%s" % [world.catalog.entities.size(), global.CivilizationRules.size(), cohorts, empty, success])
|
||||
for entity in world.catalog.entities.values(): entity.free()
|
||||
world.free()
|
||||
quit(0 if success and empty.is_empty() else 1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://1icskkk5nld8
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
extends "res://world/owniverse/object_ledger.gd"
|
||||
var checks: int = 0
|
||||
var failures: int = 0
|
||||
|
||||
func round_object_allocations(target: Dictionary, affected: Variant = null) -> void:
|
||||
var reference: Dictionary = target.duplicate(true)
|
||||
super.round_object_allocations(reference)
|
||||
super.round_object_allocations(target, affected)
|
||||
checks += 1
|
||||
if not E.eq(target, reference):
|
||||
failures += 1
|
||||
push_error("Selective rounding differs from full rounding")
|
||||
+1
@@ -0,0 +1 @@
|
||||
uid://csew6jyuiwxh0
|
||||
Vendored
+8
@@ -1,4 +1,12 @@
|
||||
extends "res://stage/stage.gd"
|
||||
var actions_refreshes: int = 0
|
||||
|
||||
func tick_ui(delta: float) -> void:
|
||||
super._process(delta)
|
||||
|
||||
func _set_active_page_actions() -> void:
|
||||
actions_refreshes += 1
|
||||
super._set_active_page_actions()
|
||||
|
||||
# Keep scene wiring and display logic, without accessing the player's save.
|
||||
func load_data() -> bool:
|
||||
|
||||
@@ -38,6 +38,43 @@ func dispose(world) -> void:
|
||||
|
||||
func run() -> void:
|
||||
var world = fixture()
|
||||
world.catalog.entities.N.lifespan = 100
|
||||
world.batches.append_batch(world.state, "N", 2, 10, 10)
|
||||
world.batches.append_batch(world.state, "N", 3, 10, 10)
|
||||
check(world.state.batches.N.size() == 1 and E.eq(world.state.batches.N[0].amount, 5), "Repeated insertion merges using the cohort index")
|
||||
world.batches.append_batch(world.state, "N", 1, 0, 0)
|
||||
check(E.eq(world.state.batches.N[0].created_from, 0), "Earlier insertion preserves chronological order")
|
||||
var replaced: Array = world.state.batches.N.duplicate(true)
|
||||
world.state.batches.N = replaced
|
||||
world.batches.append_batch(world.state, "N", 7, 10, 10)
|
||||
check(E.eq(replaced[1].amount, 12), "Replacing cohort array invalidates cached references")
|
||||
var separate: Dictionary = world.state.duplicate(true)
|
||||
world.batches.append_batch(separate, "N", 9, 10, 10)
|
||||
check(E.eq(separate.batches.N[1].amount, 21) and E.eq(world.state.batches.N[1].amount, 12), "Index is isolated between transaction states")
|
||||
dispose(world)
|
||||
world = fixture()
|
||||
for speed in range(1, 7):
|
||||
world.state.speed = speed
|
||||
check(E.eq(world.batches.window_duration(world.state, 100), 2.5 if speed < 4 else pow(10, speed - 2)), "Windows follow selected speed")
|
||||
check(E.eq(world.batches.window_duration(world.state, 5), 5), "Short explosions retain their timing")
|
||||
var huge: Variant = E.of("10000000000000000000000000000000000000000")
|
||||
world.catalog.entities.N.lifespan = 100
|
||||
world.state.batches.N = [
|
||||
{"amount": huge, "start_year": 100, "end_year": 110, "created_from": 0, "created_to": 10, "withdraw_remainder": 0.25},
|
||||
{"amount": 7, "start_year": 120, "end_year": 130, "created_from": 20, "created_to": 30, "withdraw_remainder": 0.5}
|
||||
]
|
||||
world.batches.coarsen(world.state)
|
||||
check(world.state.batches.N.size() == 1, "High speed combines nearby cohorts")
|
||||
check(E.eq(world.state.batches.N[0].amount, E.add(huge, 7)), "Coarsening preserves exact large whole counts")
|
||||
check(E.eq(world.state.batches.N[0].withdraw_remainder, 0.75), "Coarsening preserves withdrawal carry")
|
||||
var combined: Dictionary = world.state.batches.N[0].duplicate(true)
|
||||
world.batches.coarsen(world.state)
|
||||
check(world.state.batches.N[0] == combined, "Coarsening is idempotent")
|
||||
world.state.speed = 1
|
||||
world.batches.coarsen(world.state)
|
||||
check(world.state.batches.N[0] == combined, "Slowing down does not invent finer history")
|
||||
dispose(world)
|
||||
world = fixture()
|
||||
world.state.batch_origins.LGRB = E.of("10357034.80000000074505806")
|
||||
var boundary: Variant = E.of("41215289.79999999701976776")
|
||||
for interval in [[E.subtract(boundary, E.of("2.5")), E.add(boundary, E.of("2.25"))], [boundary, E.add(boundary, 2)]]:
|
||||
@@ -54,5 +91,23 @@ func run() -> void:
|
||||
check(E.eq(total, 100), "Boundary correction preserves all objects")
|
||||
check(E.le(world.state.batches.LGRB.size(), 7), "Finite number of windows")
|
||||
dispose(world)
|
||||
world = fixture()
|
||||
world.batches.append_batch(world.state, "N", 100, 100, 100)
|
||||
var first: Dictionary = world.state.batches.N[0].duplicate(true)
|
||||
world.batches.append_batch(world.state, "N", 7, 100, 100)
|
||||
check(world.state.batches.N.size() == 1 and E.eq(world.state.batches.N[0].amount, 107), "Repeated births merge directly into one window")
|
||||
check(E.eq(world.state.batches.N[0].start_year, first.start_year), "Direct merge preserves transition time")
|
||||
check(world.batches.withdraw_batches(world.state, "N", 0).is_empty(), "Future cohorts are not withdrawn")
|
||||
var untouched: Array = world.state.batches.N
|
||||
check(world.batches.withdraw_batches(world.state, "N", 1).is_empty() and is_same(untouched, world.state.batches.N), "Readiness index skips future cohorts")
|
||||
world.state.batches.N = [{"amount": 7, "start_year": 0, "end_year": 0, "created_from": 0, "created_to": 0}]
|
||||
check(world.batches.withdraw_batches(world.state, "N", 1).size() == 1, "Replacing cohorts invalidates readiness index")
|
||||
world.batches.append_batch(world.state, "N", 3, 100, 100)
|
||||
check(E.eq(world.state.batches.N[0].amount, 3), "Replacing cohorts invalidates instant merge cache")
|
||||
world.batches.withdraw_batches(world.state, "N", 0)
|
||||
world.batches.scale_batches(world.state, "N", 0)
|
||||
world.batches.withdraw_batches(world.state, "N", 1)
|
||||
check(world.state.batches.N.is_empty(), "Scaling invalidates readiness and removes zero cohorts")
|
||||
dispose(world)
|
||||
print(("Batch boundaries: %d checks, %d failures" % [checks, failures]))
|
||||
quit((0 if E.eq(failures, 0) else 1))
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
extends SceneTree
|
||||
const E = preload("res://world/owniverse/math/exact.gd")
|
||||
var checks: int = 0
|
||||
var failures: int = 0
|
||||
|
||||
func _initialize(): call_deferred("run")
|
||||
func check(condition: bool, message: String):
|
||||
checks += 1
|
||||
if not condition:
|
||||
failures += 1
|
||||
push_error(message)
|
||||
|
||||
func fixture(id: String, speed: int):
|
||||
var world = load("res://world/owniverse/owniverse.gd").new()
|
||||
world.catalog.load_definitions(root.get_node("Global").OwniverseEntities)
|
||||
for entity in world.catalog.entities.values():
|
||||
world.state.amount[entity.id] = 0
|
||||
world.state.stats.total_amount[entity.id] = 0
|
||||
if entity.group_as != "":
|
||||
world.state.amount[entity.group_as] = 0
|
||||
world.state.stats.total_amount[entity.group_as] = 0
|
||||
world.state.amount.RD = 1
|
||||
world.state.binds.RD = {id: 40000}
|
||||
world.state.batches[id] = [{"amount": 40000, "start_year": 1, "end_year": 1}]
|
||||
# A saved origin off the new grid must not backdate rounded successors.
|
||||
world.state.batch_origins[id] = 500
|
||||
world.speed = speed
|
||||
world.profile_enabled = true
|
||||
return world
|
||||
|
||||
func dispose(world):
|
||||
for entity in world.catalog.entities.values(): entity.free()
|
||||
world.free()
|
||||
|
||||
func run():
|
||||
for id in ["C0.0", "C0.1", "C0.2"]:
|
||||
var world = fixture(id, 6)
|
||||
world.evolution.evolute_to_datetime(10000)
|
||||
var physical: Dictionary = world.ledger.physical_amounts(world.state)
|
||||
var population: Variant = 0
|
||||
for stage in physical:
|
||||
if world.catalog.entities[stage].group_as == "Planet":
|
||||
population = E.add(population, physical[stage])
|
||||
check(E.eq(population, 40000), "Coarse transitions preserve all whole populations: " + id)
|
||||
for batches: Array in world.state.batches.values():
|
||||
for batch: Dictionary in batches:
|
||||
check(E.gt(batch.start_year, 10000), "Successors cannot expire inside their birth bucket: " + id)
|
||||
if id == "C0.0":
|
||||
check(E.eq(physical.get("C0.0", 0), 38300), "C0.0 attempts evolution once")
|
||||
check(E.eq(physical.get("C0.1", 0), 700), "C0.0 progress rate is applied once")
|
||||
check(E.eq(physical.get("EON5", 0), 1000), "C0.0 degradation rate is applied once")
|
||||
elif id == "C0.1":
|
||||
check(E.eq(physical.get("C0.1", 0), 38300), "C0.1 attempts evolution once")
|
||||
check(E.eq(physical.get("C0.2", 0), 700), "C0.1 successor does not immediately attempt again")
|
||||
else:
|
||||
check(world.profile.civilization_transitions[1] == 1, "Ethical civilization is processed only once per birth bucket")
|
||||
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})
|
||||
check(validation.is_empty(), "Coarse state validates: " + validation)
|
||||
world.evolution.evolute_to_datetime(20000)
|
||||
if id in ["C0.0", "C0.1"]:
|
||||
check(E.lt(world.ledger.physical_amounts(world.state)[id], 38300), "The next bucket still processes legitimate evolution: " + id)
|
||||
dispose(world)
|
||||
var fine = fixture("C0.0", 1)
|
||||
fine.evolution.evolute_to_datetime(10000)
|
||||
check(E.lt(fine.ledger.physical_amounts(fine.state)["C0.0"], 38300), "Low speeds retain multiple chronological generations")
|
||||
dispose(fine)
|
||||
var large = fixture("C0.0", 6)
|
||||
var count: Variant = E.of("10000000000000000000000000000000000000001")
|
||||
large.state.binds.RD["C0.0"] = count
|
||||
large.state.batches["C0.0"][0].amount = count
|
||||
var reference: Dictionary = large.state.duplicate(true)
|
||||
var products: Dictionary = large.life.life_products(reference, large.catalog.entities["C0.0"], count)
|
||||
large.evolution.evolute_to_datetime(10000)
|
||||
var physical: Dictionary = large.ledger.physical_amounts(large.state)
|
||||
for id in products:
|
||||
check(E.eq(physical.get(id, 0), products[id]), "Large whole population undergoes one transition: " + id)
|
||||
check(E.eq(large.state.life_remainders, reference.life_remainders), "Coarse timing preserves the single attempt's fractional carry")
|
||||
dispose(large)
|
||||
print("Coarse generations: %d checks, %d failures" % [checks, failures])
|
||||
quit(1 if failures else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ggml5q061lwh
|
||||
@@ -31,6 +31,10 @@ func run():
|
||||
verify(not E.eq(E.of("1e1000"), INF), "huge number is not infinity")
|
||||
verify(E.eq(E.floor_sum(huge, 2, 1, 2), E.divide(E.multiply(huge, E.subtract(huge, 1)), 2)), "floor sum supports huge window counts")
|
||||
var rows = JSON.parse_string(FileAccess.get_file_as_string("res://tests/fixtures/big_integer_oracle.json"))
|
||||
for row in rows.slice(0, 20):
|
||||
var scaled = B.divmod(B.parse(row[0] + "000000000000000000"), B.parse(row[1] + "000000000000000000"))
|
||||
verify(B.decimal(scaled[0]) == row[4], "cancel shared blocks preserves quotient")
|
||||
verify(B.compare(scaled[1], B.multiply(B.parse(row[5]), B.parse("1000000000000000000"))) == 0, "cancel shared blocks restores original remainder")
|
||||
for row in rows:
|
||||
var a: Array = B.parse(row[0])
|
||||
var b: Array = B.parse(row[1])
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
extends SceneTree
|
||||
const E = preload("res://world/owniverse/math/exact.gd")
|
||||
const Extra = preload("res://data/owniverse_extra_param_loader.gd")
|
||||
const Parameters = preload("res://world/owniverse/world_parameters.gd")
|
||||
var checks: int = 0
|
||||
var failures: int = 0
|
||||
func _initialize(): call_deferred("run")
|
||||
func check(ok: bool, label: String):
|
||||
checks += 1
|
||||
if not ok:
|
||||
failures += 1
|
||||
push_error(label)
|
||||
func run():
|
||||
var global = root.get_node("Global")
|
||||
check(E.eq(global.ExtraParams, Extra.get_data()), "Global table equals loader data")
|
||||
check(E.eq(global.ExtraParams.E, E.of("1e27")), "Energy parsed as exact integer")
|
||||
check(E.eq(global.ExtraParams.DA, 65536), "Export column is used, not formatted display column")
|
||||
var saved: Dictionary = global.ExtraParams.duplicate()
|
||||
global.ExtraParams.E = E.of("1e40")
|
||||
var world = load("res://world/owniverse/owniverse.gd").new()
|
||||
world.catalog.load_definitions(global.OwniverseEntities)
|
||||
check(E.eq(world.state.energy, E.of("1e40")), "New world uses configured energy")
|
||||
var initial: Dictionary = world.environment.stellar_environment(world.state)
|
||||
global.ExtraParams.DA = E.multiply(saved.DA, 4)
|
||||
check(E.near(world.environment.stellar_environment(world.state).density, E.multiply(initial.density, 2)), "Base mass changes density")
|
||||
global.ExtraParams.DA = saved.DA
|
||||
global.ExtraParams.NPC = E.multiply(saved.NPC, 2)
|
||||
check(E.near(world.environment.stellar_environment(world.state).density, E.multiply(initial.density, 2)), "NPC changes density")
|
||||
global.ExtraParams.NPC = saved.NPC
|
||||
world.state.amount.S = E.of("1e24")
|
||||
world.state.amount.LGF = 1
|
||||
var old_density = world.environment.stellar_environment(world.state).density
|
||||
global.ExtraParams.VoidSize = E.multiply(saved.VoidSize, 100)
|
||||
check(E.lt(world.environment.stellar_environment(world.state).density, old_density), "Void volume uses configured size")
|
||||
global.ExtraParams.bigcrunch1 = E.of("0.5")
|
||||
global.ExtraParams.bigcrunch2 = E.of("0.6")
|
||||
global.ExtraParams.bigcrunch3 = E.of("0.7")
|
||||
check(Parameters.density_display(E.of("0.6")).mode == "DensityCrunch", "Configured crunch start")
|
||||
check(E.near(Parameters.density_display(E.of("0.6")).position, E.of("0.5")), "Crunch indicator uses configured interval")
|
||||
check(Parameters.density_warning_color(E.of("0.6")) == Color.html("20b0dfbf"), "Configured middle crunch warning")
|
||||
global.ExtraParams.bigrip1 = E.of("0.01")
|
||||
global.ExtraParams.bigrip2 = E.of("0.001")
|
||||
global.ExtraParams.bigrip3 = E.of("0.0001")
|
||||
check(Parameters.density_display(E.of("0.001")).mode == "DensityRip", "Configured rip start")
|
||||
check(E.near(Parameters.density_display(E.of("0.001")).position, E.of("0.5")), "Rip indicator uses configured interval")
|
||||
check(Parameters.density_warning_color(E.of("0.001")) == Color.html("df2020bf"), "Configured middle rip warning")
|
||||
world.state.world_parameters.Bur = 19
|
||||
global.ExtraParams.combustibility_bonus = 1
|
||||
check(E.eq(Parameters.production(world.state, world.catalog.entities.RD), Parameters.effective(world.state, "Bur")), "Configured combustion exponent")
|
||||
global.ExtraParams.merge(saved, true)
|
||||
for entity in world.catalog.entities.values(): entity.free()
|
||||
world.free()
|
||||
print("Extra params: %d checks, %d failures" % [checks, failures])
|
||||
quit(1 if failures else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://doertyqlkrm26
|
||||
@@ -96,5 +96,25 @@ func run():
|
||||
invalid.evolute(1e308)
|
||||
check(not invalid.frame_busy and invalid.frame_clock.is_empty() and E.eq(before, invalid.state), "Overflowing elapsed float cannot enter either clock API")
|
||||
dispose(invalid)
|
||||
var adaptive = fixture()
|
||||
adaptive.frame_clock = Clock.advance(adaptive.state.time, 10.0, 6)
|
||||
var debt_clock: Dictionary = adaptive.frame_clock.duplicate(true)
|
||||
check(adaptive.update_frame_step() == 0.2, "Growing debt increases step to 0.2 seconds")
|
||||
check(adaptive.update_frame_step() == 0.5, "Large debt increases step to bounded 0.5 seconds")
|
||||
check(adaptive.update_frame_step() == 0.5 and E.eq(adaptive.frame_clock, debt_clock), "Maximum step preserves requested time")
|
||||
adaptive.frame_clock = Clock.advance(adaptive.state.time, 0.3, 6)
|
||||
check(adaptive.update_frame_step() == 0.5, "Hysteresis keeps step between thresholds")
|
||||
adaptive.frame_clock = Clock.advance(adaptive.state.time, 0.05, 6)
|
||||
check(adaptive.update_frame_step() == 0.2, "Small debt reduces step")
|
||||
check(adaptive.update_frame_step() == 0.1, "Caught-up simulation returns to base step")
|
||||
adaptive.frame_clock = Clock.advance(adaptive.state.time, 0.15, 6)
|
||||
adaptive._frame_cost_ratio = 1.1
|
||||
check(adaptive.update_frame_step() == 0.2, "Measured slow transactions increase step before two-step debt")
|
||||
adaptive.frame_clock = Clock.advance(adaptive.state.time, 0.05, 6)
|
||||
adaptive.speed = 3
|
||||
check(adaptive.update_frame_step() == 1.0 / 60.0, "Low speeds retain 60 Hz scheduling")
|
||||
adaptive.speed = 6
|
||||
check(adaptive.update_frame_step() == 0.1, "Speed switch resets adaptive step")
|
||||
dispose(adaptive)
|
||||
print("Frame evolution: %d checks, %d failures" % [checks, failures])
|
||||
quit(1 if failures else 0)
|
||||
|
||||
@@ -66,6 +66,8 @@ func run() -> void:
|
||||
world = fixture()
|
||||
world.state.amount.RD = 1
|
||||
world.state.binds.RD = {"EON5": 40000}
|
||||
# Test host preservation with a fixed rate, independent of game balance.
|
||||
world.catalog.entities.EON5.life_rule.progress = "0.00025"
|
||||
world.evolution.initialize_life_batches()
|
||||
world.evolution.evolve_batches(world.state, E.add(500000, E.divide(500000, 40)))
|
||||
check(E.eq(world.state.binds.RD["C0.0"], 10), "Intelligence emerges at the same host star")
|
||||
|
||||
@@ -30,6 +30,9 @@ func run() -> void:
|
||||
for id in requests: requests[id] = E.multiply(requests[id], E.of("1e40"))
|
||||
var limit: Variant = E.multiply(rng.randi_range(0, 600), E.of("1e40")) if sample % 3 == 0 else rng.randi_range(0, 600)
|
||||
check(E.eq(Math.allocate_whole(requests, limit), Legacy.allocate_whole(requests, limit)), "Allocation matches legacy with limits, ties and big integers")
|
||||
var whole_requests: Dictionary = {}
|
||||
for id in requests: whole_requests[id] = E.floor_value(E.maximum(0, requests[id]))
|
||||
check(E.eq(Math.allocate_whole(whole_requests, limit), Math.allocate_whole_with_total(whole_requests, limit, Math.sum(whole_requests))), "Known-total allocation matches ordinary allocation")
|
||||
var count: Variant = limit
|
||||
check(E.eq(Math.split(new_remainders, "population", count, weights), Legacy.split(old_remainders, "population", count, weights)), "Split matches legacy rounding")
|
||||
check(E.eq(new_remainders, old_remainders), "Signed remainder debt matches legacy")
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
extends SceneTree
|
||||
const E = preload("res://world/owniverse/math/exact.gd")
|
||||
const Civ = preload("res://world/owniverse/civilization_evolution.gd")
|
||||
const Fixture = preload("res://tests/fixtures/civilization_benchmark.gd")
|
||||
var checks: int = 0
|
||||
var failures: int = 0
|
||||
func _initialize(): call_deferred("run")
|
||||
func check(value: bool, message: String):
|
||||
checks += 1
|
||||
if not value:
|
||||
failures += 1
|
||||
push_error(message)
|
||||
|
||||
func reference_drift(target: Dictionary, pop: Dictionary, rows: Dictionary, diplomacy: bool, colony: bool) -> Dictionary:
|
||||
var weights: Dictionary = {}
|
||||
for other in Civ.ETHICS: weights[other] = 0
|
||||
for ethic in pop:
|
||||
var contribution: Variant = E.multiply(pop[ethic], E.of(rows[ethic].diplomacy_rate) if diplomacy else 1)
|
||||
if E.eq(contribution, 0): continue
|
||||
for other in Civ.ETHICS:
|
||||
var weight: int = 9 if colony else 1
|
||||
if other == ethic: weight = 960 if colony else 196
|
||||
elif other == Civ.OPPOSITE[ethic]: weight = 4 if colony else 0
|
||||
weights[other] = E.add(weights[other], E.multiply(contribution, weight))
|
||||
if E.le(Civ.total(weights), 0): return pop.duplicate()
|
||||
return Civ.split(target, "test", Civ.total(pop), weights)
|
||||
|
||||
func run():
|
||||
var rows: Dictionary = {}
|
||||
for ethic in Civ.ETHICS: rows[ethic] = {"diplomacy_rate": "0.125" if ethic == "m" else "1.75"}
|
||||
for count in [0, 1, 7, 999999999, E.of("1e40")]:
|
||||
for diplomacy in [false, true]:
|
||||
for colony in [false, true]:
|
||||
var actual: Dictionary = {}
|
||||
var reference: Dictionary = {}
|
||||
for round_index in 5:
|
||||
var pop: Dictionary = {"m": count, "l": round_index, "w": 9, "d": 0, "c": 137, "b": 2}
|
||||
var result: Dictionary = Civ.drift(actual, "test", pop, rows, diplomacy, colony)
|
||||
if diplomacy:
|
||||
check(E.eq(result, reference_drift(reference, pop, rows, diplomacy, colony)), "Diplomatic drift preserves allocation")
|
||||
var carries_valid: bool = true
|
||||
for value in actual.get("civilization_remainders", {}).values():
|
||||
carries_valid = carries_valid and E.ge(value, -1) and E.le(value, 1)
|
||||
check(carries_valid, "Diplomatic drift carries stay bounded")
|
||||
else:
|
||||
check(E.eq(Civ.total(result), Civ.total(pop)), "Integer flow drift conserves population")
|
||||
var valid_whole: bool = true
|
||||
for ethic in Civ.ETHICS:
|
||||
valid_whole = valid_whole and E.ge(result[ethic], 0) and E.eq(result[ethic], E.floor_value(result[ethic]))
|
||||
check(valid_whole, "Integer flow drift has nonnegative whole counts")
|
||||
var decimal_rows: Dictionary = {}
|
||||
for ethic in Civ.ETHICS: decimal_rows[ethic] = {"diplomacy_rate": "0.333333333333" if ethic == "m" else "0.017"}
|
||||
var decimal_target: Dictionary = {}
|
||||
for round_index in 20:
|
||||
var pop: Dictionary = {"m": E.of("1e40"), "l": round_index, "w": 17, "d": 4, "c": 91, "b": 2}
|
||||
var result: Dictionary = Civ.drift(decimal_target, "decimal", pop, decimal_rows, true)
|
||||
check(E.eq(Civ.total(result), Civ.total(pop)), "Decimal diplomatic drift preserves whole population")
|
||||
for ethic in result:
|
||||
check(E.ge(result[ethic], 0) and E.eq(result[ethic], E.floor_value(result[ethic])), "Decimal drift returns nonnegative whole counts")
|
||||
for speed in [1, 4, 6]:
|
||||
var world = load("res://world/owniverse/owniverse.gd").new()
|
||||
world.catalog.load_definitions(root.get_node("Global").OwniverseEntities)
|
||||
Fixture.seed(world, root.get_node("Global"), true, true)
|
||||
var rules: Dictionary = root.get_node("Global").CivilizationRules
|
||||
var id: String = rules.keys()[0]
|
||||
var modifiers: Dictionary = {"progress": 1, "degradation": 1, "war": 1, "colonization": 1}
|
||||
var original: Dictionary = Civ.coefficients(world, rules, id, modifiers).duplicate(true)
|
||||
modifiers.progress = 2
|
||||
var changed: Dictionary = Civ.coefficients(world, rules, id, modifiers)
|
||||
var row: Dictionary = rules[id].ethics.m
|
||||
var p: Variant = E.maximum(0, E.multiply(E.of(row.progress), 2))
|
||||
var d: Variant = E.maximum(0, E.of(row.degradation))
|
||||
check(E.eq(changed.m.pro, E.divide(p, E.maximum(1, E.add(p, d)))), "Coefficient cache follows modifiers")
|
||||
modifiers.progress = 1
|
||||
check(E.eq(Civ.coefficients(world, rules, id, modifiers), original), "Coefficient cache restores original rates")
|
||||
var local_rules: Dictionary = rules.duplicate(true)
|
||||
local_rules[id].ethics.m.progress = "0.375"
|
||||
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": {}}
|
||||
Civ.State.reconcile(world, target, rules, {id: 60})
|
||||
target.ethics[id].m = 100
|
||||
Civ.State.reconcile(world, target, rules, {id: 60})
|
||||
check(E.eq(Civ.total(target.ethics[id]), 60), "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")
|
||||
Civ.worship(world, target, rules)
|
||||
target.ethics[id].m = E.add(target.ethics[id].m, 5)
|
||||
var expected: Variant = 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 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
|
||||
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")
|
||||
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)
|
||||
check(world.ledger.checks > 0 and world.ledger.failures == 0, "Selective and full rounding agree at speed %d" % speed)
|
||||
for entity in world.catalog.entities.values(): entity.free()
|
||||
world.free()
|
||||
print("Simulation optimizations: %d checks, %d failures" % [checks, failures])
|
||||
quit(1 if failures else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bcvertbh5uxys
|
||||
@@ -0,0 +1,66 @@
|
||||
extends SceneTree
|
||||
const E = preload("res://world/owniverse/math/exact.gd")
|
||||
const Q = preload("res://world/owniverse/quantity_math.gd")
|
||||
var checks: int = 0
|
||||
var failures: int = 0
|
||||
func _initialize(): call_deferred("run")
|
||||
func check(value: bool, message: String):
|
||||
checks += 1
|
||||
if not value:
|
||||
failures += 1
|
||||
push_error(message)
|
||||
static func reference_split(remainders: Dictionary, key: String, count: Variant, weights: Dictionary) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
var sum_weights: Variant = Q.sum(weights)
|
||||
if (E.le(count, 0) or E.le(sum_weights, 0)): return result
|
||||
var ids: Array = weights.keys()
|
||||
var expected: Array = []
|
||||
var remainder_keys: Array = []
|
||||
var positive_sum: Variant = 0
|
||||
for id in ids:
|
||||
var remainder_key: String = key + ":" + str(id)
|
||||
remainder_keys.append(remainder_key)
|
||||
var value: Variant = E.add(E.mul_div(count, weights[id], sum_weights), remainders.get(remainder_key, 0))
|
||||
expected.append(value)
|
||||
positive_sum = E.add(positive_sum, E.maximum(0, value))
|
||||
var order: Array = []
|
||||
var rests: Array = []
|
||||
var assigned: Variant = 0
|
||||
for i in ids.size():
|
||||
var normalized: Variant = E.mul_div(E.maximum(0, expected[i]), count, positive_sum) if E.gt(positive_sum, 0) else 0
|
||||
result[ids[i]] = E.floor_value(normalized)
|
||||
assigned = E.add(assigned, result[ids[i]])
|
||||
rests.append(E.subtract(normalized, 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]))
|
||||
for i in order:
|
||||
if E.ge(assigned, count): break
|
||||
result[ids[i]] = E.add(result[ids[i]], 1)
|
||||
assigned = E.add(assigned, 1)
|
||||
for i in ids.size():
|
||||
remainders[remainder_keys[i]] = E.clamp_value(E.subtract(expected[i], result[ids[i]]), E.neg(1), 1)
|
||||
return result
|
||||
|
||||
func run():
|
||||
for count in [0, 1, 7, E.of("1.375"), 1000000, E.of("1e40"), E.of("1e80")]:
|
||||
for weights in [{}, {"a": 0, "b": 0}, {"a": 0, "b": 7}, {"b": 0, "a": 7}, {"a": 1, "b": 1, "c": 1}, {"a": E.of("0.017"), "b": E.of("0.333333333333"), "c": 0}, {"a": E.of("1e40"), "b": 17, "c": 1}]:
|
||||
for carry in [0, E.of("0.25"), E.of("-0.75")]:
|
||||
var actual: Dictionary = {"case:a": carry}
|
||||
var expected: Dictionary = actual.duplicate(true)
|
||||
for iteration in 5:
|
||||
var reference: Dictionary = reference_split(expected, "case", count, weights)
|
||||
var result: Dictionary = Q.split(actual, "case", count, weights)
|
||||
check(E.eq(result, reference), "Split preserves allocation")
|
||||
check(E.eq(actual, expected), "Split preserves rounding carries")
|
||||
for a in [0, 7, E.of("1e40"), E.of("-1e40"), E.of("1.375")]:
|
||||
var values: Array = [0, 1, -7, E.of("0.017"), E.of("1e30")]
|
||||
for divisor in [1, 3, E.of("0.017"), E.of("1e40")]:
|
||||
var results: Array = E.mul_div_many(a, values, divisor)
|
||||
for i in values.size(): check(E.eq(results[i], E.mul_div(a, values[i], divisor)), "Grouped ratios preserve scalar results")
|
||||
for count in [1, 7, 1000000000, E.of("1e40"), E.of("1e80")]:
|
||||
for factor in [200, 1000]:
|
||||
var weights: Array = [0, 17, E.of("1e40"), E.multiply(count, factor)]
|
||||
var results: Array = E.population_quotas(count, weights, factor)
|
||||
for i in weights.size(): check(E.eq(results[i], E.mul_div(count, weights[i], E.multiply(count, factor))), "Cancelled population preserves exact scalar quotas")
|
||||
print("Split optimizations: %d checks, %d failures" % [checks, failures])
|
||||
quit(1 if failures else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b4e1nd22q1705
|
||||
@@ -0,0 +1,14 @@
|
||||
extends SceneTree
|
||||
const E = preload("res://world/owniverse/math/exact.gd")
|
||||
func _initialize():
|
||||
# SceneTree initialization runs before Global._ready, like main scene construction.
|
||||
var global = root.get_node("Global")
|
||||
assert(not global.is_node_ready(), "Test must cover pre-ready initialization")
|
||||
assert(global.ExtraParams.has("E"), "Energy table exists before Global._ready")
|
||||
var scene = load("res://world/world.tscn").instantiate()
|
||||
var stage = scene.get_node("Stage")
|
||||
assert(E.eq(stage.owniverse.state.energy, global.ExtraParams.E), "Stage starts with configured energy")
|
||||
stage.owniverse.free()
|
||||
scene.free()
|
||||
print("Startup params: 3 checks, 0 failures")
|
||||
quit()
|
||||
@@ -0,0 +1 @@
|
||||
uid://5cbgaqn5s1yw
|
||||
@@ -65,6 +65,28 @@ func run() -> void:
|
||||
check(stage.effects_player.stream == stage.effects_player._effects.window_open, "Opening a window plays its effect")
|
||||
stage._on_topmenu_button_pressed("World")
|
||||
check(stage.effects_player.stream == stage.effects_player._effects.window_close, "Closing a window plays its effect")
|
||||
stage.indicator_elapsed = 1.0
|
||||
stage.active_actions_elapsed = 1.0
|
||||
stage.tick_ui(0.0)
|
||||
var refreshes: int = stage.actions_refreshes
|
||||
stage.date_time.text = "unchanged"
|
||||
stage.active_actions_elapsed = 1.0
|
||||
stage.tick_ui(0.0)
|
||||
check(stage.actions_refreshes == refreshes, "Idle UI does not recalculate production limits")
|
||||
check(stage.date_time.text == "unchanged", "Idle UI does not reformat the clock")
|
||||
stage.owniverse.state.amount.H = 3456
|
||||
stage.owniverse.presentation.refresh(stage.owniverse.state, stage.owniverse.catalog)
|
||||
stage.tick_ui(0.0)
|
||||
check(hydrogen.description.text == "3 456", "A manual publication updates idle UI")
|
||||
check(stage.actions_refreshes == refreshes + 1, "A publication invalidates production limits")
|
||||
stage.active_actions_dirty = true
|
||||
stage.tick_ui(0.0)
|
||||
check(stage.actions_refreshes == refreshes + 2, "Selection invalidation refreshes limits immediately")
|
||||
var old_pointer: float = stage.world_indicators.pointers.Agg.position.x
|
||||
stage.owniverse.environment.modify_world_parameter("Agg", 1)
|
||||
stage.indicator_elapsed = 1.0
|
||||
stage.tick_ui(0.0)
|
||||
check(stage.world_indicators.pointers.Agg.position.x != old_pointer, "World parameter actions invalidate indicators")
|
||||
for entity in stage.owniverse.catalog.entities.values(): entity.free()
|
||||
stage.owniverse.free()
|
||||
scene.free()
|
||||
|
||||
Reference in New Issue
Block a user