Implement civilization flow mechanics and profiling for resource allocation

- Added `civilization_flow.gd` to manage continuous expected flows and resource allocation within civilizations.
- Introduced `allocate_profiled` and `whole_quotas` functions in `quantity_math.gd` for precise stock allocation based on float shares.
- Enhanced `credit` logic in `object_ledger.gd` to handle production remainders more effectively.
- Updated `append_batch` in `object_batches.gd` to prevent batch creation for invalid IDs.
- Introduced `ratio_approx` function in `exact.gd` for better handling of float profiles.
- Added a benchmark script `benchmark_civilization_flows.gd` to evaluate the performance of civilization flow mechanics.
- Refactored various functions to improve clarity and efficiency in handling resource flows and allocations.
This commit is contained in:
Kirill
2026-10-07 22:51:55 +03:00
parent 6ee66a627d
commit 3fcc7211ce
41 changed files with 247959 additions and 817 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ var ExtraParams: Dictionary = {}
var Music: Dictionary = {} var Music: Dictionary = {}
var Effects: Dictionary = {} var Effects: Dictionary = {}
var batch_count: float = 40.0 # must be even not odd var batch_count: int = 40 # number of age groups per lifespan
func set_locale(locale: String) -> void: func set_locale(locale: String) -> void:
+18 -24
View File
@@ -46,9 +46,15 @@ float. Весь порядок E27 сохраняет целые единицы.
### Общие окна рождения и эволюции ### Общие окна рождения и эволюции
Обработка кадров на скоростях 4–6 использует адаптивный шаг 0.1 → 0.2 → 0.5 реальной секунды: накопленное отставание больше двух текущих шагов повышает ступень, меньше половины шага понижает её. Граница ограничивается запрошенным временем, долг не отбрасывается. Скорости 1–3 используют 1/60 секунды. При смене скорости ступень сбрасывается. Сохранённая незавершённая транзакция сначала возобновляется с прежней границей. Кооперативный бюджет кадра остаётся 40 мс. Изменение шага не изменяет ширину временных партий, но может влиять на результаты дискретных расчётов производства и глобальных параметров. На всех скоростях полный цикл симуляции обрабатывает 0,2 реальной секунды: пять циклов на секунду запрошенного времени. До накопления полного интервала расчёт не запускается. Игровая длительность цикла равна `0.2 × 10^(speed - 1)` лет. При отставании размер шага не увеличивается, долг сохраняется. Незавершённая транзакция из сохранения возобновляется с прежней границей. Кооперативный бюджет кадра — 40 мс; частота отрисовки не определяет частоту полных циклов.
Окно рождения шириной `w = lifespan / 40` при `lifespan > 10`, иначе `w = lifespan`. Окно превращения сдвинуто на `lifespan`. При частичном списании: На скоростях 4–6 укрупнение звёздных партий зависит от размера цикла и прежнего окна рождения. Размер цикла — `0.2 × 10^(speed - 1)` игровых лет: 200, 2000 и 20000 лет. Если прежнее окно `max(lifespan / 40, временное разрешение)` шире цикла, оно сохраняется: долгоживущие звёзды не дробятся на более мелкую сетку. Если оно не шире цикла, новые звёзды группы `Star` (включая красные гиганты) объединяются по общей границе завершения жизни и переходят целиком.
При обработке существующих партий объединяются только две или более партии одного типа, весь оставшийся интервал превращения которых попадает в один и тот же цикл. Широкие партии и партии, пересекающие границу циклов, остаются без изменений. Количества и накопленные остатки складываются точно; объединённая партия переходит целиком на границе цикла. Повторное укрупнение не меняет результат.
Это приближение времени перехода: задержка узкой партии не превышает одного цикла. Производство учитывает полное исчезновение объединённой партии на выбранной границе. При снижении скорости новые звёзды снова получают постепенные партии; уже созданные партии не разделяются. События SN/GRB сохраняют прежний расчёт.
На скоростях 1–3 звёзды, а также остальные объекты со сроком жизни больше 10 лет используют 40 возрастных групп: окно рождения имеет ширину `w = lifespan / 40`; для коротких событий ширина равна сроку жизни. Окно превращения сдвинуто на `lifespan`. Для облаков и первичной жизни на скоростях 4–6 сохраняется прежнее укрупнение окна по временному разрешению. При частичном списании:
``` ```
expected = remainder + (remaining_count - remainder) * elapsed / remaining_window expected = remainder + (remaining_count - remainder) * elapsed / remaining_window
@@ -56,7 +62,7 @@ converted = floor(expected)
remainder = expected - converted remainder = expected - converted
``` ```
Конец окна списывает остаток. Переход не уничтожает всю партию в начале окна. Начала и длительности сохраняют модель GI, включая годовую длительность событий SN/GRB. Конец постепенного окна списывает остаток. Новые звёздные партии высоких скоростей вместо такого окна имеют одну границу полного перехода. Длительность событий SN/GRB остаётся годовой.
## Планеты ## Планеты
@@ -123,39 +129,27 @@ HP деградирует в RP. У эонов возможен откат к п
`data/owniverse_civilization_rule_loader.gd` содержит 43 стадии по шесть этик из Unity. `OwniverseCivilizationRuleLoader` группирует строки по стадиям, формирует связи между ними и передаёт готовый словарь в `Global.CivilizationRules`. После C4.4 следует Ct: конечная стадия без новых жизненных партий. `data/owniverse_civilization_rule_loader.gd` содержит 43 стадии по шесть этик из Unity. `OwniverseCivilizationRuleLoader` группирует строки по стадиям, формирует связи между ними и передаёт готовый словарь в `Global.CivilizationRules`. После C4.4 следует Ct: конечная стадия без новых жизненных партий.
Состояние `ethics[stage][ethic]` хранит целые количества. Для существующих цивилизаций без этик количество распределяется поровну в порядке m, l, w, d, c, b; остаток распределяется по одному в этом порядке. Существующее распределение сохраняется. При внешних потерях оно пропорционально сокращается. Стадия хранит точное целое количество в физическом реестре и шесть долей `float` в `ethics[stage]`. C0.2–C4.4 не используют возрастные партии. Значение `span` задаёт средний интервал попыток: интенсивности прогресса, деградации, войны и колонизации равны соответствующим коэффициентам таблицы, делённым на эффективный `span`.
Цивилизации с непустым `home` (C2.0–C4.4) освобождаются от прежних привязок без изменения общего количества. Общий механизм переходов больше не переносит планетную привязку на такого преемника. Структуры назначаются отдельно в `civilization_homes`; при возвращении к планетным стадиям цивилизации размещаются у имеющихся карликовых звёзд. `civilization_flow.gd` вычисляет ожидаемые потоки по состоянию начала интервала. Разреженный оператор непрерывных переходов интегрируется рядом Пуассона (uniformization): один интервал может пройти несколько последовательных стадий, включая достижение Ct. Число членов ряда зависит от интенсивности и длительности интервала, а не от количества цивилизаций. Большой интервал полностью обрабатывается частями с параметром Пуассона не более 32. Промежуточные массы и составы потоков — `float`; конечные количества распределяются целыми квотами с сохранением точной общей суммы. Доли квот округляются до 15 десятичных знаков; остатки округления сохраняются между циклами.
Правило среды согласовано: число космических цивилизаций ограничено звёздным ресурсом. Для стадии `i` расход ёмкости — `population[i] * max(1, Stars[i])`. Каждое семейство `Home` имеет общий бюджет для всех использующих его стадий; звёзды внутри структур также учитываются, но повторно не суммируются. SMBH учитывается по суммарной массе в эквиваленте звёзд, согласно решению пользователя. Обычный дрейф этик интегрируется аналитически: уход 2% за средний интервал попыток распределяется между четырьмя не противоположными этиками. После смешивания приходящих потоков дипломатия постепенно смещает доли в соответствии с `diplomacy_rate`. Ct сохраняет этику без дрейфа. Это приближённое разделение процессов: переходы и дрейф используют один интервал, но не чередуются для каждого отдельного поколения.
Переход переиспользует резерв исходной стадии. При нехватке ёмкости неподдержанные переходы остаются на исходной стадии. Лимит применяется к общему механизму выходов партий и ручному созданию; расчёт прогресса C0.2+ использует тот же бюджет. Войны включены в интенсивности переходов. На столкновение ожидается 17/36 успешного прогресса и 31/18 суммарных потерь сторон, разделённых на `Order`; потери распределяются по `fall`. Счётчик столкновений и колониальные попытки интегрируются по времени пребывания на каждой стадии, включая посещённые внутри интервала. Побочный ущерб 40/3 на столкновение применяется к остающейся популяции предыдущей стадии и ограничивается её количеством. Отдельных партий победителей, нападающих и жертв нет.
При загрузке и после эволюционных поколений избыток деградирует последовательно до поддерживаемой стадии. Приоритет получают существующие более развитые цивилизации; остаток бюджета достаётся младшим. Деградация переносит количество и этики, сокращает старые партии и регистрирует новые, не уничтожая цивилизации. C2.0 при избытке становится C1.9 и распределяется по имеющимся звёздам-карликам. Если карликов нет, C1.9 остаётся свободной — отсутствующая звезда не создаётся. Планетные стадии до C2.0 не расходуют отдельную звёздную ёмкость: у одной звезды могут быть несколько планет. Колонизация агрегирует запросы по стадии назначения (на две стадии ниже отправителя), ограничивает их общим запасом GG, AB, RP и HP и заменяет ровно выделенное число планет. Целые колонии сохраняют владельца целевой планеты; состав получает дрейф 960/4/9. Исходное население не расходуется.
Резерв ёмкости логический: звёзды не расходуются и продолжают производить ресурсы. Общий бюджет пересчитывается из физических количеств, отдельная копия бронирований в сохранении не хранится. Космические стадии C2.0–C4.4 свободны от планетных привязок. Общие бюджеты звёздных семейств и структур учитывают уже занятые резервы. Неподдерживаемый промежуточный переход блокируется в операторе; конечные положительные запросы делят свободную ёмкость одновременно. Отклонённые количества возвращаются к источнику. Освободившаяся при переходах ёмкость доступна следующему шагу. Если внешние изменения уменьшили ёмкость ниже существующего населения, избыток последовательно деградирует с приоритетом старших стадий. При возвращении в планетные стадии используются реальные карликовые звёзды, при отсутствии хозяина население остаётся свободным.
Порядок полного шага: производство за интервал → непрерывные потоки существующих цивилизаций → поколения звёзд и биологической жизни и бласты → итоговые ограничения среды → тренды. Возникшие из C0.1 цивилизации C0.2 получают равномерный профиль, смешанный с существующим населением, и начинают непрерывную эволюцию со следующего шага. Разные размеры полного шага могут отличаться из-за этого порядка, ограничений среды и нелинейных эффектов; чистая непрерывная цепочка без ограничений согласована при разделении интервала.
### Расчёт поколения C0.2–C4.4 В `civilization_remainders` сохраняются квоты и дробные остатки потоков; в `civilization_stats` — войны, колонии, побочный ущерб и поклонение. Возрастные партии сохраняются для звёзд, HP, эонов, C0.0 и C0.1.
1. Из истёкшей части партии пропорционально выбираются представители шести этик. Для каждой этики читаются `progress` и `degradation`; целые исходы получаются с сохранением дробных остатков. Неудавшийся из-за лимита переход возвращается на исходную стадию.
2. Прогресс ведёт на следующую стадию; деградация распределяется по весам `fall` конкретной этики. Повторные попытки не увеличивают статистику созданных цивилизаций.
3. При флаге `fights` рассчитываются войны. Веса исходов — 1:2:3:3, доли прогресса победителей — 0:0.25:0.5:0.75; первый исход деградирует атакующих. Потери противников — 2:1:1.5:2 на войну. Два последних исхода дают 10 и 30 единиц побочного ущерба предыдущей стадии. Выбор участников ограничен живой популяцией, включая ещё не сработавшие партии; их партии сокращаются вместе с населением.
4. Для обычного дрейфа веса своей/противоположной/каждой другой этики равны 196/0/1. Дипломатия умножает исходные численности на `diplomacy_rate`, затем нормирует к прежней численности. Флаг дипломатии действует на остающихся на стадии. При переходе в Ct этика сохраняется без дрейфа.
5. При флаге `colonizes` создаются колонии на две стадии ниже отправителя. Цели — существующие GG, AB, RP, HP; колонизация заменяет цель и сохраняет её звёздную привязку. Веса дрейфа колонистов — 960/4/9. Исходная цивилизация не расходуется. Новая партия начинается в текущий игровой момент.
6. После применения переходов пересчитываются этики, поклонение (`Σ population × worship`), бласты и ограничения среды. Число операций зависит от числа типов и партий, а не от числа цивилизаций.
На скоростях 1–3 размер временной партии равен сроку жизни / 40. На скоростях 4–6 он равен максимуму этого значения и 100, 1000, 10000 игровых лет соответственно (0,1 реальной секунды). Близкие существующие партии объединяются при следующем шаге: целые количества и остатки изъятия складываются, временной интервал охватывает исходные партии. Это приближает моменты переходов внутри объединённого интервала. События со сроком жизни до 10 лет сохраняют прежние окна. При снижении скорости ранее объединённые партии не разделяются; новые получают меньшие окна. Шаг публикации на скоростях 4–6 ограничен 0,1 реальной секунды; накопленное отставание сохраняется.
Несколько частей одного типа обрабатываются агрегированно между минимальным и максимальным временем. На скоростях 4–6 новые партии жизни и цивилизаций после перехода создаются на ближайшей следующей границе интервала 100/1000/10000 лет. Начало сетки этих новых партий равно нулю независимо от старого `batch_origins`: округлённое время рождения не попадает обратно в раннюю часть старого окна. Это исключает повторные попытки эволюции той же выходящей популяции внутри её временного интервала; следующие интервалы продолжают обрабатываться. На скоростях 1–3 сохраняются исходные интервалы рождения и последовательные поколения. Звёздные события используют прежнюю схему.
Побочный ущерб неистёкшим партиям применяется в текущий момент. Все остатки округления сохраняются в `civilization_remainders`, счётчики — в `civilization_stats`.
### Структуры и периоды ### Структуры и периоды
Прямой `claim` задаёт структуру стадии и норму 1; каждая промежуточная стадия умножает норму на 10 до следующего `claim`. Начиная с C3.0 требуется соответствующий запас структур, общий для использующих его стадий. При потере запаса избыток деградирует. Этот бюджет действует одновременно со звёздным. Прямой `claim` задаёт структуру стадии и норму 1; каждая промежуточная стадия умножает норму на 10 до следующего `claim`. Начиная с C3.0 требуется соответствующий запас структур, общий для использующих его стадий. При потере запаса избыток деградирует. Этот бюджет действует одновременно со звёздным.
Размещение выбирает структуры в порядке убывания `civ_bonus`, начиная с соответствующего стадии `claim`. Прямое размещение ограничено числом структур; остальные разрешённые структуры при наличии дают общий бонус, как в Unity. Период новых партий равен `span / средневзвешенный civ_bonus`, для неразмещённой части множитель равен 1. Уже назначенные сроки партий не переписываются при изменении бонуса. Размещение выбирает структуры в порядке убывания `civ_bonus`, начиная с соответствующего стадии `claim`. Прямое размещение ограничено числом структур; остальные разрешённые структуры при наличии дают общий бонус, как в Unity. Эффективный средний интервал попыток равен `span / средневзвешенный civ_bonus`, для неразмещённой части множитель равен 1. Бонус входит в оператор следующего расчётного интервала.
Ct не получает часы. Инициализация отсутствующих партий жизни выполняется при загрузке и перед продвижением симуляции. Параметры мира и тенденции описаны ниже; интерфейс применения сил в этот перенос не входит. Ct не получает часы. Инициализация отсутствующих партий жизни выполняется при загрузке и перед продвижением симуляции. Параметры мира и тенденции описаны ниже; интерфейс применения сил в этот перенос не входит.
+42 -17
View File
@@ -122,7 +122,41 @@ godot --headless --path Owniverse.GI --script res://tests/test_time.gd
## Согласованные правила эволюции ## Согласованные правила эволюции
Сохраняются постепенные интервальные окна GI: начало преобразования — через `lifespan`, длительность окна — `lifespan / 40` при `lifespan > 10`, иначе `lifespan`. Сохраняются постепенные интервальные окна GI: срок жизни делится на 40 возрастных групп; начало преобразования — через `lifespan`, длительность окна — `lifespan / 40` при `lifespan > 10`, иначе `lifespan`.
На скоростях 4–6 узкие звёздные партии могут объединяться по границе полного
цикла смерти шириной 200/2000/20000 игровых лет. Прежнее окно шире цикла
сохраняется: долгоживущие звёзды не дробятся. Из существующих партий сливаются
только две или более одного типа, чей оставшийся переход полностью попадает
в общий цикл; широкие и пересекающие границу партии сохраняются.
`test_batch_boundaries` проверяет объединение, точные большие количества и
остатки, однократное изъятие, сериализацию, сохранение широких окон,
распределённые рождения и интеграл производства.
`benchmark_large_star_cohorts.gd` создаёт отдельные большие диагностические
сохранения со старой и новой звёздной сеткой при одинаковых количествах и
входных интервалах рождения. Переключатель `batches.cycle_stars` используется
только для сравнительного замера, в игре он включён. Парные прогоны меняют
порядок моделей и сбрасывают исходное состояние перед каждым запуском:
```sh
godot --headless --path Owniverse.GI --script res://tests/benchmark_large_star_cohorts.gd -- cycles=50 repeats=2
```
Сохранения и полные профили записываются в `tests/artifacts/large_star_cohorts_*`;
сохранение игрока не затрагивается. `generate_only` проверяет запись/чтение и
исходное равенство физических количеств без запуска замеров.
`benchmark_many_objects.gd` создаёт новую диагностическую вселенную со всеми
типами объектов и цивилизаций и 4800 синтетическими звёздными партиями,
попадающими группами в общие циклы. Это отдельный стресс-сценарий большого
числа партий; старый и новый расчёты используют одинаковый исходный состав.
```sh
godot --headless --path Owniverse.GI --script res://tests/benchmark_many_objects.gd -- cycles=40 repeats=2
```
Файлы сценария: `tests/artifacts/many_objects_fragmented_stars_*`.
Вероятностные исходы используют накопление дробного остатка GI, без случайного округления. Этот же подход выбран для будущего создания планет. Шансы планет записаны долями (`0.01` = 1%), а альтернативные исходы звёзд — процентами (`%0.1` = 0.1%). Парсер планет сохраняет дроби и поддерживает один элемент без `&`. Вероятностные исходы используют накопление дробного остатка GI, без случайного округления. Этот же подход выбран для будущего создания планет. Шансы планет записаны долями (`0.01` = 1%), а альтернативные исходы звёзд — процентами (`%0.1` = 0.1%). Парсер планет сохраняет дроби и поддерживает один элемент без `&`.
@@ -209,8 +243,7 @@ godot --path Owniverse.GI --script res://tests/benchmark_civilizations.gd -- ui
Stage вызывает `evolute_frame`: транзакция получает бюджет 40 мс и уступает Stage вызывает `evolute_frame`: транзакция получает бюджет 40 мс и уступает
следующему кадру внутри создания партий и обработки стадий. Расчёт остаётся следующему кадру внутри создания партий и обработки стадий. Расчёт остаётся
на главном потоке, состояние публикуется только после полного завершения. на главном потоке, состояние публикуется только после полного завершения.
Длительность одной транзакции ограничена обычным шагом 1/60 секунды выбранной Одна транзакция обрабатывает 0,2 реальной секунды на любой скорости. Запрошенное время хранится отдельно, поэтому отставание не превращается
скорости. Запрошенное время хранится отдельно, поэтому отставание не превращается
в один всё более тяжёлый шаг. При предельной нагрузке время симуляции может в один всё более тяжёлый шаг. При предельной нагрузке время симуляции может
отставать от запрошенного; это не ограничение FPS интерфейса и не удаление событий. отставать от запрошенного; это не ограничение FPS интерфейса и не удаление событий.
@@ -259,20 +292,12 @@ Stage вызывает `evolute_frame`: транзакция получает б
1.057. Замер включает сокращение накопленного отставания сохранения; 1.057. Замер включает сокращение накопленного отставания сохранения;
это результат конкретного сценария на Mac, а не гарантия для Android. это результат конкретного сценария на Mac, а не гарантия для Android.
Адаптивный шаг на скоростях 4–6 выбирает 0.1, 0.2 или 0.5 реальной секунды. Полный шаг фиксирован: 0,2 реальной секунды на всех скоростях, пять циклов
Долг больше двух шагов повышает ступень, меньше половины шага понижает её. на секунду запрошенного времени. Неполный интервал накапливается. При отставании
Шаг ограничен доступным запрошенным временем. Скорости 1–3 сохраняют 1/60; шаг сохраняется, долг не отбрасывается. Возобновляемая транзакция сохраняет
смена скорости сбрасывает ступень. Возобновляемая транзакция сохраняет точную границу из checkpoint. Бюджет кадра остаётся 40 мс. `test_frame_evolution`
границу из 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 использует шесть взвешенных вкладов вместо матрицы Дипломатический drift использует шесть взвешенных вкладов вместо матрицы
пар этик: вес = сумма вкладов + 195 × свой вклад − противоположный вклад. пар этик: вес = сумма вкладов + 195 × свой вклад − противоположный вклад.
@@ -92,3 +92,290 @@ godot --path Owniverse.GI --script res://tests/benchmark_full_ui.gd
Финальная 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. Финальная 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 за прогон не найдено. На компьютере прошли тесты состояния (70), эволюции (555), оптимизаций симуляции (364), индикаторов (63) и жизни (25). Полный набор всё ещё сообщает о двух отказах в `test_descriptions.gd` при обновлении выбранного описания и смене языка; оба относятся к UI-описаниям, а не к модели этик. В логах Android ошибок GDScript/Godot за прогон не найдено.
## Непрерывная модель цивилизаций — 2026-10-06
Сравнены исходная симуляция из коммита `6ee66a6` и новая модель потоков. Оба запуска используют один диагностический JSON, скорость 6, 100 последовательных полных циклов с запросом 0,1 секунды каждый и включённый CPU-профиль. Подготовка каталога и чтение JSON не включены в длительность цикла. Запуски выполнены последовательно, без параллельных тестов. Сохранение игрока не читалось и не изменялось.
| Полный цикл и пропускная способность | До | После |
|---|---:|---:|
| Среднее время полного цикла | 811,292 мс | 401,796 мс |
| Минимум / максимум | 488,914 / 963,141 мс | 323,427 / 435,607 мс |
| Завершённых полных циклов в секунду | 1,233 | 2,489 |
| Обработанных секунд симуляции на секунду CPU | 0,123 | 0,249 |
| Применено игровых лет за 100 циклов | 1 000 000 | 1 000 000 |
Полный цикл ускорился в **2,02 раза**, среднее время сократилось на **50,5%**. Цель **100 мс / 10 полных циклов в секунду не достигнута**: для неё требуется ещё примерно 4,02-кратное ускорение текущей версии. При непрерывном запросе времени на шестой скорости и шаге 0,1 секунды этот стресс-сценарий продолжит накапливать долг. Это измерение полных завершённых шагов симуляции, без показателя FPS.
Текущие внешние интервалы CPU-профиля, по убыванию среднего времени на цикл:
| Расчёт | Среднее за цикл |
|---|---:|
| Поколения звёзд и биологической жизни | 151,6 мс |
| Подготовка непрерывных потоков цивилизаций | 98,5 мс |
| Применение потоков к количествам и владельцам | 53,2 мс |
| Производство за период | 28,7 мс |
| Тренды | 18,1 мс |
| Дрейф, проверка вместимости и поклонение после потоков | 8,8 мс |
| Инициализация жизни | 6,1 мс |
| Ограничение потоков ресурсными бюджетами | 5,8 мс |
| Применение колонизации | 5,5 мс |
Внутри подготовки потоков разреженный оператор занимает 24,5 мс на цикл; этот вложенный интервал нельзя прибавлять к 98,5 мс. Внешние интервалы не покрывают всё время: остальная работа включает копирование транзакции, построение физических снимков и обновление представления.
Модель C0.2–C4.4 теперь использует точные целые количества и шесть долей float без возрастных партий. Оператор учитывает несколько последовательных переходов за интервал. Конечные количества и ресурсные потолки сохраняются точно; пропорции потоков и владельцев приближены float и квантованием долей до 15 десятичных знаков. Изменяется распределение событий во времени, поэтому тесты проверяют законы новой модели, сохранение количества, владение, ресурсные ограничения и воспроизводимое продолжение сохранения.
Регрессия: 27 из 28 наборов прошли. Новые переходы — 1895 проверок; арифметика распределения и кеши — 1889; кадровое выполнение — 24. В `test_descriptions.gd` сохраняются две ранее обнаруженные ошибки обновления выбранного описания и языка; этот UI-код не изменён в данном рефакторинге.
Повторение из папки `Owniverse.GI`:
```sh
godot --headless --path . --script res://tests/benchmark_civilization_flows.gd -- tests/artifacts/all_objects_speed6.adventure.json 0.1 profile cycles=100
```
## Фиксированные пять циклов в секунду — 2026-10-07
Текущая версия после отката агрегации и возврата 40 возрастных групп.
Планировщик использует шаг 0,2 реальной секунды на всех скоростях. Замер:
диагностическое сохранение со всеми объектами и цивилизациями, скорость 6,
100 полных синхронных циклов с CPU-профилем, без отрисовки интерфейса.
Проверка сохранения: OK. Исходное сохранение не изменялось.
| Метрика | Результат |
|---|---:|
| Среднее время полного цикла | 439,228 мс |
| Минимум / максимум | 340,370 / 483,152 мс |
| Завершённых полных циклов в секунду | 2,277 |
| Требуется циклов в секунду | 5 |
| Обработано игровых лет | 2 000 000 |
| Запрошенное время сценария | 20 с |
| Время расчёта 100 циклов | 43,923 с |
| Пропускная способность | 0,455 с симуляции / с расчёта |
Цель 200 мс на полный цикл не достигнута: по среднему времени требуется
ускорение примерно в 2,20 раза. Увеличение шага с 0,1 до 0,2 секунды улучшило
отношение обработанного времени к затратам, но при непрерывном поступлении
времени этот стресс-сценарий продолжит накапливать долг. Это измерение
пропускной способности симуляции, без оценки FPS. Один прогон не задаёт
гарантированную производительность для других сохранений или устройств.
Внешние интервалы CPU-профиля, по убыванию среднего времени на цикл:
| Расчёт | Среднее на цикл |
|---|---:|
| Поколения звёзд и биологической жизни | 168,294 мс |
| Подготовка непрерывных потоков цивилизаций | 111,835 мс |
| Применение потоков к количествам и владельцам | 59,516 мс |
| Производство за период | 29,248 мс |
| Тренды | 17,787 мс |
| Завершение цивилизационных потоков | 8,864 мс |
| Ограничение потоков вместимостью | 6,439 мс |
| Инициализация жизни | 6,172 мс |
| Колонизация | 6,111 мс |
Вложенные интервалы нельзя прибавлять к внешним: построение физических
снимков — 27,845 мс/цикл; изъятие партий — 42,015 мс/цикл; решение оператора
цивилизаций — 31,835 мс/цикл. Проверки кадрового планировщика:
35 проверок, 0 ошибок, включая все шесть скоростей, сохранение долга,
возобновление расчёта и пять завершённых циклов на одну секунду запроса.
```sh
godot --headless --path . --script res://tests/benchmark_civilization_flows.gd -- tests/artifacts/all_objects_speed6.adventure.json 0.2 profile cycles=100
```
## Новые звёздные партии с целым переходом за цикл — 2026-10-07
На скоростях 4–6 новые звёзды одного типа объединяются по общей границе
завершения жизни и переходят целиком. Временная сетка равна одному полному
циклу (200/2000/20000 игровых лет). Старые постепенные партии из сохранения
не преобразуются; SN/GRB сохраняют прежнюю обработку.
Повторён тот же замер: диагностическое сохранение, скорость 6, шаг 0,2 с,
100 полных циклов с CPU-профилем. Проверка сохранения OK.
| Метрика | До | После |
|---|---:|---:|
| Среднее время полного цикла | 439,228 мс | 445,690 мс |
| Минимум / максимум | 340,370 / 483,152 мс | 345,276 / 486,647 мс |
| Полных циклов в секунду | 2,277 | 2,244 |
| Пропускная способность | 0,455 | 0,449 |
| Обработано игровых лет | 2 000 000 | 2 000 000 |
| Поколения, среднее на цикл | 168,294 мс | 171,039 мс |
| Обработка партий новорождённых, среднее на цикл | 7,768 мс | 6,199 мс |
| Вызовы обработки партий новорождённых | 16 147 | 13 885 |
Общего ускорения на этой выборке нет: среднее выросло примерно на 1,5%.
Обработка новорождённых занимает на 20% меньше времени, но это небольшая
доля полного цикла. Замер включает существующие постепенные партии;
он не доказывает ускорения вселенной, целиком созданной по новой схеме.
Цель пяти полных циклов в секунду остаётся недостигнутой.
Проверки: границы партий — 109, звёздные правила — 333, короткие события —
841, укрупнённые поколения — 28, кадровое выполнение — 35; все без ошибок.
Тестируются точные большие количества, целый однократный переход,
распределённые рождения, сохранения и интеграл производства.
## Первоначальный замер больших сохранений до исправления сетки — 2026-10-07
Созданы `large_star_cohorts_legacy.adventure.json` и
`large_star_cohorts_cycle.adventure.json`: по 6×10^60 звёзд каждого из 15
типов (главная последовательность и красные гиганты), остальные объекты,
жизнь и цивилизации взяты из диагностического сценария. Возраст — 10^12 лет.
Для каждого типа заново сформирована одинаковая история рождения длиной
`min(эффективный срок жизни, 2 млн лет)` перед началом завершения жизни.
Исходные физические количества и этические профили совпадают строго.
Сохранения валидируются и проходят точное сравнение состояния после записи
и чтения JSON с полной точностью float. Сохранение игрока не изменялось.
Старая схема измеряется переключателем `batches.cycle_stars = false`, новая
включена по умолчанию. В обоих вариантах одинаковый остальной код симуляции,
скорость 6, шаг 0,2 с и CPU-профиль. Два прогона по 50 полных циклов на модель,
порядок запусков: старая → новая → новая → старая. Перед каждым прогоном
восстанавливается исходное состояние; каждый обрабатывает 1 млн игровых лет.
| Метрика | Старая схема | Новая схема |
|---|---:|---:|
| Среднее первого прогона | 417,265 мс | 423,419 мс |
| Среднее второго прогона | 431,081 мс | 427,011 мс |
| Среднее всех 100 циклов на модель | 424,173 мс | 425,215 мс |
| Полных циклов в секунду | 2,358 | 2,352 |
| Пропускная способность | 0,472 | 0,470 |
| Звёздных партий в исходном сохранении | 118 | 1 300 |
| Пиковое число звёздных партий | 132 | 1 300 |
| Поколения, среднее на цикл | 157,711 мс | 147,906 мс |
| Производство, среднее на цикл | 28,692 мс | 34,704 мс |
| Применение потоков цивилизаций, среднее на цикл | 58,642 мс | 62,160 мс |
| Изъятие партий, вложенное в поколения | 37,015 мс | 41,379 мс |
| Партии новорождённых, вложенное в поколения | 7,648 мс | 4,248 мс |
**Устойчивого выигрыша полного цикла не обнаружено.** Разница средних —
около +0,25% у новой схемы; в первом парном сравнении она медленнее, во втором
быстрее. Поколения занимают примерно на 6,2% меньше времени, но производство
стало дороже примерно на 21%. Пять полных циклов в секунду не достигнуты.
Причина роста числа партий: для долгоживущих звёзд сетка одного цикла
значительно мельче `lifespan / 40`. Например, у RD прежняя ширина — 1 млрд
лет, новая на скорости 6 — 20 тыс. лет. Поэтому изменение не всегда
укрупняет партии: на таком наборе исходных рождений их стало в 11 раз больше.
Итоговое временное распределение переходов у моделей различается; совпадение
их конечных состояний не является условием этого замера.
Для дальнейшего упрощения следует сохранять широкие группы долгоживущих
звёзд и применять целый переход только к партиям, уже укладывающимся в один
цикл. Это предложение в данном замере не реализовано.
```sh
godot --headless --path . --script res://tests/benchmark_large_star_cohorts.gd -- cycles=50 repeats=2
```
Полные результаты первоначального замера: `large_star_cohorts_before_dynamic_fix_results.json`.
Набор с 1300 партиями сохранён в `large_star_cohorts_before_dynamic_fix_cycle.adventure.json`.
Последующие файлы без этого суффикса относятся к исправленной динамической схеме.
## Исправленное динамическое укрупнение без дробления — 2026-10-07
Ошибка первоначальной реализации устранена: циклическая сетка больше не
заменяет широкие возрастные окна долгоживущих звёзд. При создании новая сетка
используется только если прежнее окно не шире цикла. Иначе используется
прежнее широкое окно. Из существующих партий объединяются две или более
одного типа, чей оставшийся интервал завершения целиком лежит в общем цикле.
Партии, пересекающие границу циклов или занимающие несколько циклов, не делятся.
Повторён парный замер на отдельных больших сохранениях, с теми же исходными
количествами и входными интервалами рождения: скорость 6, шаг 0,2 с,
два прогона по 50 циклов на модель в порядке старая → новая → новая → старая.
Каждый прогон обрабатывает 1 млн игровых лет; состояния валидируются до и
после, записанные файлы проходят точное сравнение после чтения.
| Метрика | Старая схема | Исправленная динамическая |
|---|---:|---:|
| Исходных звёздных партий | 118 | 113 |
| Пиковое число партий | 132 | 127 |
| Число партий после 50 циклов | 130 | 125 |
| Среднее первого прогона | 420,388 мс | 424,307 мс |
| Среднее второго прогона | 431,605 мс | 428,498 мс |
| Среднее всех 100 циклов на модель | 425,996 мс | 426,402 мс |
| Полных циклов в секунду | 2,347 | 2,345 |
| Поколения, среднее на цикл | 158,622 мс | 157,606 мс |
| Партии новорождённых, вложенный интервал | 7,679 мс | 6,693 мс |
На этом наборе партиям стало меньше примерно на 4,2%, вместо прежнего
ошибочного роста до 1300. Проверено, что долгая группа RD остаётся одной
широкой партией и её окно не меняется при добавлении новых звёзд.
Новый тест запрещает рост числа исходных партий в диагностическом сценарии.
Устойчивого ускорения полного цикла по этим прогонам нет: разница средних
около +0,10%; знак разницы в отдельных парных сравнениях меняется.
Обработка партий новорождённых занимает примерно на 13% меньше времени,
но её доля недостаточна для заметного выигрыша всего расчёта.
Цель пяти циклов в секунду по-прежнему не достигнута.
Проверки: границы партий — 117, звёздные правила — 333, короткие события —
841, кадровое выполнение — 35; все без ошибок. Дополнительные проверки
охватывают сохранение широких окон, объединение старых партий с точными
количествами и остатками, соседние циклы и повторное укрупнение.
Актуальные сохранения: `large_star_cohorts_legacy.adventure.json` и
`large_star_cohorts_cycle.adventure.json`; результаты: `large_star_cohorts_results.json`.
## Новая вселенная с множеством объектов и мелких звёздных партий — 2026-10-07
Создано отдельное состояние с нуля по текущим таблицам: 150 типов объектов,
все 44 стадии этических цивилизаций (включая Ct), 15 типов живых звёзд.
Запасы большинства объектов — 10^60 каждого типа; планет и цивилизаций —
10^28. Жизнь размещена у четырёх типов карликов, четверть звёзд каждого типа
связана со структурами. Этические профили различаются между стадиями.
Высокий запас пространства сохраняет жизнь при бластах. Интерфейс не отрисовывался.
Для проверки укрупнения заданы валидные синтетические исторические партии:
40 последовательных циклов завершения × 8 узких партий × 15 типов звёзд.
Все восемь партий типа целиком завершаются в общем цикле; это целевой
стресс-сценарий большого числа партий, а не утверждение, что обычное создание
с неизменным временем жизни обязательно порождает такую фрагментацию.
Исходный состав и интервалы одинаковы для обеих моделей. Долгоживущие звёзды
не дробились алгоритмом: ему переданы уже существующие узкие партии.
До укрупнения — 4800 звёздных партий. Старая схема сохраняет 4560,
исправленная динамическая — 600. Созданы два отдельных диагностических файла,
проверены запись/чтение без изменения состояния и валидация до и после каждого
прогона. Сохранение игрока не затрагивалось.
Два прогона по 40 полных циклов на модель; порядок старая → новая → новая →
старая. Скорость 6, фиксированный шаг 0,2 с, CPU-профиль включён.
Каждый прогон восстанавливает исходное состояние и обрабатывает 800 тыс. лет.
| Метрика | Старая схема | Динамическое укрупнение |
|---|---:|---:|
| Исходных звёздных партий после укрупнения | 4 560 | 600 |
| Пиковое число партий | 4 560 | 607 |
| Среднее первого прогона | 612,606 мс | 530,285 мс |
| Среднее второго прогона | 624,625 мс | 532,578 мс |
| Среднее всех 80 циклов на модель | 618,616 мс | 531,431 мс |
| Полных циклов в секунду | 1,617 | 1,882 |
| Обработанных секунд симуляции / секунду расчёта | 0,323 | 0,376 |
| Поколения, среднее на цикл | 211,916 мс | 138,339 мс |
| Производство, среднее на цикл | 51,201 мс | 38,038 мс |
| Подготовка потоков цивилизаций | 113,371 мс | 113,270 мс |
| Применение потоков цивилизаций | 114,343 мс | 113,820 мс |
| Колонизация | 55,284 мс | 55,291 мс |
| Обработка партий новорождённых, вложенная в поколения | 29,464 мс | 2,967 мс |
**Выигрыш полного цикла подтверждён обоими парными сравнениями:** время
сократилось на 14,1%, ускорение около 1,16 раза. Поколения занимают на 34,7%
меньше времени. Механизм даёт эффект при множестве подходящих для объединения
партий; в предыдущем сценарии с небольшим числом широких партий эффект был
незаметен. Изменение моментов переходов допускается моделью: конечные состояния
двух схем не обязаны совпадать, но обе полностью обработали запрошенный интервал.
Пять циклов в секунду не достигнуты: для данного сценария требуется ещё
примерно 2,66-кратное ускорение. Главные оставшиеся затраты — поколения и
подготовка/применение цивилизационных потоков; колонизация также заметна.
Сохранения: `many_objects_fragmented_stars_legacy.adventure.json` и
`many_objects_fragmented_stars_cycle.adventure.json`.
Полные результаты: `many_objects_fragmented_stars_results.json`.
```sh
godot --headless --path . --script res://tests/benchmark_many_objects.gd -- cycles=40 repeats=2
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,345 @@
{
"cycles_per_run": 50,
"delta_seconds": 0.2,
"profile_enabled": true,
"repeats": 2,
"runs": [
{
"cycles": 50,
"cycles_per_second": 2.39655612966922,
"final_stellar_cohorts": 130,
"game_years": "1000000",
"max_ms": 454.421,
"mean_ms": 417.26542,
"min_ms": 349.968,
"model": "legacy",
"p95_ms": 448.31,
"peak_stellar_cohorts": 132,
"profile": {
"assign_homes": [
58334,
200
],
"civilization_flow_capacity": [
308485,
50
],
"civilization_flow_colonies": [
272661,
50
],
"civilization_flow_finish": [
432935,
50
],
"civilization_flow_ledger": [
2884631,
50
],
"civilization_flow_plan": [
5425115,
50
],
"civilization_operator_build": [
151687,
50
],
"civilization_operator_solve": [
1609348,
2150
],
"enforce_capacity": [
871224,
200
],
"generation_products": [
542267,
100
],
"generation_withdrawal": [
1820057,
150
],
"generations": [
7751170,
50
],
"initialize": [
297786,
50
],
"newborn_batches": [
375307,
8322
],
"physical_amounts_build": [
1351719,
900
],
"production": [
1410303,
50
],
"trends": [
879262,
50
]
},
"repeat": 1,
"throughput": 0.479311225933843
},
{
"cycles": 50,
"cycles_per_second": 2.36172713483247,
"final_stellar_cohorts": 1300,
"game_years": "1000000",
"max_ms": 460.373,
"mean_ms": 423.41894,
"min_ms": 350.862,
"model": "cycle",
"p95_ms": 458.712,
"peak_stellar_cohorts": 1300,
"profile": {
"assign_homes": [
52297,
176
],
"civilization_flow_capacity": [
314752,
50
],
"civilization_flow_colonies": [
277416,
50
],
"civilization_flow_finish": [
442436,
50
],
"civilization_flow_ledger": [
3097867,
50
],
"civilization_flow_plan": [
5500302,
50
],
"civilization_operator_build": [
156156,
50
],
"civilization_operator_solve": [
1624350,
2150
],
"enforce_capacity": [
841074,
176
],
"generation_products": [
441260,
76
],
"generation_withdrawal": [
2058882,
126
],
"generations": [
7358962,
50
],
"initialize": [
304251,
50
],
"newborn_batches": [
211077,
5960
],
"physical_amounts_build": [
1293092,
826
],
"production": [
1725690,
50
],
"trends": [
899707,
50
]
},
"repeat": 1,
"throughput": 0.472345426966493
},
{
"cycles": 50,
"cycles_per_second": 2.34186004575995,
"final_stellar_cohorts": 1300,
"game_years": "1000000",
"max_ms": 465.408,
"mean_ms": 427.011,
"min_ms": 351.168,
"model": "cycle",
"p95_ms": 460.645,
"peak_stellar_cohorts": 1300,
"profile": {
"assign_homes": [
52872,
176
],
"civilization_flow_capacity": [
317075,
50
],
"civilization_flow_colonies": [
279484,
50
],
"civilization_flow_finish": [
446195,
50
],
"civilization_flow_ledger": [
3118109,
50
],
"civilization_flow_plan": [
5537625,
50
],
"civilization_operator_build": [
158082,
50
],
"civilization_operator_solve": [
1637405,
2150
],
"enforce_capacity": [
854481,
176
],
"generation_products": [
445328,
76
],
"generation_withdrawal": [
2078977,
126
],
"generations": [
7431634,
50
],
"initialize": [
308950,
50
],
"newborn_batches": [
213741,
5960
],
"physical_amounts_build": [
1308770,
826
],
"production": [
1744677,
50
],
"trends": [
903605,
50
]
},
"repeat": 2,
"throughput": 0.468372009151989
},
{
"cycles": 50,
"cycles_per_second": 2.31974771537327,
"final_stellar_cohorts": 130,
"game_years": "1000000",
"max_ms": 462.587,
"mean_ms": 431.08136,
"min_ms": 364.143,
"model": "legacy",
"p95_ms": 461.998,
"peak_stellar_cohorts": 132,
"profile": {
"assign_homes": [
60468,
200
],
"civilization_flow_capacity": [
318914,
50
],
"civilization_flow_colonies": [
281067,
50
],
"civilization_flow_finish": [
446947,
50
],
"civilization_flow_ledger": [
2979604,
50
],
"civilization_flow_plan": [
5570636,
50
],
"civilization_operator_build": [
158593,
50
],
"civilization_operator_solve": [
1643754,
2150
],
"enforce_capacity": [
904863,
200
],
"generation_products": [
564253,
100
],
"generation_withdrawal": [
1881469,
150
],
"generations": [
8019908,
50
],
"initialize": [
312899,
50
],
"newborn_batches": [
389499,
8322
],
"physical_amounts_build": [
1396769,
900
],
"production": [
1458932,
50
],
"trends": [
909402,
50
]
},
"repeat": 2,
"throughput": 0.463949543074653
}
],
"speed": 6
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,345 @@
{
"cycles_per_run": 50,
"delta_seconds": 0.2,
"profile_enabled": true,
"repeats": 2,
"runs": [
{
"cycles": 50,
"cycles_per_second": 2.3787548645537,
"final_stellar_cohorts": 130,
"game_years": "1000000",
"max_ms": 453.742,
"mean_ms": 420.388,
"min_ms": 354.113,
"model": "legacy",
"p95_ms": 452.604,
"peak_stellar_cohorts": 132,
"profile": {
"assign_homes": [
59021,
200
],
"civilization_flow_capacity": [
310616,
50
],
"civilization_flow_colonies": [
276485,
50
],
"civilization_flow_finish": [
436890,
50
],
"civilization_flow_ledger": [
2908982,
50
],
"civilization_flow_plan": [
5444724,
50
],
"civilization_operator_build": [
152980,
50
],
"civilization_operator_solve": [
1604917,
2150
],
"enforce_capacity": [
879602,
200
],
"generation_products": [
550010,
100
],
"generation_withdrawal": [
1834842,
150
],
"generations": [
7827667,
50
],
"initialize": [
300448,
50
],
"newborn_batches": [
379245,
8322
],
"physical_amounts_build": [
1360704,
900
],
"production": [
1417179,
50
],
"trends": [
884554,
50
]
},
"repeat": 1,
"throughput": 0.47575097291074
},
{
"cycles": 50,
"cycles_per_second": 2.3567861205283,
"final_stellar_cohorts": 125,
"game_years": "1000000",
"max_ms": 461.919,
"mean_ms": 424.30664,
"min_ms": 355.62,
"model": "cycle",
"p95_ms": 459.138,
"peak_stellar_cohorts": 127,
"profile": {
"assign_homes": [
59817,
200
],
"civilization_flow_capacity": [
314882,
50
],
"civilization_flow_colonies": [
279378,
50
],
"civilization_flow_finish": [
442443,
50
],
"civilization_flow_ledger": [
2950938,
50
],
"civilization_flow_plan": [
5516132,
50
],
"civilization_operator_build": [
156869,
50
],
"civilization_operator_solve": [
1618413,
2150
],
"enforce_capacity": [
892637,
200
],
"generation_products": [
529169,
100
],
"generation_withdrawal": [
1861386,
150
],
"generations": [
7833909,
50
],
"initialize": [
304443,
50
],
"newborn_batches": [
332349,
7522
],
"physical_amounts_build": [
1382887,
900
],
"production": [
1444907,
50
],
"trends": [
896615,
50
]
},
"repeat": 1,
"throughput": 0.471357224105661
},
{
"cycles": 50,
"cycles_per_second": 2.33373492243879,
"final_stellar_cohorts": 125,
"game_years": "1000000",
"max_ms": 471.891,
"mean_ms": 428.49768,
"min_ms": 363.562,
"model": "cycle",
"p95_ms": 458.377,
"peak_stellar_cohorts": 127,
"profile": {
"assign_homes": [
60419,
200
],
"civilization_flow_capacity": [
317546,
50
],
"civilization_flow_colonies": [
281187,
50
],
"civilization_flow_finish": [
447446,
50
],
"civilization_flow_ledger": [
2973320,
50
],
"civilization_flow_plan": [
5561963,
50
],
"civilization_operator_build": [
157327,
50
],
"civilization_operator_solve": [
1631869,
2150
],
"enforce_capacity": [
900410,
200
],
"generation_products": [
541714,
100
],
"generation_withdrawal": [
1875079,
150
],
"generations": [
7926735,
50
],
"initialize": [
307215,
50
],
"newborn_batches": [
336903,
7522
],
"physical_amounts_build": [
1400661,
900
],
"production": [
1457871,
50
],
"trends": [
908259,
50
]
},
"repeat": 2,
"throughput": 0.466746984487757
},
{
"cycles": 50,
"cycles_per_second": 2.31693373652923,
"final_stellar_cohorts": 130,
"game_years": "1000000",
"max_ms": 466.374,
"mean_ms": 431.60492,
"min_ms": 366.359,
"model": "legacy",
"p95_ms": 465.621,
"peak_stellar_cohorts": 132,
"profile": {
"assign_homes": [
60684,
200
],
"civilization_flow_capacity": [
318805,
50
],
"civilization_flow_colonies": [
282225,
50
],
"civilization_flow_finish": [
448335,
50
],
"civilization_flow_ledger": [
2980134,
50
],
"civilization_flow_plan": [
5579243,
50
],
"civilization_operator_build": [
157141,
50
],
"civilization_operator_solve": [
1638102,
2150
],
"enforce_capacity": [
904255,
200
],
"generation_products": [
565486,
100
],
"generation_withdrawal": [
1885470,
150
],
"generations": [
8034541,
50
],
"initialize": [
308996,
50
],
"newborn_batches": [
388637,
8322
],
"physical_amounts_build": [
1400258,
900
],
"production": [
1465783,
50
],
"trends": [
913581,
50
]
},
"repeat": 2,
"throughput": 0.463386747305846
}
],
"speed": 6
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,345 @@
{
"cycles_per_run": 40,
"delta_seconds": 0.2,
"profile_enabled": true,
"repeats": 2,
"runs": [
{
"cycles": 40,
"cycles_per_second": 1.6323701614814,
"final_stellar_cohorts": 105,
"game_years": "800000",
"max_ms": 628.888,
"mean_ms": 612.60615,
"min_ms": 564.108,
"model": "legacy",
"p95_ms": 624.987,
"peak_stellar_cohorts": 4560,
"profile": {
"assign_homes": [
48558,
160
],
"civilization_flow_capacity": [
263456,
40
],
"civilization_flow_colonies": [
2192836,
40
],
"civilization_flow_finish": [
401558,
40
],
"civilization_flow_ledger": [
4523753,
40
],
"civilization_flow_plan": [
4491368,
40
],
"civilization_operator_build": [
128185,
40
],
"civilization_operator_solve": [
1314272,
1720
],
"enforce_capacity": [
755412,
160
],
"generation_products": [
621550,
80
],
"generation_withdrawal": [
2524452,
120
],
"generations": [
8393123,
40
],
"initialize": [
293736,
40
],
"newborn_batches": [
1166060,
19424
],
"physical_amounts_build": [
1707597,
675
],
"production": [
2026863,
40
],
"trends": [
716666,
40
]
},
"repeat": 1,
"throughput": 0.32647403229628
},
{
"cycles": 40,
"cycles_per_second": 1.88577938012452,
"final_stellar_cohorts": 99,
"game_years": "800000",
"max_ms": 564.379,
"mean_ms": 530.284725,
"min_ms": 448.092,
"model": "cycle",
"p95_ms": 560.287,
"peak_stellar_cohorts": 607,
"profile": {
"assign_homes": [
39924,
132
],
"civilization_flow_capacity": [
265954,
40
],
"civilization_flow_colonies": [
2204837,
40
],
"civilization_flow_finish": [
404568,
40
],
"civilization_flow_ledger": [
4540902,
40
],
"civilization_flow_plan": [
4517593,
40
],
"civilization_operator_build": [
128322,
40
],
"civilization_operator_solve": [
1324328,
1720
],
"enforce_capacity": [
672871,
132
],
"generation_products": [
184761,
52
],
"generation_withdrawal": [
2150050,
92
],
"generations": [
5519639,
40
],
"initialize": [
295834,
40
],
"newborn_batches": [
118202,
3115
],
"physical_amounts_build": [
1594387,
612
],
"production": [
1526313,
40
],
"trends": [
723713,
40
]
},
"repeat": 1,
"throughput": 0.377155876024903
},
{
"cycles": 40,
"cycles_per_second": 1.87765994001252,
"final_stellar_cohorts": 99,
"game_years": "800000",
"max_ms": 566.428,
"mean_ms": 532.5778,
"min_ms": 456.191,
"model": "cycle",
"p95_ms": 565.345,
"peak_stellar_cohorts": 607,
"profile": {
"assign_homes": [
40238,
132
],
"civilization_flow_capacity": [
264957,
40
],
"civilization_flow_colonies": [
2218466,
40
],
"civilization_flow_finish": [
405624,
40
],
"civilization_flow_ledger": [
4564735,
40
],
"civilization_flow_plan": [
4543992,
40
],
"civilization_operator_build": [
127427,
40
],
"civilization_operator_solve": [
1338172,
1720
],
"enforce_capacity": [
672773,
132
],
"generation_products": [
185633,
52
],
"generation_withdrawal": [
2159985,
92
],
"generations": [
5547513,
40
],
"initialize": [
297516,
40
],
"newborn_batches": [
119187,
3115
],
"physical_amounts_build": [
1590461,
612
],
"production": [
1516716,
40
],
"trends": [
731042,
40
]
},
"repeat": 2,
"throughput": 0.375531988002504
},
{
"cycles": 40,
"cycles_per_second": 1.60095961519335,
"final_stellar_cohorts": 105,
"game_years": "800000",
"max_ms": 660.704,
"mean_ms": 624.625375,
"min_ms": 562.732,
"model": "legacy",
"p95_ms": 638.671,
"peak_stellar_cohorts": 4560,
"profile": {
"assign_homes": [
49183,
160
],
"civilization_flow_capacity": [
268901,
40
],
"civilization_flow_colonies": [
2229856,
40
],
"civilization_flow_finish": [
406135,
40
],
"civilization_flow_ledger": [
4623708,
40
],
"civilization_flow_plan": [
4578330,
40
],
"civilization_operator_build": [
130249,
40
],
"civilization_operator_solve": [
1340466,
1720
],
"enforce_capacity": [
765788,
160
],
"generation_products": [
635386,
80
],
"generation_withdrawal": [
2573324,
120
],
"generations": [
8560126,
40
],
"initialize": [
297396,
40
],
"newborn_batches": [
1191081,
19424
],
"physical_amounts_build": [
1738195,
675
],
"production": [
2069255,
40
],
"trends": [
735591,
40
]
},
"repeat": 2,
"throughput": 0.320191923038669
}
],
"speed": 6
}
+63
View File
@@ -0,0 +1,63 @@
extends SceneTree
## Full-cycle throughput benchmark; diagnostic input is never modified.
const E = preload("res://world/owniverse/math/exact.gd")
func _initialize():
call_deferred("run")
func restore_json_integers(value):
# JSON parses ordinary integer tokens as float. Large integers are $exact.
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_json_integers(value[key])
elif value is Array:
for i in value.size(): value[i] = restore_json_integers(value[i])
return value
func run():
var args = OS.get_cmdline_user_args()
if args.is_empty():
print("Usage: --script tests/benchmark_civilization_flows.gd -- /path/to/save.adventure.json [delta_seconds] [profile] [cycles=100]")
quit(1)
return
var data = JSON.parse_string(FileAccess.get_file_as_string(args[0]))
if not data is Dictionary or data.get("format_version") != 6:
push_error("Expected diagnostic JSON format 6")
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)
return
for speed in [6]:
var world = load("res://world/owniverse/owniverse.gd").new()
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 = []
var cycles: int = 100
for arg in args:
if arg.begins_with("cycles="): cycles = maxi(1, arg.trim_prefix("cycles=").to_int())
var initial_clock: Variant = world.state.time.datetime
for cycle in cycles:
var start: int = Time.get_ticks_usec()
world.evolute(delta)
elapsed.append((Time.get_ticks_usec() - start) / 1000.0)
print("Speed %d, %d complete %.3f-second steps" % [speed, cycles, delta])
print("Min/max ms: ", elapsed.min(), "/", elapsed.max())
print("Game years applied: ", E.fixed(E.subtract(world.state.time.datetime, initial_clock)))
var total_ms: float = 0.0
for sample in elapsed: total_ms += sample
var throughput: float = float(cycles) * delta / (total_ms / 1000.0)
print("Mean complete cycle ms: ", total_ms / cycles)
print("Full cycles/s: ", float(cycles) * 1000.0 / total_ms)
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 @@
uid://biwpwcsboh0s7
+1 -1
View File
@@ -40,7 +40,7 @@ func run():
for id in stage.owniverse.catalog.entities: for id in stage.owniverse.catalog.entities:
if E.gt(stage.owniverse.ledger.physical_amount(stage.owniverse.state, id), 0): remaining_objects += 1 if E.gt(stage.owniverse.ledger.physical_amount(stage.owniverse.state, id), 0): remaining_objects += 1
print("Remaining objects: ", remaining_objects) print("Remaining objects: ", remaining_objects)
print("Final adaptive step: ", stage.owniverse.frame_step_seconds) print("Simulation step: ", stage.owniverse.frame_step_seconds)
for entity in stage.owniverse.catalog.entities.values(): entity.free() for entity in stage.owniverse.catalog.entities.values(): entity.free()
stage.owniverse.free() stage.owniverse.free()
scene.free() scene.free()
+130
View File
@@ -0,0 +1,130 @@
extends SceneTree
## Paired old/new cohort timing on separate generated large diagnostic saves.
const E = preload("res://world/owniverse/math/exact.gd")
var cycles: int = 50
var repeats: int = 2
var artifact_prefix: String = "large_star_cohorts"
func _initialize(): call_deferred("run")
func restore_json_integers(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_json_integers(value[key])
elif value is Array:
for i in value.size(): value[i] = restore_json_integers(value[i])
return value
func dispose(world):
for entity in world.catalog.entities.values(): entity.free()
world.free()
func world_from(state: Dictionary, use_cycles: bool):
var world = load("res://world/owniverse/owniverse.gd").new()
world.catalog.load_definitions(root.get_node("Global").OwniverseEntities)
world.state = state.duplicate(true)
world.speed = 6
world.batches.cycle_stars = use_cycles
return world
func cohort_count(world) -> int:
var total: int = 0
for id in world.state.batches:
if world.catalog.entities[id].group_as == "Star": total += world.state.batches[id].size()
return total
func make_state(initial: Dictionary, use_cycles: bool) -> Dictionary:
var world = world_from(initial, use_cycles)
var anchor: Variant = E.of("1e12")
var shift: Variant = E.subtract(anchor, world.state.time.datetime)
for id in world.state.batches:
for batch: Dictionary in world.state.batches[id]:
for field in ["created_from", "created_to", "start_year", "end_year", "processed_until"]:
if batch.has(field): batch[field] = E.add(batch[field], shift)
for id in world.state.batch_origins:
world.state.batch_origins[id] = E.add(world.state.batch_origins[id], shift)
world.state.time = {"datetime": anchor, "year": anchor}
var physical: Dictionary = world.ledger.physical_amounts(world.state)
var populations: Dictionary = {}
for id in world.catalog.entities:
var entity = world.catalog.entities[id]
if entity.group_as != "Star" or E.le(entity.lifespan, 0) or E.le(physical.get(id, 0), 0): continue
world.state.batches[id] = []
world.state.batch_origins.erase(id)
var lifespan: Variant = world.batches.effective_lifespan(world.state, id)
var birth_from: Variant = E.subtract(anchor, lifespan)
var duration: Variant = E.minimum(lifespan, 2000000)
world.batches.append_batch(world.state, id, physical[id], birth_from, E.add(birth_from, duration))
populations[id] = E.fixed(physical[id])
var payload: Dictionary = {"format_version": 6, "app_version": "0.7.0", "ui": {"current_page": 0}, "state": world.state,
"simulation": {"active_clock": {}, "requested_clock": {}, "productions": [], "speed": 6}}
var error: String = root.get_node("SaveManager").validate_data(payload)
assert(error.is_empty(), error)
var suffix: String = "cycle" if use_cycles else "legacy"
var path: String = "res://tests/artifacts/%s_%s.adventure.json" % [artifact_prefix, suffix]
var output = FileAccess.open(path, FileAccess.WRITE)
assert(output != null)
output.store_string(JSON.stringify(E.encode(payload), " ", true, true))
output.close()
print("Generated %s: star_types=%d stellar_cohorts=%d populations=%s" % [suffix, populations.size(), cohort_count(world), str(populations)])
var restored: Dictionary = E.decode(restore_json_integers(JSON.parse_string(FileAccess.get_file_as_string(path))))
assert(E.eq(restored.state, world.state), "Diagnostic file roundtrip changed initial state")
dispose(world)
return restored.state
func measure(initial: Dictionary, use_cycles: bool, run_index: int) -> Dictionary:
var world = world_from(initial, use_cycles)
world.profile_enabled = true
var samples: Array = []
var total_usec: int = 0
var initial_year: Variant = world.state.time.datetime
var peak_cohorts: int = cohort_count(world)
for cycle in cycles:
var started: int = Time.get_ticks_usec()
world.evolute(0.2)
var elapsed: int = Time.get_ticks_usec() - started
total_usec += elapsed
samples.append(elapsed / 1000.0)
peak_cohorts = maxi(peak_cohorts, cohort_count(world))
var applied: Variant = E.subtract(world.state.time.datetime, initial_year)
assert(E.eq(applied, cycles * 20000))
var error: String = root.get_node("SaveManager").validate_data({"format_version": 6, "app_version": "0.7.0", "ui": {"current_page": 0}, "state": world.state})
assert(error.is_empty(), error)
samples.sort()
var result: Dictionary = {"model": "cycle" if use_cycles else "legacy", "repeat": run_index, "cycles": cycles,
"mean_ms": total_usec / (1000.0 * cycles), "min_ms": samples.front(), "max_ms": samples.back(), "p95_ms": samples[int(ceil(samples.size() * 0.95)) - 1],
"cycles_per_second": cycles * 1000000.0 / total_usec, "throughput": cycles * 200000.0 / total_usec,
"game_years": E.fixed(applied), "peak_stellar_cohorts": peak_cohorts, "final_stellar_cohorts": cohort_count(world), "profile": world.profile.duplicate(true)}
print("PAIRED_RESULT ", JSON.stringify(result))
dispose(world)
return result
func run():
for arg in OS.get_cmdline_user_args():
if arg.begins_with("cycles="): cycles = maxi(1, arg.trim_prefix("cycles=").to_int())
if arg.begins_with("repeats="): repeats = maxi(1, arg.trim_prefix("repeats=").to_int())
var raw = JSON.parse_string(FileAccess.get_file_as_string("res://tests/artifacts/all_objects_speed6.adventure.json"))
var payload: Dictionary = E.decode(restore_json_integers(raw))
assert(root.get_node("SaveManager").validate_data(payload).is_empty())
var legacy: Dictionary = make_state(payload.state, false)
var current: Dictionary = make_state(payload.state, true)
var reference = world_from(legacy, false)
var candidate = world_from(current, true)
assert(E.eq(reference.ledger.physical_amounts(reference.state), candidate.ledger.physical_amounts(candidate.state)))
assert(E.eq(reference.state.ethics, candidate.state.ethics))
assert(cohort_count(candidate) <= cohort_count(reference), "Dynamic widening increased the fixture cohort count")
dispose(reference)
dispose(candidate)
if OS.get_cmdline_user_args().has("generate_only"):
print("Generated states validate and survive disk roundtrip; physical amounts and ethics match")
quit()
return
var results: Array = []
for repeat_index in repeats:
var order: Array = [false, true] if repeat_index % 2 == 0 else [true, false]
for use_cycles in order:
results.append(measure(current if use_cycles else legacy, use_cycles, repeat_index + 1))
var output = FileAccess.open("res://tests/artifacts/%s_results.json" % artifact_prefix, FileAccess.WRITE)
output.store_string(JSON.stringify({"delta_seconds": 0.2, "speed": 6, "cycles_per_run": cycles, "repeats": repeats, "profile_enabled": true, "runs": results}, " "))
output.close()
quit()
@@ -0,0 +1 @@
uid://hl20vbt440nb
+111
View File
@@ -0,0 +1,111 @@
extends "res://tests/benchmark_large_star_cohorts.gd"
## Fresh all-object stress world with many valid cohorts sharing death cycles.
const Fixture = preload("res://tests/fixtures/civilization_benchmark.gd")
var death_cycles: int = 40
var fragments_per_cycle: int = 8
func _initialize():
artifact_prefix = "many_objects_fragmented_stars"
cycles = 40
call_deferred("run")
func make_state(_initial: Dictionary, use_cycles: bool) -> Dictionary:
var world = load("res://world/owniverse/owniverse.gd").new()
world.catalog.load_definitions(root.get_node("Global").OwniverseEntities)
var global: Node = root.get_node("Global")
Fixture.seed(world, global)
world.batches.cycle_stars = use_cycles
var anchor: Variant = E.of("1e12")
var stock: Variant = E.of("1e60")
var planets: Variant = E.of("1e28")
world.state.time = {"datetime": anchor, "year": anchor}
world.state.energy = E.of("1e100")
world.state.batches = {}
world.state.batch_origins = {}
world.state.binds = {}
world.state.ethics = {}
for id in world.catalog.entities:
world.state.amount[id] = 0 if world.catalog.is_group(id) else (planets if world.catalog.entities[id].group_as == "Planet" else stock)
world.state.stats.total_amount[id] = world.state.amount[id]
# Keep the synthetic universe sparse enough for blast damage to coexist
# with all life stages instead of sterilizing them on the first cycle.
world.state.amount.S = E.of("1e300")
world.state.amount.H = E.of("1e80")
world.state.amount.He = E.of("1e79")
world.state.amount.Met = E.of("1e79")
var dwarfs: Array = ["RD", "OD", "YD", "WYD"]
var stages: int = 0
for id in global.CivilizationRules:
world.state.amount[id] = planets
var weights: Array = [0.35, 0.2, 0.15, 0.12, 0.1, 0.08]
var profile: Dictionary = {}
var ethics: Array = ["m", "l", "w", "d", "c", "b"]
for e in 6: profile[ethics[e]] = weights[(e + stages) % 6]
world.state.ethics[id] = profile
stages += 1
# Planetary stages are hosted by all four kinds of dwarf stars.
for id in world.catalog.entities:
if world.catalog.entities[id].group_as != "Planet" or world.catalog.is_group(id): continue
if global.CivilizationRules.has(id) and str(global.CivilizationRules[id].home) != "": continue
var held: Variant = E.divide(world.state.amount[id], 4)
world.state.amount[id] = 0
for dwarf in dwarfs:
if not world.state.binds.has(dwarf): world.state.binds[dwarf] = {}
world.state.binds[dwarf][id] = held
# Bound stars remain physical and continue producing inside structures.
var structures: Array = ["SCD", "SCM", "SCG", "GEM", "GSD"]
var stellar_types: int = 0
for id in world.catalog.entities:
if world.catalog.entities[id].group_as != "Star" or E.le(world.catalog.entities[id].lifespan, 0): continue
var held: Variant = E.divide(stock, 4)
var owner: String = structures[stellar_types % structures.size()]
if not world.state.binds.has(owner): world.state.binds[owner] = {}
world.state.binds[owner][id] = held
world.state.amount[id] = E.subtract(stock, held)
stellar_types += 1
world.ledger.refresh_groups(world.state)
var physical: Dictionary = world.ledger.physical_amounts(world.state)
var active_types: int = 0
for id in world.catalog.entities:
if world.catalog.is_group(id) or E.le(physical.get(id, 0), 0): continue
active_types += 1
world.state.stats.total_amount[id] = physical[id]
var entity = world.catalog.entities[id]
if E.le(entity.lifespan, 0) or global.CivilizationRules.has(id): continue
if entity.group_as != "Star":
world.batches.append_batch(world.state, id, physical[id], anchor, anchor)
continue
# Valid but deliberately fragmented historical age cohorts. The test
# stresses merge eligibility, rather than claiming these are produced
# by the ordinary steady-state creation path with one fixed origin.
var span: Variant = world.batches.effective_lifespan(world.state, id)
var count: int = death_cycles * fragments_per_cycle
var assigned: Variant = 0
world.state.batches[id] = []
for n in count:
var cycle: int = n / fragments_per_cycle
var fragment: int = n % fragments_per_cycle
var start: Variant = E.add(anchor, cycle * 20000 + (fragment + 1) * 1800)
var end: Variant = E.add(start, 500)
var quota: Variant = E.floor_value(E.mul_div(physical[id], n + 1, count))
world.state.batches[id].append({"amount": E.subtract(quota, assigned), "created_from": E.subtract(start, span), "created_to": E.subtract(end, span), "start_year": start, "end_year": end})
assigned = quota
assert(E.eq(assigned, physical[id]))
world.state.batch_origins[id] = world.state.batches[id][0].created_from
var raw_cohorts: int = cohort_count(world)
world.batches.coarsen(world.state)
var payload: Dictionary = {"format_version": 6, "app_version": "0.7.0", "ui": {"current_page": 0}, "state": world.state,
"simulation": {"active_clock": {}, "requested_clock": {}, "productions": [], "speed": 6}}
var error: String = root.get_node("SaveManager").validate_data(payload)
assert(error.is_empty(), error)
var suffix: String = "cycle" if use_cycles else "legacy"
var path: String = "res://tests/artifacts/%s_%s.adventure.json" % [artifact_prefix, suffix]
var output = FileAccess.open(path, FileAccess.WRITE)
assert(output != null)
output.store_string(JSON.stringify(E.encode(payload), " ", true, true))
output.close()
var restored: Dictionary = E.decode(restore_json_integers(JSON.parse_string(FileAccess.get_file_as_string(path))))
assert(E.eq(restored.state, world.state), "Save roundtrip changed the stress state")
print("Generated %s: active_types=%d civilization_stages=%d stellar_types=%d raw_cohorts=%d stored_stellar_cohorts=%d" % [suffix, active_types, stages, stellar_types, raw_cohorts, cohort_count(world)])
dispose(world)
return restored.state
+1
View File
@@ -0,0 +1 @@
uid://d0plagt7blmcf
+98
View File
@@ -109,5 +109,103 @@ func run() -> void:
world.batches.withdraw_batches(world.state, "N", 1) world.batches.withdraw_batches(world.state, "N", 1)
check(world.state.batches.N.is_empty(), "Scaling invalidates readiness and removes zero cohorts") check(world.state.batches.N.is_empty(), "Scaling invalidates readiness and removes zero cohorts")
dispose(world) dispose(world)
for selected_speed in [4, 5, 6]:
world = fixture()
world.speed = selected_speed
world.state.time = {"datetime": 37, "year": 37}
var cycle_width: Variant = E.multiply(E.power(10, selected_speed - 1), E.of("0.2"))
world.catalog.entities.HG.lifespan = E.multiply(cycle_width, 3)
world.state.batches = {}
var life: Variant = world.batches.effective_lifespan(world.state, "HG")
var deadline: Variant = E.add(37, E.multiply(cycle_width, 4))
world.batches.append_batch(world.state, "HG", huge, E.add(37, 1), E.add(37, 1))
world.batches.append_batch(world.state, "HG", 7, E.add(37, 2), E.add(37, 2))
check(world.state.batches.HG.size() == 1 and E.eq(world.state.batches.HG[0].amount, E.add(huge, 7)), "Births in the same death cycle merge exactly")
var point: Dictionary = world.state.batches.HG[0]
check(E.eq(point.start_year, deadline) and E.eq(point.end_year, deadline), "High-speed stellar cohort transitions at one cycle boundary")
var born: Variant = E.add(37, 1)
var delay: Variant = E.subtract(deadline, E.add(born, life))
check(E.ge(delay, 0) and E.le(delay, cycle_width), "Transition is delayed by at most one cycle")
var encoded: Dictionary = E.decode(E.encode(world.state))
check(E.eq(encoded.batches, world.state.batches), "Cycle cohorts survive exact save roundtrip")
check(world.batches.withdraw_batches(world.state, "HG", E.subtract(deadline, 1)).is_empty(), "Cycle cohort is not withdrawn partially")
var expired: Array = world.batches.withdraw_batches(world.state, "HG", deadline)
check(expired.size() == 1 and E.eq(expired[0].amount, E.add(huge, 7)) and world.state.batches.HG.is_empty(), "Boundary withdraws the exact whole cohort once")
check(world.batches.withdraw_batches(world.state, "HG", deadline).is_empty(), "Repeated boundary cannot withdraw twice")
world.batches.append_batch(world.state, "HG", 3, E.add(37, 1), E.add(37, 1))
check(E.eq(world.state.batches.HG[0].amount, 3), "Withdrawal invalidates cycle insertion references")
world.state.batches.HG = []
world.batches.append_batch(world.state, "HG", 11, 37, E.add(37, E.multiply(cycle_width, 3)))
var total: Variant = 0
for batch: Dictionary in world.state.batches.HG:
total = E.add(total, batch.amount)
check(E.eq(batch.start_year, batch.end_year), "Distributed births create point cohorts")
check(E.eq(total, 11) and world.state.batches.HG.size() == 3, "Distributed births retain all integers across death cycles")
var before_coarsen: Dictionary = world.state.duplicate(true)
world.batches.coarsen(world.state)
check(E.eq(before_coarsen, world.state), "Coarsening cannot smear point stellar transitions")
world.state.batches.HG = []
var completed: Dictionary = {}
var cutoff: Variant = E.add(37, E.multiply(cycle_width, 10))
var integral: Variant = world.batches.append_batch(world.state, "HG", 11, 37, E.add(37, E.multiply(cycle_width, 3)), completed, cutoff)
var reference_integral: Variant = 0
total = 0
for part: Dictionary in completed.HG:
total = E.add(total, part.amount)
reference_integral = E.add(reference_integral, E.multiply(part.amount, E.subtract(cutoff, part.to)))
reference_integral = E.subtract(E.multiply(11, E.subtract(cutoff, E.add(37, E.multiply(cycle_width, E.of("1.5"))))), reference_integral)
check(E.eq(total, 11) and world.state.batches.HG.is_empty(), "Already completed stars retain counts without stored batches")
check(E.eq(integral, reference_integral), "Lifetime production agrees with point transitions")
world.speed = 1
world.state.batches.HG = []
world.batches.append_batch(world.state, "HG", 10, 100, 100)
check(E.gt(world.state.batches.HG[0].end_year, world.state.batches.HG[0].start_year), "Low speed retains gradual stellar transitions")
dispose(world)
world = fixture()
world.speed = 6
world.state.amount.RHG = 5
world.batches.append_batch(world.state, "RHG", 5, 0, 0)
world.ledger.refresh_groups(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(), "Cycle-star state born at zero validates: " + validation)
var lifespan: Variant = world.batches.effective_lifespan(world.state, "RHG")
check(E.eq(world.state.batches.RHG[0].start_year, lifespan), "Birth exactly aligned with a death boundary adds no extra cycle")
dispose(world)
world = fixture()
world.speed = 6
world.state.batches.RD = []
world.batches.append_batch(world.state, "RD", huge, 0, 2000000)
check(world.state.batches.RD.size() == 1, "Long-lived stars are not subdivided into cycle buckets")
var long_cohort: Dictionary = world.state.batches.RD[0].duplicate(true)
world.batches.append_batch(world.state, "RD", 7, 10000, 20000)
check(world.state.batches.RD.size() == 1 and E.eq(world.state.batches.RD[0].amount, E.add(huge, 7)), "New births still merge into their original wide stellar group")
check(E.eq(world.state.batches.RD[0].start_year, long_cohort.start_year) and E.eq(world.state.batches.RD[0].end_year, long_cohort.end_year), "Long-lived transition interval is preserved")
world.state.batches.RD = [
{"amount": huge, "start_year": 1000, "end_year": 2000, "withdraw_remainder": 0.25},
{"amount": 7, "start_year": 3000, "end_year": 4000, "withdraw_remainder": 0.5},
{"amount": 9, "start_year": 19000, "end_year": 21000},
{"amount": 11, "start_year": 22000, "end_year": 1000000},
{"amount": 13, "start_year": 21000, "end_year": 22000},
]
var spanning: Dictionary = world.state.batches.RD[2].duplicate(true)
var wide: Dictionary = world.state.batches.RD[3].duplicate(true)
world.batches.coarsen(world.state)
check(world.state.batches.RD.size() == 4, "Only cohorts ending entirely within the same cycle are merged")
var merged: Dictionary = {}
var broad_kept: bool = false
var crossing_kept: bool = false
for batch: Dictionary in world.state.batches.RD:
if E.eq(batch.start_year, 20000) and E.eq(batch.end_year, 20000): merged = batch
if E.eq(batch, wide): broad_kept = true
if E.eq(batch, spanning): crossing_kept = true
check(not merged.is_empty() and E.eq(merged.amount, E.add(huge, 7)) and E.eq(merged.withdraw_remainder, 0.75), "Merged stellar cohort retains exact population and carries")
check(broad_kept and crossing_kept, "Wide and cycle-crossing cohorts remain unchanged")
var once: Dictionary = world.state.duplicate(true)
world.batches.coarsen(world.state)
check(E.eq(world.state, once), "Dynamic stellar merging is idempotent")
world.state.batches.HG = [{"amount": 3, "start_year": 20000, "end_year": 20000}, {"amount": 4, "start_year": 20001, "end_year": 21000}]
world.batches.coarsen(world.state)
check(world.state.batches.HG.size() == 2, "An exact boundary belongs to the preceding cycle")
dispose(world)
print(("Batch boundaries: %d checks, %d failures" % [checks, failures])) print(("Batch boundaries: %d checks, %d failures" % [checks, failures]))
quit((0 if E.eq(failures, 0) else 1)) quit((0 if E.eq(failures, 0) else 1))
+96 -124
View File
@@ -1,164 +1,136 @@
extends SceneTree extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd") const E = preload("res://world/owniverse/math/exact.gd")
const Clock = preload("res://world/owniverse/simulation_time.gd") const State = preload("res://world/owniverse/civilization_state.gd")
var checks: int = 0 var checks: int = 0
var failures: int = 0 var failures: int = 0
func _initialize() -> void: func _initialize(): call_deferred("run")
call_deferred("run") func check(value: bool, message: String) -> void:
checks += 1
func check(condition: bool, message: String) -> void: if not value:
checks = E.add(checks, 1) failures += 1
if (not condition):
failures = E.add(failures, 1)
push_error(message) push_error(message)
func fixture(): func fixture():
var world = load("res://world/owniverse/owniverse.gd").new() var world = load("res://world/owniverse/owniverse.gd").new()
var global: Node = root.get_node("Global") world.catalog.load_definitions(root.get_node("Global").OwniverseEntities)
for id in global.OwniverseEntities: for id in world.catalog.entities:
var entity = load("res://world/owniverse/owniverse_entity.gd").new(global.OwniverseEntities[id])
world.catalog.entities[id] = entity
world.state.amount[id] = 0 world.state.amount[id] = 0
world.state.stats.total_amount[id] = 0 world.state.stats.total_amount[id] = 0
if E.ne(entity.group_as, ""): for id in ["RD", "OD", "YD", "WYD", "BG", "TWD", "SNS", "MGT", "BH25", "SMBH", "SCD", "SCS", "SCM", "SCL", "SCG", "GED", "GES", "GEM", "GEL", "GEG", "LGG", "LSC", "LGF", "LGW", "LUB"]:
world.state.amount[entity.group_as] = 0 if world.state.amount.has(id): world.state.amount[id] = E.of("1e60")
world.state.stats.total_amount[entity.group_as] = 0
world.catalog.entities["N"].life_product = {}
world.catalog.entities["N"].product_per_year_type = "H"
world.catalog.entities["N"].product_per_year_amount = 10
world.catalog.entities["N"].auto = 0
world.state.amount.N = 1
return world return world
func dispose(world) -> void: func dispose(world):
for entity in world.catalog.entities.values(): for entity in world.catalog.entities.values(): entity.free()
entity.free()
world.free() world.free()
func seed_population(world, id: String, count: Variant, time: Variant = 1.0) -> void: func population(world) -> Variant:
world.state.amount.N = 0 var result: Variant = 0
world.state.amount[id] = count for id in world.ledger.physical_amounts(world.state):
world.state.batches[id] = [{"amount": count, "start_year": time, "end_year": time}] if root.get_node("Global").CivilizationRules.has(id) or world.catalog.entities[id].group_as == "Planet":
world.evolution.initialize_life_batches() result = E.add(result, world.ledger.physical_amount(world.state, id))
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 physical:
check(E.ge(physical[id], 0) and E.eq(physical[id], E.floor_value(physical[id])), "Physical counts remain nonnegative whole: " + id)
for id in world.state.ethics: for id in world.state.ethics:
var total: float = 0.0 var total: float = 0.0
for n in world.state.ethics[id].values(): for value in world.state.ethics[id].values():
var share: float = 0.0 + n check(is_finite(value) and value >= 0.0 and value <= 1.0, "Finite normalized ethic shares")
if not is_finite(share) or share < 0.0 or share > 1.0: check(false, "Invalid ethic share") total += value
total += share check(absf(total - 1.0) < 1e-12, "Ethic shares sum to one: " + id)
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 configure(rules: Dictionary, progress: float, degradation: float, span: int = 100) -> void:
for id in rules:
if rules[id].ethics.is_empty(): continue
rules[id].span = span
rules[id].fights = false
rules[id].colonizes = false
rules[id].diplomacy = false
for row in rules[id].ethics.values():
row.progress = progress
row.degradation = degradation
row.war = 0
row.colonization = 0
row.fall = rules[id].previous + ":1"
func run():
var global: Node = root.get_node("Global") var global: Node = root.get_node("Global")
var baseline: Dictionary = global.CivilizationRules.duplicate(true) var baseline: Dictionary = global.CivilizationRules.duplicate(true)
configure(global.CivilizationRules, 1.0, 0.0)
var world = fixture() var world = fixture()
seed_population(world, "C0.2", 60000) var count: Variant = E.of("10000000000000000000000000000000000000007")
world.evolution.evolve_batches(world.state, 1) world.state.amount["C0.2"] = count
check(E.gt(world.state.amount["C0.3"], 0), "C0.2 actually progresses") world.state.ethics["C0.2"] = State.empty_profile()
check(E.gt(world.state.amount["C0.1"], 0), "C0.2 actually degrades") world.civilization_flow.evolve(world.state, 0, 100)
check(E.eq(world.state.civilization_stats.get("wars", 0), 0), "Table disables early wars") check(E.eq(population(world), count), "Continuous chain conserves arbitrary-size whole population")
check(E.eq(E.add(E.add(world.state.amount["C0.2"], world.state.amount["C0.3"]), world.state.amount["C0.1"]), 60000), "Ordinary evolution conserves population") check(absf(E.approx(E.divide(world.state.amount["C0.2"], count)) - exp(-1.0)) < 1e-12, "Survival follows the exponential interval law")
check(E.eq(world.state.stats.total_amount["C0.2"], 0), "Survivors do not count as new civilizations") check(E.gt(world.state.amount["C0.4"], 0), "One interval includes multiple successive stages")
check(E.gt(world.state.civilization_stats.worship, 0), "Worship derived from surviving ethics") check(not world.state.batches.has("C0.2") and not world.state.batches.has("C0.3"), "Civilizations do not create age batches")
invariants(world) invariants(world)
var restored = fixture() var half = fixture()
restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state)))) half.state.amount["C0.2"] = count
world.evolution.evolve_batches(world.state, 2100) half.state.ethics["C0.2"] = State.empty_profile()
restored.evolution.evolve_batches(restored.state, 2100) half.civilization_flow.evolve(half.state, 0, 50)
check(E.eq(world.state, restored.state), "Save resumes deterministic ethical evolution") half.civilization_flow.evolve(half.state, 50, 100)
invariants(world) for id in global.CivilizationRules:
dispose(restored) check(E.approx(E.divide(E.absolute(E.subtract(world.state.amount.get(id, 0), half.state.amount.get(id, 0))), count)) < 1e-12, "Splitting the interval preserves continuous dynamics: " + id)
dispose(half)
dispose(world) dispose(world)
configure(global.CivilizationRules, 0.0, 0.0)
world = fixture() world = fixture()
world.state.amount.RD = 100000000 world.state.amount["C0.2"] = 10001
seed_population(world, "C2.0", 60000) world.state.ethics["C0.2"] = {"m": 1.0, "l": 0.0, "w": 0.0, "d": 0.0, "c": 0.0, "b": 0.0}
world.state.amount["C1.9"] = 10000 world.civilization_flow.evolve(world.state, 0, 100)
world.evolution.initialize_life_batches() check(E.eq(world.state.amount["C0.2"], 10001), "Disabled stage movement retains every individual")
world.evolution.evolve_batches(world.state, 1) check(world.state.ethics["C0.2"].m < 1.0 and world.state.ethics["C0.2"].m > 0.9, "Analytical drift changes shares without materializing populations")
check(E.gt(world.state.civilization_stats.wars, 0), "Wars execute when enabled")
check(E.gt(world.state.civilization_stats.collateral, 0), "Wars affect previous-stage populations")
check(E.gt(world.state.amount["C2.1"], 0), "Winners and peaceful progress reach next stage")
invariants(world) invariants(world)
dispose(world) dispose(world)
configure(global.CivilizationRules, 1.0, 0.0)
world = fixture() world = fixture()
world.state.amount.RD = 1 for id in ["RD", "OD", "YD", "WYD"]: world.state.amount[id] = 0
world.state.binds.RD = {"HP": 5, "GG": 5, "AB": 5, "RP": 5} world.state.amount["C1.9"] = 101
seed_population(world, "C1.6", 60000) world.civilization_flow.evolve(world.state, 0, 100)
for row : Dictionary in global.CivilizationRules["C1.6"].ethics.values(): row.degradation = "0" check(E.eq(world.state.amount["C1.9"], 101), "Unfunded interstellar flows return to their source")
world.evolution.evolve_batches(world.state, 1) check(E.eq(population(world), 101), "Resource admission conserves exact population")
var after: Dictionary = world.ledger.physical_amounts(world.state) dispose(world)
check(E.eq(world.state.civilization_stats.colonies, 20), "Colonization limited to real targets")
check(E.eq(E.add(E.add(E.add(after.HP, after.GG), after.AB), after.RP), 0), "Colonized objects consumed once") configure(global.CivilizationRules, 0.0, 0.0, 1)
check(E.eq(world.state.binds.RD.get("C1.4", 0), 20), "Colonies retain target host and use two-stages-earlier level") world = fixture()
var totals: Variant = 0 world.state.amount["C1.6"] = 60000
for id in after: world.state.binds.RD = {"HP": 5, "RP": 5, "GG": 5, "AB": 5}
if E.eq(world.catalog.entities[id].group_as, "Planet"): totals = E.add(totals, after[id]) global.CivilizationRules["C1.6"].colonizes = true
check(E.eq(totals, 60020), "Colonization replaces planets, does not create extra worlds") for row in global.CivilizationRules["C1.6"].ethics.values(): row.colonization = 1
world.civilization_flow.evolve(world.state, 0, 1)
check(E.eq(world.state.civilization_stats.colonies, 20), "Integrated colonization is limited by real planet stock")
check(E.eq(world.state.binds.RD.get("C1.4", 0), 20), "Colonies retain the target stellar owner")
check(E.eq(population(world), 60020), "Colonization creates precisely the consumed number of occupied worlds")
var physical: Dictionary = world.ledger.physical_amounts(world.state)
for resource in ["HP", "RP", "GG", "AB"]: check(E.eq(physical[resource], 0), "Planet stock consumed once: " + resource)
invariants(world) invariants(world)
dispose(world) dispose(world)
global.CivilizationRules = baseline.duplicate(true) global.CivilizationRules = baseline.duplicate(true)
world = fixture() world = fixture()
world.state.amount.RD = E.of("1e25") world.state.amount["C2.0"] = 60000
world.state.amount.BG = E.of("1e25") world.state.amount["C1.9"] = 10000
world.state.amount.TWD = E.of("1e25") world.civilization_flow.evolve(world.state, 0, 20000)
world.state.amount.SNS = E.of("1e25") check(E.gt(world.state.civilization_stats.wars, 0), "War rewards integrate encounters during the interval")
world.state.amount.MGT = E.of("1e25") check(E.gt(world.state.civilization_stats.collateral, 0), "Collateral damage reaches previous stages")
world.state.amount.BH25 = E.of("1e25") check(E.eq(population(world), 70000), "War outcomes and collateral conserve occupied-world counts")
world.state.amount.SMBH = E.of("1e26")
for id in world.catalog.entities:
if E.ne(world.catalog.entities[id].claim, ""): world.state.amount[id] = E.of("1e12")
# Accelerated deterministic fixture traverses every enabled stage, including Ct.
for id in global.CivilizationRules:
if E.eq(id, "Ct"): continue
global.CivilizationRules[id].fights = false
global.CivilizationRules[id].colonizes = false
global.CivilizationRules[id].diplomacy = false
for row : Dictionary in global.CivilizationRules[id].ethics.values():
row.progress = "1"
row.degradation = "0"
world.catalog.entities[id].lifespan = 1
world.catalog.entities[id].lifespan_delta = 1
global.CivilizationRules[id].span = 1
seed_population(world, "C0.2", 6)
world.evolution.evolve_batches(world.state, 1000)
check(E.eq(world.state.amount.Ct, 6), "Complete C0.2 -> C4.4 -> Ct chain runs")
check((not world.state.batches.has("Ct")), "Transcendence is terminal")
invariants(world) invariants(world)
var restored = fixture()
restored.state = E.decode(E.encode(world.state))
world.civilization_flow.evolve(world.state, 20000, 30000)
restored.civilization_flow.evolve(restored.state, 20000, 30000)
check(E.eq(world.state, restored.state), "Save resume reproduces flow populations and carries")
dispose(restored)
dispose(world) dispose(world)
global.CivilizationRules = baseline global.CivilizationRules = baseline
print("Civilization flows: %d checks, %d failures" % [checks, failures])
world = fixture() quit(1 if failures else 0)
world.state.amount.RD = 10000
world.state.amount.SCS = 1
world.state.amount["C2.4"] = 1
world.evolution.initialize_life_batches()
var span: Variant = world.state.batches["C2.4"][0].start_year
check(E.near(span, E.divide(20000, E.of("3.6"))), "Structure bonus shortens newly registered lifespan")
dispose(world)
var evolution = load("res://world/owniverse/civilization_evolution.gd")
var balanced: Dictionary = {"m": 10000, "l": 10000, "w": 10000, "d": 10000, "c": 10000, "b": 10000}
var neutral: Dictionary = evolution.drift({}, "test", balanced, global.CivilizationRules["C2.0"].ethics)
var diplomatic: Dictionary = evolution.drift({}, "test", balanced, global.CivilizationRules["C2.0"].ethics, true)
check(E.eq(neutral, balanced), "Ordinary drift preserves balanced ethics")
check(E.gt(diplomatic.d, diplomatic.w), "Diplomacy uses per-ethic influence")
check(E.eq(evolution.total(diplomatic), 60000), "Diplomacy conserves population")
world = fixture()
world.state.amount.RD = E.of("1e15")
world.state.amount.BG = E.of("1e15")
world.state.amount["C3.0"] = 6
world.evolution.initialize_life_batches()
check((E.eq(world.state.amount["C3.0"], 0) and E.eq(world.state.amount["C2.9"], 6)), "Missing mandatory structures degrade existing civilizations")
invariants(world)
dispose(world)
print(("Civilization evolution: %d checks, %d failures" % [checks, failures]))
quit((0 if E.eq(failures, 0) else 1))
+3 -2
View File
@@ -79,7 +79,8 @@ func run() -> void:
row.colonization = "0" row.colonization = "0"
world.state.binds.RD = {"C1.9": 10} world.state.binds.RD = {"C1.9": 10}
world.state.batches["C1.9"] = [{"amount": 10, "start_year": 1, "end_year": 1}] world.state.batches["C1.9"] = [{"amount": 10, "start_year": 1, "end_year": 1}]
world.evolution.evolve_batches(world.state, 1) global.CivilizationRules["C1.9"].span = E.of("0.001")
world.civilization_flow.evolve(world.state, 0, 1)
check((E.eq(world.state.amount["C2.0"], 10) and E.eq(world.state.binds.RD.get("C2.0", 0), 0)), "Space successor does not inherit planetary binding") check((E.eq(world.state.amount["C2.0"], 10) and E.eq(world.state.binds.RD.get("C2.0", 0), 0)), "Space successor does not inherit planetary binding")
dispose(world) dispose(world)
var capacity = load("res://world/owniverse/civilization_state.gd") var capacity = load("res://world/owniverse/civilization_state.gd")
@@ -133,7 +134,7 @@ func run() -> void:
row.colonization = "0" row.colonization = "0"
world.state.binds.RD = {"C1.9": 5} world.state.binds.RD = {"C1.9": 5}
world.state.batches["C1.9"] = [{"amount": 5, "start_year": 1, "end_year": 1}] world.state.batches["C1.9"] = [{"amount": 5, "start_year": 1, "end_year": 1}]
world.evolution.evolve_batches(world.state, 1) world.civilization_flow.evolve(world.state, 0, 1)
check((E.eq(world.state.amount["C2.0"], 2) and E.eq(world.state.binds.RD["C1.9"], 3)), "Unfunded transition retains the original planetary civilization") check((E.eq(world.state.amount["C2.0"], 2) and E.eq(world.state.binds.RD["C1.9"], 3)), "Unfunded transition retains the original planetary civilization")
dispose(world) dispose(world)
print(("Civilization state: %d checks, %d failures" % [checks, failures])) print(("Civilization state: %d checks, %d failures" % [checks, failures]))
+1 -1
View File
@@ -53,7 +53,7 @@ func run():
check(E.eq(physical.get("C0.1", 0), 38300), "C0.1 attempts evolution once") 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") check(E.eq(physical.get("C0.2", 0), 700), "C0.1 successor does not immediately attempt again")
else: else:
check(world.profile.civilization_transitions[1] == 1, "Ethical civilization is processed only once per birth bucket") check(world.profile.civilization_flow_plan[1] == 1, "Ethical civilization uses one continuous interval plan")
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")
+25 -24
View File
@@ -35,7 +35,7 @@ func run():
direct = fixture() direct = fixture()
sliced = fixture() sliced = fixture()
var initial_h: Variant = sliced.state.amount.H var initial_h: Variant = sliced.state.amount.H
sliced.evolute_frame(1.0 / 60.0, self) sliced.evolute_frame(0.2, self)
check(sliced.frame_busy, "Heavy transaction yields before publishing") check(sliced.frame_busy, "Heavy transaction yields before publishing")
check(E.eq(sliced.state.time.year, 0), "Published clock stays at completed transaction") check(E.eq(sliced.state.time.year, 0), "Published clock stays at completed transaction")
sliced.request_production("H", 1, true) sliced.request_production("H", 1, true)
@@ -45,7 +45,7 @@ func run():
sliced.speed = 3 sliced.speed = 3
check(sliced.speed == 3 and sliced.state.speed == 6, "Speed change is visible and cannot mutate an unfinished transaction") check(sliced.speed == 3 and sliced.state.speed == 6, "Speed change is visible and cannot mutate an unfinished transaction")
while sliced.frame_busy: await process_frame while sliced.frame_busy: await process_frame
direct.evolute(1.0 / 60.0) direct.evolute(0.2)
direct.production.produce_hs("H", 1) direct.production.produce_hs("H", 1)
direct.speed = 3 direct.speed = 3
check(E.eq(direct.state, sliced.state), "Queued action and speed change survive publication") check(E.eq(direct.state, sliced.state), "Queued action and speed change survive publication")
@@ -60,7 +60,7 @@ func run():
dispose(direct) dispose(direct)
dispose(sliced) dispose(sliced)
var original = fixture() var original = fixture()
original.evolute_frame(1.0 / 60.0, self) original.evolute_frame(0.2, self)
original.request_production("H", 123, true) original.request_production("H", 123, true)
original.evolute_frame(0.01, self) original.evolute_frame(0.01, self)
original.speed = 3 original.speed = 3
@@ -84,7 +84,7 @@ func run():
dispose(resumed) dispose(resumed)
dispose(expected) dispose(expected)
var cancelled = fixture() var cancelled = fixture()
cancelled.evolute_frame(0.133333333333, self) cancelled.evolute_frame(0.2, self)
check(cancelled.frame_busy, "Cancellation fixture has suspended work") check(cancelled.frame_busy, "Cancellation fixture has suspended work")
var budget_ref: RefCounted = cancelled.frame_budget var budget_ref: RefCounted = cancelled.frame_budget
dispose(cancelled) dispose(cancelled)
@@ -96,25 +96,26 @@ func run():
invalid.evolute(1e308) 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") 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) dispose(invalid)
var adaptive = fixture() for selected_speed in range(1, 7):
adaptive.frame_clock = Clock.advance(adaptive.state.time, 10.0, 6) var scheduled = fixture()
var debt_clock: Dictionary = adaptive.frame_clock.duplicate(true) scheduled.speed = selected_speed
check(adaptive.update_frame_step() == 0.2, "Growing debt increases step to 0.2 seconds") scheduled.evolute_frame(0.1, self)
check(adaptive.update_frame_step() == 0.5, "Large debt increases step to bounded 0.5 seconds") check(not scheduled.frame_busy and scheduled.frame_commits == 0, "Wait for a full step at speed %d" % selected_speed)
check(adaptive.update_frame_step() == 0.5 and E.eq(adaptive.frame_clock, debt_clock), "Maximum step preserves requested time") scheduled.evolute_frame(0.1, self)
adaptive.frame_clock = Clock.advance(adaptive.state.time, 0.3, 6) while scheduled.frame_busy: await process_frame
check(adaptive.update_frame_step() == 0.5, "Hysteresis keeps step between thresholds") var cadence_clock: Dictionary = Clock.advance({"datetime": 0, "year": 0}, 0.2, selected_speed)
adaptive.frame_clock = Clock.advance(adaptive.state.time, 0.05, 6) check(scheduled.frame_commits == 1 and E.eq(scheduled.state.time, cadence_clock), "One complete 0.2 second cycle at speed %d" % selected_speed)
check(adaptive.update_frame_step() == 0.2, "Small debt reduces step") scheduled.evolute_frame(0.0, self)
check(adaptive.update_frame_step() == 0.1, "Caught-up simulation returns to base step") check(not scheduled.frame_busy and scheduled.frame_commits == 1, "No extra cycle without elapsed time")
adaptive.frame_clock = Clock.advance(adaptive.state.time, 0.15, 6) dispose(scheduled)
adaptive._frame_cost_ratio = 1.1 var behind = fixture()
check(adaptive.update_frame_step() == 0.2, "Measured slow transactions increase step before two-step debt") behind.evolute_frame(1.0, self)
adaptive.frame_clock = Clock.advance(adaptive.state.time, 0.05, 6) while behind.frame_busy: await process_frame
adaptive.speed = 3 check(E.eq(behind.state.time.datetime, 20000) and E.eq(behind.frame_clock.datetime, 100000), "Debt keeps the fixed step and preserves all requested time")
check(adaptive.update_frame_step() == 1.0 / 60.0, "Low speeds retain 60 Hz scheduling") for cycle in 4:
adaptive.speed = 6 behind.evolute_frame(0.0, self)
check(adaptive.update_frame_step() == 0.1, "Speed switch resets adaptive step") while behind.frame_busy: await process_frame
dispose(adaptive) check(behind.frame_commits == 5 and E.eq(behind.state.time, behind.frame_clock), "One second is processed in exactly five full cycles")
dispose(behind)
print("Frame evolution: %d checks, %d failures" % [checks, failures]) print("Frame evolution: %d checks, %d failures" % [checks, failures])
quit(1 if failures else 0) quit(1 if failures else 0)
+2 -2
View File
@@ -46,7 +46,7 @@ func run() -> void:
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), "Initialization is idempotent") check(E.eq(world.state, before), "Initialization is idempotent")
world.evolution.evolve_batches(world.state, E.add(5000000, E.divide(5000000, 40))) world.evolution.evolve_batches(world.state, E.add(5000000, E.divide(5000000, root.get_node("Global").batch_count)))
check(E.eq(world.state.amount.EON1, 10), "HP abiogenesis follows source probability") check(E.eq(world.state.amount.EON1, 10), "HP abiogenesis follows source probability")
check(E.eq(world.state.amount.RP, 100), "HP degradation produces RP") check(E.eq(world.state.amount.RP, 100), "HP degradation produces RP")
check(E.eq(world.state.amount.HP, 39890), "Unchanged planets retry, not disappear") check(E.eq(world.state.amount.HP, 39890), "Unchanged planets retry, not disappear")
@@ -69,7 +69,7 @@ func run() -> void:
# Test host preservation with a fixed rate, independent of game balance. # Test host preservation with a fixed rate, independent of game balance.
world.catalog.entities.EON5.life_rule.progress = "0.00025" world.catalog.entities.EON5.life_rule.progress = "0.00025"
world.evolution.initialize_life_batches() world.evolution.initialize_life_batches()
world.evolution.evolve_batches(world.state, E.add(500000, E.divide(500000, 40))) world.evolution.evolve_batches(world.state, E.add(500000, E.divide(500000, root.get_node("Global").batch_count)))
check(E.eq(world.state.binds.RD["C0.0"], 10), "Intelligence emerges at the same host star") check(E.eq(world.state.binds.RD["C0.0"], 10), "Intelligence emerges at the same host star")
check(E.eq(world.state.amount["C0.0"], 0), "Life transition keeps planet bound") check(E.eq(world.state.amount["C0.0"], 0), "Life transition keeps planet bound")
check(world.state.batches.has("C0.0"), "New C0.0 starts civilization evolution") check(world.state.batches.has("C0.0"), "New C0.0 starts civilization evolution")
+1 -1
View File
@@ -17,7 +17,7 @@ func run() -> void:
var World = load("res://world/owniverse/owniverse.gd") var World = load("res://world/owniverse/owniverse.gd")
var first = World.new() var first = World.new()
var second = World.new() var second = World.new()
var names: Array = ["batches", "ledger", "production", "automatic", "stellar", "life", "environment", "evolution"] var names: Array = ["batches", "ledger", "production", "automatic", "stellar", "life", "environment", "evolution", "civilization_flow"]
var references: Array = [] var references: Array = []
for name in names: for name in names:
check(E.ne(first.get(name), second.get(name)), E.add("World has its own ", name)) check(E.ne(first.get(name), second.get(name)), E.add("World has its own ", name))
+34 -46
View File
@@ -1,6 +1,7 @@
extends SceneTree extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd") const E = preload("res://world/owniverse/math/exact.gd")
const Civ = preload("res://world/owniverse/civilization_evolution.gd") const Civ = preload("res://world/owniverse/civilization_evolution.gd")
const Q = preload("res://world/owniverse/quantity_math.gd")
const Fixture = preload("res://tests/fixtures/civilization_benchmark.gd") const Fixture = preload("res://tests/fixtures/civilization_benchmark.gd")
var checks: int = 0 var checks: int = 0
var failures: int = 0 var failures: int = 0
@@ -11,52 +12,36 @@ func check(value: bool, message: String):
failures += 1 failures += 1
push_error(message) 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(): func run():
var rows: Dictionary = {} for count in [0, 1, 7, 999999999, E.of("1e40"), E.of("1e400")]:
for ethic in Civ.ETHICS: rows[ethic] = {"diplomacy_rate": "0.125" if ethic == "m" else "1.75"} var carry: Dictionary = {}
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: for round_index in 20:
var pop: Dictionary = {"m": E.of("1e40"), "l": round_index, "w": 17, "d": 4, "c": 91, "b": 2} var weights: Dictionary = {"m": round_index + 1.0, "l": 0.0, "w": 9.0, "d": 4.0, "c": 137.0, "b": 2.0}
var result: Dictionary = Civ.drift(decimal_target, "decimal", pop, decimal_rows, true) var profile: Dictionary = Civ.State.normalize_profile(weights)
check(E.eq(Civ.total(result), Civ.total(pop)), "Decimal diplomatic drift preserves whole population") var shares: Array = []
for ethic in result: for ethic in Civ.ETHICS: shares.append(profile[ethic])
check(E.ge(result[ethic], 0) and E.eq(result[ethic], E.floor_value(result[ethic])), "Decimal drift returns nonnegative whole counts") var result: Dictionary = Q.split_profile(carry, "profile", count, Civ.ETHICS, shares)
check(E.eq(Q.sum(result), count), "Profile quotas conserve arbitrary-size whole totals")
for value in result.values(): check(E.ge(value, 0) and E.eq(value, E.floor_value(value)), "Profile quotas stay nonnegative whole")
for value in carry.values(): check(is_finite(value) and value >= -1.0 and value <= 1.0, "Signed profile carries stay bounded")
check(absf(E.ratio_approx(E.of("1e400"), E.of("3e400")) - 1.0 / 3.0) < 1e-15, "Float profile ratio handles values beyond float range")
for count in [1, 101, E.of("1e40"), E.of("1e400")]:
var stocks: Dictionary = {"a": count, "b": E.add(count, 1), "c": 0}
var total: Variant = Q.sum(stocks)
for limit in [0, count, E.subtract(total, 1), total]:
var allocated: Dictionary = Q.allocate_profiled(stocks, limit)
check(E.eq(Q.sum(allocated), limit), "Profiled stock allocation retains the exact granted total")
for id in stocks: check(E.ge(allocated[id], 0) and E.le(allocated[id], stocks[id]), "Profiled stock allocation respects every owner ceiling")
for probability in [0.1, 1.0 / 3.0, 1.23456789]:
var carries: Dictionary = {}
var old_carry: Variant = 0
for iteration in 20:
var expected: Variant = E.add(E.multiply(count, probability), old_carry)
var whole: Variant = E.floor_value(expected)
old_carry = E.maximum(0, E.subtract(expected, whole))
var actual: Variant = Q.accumulate_rate(carries, "rate", count, probability)
check(E.eq(actual, whole), "Fast float carries preserve the whole result of rate accumulation")
check(E.near(carries.rate, old_carry), "Fast float carries preserve the fractional remainder")
for speed in [1, 4, 6]: for speed in [1, 4, 6]:
var world = load("res://world/owniverse/owniverse.gd").new() var world = load("res://world/owniverse/owniverse.gd").new()
world.catalog.load_definitions(root.get_node("Global").OwniverseEntities) world.catalog.load_definitions(root.get_node("Global").OwniverseEntities)
@@ -70,13 +55,13 @@ func run():
var row: Dictionary = rules[id].ethics.m var row: Dictionary = rules[id].ethics.m
var p: Variant = E.maximum(0, E.multiply(E.of(row.progress), 2)) var p: Variant = E.maximum(0, E.multiply(E.of(row.progress), 2))
var d: Variant = E.maximum(0, E.of(row.degradation)) 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") check(E.near(changed.m.pro, E.divide(p, E.maximum(1, E.add(p, d)))), "Coefficient cache follows modifiers")
modifiers.progress = 1 modifiers.progress = 1
check(E.eq(Civ.coefficients(world, rules, id, modifiers), original), "Coefficient cache restores original rates") check(E.eq(Civ.coefficients(world, rules, id, modifiers), original), "Coefficient cache restores original rates")
var local_rules: Dictionary = rules.duplicate(true) var local_rules: Dictionary = rules.duplicate(true)
local_rules[id].ethics.m.progress = "0.375" local_rules[id].ethics.m.progress = "0.375"
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.near(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.amount = {}
target.binds = {} target.binds = {}
@@ -114,6 +99,9 @@ func run():
expected += n expected += n
check(E.near(Civ.worship(world, target, worship_rules), expected), "Worship cache detects changed rules") 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)
# Civilizations no longer trigger batch generations. Seed an actual
# stellar completion so the selective-rounding path is exercised.
world.state.batches.BG = [{"amount": world.state.amount.BG, "start_year": 1, "end_year": 1}]
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)
check(world.ledger.checks > 0 and world.ledger.failures == 0, "Selective and full rounding agree at speed %d" % speed) check(world.ledger.checks > 0 and world.ledger.failures == 0, "Selective and full rounding agree at speed %d" % speed)
+1 -1
View File
@@ -70,7 +70,7 @@ func run() -> void:
check(E.eq(world.stellar.evolution_products(world.state, world.catalog.entities.HRG, 37), restored.stellar.evolution_products(restored.state, restored.catalog.entities.HRG, 37)), "Evolution carry survives serialization") check(E.eq(world.stellar.evolution_products(world.state, world.catalog.entities.HRG, 37), restored.stellar.evolution_products(restored.state, restored.catalog.entities.HRG, 37)), "Evolution carry survives serialization")
dispose(restored) dispose(restored)
# Real RD lifespan, four stars spread over the existing lifespan/40 window. # Real RD lifespan, four stars spread over one age-group window.
world.state.amount.N = 0 world.state.amount.N = 0
world.state.amount.RD = 4 world.state.amount.RD = 4
world.batches.append_batch(world.state, "RD", 4, 0, 0) world.batches.append_batch(world.state, "RD", 4, 0, 0)
+2 -5
View File
@@ -1,7 +1,6 @@
extends SceneTree extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd") const E = preload("res://world/owniverse/math/exact.gd")
const CivilizationState = preload("res://world/owniverse/civilization_state.gd") const CivilizationState = preload("res://world/owniverse/civilization_state.gd")
const CivilizationEvolution = preload("res://world/owniverse/civilization_evolution.gd")
const Parameters = preload("res://world/owniverse/world_parameters.gd") const Parameters = preload("res://world/owniverse/world_parameters.gd")
const Descriptions = preload("res://world/owniverse/item_description.gd") const Descriptions = preload("res://world/owniverse/item_description.gd")
@@ -98,10 +97,8 @@ func run() -> void:
var next_stage: String = global.CivilizationRules["C0.2"].next var next_stage: String = global.CivilizationRules["C0.2"].next
var outcomes: Array = [] var outcomes: Array = []
for target in [baseline, advanced]: for target in [baseline, advanced]:
var expired: Dictionary = {"C0.2": [{"amount": 1000000, "from": 0, "to": 1}]} world.civilization_flow.evolve(target, 0, 2000)
var budget: Dictionary = CivilizationState.stellar_budget(world, target, global.CivilizationRules) outcomes.append(target.amount.get(next_stage, 0))
CivilizationEvolution.prepare(world, target, expired, 1, budget, global.CivilizationRules)
outcomes.append(expired["C0.2"][0].products.get(next_stage, 0))
check(E.gt(outcomes[1], outcomes[0]), "Aggression changes actual ethical civilization transitions") check(E.gt(outcomes[1], outcomes[0]), "Aggression changes actual ethical civilization transitions")
dispose(world) dispose(world)
+2 -2
View File
@@ -35,7 +35,7 @@ Stage обращается непосредственно к `world.presentation
| `automatic_production.gd` / `automatic` | Выпуск за период и жизнь новорождённых объектов, разделение ресурсов на обычные и автоматические, энергия, квоты звёзд и бурых карликов | | `automatic_production.gd` / `automatic` | Выпуск за период и жизнь новорождённых объектов, разделение ресурсов на обычные и автоматические, энергия, квоты звёзд и бурых карликов |
| `stellar_rules.gd` / `stellar` | Продукты звёздной эволюции, создание планет, выбор звёздных преемников, два уровня разрушения и глобальные бласты | | `stellar_rules.gd` / `stellar` | Продукты звёздной эволюции, создание планет, выбор звёздных преемников, два уровня разрушения и глобальные бласты |
| `life_evolution.gd` / `life` | Прогресс, деградация и повторная попытка планетарной жизни | | `life_evolution.gd` / `life` | Прогресс, деградация и повторная попытка планетарной жизни |
| `civilization_evolution.gd`, `civilization_state.gd` | Существующие правила цивилизаций: этики, войны, колонизация, лимиты и места обитания | | `civilization_flow.gd`, `civilization_state.gd` | Непрерывные потоки цивилизаций, доли этик, целые квоты, лимиты и места обитания |
| `universe_environment.gd` / `environment` | Плотность, металличность, доля попаданий бластов, изменение параметров мира с сохранением возраста звёзд | | `universe_environment.gd` / `environment` | Плотность, металличность, доля попаданий бластов, изменение параметров мира с сохранением возраста звёзд |
| `evolution_coordinator.gd` / `evolution` | Порядок фаз шага, последовательные поколения и публикация нового состояния | | `evolution_coordinator.gd` / `evolution` | Порядок фаз шага, последовательные поколения и публикация нового состояния |
@@ -50,7 +50,7 @@ Stage обращается непосредственно к `world.presentation
1. Определить обработанную десятую года. При продвижении времени восстановить недостающие партии жизни и проверить вместимость. 1. Определить обработанную десятую года. При продвижении времени восстановить недостающие партии жизни и проверить вместимость.
2. Скопировать состояние, посчитать физические количества и производство за период. 2. Скопировать состояние, посчитать физические количества и производство за период.
3. Обработать поколения до конца интервала. Для каждого поколения: локальные разрушения → выбытие партий всех типов → план переходов → резервирование передачи привязок → зачисление преемников и их партий → производство новорождённых → применение привязок и округление → завершение переходов цивилизаций → глобальные бласты → проверка вместимости. 3. Интегрировать непрерывные потоки цивилизаций за весь интервал, затем обработать поколения звёзд и биологической жизни до конца интервала. Для каждого поколения: локальные разрушения → выбытие партий всех типов → план переходов → резервирование передачи привязок → зачисление преемников и их партий → производство новорождённых → применение привязок и округление → глобальные бласты → проверка вместимости.
4. Обновить групповые количества, ограничить автоматические ресурсы и продвинуть параметры мира. 4. Обновить групповые количества, ограничить автоматические ресурсы и продвинуть параметры мира.
5. Опубликовать новое состояние и обновить представление количеств. 5. Опубликовать новое состояние и обновить представление количеств.
+8 -448
View File
@@ -1,191 +1,11 @@
extends RefCounted extends RefCounted
## Shared civilization coefficients and worship derived from float profiles.
const E = preload("res://world/owniverse/math/exact.gd") const E = preload("res://world/owniverse/math/exact.gd")
const QuantityMath = preload("res://world/owniverse/quantity_math.gd")
const State = preload("res://world/owniverse/civilization_state.gd") const State = preload("res://world/owniverse/civilization_state.gd")
const WorldParameters = preload("res://world/owniverse/world_parameters.gd")
const ETHICS = State.ETHICS const ETHICS = State.ETHICS
const OPPOSITE = {"m": "l", "l": "m", "w": "d", "d": "w", "c": "b", "b": "c"} const OPPOSITE = {"m": "l", "l": "m", "w": "d", "d": "w", "c": "b", "b": "c"}
const DIPLOMACY_SCALE: int = 1000000000
static var _diplomacy_rates: Dictionary = {}
static func total(pop: Dictionary) -> Variant: # Cache the weighted float worship rate multiplied by the exact population.
return QuantityMath.sum(pop)
static func add(to: Dictionary, from: Dictionary) -> void:
for key in from: to[key] = E.add(to.get(key, 0), from[key])
static func take(world, pop: Dictionary, count: Variant, population_total: Variant = null) -> Dictionary:
var taken: Dictionary = QuantityMath.allocate_whole_with_total(pop, count, population_total) if population_total != null else QuantityMath.allocate_whole(pop, count)
for key in taken: pop[key] = E.subtract(pop[key], taken[key])
return taken
static func rate(target: Dictionary, key: String, count: Variant, probability: Variant) -> Variant:
if (not target.has("civilization_remainders")): target.civilization_remainders = {}
return QuantityMath.accumulate_rate(target.civilization_remainders, key, count, probability)
# Fixed-size largest-remainder apportionment with saved signed rounding errors.
# Runtime depends on the number of branches, never the number of civilizations.
static func split(target: Dictionary, key: String, count: Variant, weights: Dictionary) -> Dictionary:
if E.le(count, 0): return {}
if (not target.has("civilization_remainders")): target.civilization_remainders = {}
return QuantityMath.split(target.civilization_remainders, key, count, weights)
static func drift(target: Dictionary, key: String, pop: Dictionary, rows: Dictionary, diplomacy: bool = false, colony: bool = false, population: Variant = null) -> Dictionary:
if population == null: population = total(pop)
if E.le(population, 0): return pop.duplicate()
if not diplomacy and E.eq(population, E.floor_value(population)):
return direct_drift(target, key, pop, colony, population)
var weights: Array = []
if not diplomacy:
# Integer populations: exact algebraic reduction of the 6 x 6 matrix.
var common: Variant = E.multiply(population, 9) if colony else population
for ethic in ETHICS:
weights.append(E.subtract(E.add(common, E.multiply(pop.get(ethic, 0), 951 if colony else 195)), E.multiply(pop.get(OPPOSITE[ethic], 0), 5 if colony else 1)))
else:
# Algebraic reduction of the diplomatic 6 x 6 matrix.
var rates: Array = diplomacy_rates(rows)
var contributions: Array = [0, 0, 0, 0, 0, 0]
var combined: Variant = 0
for ethic in pop:
var index: int = ETHICS.find(ethic)
var contribution: Variant = E.multiply(pop[ethic], rates[index])
contributions[index] = contribution
combined = E.add(combined, contribution)
var common: Variant = E.multiply(combined, 9) if colony else combined
for ethic in ETHICS:
weights.append(E.maximum(0, E.subtract(E.add(common, E.multiply(contributions[ETHICS.find(ethic)], 951 if colony else 195)), E.multiply(contributions[ETHICS.find(OPPOSITE[ethic])], 5 if colony else 1))))
if E.le(QuantityMath.sum_array(weights), 0): return pop.duplicate()
if not target.has("civilization_remainders"): target.civilization_remainders = {}
return QuantityMath.split_arrays(target.civilization_remainders, key, population, ETHICS, weights, 0, true)
static func diplomacy_rates(rows: Dictionary) -> Array:
var signature: String = ""
for ethic in ETHICS: signature += str(rows[ethic].diplomacy_rate) + ";"
if _diplomacy_rates.has(signature): return _diplomacy_rates[signature]
var rates: Array = []
for ethic in ETHICS:
var exact_rate: Variant = E.of(rows[ethic].diplomacy_rate)
rates.append(E.integer(E.round_value(E.multiply(exact_rate, DIPLOMACY_SCALE))))
if _diplomacy_rates.size() >= 256: _diplomacy_rates.clear()
rates.make_read_only()
_diplomacy_rates[signature] = rates
return rates
static func direct_drift(target: Dictionary, key: String, pop: Dictionary, colony: bool, population: Variant) -> Dictionary:
if not target.has("civilization_remainders"): target.civilization_remainders = {}
var carries: Dictionary = target.civilization_remainders
var result: Dictionary = {}
for ethic in ETHICS: result[ethic] = 0
for ethic in ETHICS:
if E.ne(pop.get(ethic, 0), E.floor_value(pop.get(ethic, 0))): return fractional_direct_fallback(target, key, pop, colony, population)
var divisor: int = 25 if colony else 50
for source in ETHICS:
var count: Variant = pop.get(source, 0)
if E.le(count, 0): continue
var whole: Variant = E.floor_value(E.divide(count, divisor))
var count_remainder: int = E.integer(E.subtract(count, E.multiply(whole, divisor)))
var remainder_key: String = key + ":flow:" + source
var pending: int = count_remainder + int(carries.get(remainder_key, 0))
var outgoing: Variant = E.add(whole, pending / divisor)
carries[remainder_key] = pending % divisor
result[source] = E.add(result[source], E.subtract(count, outgoing))
if E.le(outgoing, 0): continue
if not colony:
var neighbor_base: Variant = E.floor_value(E.divide(outgoing, 4))
var extras: int = E.integer(E.subtract(outgoing, E.multiply(neighbor_base, 4)))
var neighbors: Array = []
for destination in ETHICS:
if destination != source and destination != OPPOSITE[source]: neighbors.append(destination)
var cursor_key: String = key + ":flow_cursor:" + source
var cursor: int = int(carries.get(cursor_key, 0))
for neighbor_index in 4:
var destination: String = neighbors[neighbor_index]
var add: Variant = E.add(neighbor_base, 1 if ((neighbor_index - cursor + 4) % 4) < extras else 0)
result[destination] = E.add(result[destination], add)
carries[cursor_key] = (cursor + extras) % 4
else:
distribute_colonial_flow(result, carries, key, source, outgoing)
assert(E.eq(total(result), population), "Direct ethic drift must conserve population")
return result
static func distribute_colonial_flow(result: Dictionary, carries: Dictionary, key: String, source: String, outgoing: Variant) -> void:
# Colonial flow is 9:9:9:9:4 among the other ethics (total weight 40).
var base: Variant = E.floor_value(E.divide(outgoing, 40))
var tail: int = E.integer(E.subtract(outgoing, E.multiply(base, 40)))
var destinations: Array = []
var weights: Array = []
for destination in ETHICS:
if destination == source: continue
destinations.append(destination)
weights.append(4 if destination == OPPOSITE[source] else 9)
var assigned: Variant = 0
var rests: Array = []
var quotas: Array = []
for i in destinations.size():
var carry_key: String = key + ":col_flow:" + source + ":" + destinations[i]
var numerator: int = tail * weights[i] + int(carries.get(carry_key, 0))
var extra: int = floori(float(numerator) / 40.0)
var rest: int = numerator - extra * 40
quotas.append(E.add(E.multiply(base, weights[i]), extra))
rests.append(rest)
assigned = E.add(assigned, quotas[i])
carries[carry_key] = rest
var missing: int = E.integer(E.subtract(outgoing, assigned))
var order: Array = [0, 1, 2, 3, 4]
order.sort_custom(func(a: int, b: int) -> bool: return rests[a] > rests[b] if rests[a] != rests[b] else destinations[a] < destinations[b])
for i in order:
if missing <= 0: break
quotas[i] = E.add(quotas[i], 1)
var carry_key: String = key + ":col_flow:" + source + ":" + destinations[i]
carries[carry_key] = int(carries[carry_key]) - 40
missing -= 1
for i in destinations.size(): result[destinations[i]] = E.add(result[destinations[i]], quotas[i])
static func fractional_direct_fallback(target: Dictionary, key: String, pop: Dictionary, colony: bool, population: Variant) -> Dictionary:
var weights: Dictionary = {}
for ethic in ETHICS:
var common: Variant = E.multiply(population, 9) if colony else population
weights[ethic] = E.subtract(E.add(common, E.multiply(pop.get(ethic, 0), 951 if colony else 195)), E.multiply(pop.get(OPPOSITE[ethic], 0), 5 if colony else 1))
if not target.has("civilization_remainders"): target.civilization_remainders = {}
return QuantityMath.split(target.civilization_remainders, key, population, weights)
static func emit(plan: Dictionary, destination: String, pop: Dictionary) -> void:
if (not plan.outputs.has(destination)): plan.outputs[destination] = {}
add(plan.outputs[destination], pop)
static func fall(target: Dictionary, plan: Dictionary, rules: Dictionary, pop: Dictionary) -> void:
for ethic in pop:
if E.le(pop[ethic], 0): continue
var weights: Dictionary = QuantityMath.rule_weights(str(rules[plan.id].ethics[ethic].fall))
if weights.is_empty(): weights = {rules[plan.id].previous: 1}
var shares: Dictionary = split(target, E.add(E.add(E.add("fall:", plan.id), ":"), ethic), pop[ethic], weights)
for stage in shares: emit(plan, stage, {ethic: shares[stage]})
static func progress(world, target: Dictionary, rules: Dictionary, budget: Dictionary, plan: Dictionary, pop: Dictionary, population_total: Variant = null) -> Dictionary:
var successor: String = rules[plan.id].next
if population_total == null: population_total = total(pop)
var admitted: Variant = State.admit_transition(budget, rules, plan.id, successor, population_total)
var accepted: Dictionary = take(world, pop, admitted, population_total)
emit(plan, successor, accepted)
return pop
static func new_plan(id: String, year: Variant) -> Dictionary:
return {"id": id, "from": year, "to": E.neg(INF), "amount": 0, "outputs": {}}
static func consume_untriggered(world, target: Dictionary, plan: Dictionary, pop: Dictionary, count: Variant, year: Variant, population_total: Variant = null) -> Dictionary:
var old_total: Variant = total(pop) if population_total == null else population_total
var actual: Variant = E.floor_value(E.minimum(E.maximum(0, count), old_total))
var removed: Dictionary = take(world, pop, actual, old_total)
if E.gt(actual, 0):
world.batches.scale_batches(target, plan.id, E.divide((E.subtract(old_total, actual)), old_total))
plan.amount = E.add(plan.amount, actual)
plan.to = E.maximum(plan.to, year)
return removed
# Cache products per ethic, then sum them in the original stage/ethic order.
# 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) var physical: Dictionary = world.ledger.physical_amounts(target)
@@ -217,271 +37,11 @@ static func coefficients(world, rules: Dictionary, id: String, modifiers: Dictio
var rates: Dictionary = {} var rates: Dictionary = {}
for ethic in rules[id].ethics: for ethic in rules[id].ethics:
var row: Dictionary = rules[id].ethics[ethic] var row: Dictionary = rules[id].ethics[ethic]
var p: Variant = E.maximum(0, E.multiply(E.of(row.progress), modifiers.progress)) var p: float = maxf(0.0, E.approx(E.of(row.progress)) * E.approx(modifiers.progress))
var d: Variant = E.maximum(0, E.multiply(E.of(row.degradation), modifiers.degradation)) var d: float = maxf(0.0, E.approx(E.of(row.degradation)) * E.approx(modifiers.degradation))
var scale: Variant = E.maximum(1, E.add(p, d)) var scale: float = maxf(1.0, p + d)
rates[ethic] = {"pro": E.divide(p, scale), "deg": E.divide(d, scale), rates[ethic] = {"pro": p / scale, "deg": d / scale,
"war": E.minimum(1, E.multiply(E.of(row.war), modifiers.war)), "war": clampf(E.approx(E.of(row.war)) * E.approx(modifiers.war), 0.0, 1.0),
"col": E.minimum(1, E.multiply(E.of(row.colonization), modifiers.colonization))} "col": clampf(E.approx(E.of(row.colonization)) * E.approx(modifiers.colonization), 0.0, 1.0)}
world.ledger.civilization_coefficients[id] = {"signature": signature, "rates": rates} world.ledger.civilization_coefficients[id] = {"signature": signature, "rates": rates}
return rates return rates
static func prepare(world, target: Dictionary, expired: Dictionary, year: Variant, budget: Dictionary, rules: Dictionary, physical: Dictionary = {}, budget_timer: RefCounted = null, expired_totals: Dictionary = {}) -> Dictionary:
var profile_started: int = world.profile_start()
var ids: Array = []
for id in expired:
if rules.has(id) and not expired[id].is_empty(): ids.append(id)
if ids.is_empty():
world.profile_end("civilization_prepare", profile_started)
return {"active": false}
var reconcile_ids: Array = []
for id in ids:
reconcile_ids.append(id)
if rules[id].fights and not reconcile_ids.has(rules[id].previous): reconcile_ids.append(rules[id].previous)
if budget_timer != null: budget_timer.call("set_phase", "civilization_reconcile")
State.reconcile(world, target, rules, physical, reconcile_ids)
if physical.is_empty(): physical = world.ledger.physical_amounts(target)
var remaining: Dictionary = {}
for id in reconcile_ids:
if rules.has(id):
remaining[id] = State.materialize_profile(target, id, target.ethics.get(id, {}), physical.get(id, 0))
var copied: Dictionary = {}
var plans: Dictionary = {}
var colonies: Array = []
var collateral: Dictionary = {}
var modifiers: Dictionary = WorldParameters.life_modifiers(target)
var order: Variant = WorldParameters.value(target, "Order")
if (not target.has("civilization_stats")): target.civilization_stats = {}
ids.sort()
for id in ids:
if budget_timer != null: budget_timer.call("set_phase", "civilization_stage:" + id)
if budget_timer != null and not await budget_timer.pause(): return {}
if ((not rules.has(id)) or expired[id].is_empty()): continue
var plan: Dictionary = new_plan(id, year)
plans[id] = plan
plan.amount = expired_totals.get(id, 0)
for part : Dictionary in expired[id]:
if not expired_totals.has(id): plan.amount = E.add(plan.amount, part.amount)
plan.from = E.minimum(plan.from, part.from)
plan.to = E.maximum(plan.to, part.to)
remaining[id] = remaining[id].duplicate()
copied[id] = true
var triggered: Dictionary = take(world, remaining[id], plan.amount)
var successes: Dictionary = {}
var failures: Dictionary = {}
var warriors: Dictionary = {}
var settlers: Dictionary = {}
var stage_rates: Dictionary = coefficients(world, rules, id, modifiers)
for ethic in triggered:
if budget_timer != null: budget_timer.call("set_phase", "civilization_ethic:" + id)
if budget_timer != null and not await budget_timer.pause(): return {}
var probability: Dictionary = stage_rates[ethic]
var prefix: String = id + ":" + ethic
var pro: Variant = rate(target, prefix + ":pro", triggered[ethic], probability.pro)
var deg: Variant = rate(target, prefix + ":deg", triggered[ethic], probability.deg)
var bounded: Dictionary = QuantityMath.allocate_whole({"pro": pro, "deg": deg}, triggered[ethic])
if rules[id].fights: warriors[ethic] = rate(target, prefix + ":war", triggered[ethic], probability.war)
if rules[id].colonizes: settlers[ethic] = rate(target, prefix + ":col", triggered[ethic], probability.col)
successes[ethic] = bounded.pro
failures[ethic] = bounded.deg
triggered[ethic] = E.subtract(triggered[ethic], E.add(bounded.pro, bounded.deg))
var profile_transitions: int = world.profile_start()
add(triggered, progress(world, target, rules, budget, plan, successes))
if budget_timer != null and not await budget_timer.pause(): return {}
fall(target, plan, rules, failures)
world.profile_end("civilization_transitions", profile_transitions)
if rules[id].fights:
var profile_wars: int = world.profile_start()
for ethic in warriors:
if budget_timer != null: budget_timer.call("set_phase", "civilization_war:" + id)
if budget_timer != null and not await budget_timer.pause(): return {}
warriors[ethic] = E.minimum(warriors[ethic], triggered[ethic])
var warriors_total: Variant = total(warriors)
var triggered_total: Variant = total(triggered)
var remaining_total: Variant = total(remaining[id])
var wars: Variant = E.minimum(warriors_total, E.floor_value(E.divide(E.add(triggered_total, remaining_total), 2)))
warriors = QuantityMath.allocate_whole_with_total(warriors, wars, warriors_total)
for ethic in warriors: triggered[ethic] = E.subtract(triggered[ethic], warriors[ethic])
triggered_total = E.subtract(triggered_total, wars)
# Integrate the four equally weighted battle outcomes directly.
# Their expected coefficients are wins=17/36, deaths=1/9,
# total losses=29/18, and collateral=40/3 per battle. Rate
# remainders preserve fractional results between generations.
var wins_requested: Variant = rate(target, E.add(id, ":wins"), wars, E.of("17/36"))
var winners: Dictionary = take(world, warriors, wins_requested, wars)
var winner_total: Variant = E.floor_value(E.minimum(E.maximum(0, wins_requested), wars))
warriors_total = E.subtract(wars, winner_total)
var deaths_expected: Variant = E.divide(wars, 9)
var losses_requested: Variant = rate(target, E.add(id, ":attackers_lost"), E.divide(deaths_expected, order), 1)
var attackers_lost: Dictionary = take(world, warriors, losses_requested, warriors_total)
var attackers_lost_total: Variant = E.floor_value(E.minimum(E.maximum(0, losses_requested), warriors_total))
warriors_total = E.subtract(warriors_total, attackers_lost_total)
add(triggered, progress(world, target, rules, budget, plan, winners, winner_total))
triggered_total = total(triggered)
if budget_timer != null and not await budget_timer.pause(): return {}
fall(target, plan, rules, attackers_lost)
remaining_total = total(remaining[id])
var losses_expected: Variant = E.multiply(wars, E.of("29/18"))
var losses: Variant = E.minimum(rate(target, E.add(id, ":loss"), E.divide(losses_expected, order), 1), E.add(triggered_total, remaining_total))
var sides: Dictionary = QuantityMath.allocate_whole_with_total({"due": triggered_total, "later": remaining_total}, losses, E.add(triggered_total, remaining_total))
var victims: Dictionary = take(world, triggered, sides.due, triggered_total)
triggered_total = E.subtract(triggered_total, sides.due)
add(victims, consume_untriggered(world, target, plan, remaining[id], sides.later, year, remaining_total))
if budget_timer != null and not await budget_timer.pause(): return {}
fall(target, plan, rules, victims)
add(triggered, warriors)
triggered_total = E.add(triggered_total, warriors_total)
var damage_expected: Variant = E.multiply(wars, E.of("40/3"))
var damage: Variant = rate(target, E.add(id, ":collateral"), E.divide(damage_expected, order), 1)
collateral[rules[id].previous] = E.add(collateral.get(rules[id].previous, 0), damage)
target.civilization_stats.wars = E.add(target.civilization_stats.get("wars", 0), wars)
world.profile_end("wars", profile_wars)
emit(plan, id, triggered)
if E.gt(total(settlers), 0): colonies.append({"stage": rules[id].colony, "source": id, "population": settlers})
# Collateral is applied after all stages planned their own attempts.
for id in collateral:
if (not rules.has(id)): continue
if not copied.has(id):
remaining[id] = remaining[id].duplicate()
copied[id] = true
if (not plans.has(id)): plans[id] = new_plan(id, year)
var plan: Dictionary = plans[id]
var retry: Dictionary = plan.outputs.get(id, {})
var retry_total: Variant = total(retry)
var remaining_total: Variant = total(remaining[id])
var amount: Variant = E.minimum(collateral[id], 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)
add(victims, consume_untriggered(world, target, plan, remaining[id], sides.later, year, remaining_total))
if budget_timer != null and not await budget_timer.pause(): return {}
fall(target, plan, rules, victims)
target.civilization_stats.collateral = E.add(target.civilization_stats.get("collateral", 0), total(victims))
var incoming: Dictionary = {}
var planetary: Dictionary = {}
for id in plans:
if budget_timer != null: budget_timer.call("set_phase", "civilization_products:" + id)
if budget_timer != null and not await budget_timer.pause(): return {}
var plan: Dictionary = plans[id]
var products: Dictionary = {}
for destination in plan.outputs:
if budget_timer != null: budget_timer.call("set_phase", "civilization_drift:" + id)
if budget_timer != null and not await budget_timer.pause(): return {}
var pop: Dictionary = plan.outputs[destination]
var count: Variant = total(pop)
if rules.has(destination):
if E.ne(destination, "Ct") and E.gt(count, 0):
var profile_drift: int = world.profile_start()
pop = drift(target, E.add(E.add(E.add(id, ":"), destination), ":drift"), pop, rules[id].ethics, (E.eq(destination, id) and rules[id].diplomacy), false, count)
world.profile_end("ethics_drift", profile_drift)
if (not incoming.has(destination)): incoming[destination] = {}
add(incoming[destination], pop)
products[destination] = count
if (State.is_space_civilization(id, rules) and E.eq(world.catalog.entities[destination].group_as, "Planet")):
planetary[destination] = E.add(planetary.get(destination, 0), count)
expired[id] = [{"amount": plan.amount, "from": plan.from, "to": E.maximum(plan.from, plan.to), "products": products}]
world.profile_end("civilization_prepare", profile_started)
return {"active": true, "remaining": remaining, "incoming": incoming, "planetary": planetary, "colonies": colonies, "base_physical": physical.duplicate()}
static func finish(world, target: Dictionary, transaction: Dictionary, year: Variant, rules: Dictionary, budget_timer: RefCounted = null) -> void:
var profile_started: int = world.profile_start()
if not transaction.get("active", true):
State.reconcile(world, target, rules)
if not target.has("civilization_stats"): target.civilization_stats = {}
target.civilization_stats.worship = worship(world, target, rules)
world.profile_end("civilization_finish", profile_started)
return
var remaining: Dictionary = transaction.remaining
var incoming: Dictionary = transaction.incoming
for id in remaining:
var counts: Dictionary = remaining[id].duplicate()
add(counts, incoming.get(id, {}))
target.ethics[id] = State.profile_from_counts(counts)
for id in incoming:
if not remaining.has(id):
target.ethics[id] = State.merge_profile(target.ethics.get(id, {}), transaction.base_physical.get(id, 0), incoming[id])
# Space civilizations that fall back to planetary stages need a stellar host.
var physical: Dictionary = world.ledger.physical_amounts(target)
var hosts: Dictionary = {}
for id in physical:
if (E.eq(world.catalog.entities[id].event_group, "DwStar") and E.gt(physical[id], 0)): hosts[id] = physical[id]
for id in transaction.planetary:
var shares: Dictionary = split(target, E.add("return:", id), E.minimum(transaction.planetary[id], target.amount.get(id, 0)), hosts)
for owner in shares:
if (not target.binds.has(owner)): target.binds[owner] = {}
target.binds[owner][id] = E.add(target.binds[owner].get(id, 0), shares[owner])
target.amount[id] = E.subtract(target.amount[id], shares[owner])
if budget_timer != null:
await colonize(world, target, transaction.colonies, year, rules, physical, budget_timer)
if budget_timer.cancelled: return
else:
colonize(world, target, transaction.colonies, year, rules, physical)
State.reconcile(world, target, rules, physical if transaction.colonies.is_empty() else {})
target.civilization_stats.worship = worship(world, target, rules)
world.profile_end("civilization_finish", profile_started)
static func colonize(world, target: Dictionary, colonies: Array, year: Variant, rules: Dictionary, physical: Dictionary = {}, budget_timer: RefCounted = null) -> void:
if colonies.is_empty(): return
var profile_colonies: int = world.profile_start()
if physical.is_empty(): physical = world.ledger.physical_amounts(target)
var stock: Dictionary = {}
for id in ["GG", "AB", "RP", "HP"]: stock[id] = physical.get(id, 0)
var requests: Dictionary = {}
for i in colonies.size(): requests[str(i)] = total(colonies[i].population)
var granted: Dictionary = QuantityMath.allocate_whole(requests, total(stock))
var additions: Dictionary = {}
var credit_events: Array = []
var batch_additions: Dictionary = {}
var debits: Dictionary = {}
var slots_by_resource: Dictionary = {}
for id in stock:
var slots: Dictionary = {"": target.amount.get(id, 0)}
for owner in target.binds: slots[owner] = target.binds[owner].get(id, 0)
slots_by_resource[id] = slots
debits[id] = {}
for i in colonies.size():
if budget_timer != null and not await budget_timer.pause(): return
var colony: Dictionary = colonies[i]
var population: Dictionary = QuantityMath.allocate_whole(colony.population, granted[str(i)])
population = drift(target, E.add(colony.source, ":colonies"), population, rules[colony.source].ethics, false, true)
var count: Variant = total(population)
var targets: Dictionary = QuantityMath.allocate_whole(stock, count)
for id in targets:
var amount: Variant = targets[id]
if E.le(amount, 0): continue
var slots: Dictionary = slots_by_resource[id]
var taken: Dictionary = QuantityMath.allocate_whole(slots, amount)
stock[id] = E.subtract(stock[id], amount)
for owner in taken:
if E.le(taken[owner], 0): continue
slots[owner] = E.subtract(slots.get(owner, 0), taken[owner])
debits[id][owner] = E.add(debits[id].get(owner, 0), taken[owner])
credit_events.append({"stage": colony.stage, "owner": owner, "amount": taken[owner]})
batch_additions[colony.stage] = E.add(batch_additions.get(colony.stage, 0), taken[owner])
if not additions.has(colony.stage): additions[colony.stage] = {}
add(additions[colony.stage], population)
target.civilization_stats.colonies = E.add(target.civilization_stats.get("colonies", 0), count)
# Commit each resource and owner once after all colonies have consumed their
# sequentially allocated shares. This keeps allocation order exact while
# avoiding repeated batch scaling and ledger mutations in the inner loop.
for id in debits:
var removed: Variant = E.subtract(physical.get(id, 0), stock[id])
if E.gt(physical.get(id, 0), 0):
world.batches.scale_batches(target, id, E.subtract(1, E.divide(removed, physical[id])))
for owner in debits[id]:
if E.eq(owner, ""):
target.amount[id] = E.subtract(target.amount.get(id, 0), debits[id][owner])
elif target.binds.has(owner):
target.binds[owner][id] = E.subtract(target.binds[owner].get(id, 0), debits[id][owner])
for event: Dictionary in credit_events:
var stage: String = event.stage
var owner: String = event.owner
var created: Variant = world.ledger.credit(target, stage, event.amount)
if E.ne(owner, ""):
target.amount[stage] = E.subtract(target.amount[stage], created)
if not target.binds.has(owner): target.binds[owner] = {}
target.binds[owner][stage] = E.add(target.binds[owner].get(stage, 0), created)
for stage in batch_additions:
world.batches.append_batch(target, stage, batch_additions[stage], year, year)
for id in additions:
target.ethics[id] = State.merge_profile(target.ethics.get(id, {}), physical.get(id, 0), additions[id])
world.profile_end("colonization", profile_colonies)
+480
View File
@@ -0,0 +1,480 @@
extends "res://world/owniverse/simulation_module.gd"
## Continuous expected flows. Only ledger quantities and rounding carries are exact.
const E = preload("res://world/owniverse/math/exact.gd")
const State = preload("res://world/owniverse/civilization_state.gd")
const Q = preload("res://world/owniverse/quantity_math.gd")
const Civ = preload("res://world/owniverse/civilization_evolution.gd")
const Parameters = preload("res://world/owniverse/world_parameters.gd")
const ETHICS = State.ETHICS
var _generator_key: String = ""
var _generator: Dictionary = {}
var _solutions: Dictionary = {}
func _edges(target: Dictionary, rules: Dictionary) -> Dictionary:
var modifiers: Dictionary = Parameters.life_modifiers(target)
var order: float = maxf(1e-12, E.approx(Parameters.value(target, "Order")))
var stages: Array = rules.keys()
stages.sort()
var rates: Dictionary = {}
var spans: Dictionary = {}
var progression: Dictionary = {}
var budget: Dictionary = State.stellar_budget(world, target, rules)
var signature: String = str(modifiers) + ":" + str(order)
for id in stages.duplicate():
if rules[id].ethics.is_empty(): continue
rates[id] = Civ.coefficients(world, rules, id, modifiers)
progression[id] = _can_progress(id, rules[id].next, budget, rules)
spans[id] = maxf(1e-12, E.approx(State.effective_span(world, target, id, rules)))
signature += id + str(spans[id]) + str(rates[id]) + str(rules[id]) + str(progression[id])
for row: Dictionary in rules[id].ethics.values():
for destination in Q.rule_weights(str(row.fall)):
if not stages.has(destination): stages.append(destination)
if signature == _generator_key: return _generator
var build_started: int = world.profile_start()
var index: Dictionary = {}
for i in stages.size(): index[stages[i]] = i
var rows: Array = []
var rewards: Array = []
var maximum: float = 0.0
for id in stages:
for e in 6:
var edges: Dictionary = {}
var reward: Dictionary = {"war": 0.0, "col": 0.0, "colony": "", "previous": ""}
if rates.has(id):
var ethic: String = ETHICS[e]
var r: Dictionary = rates[id][ethic]
var span: float = spans[id]
var war: float = minf(0.5, E.approx(r.war)) if rules[id].fights else 0.0
var progress: float = (E.approx(r.pro) + war * 17.0 / 36.0) / span if progression[id] else 0.0
var degradation: float = (E.approx(r.deg) + war * 31.0 / 18.0 / order) / span
var next_node: int = index[rules[id].next] * 6 + e
edges[next_node] = progress
var falls: Dictionary = Q.rule_weights(str(rules[id].ethics[ethic].fall))
if falls.is_empty(): falls = {rules[id].previous: 1}
var fall_total: float = 0.0
for weight in falls.values(): fall_total += E.approx(weight)
for destination in falls:
var node: int = index[destination] * 6 + e
edges[node] = float(edges.get(node, 0.0)) + degradation * E.approx(falls[destination]) / fall_total
reward = {"war": war / span, "col": E.approx(r.col) / span if rules[id].colonizes else 0.0,
"colony": rules[id].colony, "previous": rules[id].previous}
var outgoing: float = 0.0
var self_node: int = index[id] * 6 + e
edges.erase(self_node)
for rate_value in edges.values(): outgoing += rate_value
maximum = maxf(maximum, outgoing)
rows.append({"edges": edges, "outgoing": outgoing})
rewards.append(reward)
_generator_key = signature
_solutions.clear()
var targets: Array = []
var probabilities: Array = []
for node in rows.size():
var destinations: PackedInt32Array = []
var weights: PackedFloat64Array = []
destinations.append(node)
weights.append(maxf(0.0, 1.0 - rows[node].outgoing / maximum) if maximum > 0.0 else 1.0)
for destination in rows[node].edges:
destinations.append(destination)
weights.append(rows[node].edges[destination] / maximum if maximum > 0.0 else 0.0)
targets.append(destinations)
probabilities.append(weights)
_generator = {"stages": stages, "index": index, "rows": rows, "rewards": rewards, "lambda": maximum, "order": order, "targets": targets, "probabilities": probabilities}
world.profile_end("civilization_operator_build", build_started)
return _generator
func _can_progress(source: String, destination: String, budget: Dictionary, rules: Dictionary) -> bool:
var home: String = rules[destination].home
if home != "":
var old_cost: Variant = E.maximum(1, rules[source].stars) if rules[source].home == home else 0
var net: Variant = E.subtract(E.maximum(1, rules[destination].stars), old_cost)
if E.gt(net, 0) and E.lt(budget.free.get(home, 0), net): return false
var new_req: Dictionary = budget.habitats.requirements.get(destination, {})
var old_req: Dictionary = budget.habitats.requirements.get(source, {})
if not new_req.is_empty():
var net: Variant = E.subtract(new_req.units, old_req.get("units", 0) if old_req.get("id", "") == new_req.id else 0)
var free: Variant = E.subtract(budget.habitats.capacity.get(new_req.id, 0), budget.habitats.requested.get(new_req.id, 0))
if E.gt(net, 0) and E.lt(free, net): return false
return true
## Uniformization computes exp(Q*t) through a Poisson series of sparse steps.
## Absorbing biological stages and Ct retain their mass. No one-transition cap.
func _solve(generator: Dictionary, source: String, profile: Dictionary, years: float) -> Dictionary:
var key: String = source + ":" + str(years)
var basis: Dictionary = _solutions.get(key, {})
if basis.is_empty():
var started: int = world.profile_start()
basis = _solve_basis(generator, source, years)
world.profile_end("civilization_operator_solve", started)
if _solutions.size() >= 512: _solutions.clear()
_solutions[key] = basis
var mass: PackedFloat64Array = basis.mass.duplicate()
var occupation: PackedFloat64Array = basis.occupation.duplicate()
var shares: PackedFloat64Array = []
for ethic in ETHICS: shares.append(profile[ethic])
for node in mass.size():
mass[node] *= shares[node % 6]
occupation[node] *= shares[node % 6]
return {"mass": mass, "occupation": occupation, "occupation_scale": 1.0}
func _solve_basis(generator: Dictionary, source: String, years: float) -> Dictionary:
var size: int = generator.rows.size()
var values: PackedFloat64Array = []
values.resize(size)
var active: PackedInt32Array = []
var offset: int = generator.index[source] * 6
for e in 6:
values[offset + e] = 1.0
active.append(offset + e)
var mass: PackedFloat64Array = []
var occupation: PackedFloat64Array = []
mass.resize(size)
occupation.resize(size)
var frequency: float = generator.lambda
if frequency <= 0.0:
mass = values.duplicate()
for node in active: occupation[node] = years
return {"mass": mass, "occupation": occupation}
var mean: float = frequency * years
var weight: float = exp(-mean)
var cumulative: float = 0.0
var limit: int = maxi(32, ceili(mean + 12.0 * sqrt(mean) + 32.0))
for step in limit:
cumulative = minf(1.0, cumulative + weight)
var time_weight: float = maxf(0.0, 1.0 - cumulative) / frequency
for node in active:
mass[node] += weight * values[node]
occupation[node] += time_weight * values[node]
if step > mean and weight < 1e-18: break
var next: PackedFloat64Array = []
next.resize(size)
var next_active: PackedInt32Array = []
for node in active:
var destinations: PackedInt32Array = generator.targets[node]
var probabilities: PackedFloat64Array = generator.probabilities[node]
for i in destinations.size():
var contribution: float = values[node] * probabilities[i]
if contribution <= 0.0: continue
var destination: int = destinations[i]
if next[destination] == 0.0: next_active.append(destination)
next[destination] += contribution
values = next
active = next_active
weight *= mean / float(step + 1)
var integrated: PackedFloat64Array = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
for node in size: integrated[node % 6] += occupation[node]
for e in 6:
if integrated[e] <= 0.0:
occupation[offset + e] = years
else:
var factor: float = years / integrated[e]
for node in range(e, size, 6): occupation[node] *= factor
return {"mass": mass, "occupation": occupation}
func _drift(target: Dictionary, rules: Dictionary, generator: Dictionary, years: float) -> void:
for id in rules:
if rules[id].ethics.is_empty() or not target.ethics.has(id): continue
var profile: Dictionary = State.normalize_profile(target.ethics[id])
var span: float = maxf(1e-12, E.approx(State.effective_span(world, target, id, rules)))
var duration: float = years / span
var pair_decay: float = exp(-0.03 * duration)
var opposite_decay: float = exp(-0.02 * duration)
var result: Dictionary = {}
for ethic in ETHICS:
var opposite: float = profile[Civ.OPPOSITE[ethic]]
result[ethic] = 1.0 / 6.0 + pair_decay * ((profile[ethic] + opposite) * 0.5 - 1.0 / 6.0) + opposite_decay * (profile[ethic] - opposite) * 0.5
if rules[id].diplomacy:
var weighted: Dictionary = {}
for ethic in ETHICS: weighted[ethic] = result[ethic] * maxf(0.0, E.approx(E.of(rules[id].ethics[ethic].diplomacy_rate)))
weighted = State.normalize_profile(weighted)
var fraction: float = 1.0 - exp(-duration)
for ethic in ETHICS: result[ethic] = lerpf(result[ethic], weighted[ethic], fraction)
target.ethics[id] = State.normalize_profile(result)
func _profile(vector: Array) -> Dictionary:
var result: Dictionary = {}
for e in 6: result[ETHICS[e]] = vector[e]
return State.normalize_profile(result)
func _merge(plan: Dictionary, destination: String, count: Variant, profile: Dictionary) -> void:
if E.le(count, 0): return
var previous: Variant = plan.counts.get(destination, 0)
plan.profiles[destination] = State.merge_profiles(plan.profiles.get(destination, {}), previous, profile, count)
plan.counts[destination] = E.add(previous, count)
func _plans(target: Dictionary, physical: Dictionary, rules: Dictionary, generator: Dictionary, years: float) -> Dictionary:
var plans: Dictionary = {}
var colonies: Dictionary = {}
var damages: Dictionary = {}
var wars: Variant = 0
for source in rules:
var count: Variant = physical.get(source, 0)
if E.le(count, 0) or rules[source].ethics.is_empty(): continue
var solved: Dictionary = _solve(generator, source, State.normalize_profile(target.ethics.get(source, {})), years)
var vectors: Dictionary = {}
for node in solved.mass.size():
if solved.mass[node] <= 0.0: continue
var destination: String = generator.stages[int(node) / 6]
if not vectors.has(destination): vectors[destination] = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
vectors[destination][int(node) % 6] += solved.mass[node]
var ids: Array = []
var shares: Array = []
var profiles: Dictionary = {}
var retained: float = 0.0
for destination in vectors:
var share: float = 0.0
for value in vectors[destination]: share += value
profiles[destination] = _profile(vectors[destination])
if destination == source or share < 0.5e-15:
retained += share
else:
ids.append(destination)
shares.append(share)
ids.append(source)
shares.append(retained)
if not profiles.has(source): profiles[source] = State.normalize_profile(target.ethics.get(source, {}))
var counts: Dictionary = Q.split_profile(target.civilization_remainders, "flow:" + source, count, ids, shares)
plans[source] = {"counts": counts, "profiles": profiles}
var colony_vectors: Dictionary = {}
var damage: Dictionary = {}
var war_reward: float = 0.0
for node in solved.occupation.size():
if solved.occupation[node] <= 0.0: continue
var reward: Dictionary = generator.rewards[node]
var duration: float = solved.occupation[node] * solved.occupation_scale
war_reward += duration * reward.war
if reward.war > 0.0:
damage[reward.previous] = float(damage.get(reward.previous, 0.0)) + duration * reward.war * 40.0 / 3.0 / generator.order
if reward.col <= 0.0: continue
if not colony_vectors.has(reward.colony): colony_vectors[reward.colony] = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
colony_vectors[reward.colony][int(node) % 6] += duration * reward.col
wars = E.add(wars, Q.accumulate_rate(target.civilization_remainders, "flow:wars:" + source, count, war_reward))
for destination in damage:
damages[destination] = E.add(damages.get(destination, 0), Q.accumulate_rate(target.civilization_remainders, "flow:damage:" + source + ":" + destination, count, damage[destination]))
for destination in colony_vectors:
var reward: float = 0.0
for value in colony_vectors[destination]: reward += value
var quantity: Variant = Q.accumulate_rate(target.civilization_remainders, "flow:colony:" + source + ":" + destination, count, reward)
if not colonies.has(destination): colonies[destination] = {"counts": {}, "profiles": {}}
_merge(colonies[destination], destination, quantity, _profile(colony_vectors[destination]))
return {"plans": plans, "colonies": colonies, "damages": damages, "wars": wars}
## Positive resource costs share the same frozen free budget. Rejected movement
## stays at its source. Releases become available after this transaction commits.
func _limit(plans: Dictionary, budget: Dictionary, rules: Dictionary) -> void:
var demands: Dictionary = {}
var costs: Dictionary = {}
for source in plans:
costs[source] = {}
for destination in plans[source].counts:
if destination == source or not rules.has(destination): continue
var amount: Variant = plans[source].counts[destination]
if E.le(amount, 0): continue
var resource_costs: Dictionary = {}
var home: String = rules[destination].home
if home != "":
var old_cost: Variant = rules[source].stars if rules[source].home == home else 0
var net: Variant = E.maximum(0, E.subtract(E.maximum(1, rules[destination].stars), old_cost))
if E.gt(net, 0): resource_costs["star:" + home] = net
var housing: Dictionary = budget.habitats
var new_req: Dictionary = housing.requirements.get(destination, {})
var old_req: Dictionary = housing.requirements.get(source, {})
if not new_req.is_empty():
var net: Variant = E.maximum(0, E.subtract(new_req.units, old_req.get("units", 0) if old_req.get("id", "") == new_req.id else 0))
if E.gt(net, 0): resource_costs["house:" + new_req.id] = net
costs[source][destination] = resource_costs
for resource in resource_costs:
demands[resource] = E.add(demands.get(resource, 0), E.multiply(amount, resource_costs[resource]))
var factors: Dictionary = {}
for resource in demands:
var available: Variant
if resource.begins_with("star:"):
available = budget.free.get(resource.trim_prefix("star:"), 0)
else:
var id: String = resource.trim_prefix("house:")
available = E.maximum(0, E.subtract(budget.habitats.capacity.get(id, 0), budget.habitats.requested.get(id, 0)))
factors[resource] = [available, demands[resource]]
for source in costs:
for destination in costs[source]:
var requested: Variant = plans[source].counts[destination]
var allowed: Variant = requested
for resource in costs[source][destination]:
var ratio: Array = factors[resource]
if E.lt(ratio[0], ratio[1]): allowed = E.minimum(allowed, E.floor_value(E.mul_div(requested, ratio[0], ratio[1])))
var rejected: Variant = E.subtract(requested, allowed)
if E.le(rejected, 0): continue
plans[source].counts[destination] = allowed
_merge(plans[source], source, rejected, plans[source].profiles[destination])
func _commit(target: Dictionary, physical: Dictionary, plans: Dictionary, rules: Dictionary, year: Variant) -> void:
var credits: Dictionary = {}
var contributions: Dictionary = {}
var births: Dictionary = {}
for source in plans:
var plan: Dictionary = plans[source]
var slots: Dictionary = {"": target.amount.get(source, 0)}
for owner in target.binds:
var held: Variant = target.binds[owner].get(source, 0)
if E.gt(held, 0): slots[owner] = held
for destination in plan.counts:
var amount: Variant = plan.counts[destination]
if E.le(amount, 0): continue
if not contributions.has(destination): contributions[destination] = []
contributions[destination].append({"amount": amount, "profile": plan.profiles[destination]})
if destination == source: continue
var taken: Dictionary = Q.allocate_profiled(slots, amount)
for owner in taken:
if E.le(taken[owner], 0): continue
slots[owner] = E.subtract(slots[owner], taken[owner])
var host: String = owner
if State.is_space_civilization(destination, rules) or destination == "Ct": host = ""
elif owner == "" and State.is_space_civilization(source, rules): host = "@host"
if not credits.has(destination): credits[destination] = {}
credits[destination][host] = E.add(credits[destination].get(host, 0), taken[owner])
target.amount[source] = slots[""]
for owner in slots:
if owner != "": target.binds[owner][source] = slots[owner]
var hosts: Dictionary = {}
for id in physical:
if world.catalog.entities[id].event_group == "DwStar" and E.gt(physical[id], 0): hosts[id] = physical[id]
for destination in credits:
var created: Variant = world.ledger.credit(target, destination, Q.sum(credits[destination]))
for owner in credits[destination]:
var count: Variant = credits[destination][owner]
if owner == "" or E.le(count, 0): continue
if owner == "@host":
if hosts.is_empty(): continue
var ownership: Dictionary = Q.split_profile(target.civilization_remainders, "flow:hosts:" + destination, count, hosts.keys(), _host_shares(hosts))
for host in ownership:
if E.le(ownership[host], 0): continue
if not target.binds.has(host): target.binds[host] = {}
target.binds[host][destination] = E.add(target.binds[host].get(destination, 0), ownership[host])
target.amount[destination] = E.subtract(target.amount[destination], count)
else:
target.binds[owner][destination] = E.add(target.binds[owner].get(destination, 0), count)
target.amount[destination] = E.subtract(target.amount[destination], count)
if not rules.has(destination): births[destination] = created
var final: Dictionary = world.ledger.physical_amounts(target)
for destination in contributions:
if not rules.has(destination) or E.le(final.get(destination, 0), 0): continue
var profile: Dictionary = {}
for ethic in ETHICS: profile[ethic] = 0.0
var existing: Variant = physical.get(destination, 0) if not plans.has(destination) else 0
if E.gt(existing, 0):
var original: Dictionary = State.normalize_profile(target.ethics.get(destination, {}))
var ratio: float = E.ratio_approx(existing, final[destination])
for ethic in ETHICS: profile[ethic] += original[ethic] * ratio
for entry: Dictionary in contributions[destination]:
var ratio: float = E.ratio_approx(entry.amount, final[destination])
for ethic in ETHICS: profile[ethic] += entry.profile[ethic] * ratio
target.ethics[destination] = State.normalize_profile(profile)
for destination in births: world.batches.append_batch(target, destination, births[destination], year, year)
func _host_shares(hosts: Dictionary) -> Array:
var total: Variant = Q.sum(hosts)
var result: Array = []
for value in hosts.values(): result.append(E.ratio_approx(value, total))
return result
func _colonize(target: Dictionary, colonies: Dictionary, year: Variant, rules: Dictionary) -> void:
if colonies.is_empty(): return
var physical: Dictionary = world.ledger.physical_amounts(target)
var stocks: Dictionary = {}
for id in ["GG", "AB", "RP", "HP"]: stocks[id] = physical.get(id, 0)
var requests: Dictionary = {}
for id in colonies: requests[id] = colonies[id].counts.get(id, 0)
var granted: Dictionary = Q.allocate_profiled(requests, Q.sum(stocks))
var total: Variant = Q.sum(granted)
if E.le(total, 0): return
var taken_resources: Dictionary = Q.allocate_profiled(stocks, total)
var remaining: Dictionary = granted.duplicate()
for resource in taken_resources:
var removed: Variant = taken_resources[resource]
if E.le(removed, 0): continue
var owners: Dictionary = {"": target.amount.get(resource, 0)}
for owner in target.binds:
if E.gt(target.binds[owner].get(resource, 0), 0): owners[owner] = target.binds[owner][resource]
var taken: Dictionary = Q.allocate_profiled(owners, removed)
world.batches.scale_batches(target, resource, E.subtract(1, E.divide(removed, stocks[resource])))
for owner in taken:
var amount: Variant = taken[owner]
if E.le(amount, 0): continue
if owner == "": target.amount[resource] = E.subtract(target.amount[resource], amount)
else: target.binds[owner][resource] = E.subtract(target.binds[owner][resource], amount)
var allocation: Dictionary = Q.allocate_profiled(remaining, amount)
for destination in allocation:
var count: Variant = allocation[destination]
if E.le(count, 0): continue
remaining[destination] = E.subtract(remaining[destination], count)
var created: Variant = world.ledger.credit(target, destination, count)
if owner != "" and not State.is_space_civilization(destination, rules):
target.amount[destination] = E.subtract(target.amount[destination], created)
target.binds[owner][destination] = E.add(target.binds[owner].get(destination, 0), created)
for id in granted:
if E.le(granted[id], 0): continue
var profile: Dictionary = colonies[id].profiles[id]
var drifted: Dictionary = {}
for ethic in ETHICS:
drifted[ethic] = 0.0
for source in ETHICS:
drifted[ethic] += profile[source] * (0.96 if source == ethic else (0.004 if Civ.OPPOSITE[source] == ethic else 0.009))
target.ethics[id] = State.merge_profiles(target.ethics.get(id, {}), physical.get(id, 0), drifted, granted[id])
target.civilization_stats.colonies = E.add(target.civilization_stats.get("colonies", 0), total)
func evolve(target: Dictionary, from_year: Variant, to_year: Variant, timer: RefCounted = null) -> void:
var keep_alive: RefCounted = self
var years: float = E.approx(E.subtract(to_year, from_year))
if years <= 0.0: return
var rules: Dictionary = Global.CivilizationRules
var generator: Dictionary = {}
# Bound the Poisson mean, processing the entire requested interval.
var maximum_years: float = years
var cursor: Variant = from_year
while E.lt(cursor, to_year):
if timer != null and not await timer.pause(): return
generator = _edges(target, rules)
maximum_years = 32.0 / generator.lambda if generator.lambda > 0.0 else years
var end: Variant = E.minimum(to_year, E.add(cursor, E.of(maximum_years)))
var physical: Dictionary = world.ledger.physical_amounts(target)
var started: int = world.profile_start()
var transaction: Dictionary = _plans(target, physical, rules, generator, E.approx(E.subtract(end, cursor)))
world.profile_end("civilization_flow_plan", started)
if timer != null and not await timer.pause(): return
var plans: Dictionary = transaction.plans
var damage_total: Variant = 0
for source in transaction.damages:
if not plans.has(source): continue
var retained: Variant = plans[source].counts.get(source, 0)
var amount: Variant = E.minimum(retained, transaction.damages[source])
if E.le(amount, 0): continue
var profile: Dictionary = plans[source].profiles[source]
var weights: Dictionary = {}
for ethic in ETHICS:
var falls: Dictionary = Q.rule_weights(str(rules[source].ethics[ethic].fall))
if falls.is_empty(): falls = {rules[source].previous: 1}
var sum: float = 0.0
for weight in falls.values(): sum += E.approx(weight)
for destination in falls: weights[destination] = float(weights.get(destination, 0.0)) + profile[ethic] * E.approx(falls[destination]) / sum
var split: Dictionary = Q.split_profile(target.civilization_remainders, "flow:collateral:" + source, amount, weights.keys(), weights.values())
plans[source].counts[source] = E.subtract(retained, amount)
for destination in split: _merge(plans[source], destination, split[destination], profile)
damage_total = E.add(damage_total, amount)
started = world.profile_start()
_limit(plans, State.stellar_budget(world, target, rules, physical), rules)
world.profile_end("civilization_flow_capacity", started)
started = world.profile_start()
_commit(target, physical, plans, rules, end)
world.profile_end("civilization_flow_ledger", started)
if timer != null and not await timer.pause(): return
started = world.profile_start()
_colonize(target, transaction.colonies, end, rules)
world.profile_end("civilization_flow_colonies", started)
started = world.profile_start()
_drift(target, rules, generator, E.approx(E.subtract(end, cursor)))
target.civilization_stats.wars = E.add(target.civilization_stats.get("wars", 0), transaction.wars)
target.civilization_stats.collateral = E.add(target.civilization_stats.get("collateral", 0), damage_total)
State.enforce_capacity(world, target, rules, end)
State.reconcile(world, target, rules)
target.civilization_stats.worship = Civ.worship(world, target, rules)
world.profile_end("civilization_flow_finish", started)
cursor = end
+1
View File
@@ -0,0 +1 @@
uid://clon18ixi6yqx
+6 -28
View File
@@ -28,33 +28,11 @@ static func normalize_profile(profile: Dictionary) -> Dictionary:
result[ETHICS.back()] = maxf(0.0, 1.0 - assigned) result[ETHICS.back()] = maxf(0.0, 1.0 - assigned)
return result 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: 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) var total: Variant = E.add(base_count, incoming_count)
if E.le(total, 0): return empty_profile() 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 base_ratio: float = E.ratio_approx(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 incoming_ratio: float = E.ratio_approx(incoming_count, total) if E.gt(incoming_count, 0) else 0.0
var normalized_base: Dictionary = normalize_profile(base) var normalized_base: Dictionary = normalize_profile(base)
var result: Dictionary = {} var result: Dictionary = {}
for ethic in ETHICS: for ethic in ETHICS:
@@ -66,7 +44,7 @@ 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), ""))
# Keep one six-value float profile per stage. Absolute population remains in # Keep one six-value float profile per stage. Absolute population remains in
# the exact physical object ledger and is materialized only when a transition needs it. # the exact physical object ledger; flow calculations use shares directly.
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 = {}
@@ -277,7 +255,7 @@ static func effective_span(world, target: Dictionary, id: String, rules: Diction
if E.le(population, 0): return base if E.le(population, 0): return base
var homes: Dictionary = stage_homes(world, target, id, world.catalog.civilization_layout(rules)) var homes: Dictionary = stage_homes(world, target, id, world.catalog.civilization_layout(rules))
target.civilization_homes[id] = homes target.civilization_homes[id] = homes
var weighted: Variant = population var multiplier: float = 1.0
for home in homes: for home in homes:
weighted = E.add(weighted, E.multiply(homes[home], (E.subtract(E.maximum(0, world.catalog.entities[home].civ_bonus), 1)))) multiplier += E.ratio_approx(homes[home], population) * (maxf(0.0, E.approx(world.catalog.entities[home].civ_bonus)) - 1.0)
return (E.mul_div(base, population, weighted) if E.gt(weighted, 0) else base) return E.approx(base) / multiplier if multiplier > 0.0 else base
+29 -30
View File
@@ -1,12 +1,14 @@
extends "res://world/owniverse/simulation_module.gd" extends "res://world/owniverse/simulation_module.gd"
const E = preload("res://world/owniverse/math/exact.gd") const E = preload("res://world/owniverse/math/exact.gd")
const CivilizationEvolution = preload("res://world/owniverse/civilization_evolution.gd")
const CivilizationState = preload("res://world/owniverse/civilization_state.gd") const CivilizationState = preload("res://world/owniverse/civilization_state.gd")
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 QuantityMath = preload("res://world/owniverse/quantity_math.gd") const QuantityMath = preload("res://world/owniverse/quantity_math.gd")
func initialize_life_batches() -> void: func initialize_life_batches() -> void:
for id in Global.CivilizationRules:
world.state.batches.erase(id)
world.state.batch_origins.erase(id)
CivilizationState.detach_space_civilizations(world.state, Global.CivilizationRules) CivilizationState.detach_space_civilizations(world.state, Global.CivilizationRules)
CivilizationState.enforce_capacity(world, world.state, Global.CivilizationRules, world.state.time.datetime) CivilizationState.enforce_capacity(world, world.state, Global.CivilizationRules, world.state.time.datetime)
var physical: Dictionary = world.ledger.physical_amounts(world.state) var physical: Dictionary = world.ledger.physical_amounts(world.state)
@@ -51,6 +53,12 @@ func evolute_to_datetime(datetime: Variant, clock: Dictionary = {}, budget: RefC
var profile_production: int = world.profile_start() var profile_production: int = world.profile_start()
world.automatic.produce_for_period(previous, next, quantities, distribution, from_year, to_year) world.automatic.produce_for_period(previous, next, quantities, distribution, from_year, to_year)
world.profile_end("production", profile_production) world.profile_end("production", profile_production)
if budget != null:
budget.call("set_phase", "civilization_flows")
await world.civilization_flow.evolve(next, from_year, to_year, budget)
if budget.cancelled: return
else:
world.civilization_flow.evolve(next, from_year, to_year)
var profile_generations: int = world.profile_start() var profile_generations: int = world.profile_start()
if budget != null: if budget != null:
await evolve_batches(next, to_year, budget) await evolve_batches(next, to_year, budget)
@@ -86,8 +94,8 @@ func evolve_batches(target: Dictionary, to_year: Variant, budget: RefCounted = n
func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Dictionary = { }, budget: RefCounted = null, context: Dictionary = {}) -> bool: func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Dictionary = { }, budget: RefCounted = null, context: Dictionary = {}) -> bool:
var _keep_alive: RefCounted = self var _keep_alive: RefCounted = self
# Apply direct completions at the original next-generation boundary, so # Apply direct completions at their original generation boundaries, so
# binding transfers, the blast environment and capacity checks keep order. # binding transfers, 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") if budget != null: budget.call("set_phase", "generation_setup_and_withdrawal")
var completed_now: Dictionary = completed.duplicate() var completed_now: Dictionary = completed.duplicate()
@@ -97,7 +105,9 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
var quantities: Dictionary = world.ledger.physical_amounts(target) var quantities: Dictionary = world.ledger.physical_amounts(target)
var expired: Dictionary = {} var expired: Dictionary = {}
var withdrawn: Dictionary = {} var withdrawn: Dictionary = {}
var batch_ids: Array = target.batches.keys() var batch_ids: Array = []
for id in target.batches:
if not Global.CivilizationRules.has(id): batch_ids.append(id)
var planets_changed: bool = false var planets_changed: bool = false
var profile_withdraw: int = world.profile_start() var profile_withdraw: int = world.profile_start()
for id in batch_ids: for id in batch_ids:
@@ -125,8 +135,8 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
break break
if (not has_expired): if (not has_expired):
return false return false
# This per-generation summary is valid until the next transition mutates # Aggregate quantities are used to calculate the type-level transfer ratio;
# the cohort set. Reuse it for civilization planning and transfer ratios. # transition products remain calculated for each expiring cohort.
var expired_totals: Dictionary = {} var expired_totals: Dictionary = {}
for id in expired: for id in expired:
var total: Variant = 0 var total: Variant = 0
@@ -138,28 +148,25 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
if budget != null: budget.call("set_phase", "civilization_stellar_budget") 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") if budget != null: budget.call("set_phase", "civilization_prepare")
var civilization_transaction: Dictionary = await CivilizationEvolution.prepare(world, target, expired, to_year, star_budget, Global.CivilizationRules, current_quantities, budget, expired_totals)
if budget != null and budget.cancelled: return false
var profile_products: int = world.profile_start() var profile_products: int = world.profile_start()
for id in expired: for id in expired:
if budget != null and not await budget.pause(): return false if budget != null and not await budget.pause(): return false
var total: Variant = expired_totals[id] var total: Variant = expired_totals[id]
fractions[id] = (E.clamp_value(E.divide(total, quantities[id]), 0, 1) if E.gt(quantities.get(id, 0), 0) else 0) fractions[id] = (E.clamp_value(E.divide(total, quantities[id]), 0, 1) if E.gt(quantities.get(id, 0), 0) else 0)
outputs[id] = {}
var combined: Dictionary = {} var combined: Dictionary = {}
for part: Dictionary in expired[id]: for part: Dictionary in expired[id]:
if (not part.has("products")): if not part.has("products"):
part.products = world.stellar.evolution_products(target, world.catalog.entities[id], part.amount, context) part.products = world.stellar.evolution_products(target, world.catalog.entities[id], part.amount, context)
for successor in part.products.keys(): for successor in part.products.keys():
if Global.CivilizationRules.has(id): if Global.CivilizationRules.has(id): continue
continue
var requested: Variant = part.products[successor] var requested: Variant = part.products[successor]
var admitted: Variant = CivilizationState.admit_transition(star_budget, Global.CivilizationRules, id, successor, requested) var admitted: Variant = CivilizationState.admit_transition(star_budget, Global.CivilizationRules, id, successor, requested)
part.products[successor] = admitted part.products[successor] = admitted
if E.lt(admitted, requested): if E.lt(admitted, requested):
part.products[id] = E.subtract(E.add(part.products.get(id, 0), requested), admitted) part.products[id] = E.add(part.products.get(id, 0), E.subtract(requested, admitted))
for successor in part.products: for successor in part.products:
combined[successor] = E.add(combined.get(successor, 0), part.products[successor]) combined[successor] = E.add(combined.get(successor, 0), part.products[successor])
outputs[id] = {}
if E.gt(total, 0): if E.gt(total, 0):
for successor in combined: for successor in combined:
outputs[id][successor] = E.divide(combined[successor], total) outputs[id][successor] = E.divide(combined[successor], total)
@@ -175,12 +182,11 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
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 = {}
var born_civilizations: Dictionary = {}
var blast_power: Variant = 0 var blast_power: Variant = 0
for parts: Array in expired.values(): for parts: Array in expired.values():
for part: Dictionary in parts: for part: Dictionary in parts:
blast_power = E.add(blast_power, part.products.get("blast", 0)) blast_power = E.add(blast_power, part.products.get("blast", 0))
# A zero blast does not need density/mass/volume recalculated. For an actual
# 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") if budget != null: budget.call("set_phase", "transfer_and_newborns")
@@ -189,24 +195,17 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
for successor in part.products: for successor in part.products:
if budget != null and not await budget.pause(): return false if budget != null and not await budget.pause(): return false
var amount: Variant = part.products[successor] var amount: Variant = part.products[successor]
if E.le(amount, 0): continue if E.le(amount, 0) or E.eq(successor, "blast") or not world.catalog.entities.has(successor): continue
if E.eq(successor, "blast"): if E.eq(successor, id) and (not world.catalog.entities[id].life_rule.is_empty() or Global.CivilizationRules.has(id)):
continue
if (not world.catalog.entities.has(successor)):
continue
if (E.eq(successor, id) and (((not world.catalog.entities[id].life_rule.is_empty()) or Global.CivilizationRules.has(id)))):
target.amount[id] = E.add(target.amount.get(id, 0), amount) target.amount[id] = E.add(target.amount.get(id, 0), amount)
else: else:
amount = world.ledger.credit(target, successor, amount) amount = world.ledger.credit(target, successor, amount)
var produces: bool = not world.automatic.yearly_products(world.catalog.entities[successor]).is_empty() var produces: bool = not world.automatic.yearly_products(world.catalog.entities[successor]).is_empty()
var newborn_adjustment: Dictionary = {} var newborn_adjustment: Dictionary = {}
var cutoff: Variant = (to_year if world.stellar.uses_short_lived_fast_path(successor) else E.neg(INF)) var cutoff: Variant = to_year if world.stellar.uses_short_lived_fast_path(successor) else E.neg(INF)
var born_from: Variant = part.from var born_from: Variant = part.from
var born_to: Variant = part.to var born_to: Variant = part.to
if E.gt(resolution, 0) and (Global.CivilizationRules.has(successor) or not world.catalog.entities[successor].life_rule.is_empty()): if E.gt(resolution, 0) and (Global.CivilizationRules.has(successor) or not world.catalog.entities[successor].life_rule.is_empty()):
# Biological stages (including C0.0/C0.1) and civilizations
# share the coarse clock. Historical retry intervals must
# not retrigger their early population within the same bucket.
born_to = E.multiply(E.ceil_value(E.divide(born_to, resolution)), resolution) born_to = E.multiply(E.ceil_value(E.divide(born_to, resolution)), resolution)
born_from = born_to born_from = born_to
var profile_newborn: int = world.profile_start() var profile_newborn: int = world.profile_start()
@@ -220,6 +219,7 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
world.automatic.produce_newborn_lifetime(target, successor, amount, born_from, born_to, to_year, newborn_adjustment.get("past_years", 0), integral) world.automatic.produce_newborn_lifetime(target, successor, amount, born_from, born_to, to_year, newborn_adjustment.get("past_years", 0), integral)
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)
if Global.CivilizationRules.has(successor): born_civilizations[successor] = E.add(born_civilizations.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") 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)
@@ -227,11 +227,10 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
for resource in world.catalog.entities[owner].bind: for resource in world.catalog.entities[owner].bind:
for member in world.catalog.members(resource): affected[member] = true for member in world.catalog.members(resource): affected[member] = true
world.ledger.round_object_allocations(target, affected) world.ledger.round_object_allocations(target, affected)
if budget != null: for id in born_civilizations:
await CivilizationEvolution.finish(world, target, civilization_transaction, to_year, Global.CivilizationRules, budget) var current: Variant = world.ledger.physical_amount(target, id)
if budget.cancelled: return false var born: Variant = born_civilizations[id]
else: target.ethics[id] = CivilizationState.merge_profiles(target.ethics.get(id, {}), E.maximum(0, E.subtract(current, born)), CivilizationState.empty_profile(), born)
CivilizationEvolution.finish(world, target, civilization_transaction, to_year, Global.CivilizationRules)
if E.gt(blast_power, 0): world.stellar.apply_blast(target, blast_power, environment) if E.gt(blast_power, 0): world.stellar.apply_blast(target, blast_power, environment)
CivilizationState.enforce_capacity(world, target, Global.CivilizationRules, to_year) CivilizationState.enforce_capacity(world, target, Global.CivilizationRules, to_year)
return true return true
+9 -14
View File
@@ -67,20 +67,15 @@ static func divmod(a: Array, b: Array) -> Array:
assert(b != [0], "Division by zero") assert(b != [0], "Division by zero")
if b == [1]: return [a.duplicate(), [0]] if b == [1]: return [a.duplicate(), [0]]
if compare(a, b) < 0: return [[0], a.duplicate()] if compare(a, b) < 0: return [[0], a.duplicate()]
# Decimal ratios often share entire base-1e9 factors. Cancel them before # A divisor with trailing base limbs can divide the high part directly.
# long division; restore the remainder's original scale afterwards. # The low numerator limbs belong entirely to the remainder.
var shared: int = 0 var shift: int = 0
while shared < mini(a.size(), b.size()) - 1 and a[shared] == 0 and b[shared] == 0: while shift < b.size() - 1 and b[shift] == 0: shift += 1
shared += 1 if shift > 0:
if shared: var divided: Array = divmod(a.slice(shift), b.slice(shift))
var reduced: Array = divmod(a.slice(shared), b.slice(shared)) var restored: Array = a.slice(0, shift)
if reduced[1] != [0]: if divided[1] != [0]: restored.append_array(divided[1])
var restored: Array = [] return [divided[0], trim(restored)]
restored.resize(shared)
restored.fill(0)
restored.append_array(reduced[1])
reduced[1] = restored
return reduced
if b.size() == 1: if b.size() == 1:
var quotient: Array = [] var quotient: Array = []
quotient.resize(a.size()) quotient.resize(a.size())
+33
View File
@@ -132,6 +132,23 @@ static func compare(a, b) -> int:
if magnitude == 0 and x[1] != y[1]: magnitude = 1 if x[1] > y[1] else -1 if magnitude == 0 and x[1] != y[1]: magnitude = 1 if x[1] > y[1] else -1
return magnitude * x[2] return magnitude * x[2]
## Ratios used only for float profiles, never for integer quota allocation.
static func ratio_approx(a, b) -> float:
var numerator: float = approx(a)
var denominator: float = approx(b)
if is_finite(numerator) and is_finite(denominator): return numerator / denominator
var x: Array = _blocks(a)
var y: Array = _blocks(b)
var nx: int = maxi(0, x[0].size() - 3)
var ny: int = maxi(0, y[0].size() - 3)
var leading_x: float = 0.0
var leading_y: float = 0.0
for i in range(x[0].size() - 1, nx - 1, -1): leading_x = leading_x * SMALL + x[0][i]
for i in range(y[0].size() - 1, ny - 1, -1): leading_y = leading_y * SMALL + y[0][i]
var power: int = 9 * (nx - ny)
if power < -323: return 0.0
return leading_x / leading_y * pow(10.0, power) * x[2] * y[2]
static func eq(a, b) -> bool: static func eq(a, b) -> bool:
if a == null: return b == null if a == null: return b == null
if b == null: return false if b == null: return false
@@ -225,6 +242,22 @@ static func mul_div(a, b, divisor):
return make(Big.multiply(Big.multiply(x[0], y[0]), z[1]), Big.multiply(Big.multiply(x[1], y[1]), z[0]), x[2] * y[2] * z[2]) return make(Big.multiply(Big.multiply(x[0], y[0]), z[1]), Big.multiply(Big.multiply(x[1], y[1]), z[0]), x[2] * y[2] * z[2])
## Reuse the count and divisor decomposition across all quotas. ## Reuse the count and divisor decomposition across all quotas.
static func whole_quotas(count, weights: Array, divisor: int) -> Array:
assert(divisor > 0 and ge(count, 0) and eq(count, floor_value(count)))
var population: Array = _blocks(count)[0]
var denominator: Array = _int_limbs(divisor)
var result: Array = []
for weight: int in weights:
if weight == 0:
result.append([0, 0.0])
elif count is int and count <= MAX_INT / weight:
var product: int = count * weight
result.append([product / divisor, float(product % divisor) / divisor])
else:
var qr: Array = Big.divmod(Big.multiply(population, _int_limbs(weight)), denominator)
result.append([_compose(qr[0]), _fraction_ratio(qr[1], denominator)])
return result
static func mul_div_many(a, values: Array, divisor) -> Array: static func mul_div_many(a, values: Array, divisor) -> Array:
var result: Array = [] var result: Array = []
if not finite(a) or not finite(divisor): if not finite(a) or not finite(divisor):
+133
View File
@@ -4,6 +4,70 @@ const CivilizationState = preload("res://world/owniverse/civilization_state.gd")
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")
## Diagnostic switch for paired measurements of the old and new timing models.
var cycle_stars: bool = true
var _cycle_widths: Dictionary = {}
func star_cycle_width(target: Dictionary) -> Variant:
var speed: int = int(target.get("speed", 1))
if not _cycle_widths.has(speed):
_cycle_widths[speed] = E.multiply(E.power(10, speed - 1), world.frame_step_seconds)
return _cycle_widths[speed]
## The cycle grid may widen a short window, never subdivide a wide one.
func uses_cycle_star(target: Dictionary, id: String, lifespan: Variant) -> bool:
return cycle_stars and int(target.get("speed", 1)) >= 4 and world.catalog.entities[id].group_as == "Star" and E.le(window_duration(target, lifespan), star_cycle_width(target))
func coarsen_cycle_stars(target: Dictionary, id: String) -> void:
var width: Variant = star_cycle_width(target)
var anchor: Variant = target.time.datetime
var groups: Dictionary = {}
var result: Array = []
var changed: bool = false
for batch: Dictionary in target.batches[id]:
var start: Variant = E.maximum(batch.start_year, batch.get("processed_until", batch.start_year))
var end: Variant = batch.end_year
# A cohort spanning more than one cycle stays intact.
if E.gt(E.subtract(end, start), width):
result.append(batch)
continue
var first: Variant = E.ceil_value(E.divide(E.subtract(start, anchor), width)) if E.eq(start, end) else E.add(E.floor_value(E.divide(E.subtract(start, anchor), width)), 1)
var last: Variant = E.ceil_value(E.divide(E.subtract(end, anchor), width))
if not E.eq(first, last):
result.append(batch)
continue
var key: String = str(last)
if not groups.has(key):
groups[key] = {"batch": batch, "merged": false}
continue
var entry: Dictionary = groups[key]
if not entry.merged:
entry.batch = entry.batch.duplicate()
entry.merged = true
var combined: Dictionary = entry.batch
combined.amount = E.add(combined.amount, batch.amount)
combined.withdraw_remainder = E.add(combined.get("withdraw_remainder", 0), batch.get("withdraw_remainder", 0))
if combined.has("created_from") and batch.has("created_from"):
combined.created_from = E.minimum(combined.created_from, batch.created_from)
combined.created_to = E.maximum(combined.created_to, batch.created_to)
else:
var lifespan: Variant = effective_lifespan(target, id)
combined.created_from = E.minimum(combined.get("created_from", E.maximum(0, E.subtract(combined.start_year, lifespan))), batch.get("created_from", E.maximum(0, E.subtract(batch.start_year, lifespan))))
combined.created_to = E.maximum(combined.created_from, E.maximum(combined.get("created_to", E.maximum(0, E.subtract(combined.end_year, lifespan))), batch.get("created_to", E.maximum(0, E.subtract(batch.end_year, lifespan)))))
if E.eq(combined.created_to, combined.created_from): combined.created_to = E.add(combined.created_from, width)
combined.start_year = E.add(anchor, E.multiply(last, width))
combined.end_year = combined.start_year
combined.erase("processed_until")
changed = true
if not changed: return
for entry: Dictionary in groups.values(): result.append(entry.batch)
result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return E.lt(a.get("created_from", a.start_year), b.get("created_from", b.start_year)))
target.batches[id] = result
_batch_indices.erase(id)
_cycle_indices.erase(id)
_ready_indices.erase(id)
_instant_appends.erase(id)
func temporal_resolution(target: Dictionary) -> Variant: func temporal_resolution(target: Dictionary) -> Variant:
var speed: int = int(target.get("speed", 1)) var speed: int = int(target.get("speed", 1))
return E.divide(E.power(10, speed - 1), 10) if speed >= 4 else 0 return E.divide(E.power(10, speed - 1), 10) if speed >= 4 else 0
@@ -20,6 +84,9 @@ func coarsen(target: Dictionary) -> void:
for id in target.batches: for id in target.batches:
if target.batches[id].size() <= 1: continue if target.batches[id].size() <= 1: continue
if not world.catalog.entities.has(id): continue if not world.catalog.entities.has(id): continue
if cycle_stars and world.catalog.entities[id].group_as == "Star":
coarsen_cycle_stars(target, id)
continue
var lifespan: Variant = effective_lifespan(target, id) var lifespan: Variant = effective_lifespan(target, id)
if E.le(lifespan, 10): continue if E.le(lifespan, 10): continue
var width: Variant = window_duration(target, lifespan) var width: Variant = window_duration(target, lifespan)
@@ -58,6 +125,69 @@ var _instant_target: Dictionary = {}
var _instant_appends: Dictionary = {} var _instant_appends: Dictionary = {}
var _static_spans: Dictionary = {} var _static_spans: Dictionary = {}
var _star_spans: Dictionary = {} var _star_spans: Dictionary = {}
var _cycle_target: Dictionary = {}
var _cycle_indices: Dictionary = {}
## High-speed stars share a whole transition at a simulation-cycle boundary.
## Used only when the original window is no wider than a simulation cycle.
func append_cycle_star(target: Dictionary, id: String, amount: Variant, created_from: Variant, created_to: Variant, lifespan: Variant, completed: Dictionary, completed_before: Variant, budget: RefCounted, newborn_adjustment: Variant, newborn_until: Variant) -> Variant:
var width: Variant = star_cycle_width(target)
var anchor: Variant = target.time.datetime
var batches: Array = target.batches[id]
if not is_same(_cycle_target, target):
_cycle_target = target
_cycle_indices.clear()
var cached: Dictionary = _cycle_indices.get(id, {})
if cached.is_empty() or not is_same(cached.batches, batches) or cached.count != batches.size():
var deaths: Dictionary = {}
for batch: Dictionary in batches:
if E.eq(batch.start_year, batch.end_year): deaths[str(batch.end_year)] = batch
cached = {"batches": batches, "count": batches.size(), "deaths": deaths}
_cycle_indices[id] = cached
var duration: Variant = E.subtract(created_to, created_from)
var first_death: Variant = E.add(created_from, lifespan)
var index: Variant = E.ceil_value(E.divide(E.subtract(first_death, anchor), width)) if E.eq(duration, 0) else E.add(E.floor_value(E.divide(E.subtract(first_death, anchor), width)), 1)
var remaining: Variant = amount
var integral: Variant = E.multiply(amount, E.maximum(0, E.subtract(completed_before, E.divide(E.add(created_from, created_to), 2)))) if E.finite(completed_before) else NAN
var order_changed: bool = false
while true:
if budget != null and not await budget.pause(): return NAN
# Jump over windows with zero whole allocation for sparse populations.
if E.gt(duration, 0):
var threshold: Variant = E.add(created_from, E.mul_div(duration, E.add(E.subtract(amount, remaining), 1), amount))
index = E.maximum(index, E.ceil_value(E.divide(E.subtract(E.add(threshold, lifespan), anchor), width)))
var death: Variant = E.add(anchor, E.multiply(index, width))
var part_end: Variant = E.minimum(created_to, E.subtract(death, lifespan))
var count: Variant = remaining
if E.gt(duration, 0) and E.lt(part_end, created_to):
count = E.subtract(E.floor_value(E.mul_div(amount, E.subtract(part_end, created_from), duration)), E.subtract(amount, remaining))
if E.gt(count, 0):
if E.le(death, completed_before):
if not completed.has(id): completed[id] = []
completed[id].append({"amount": count, "from": death, "to": death})
integral = E.subtract(integral, E.multiply(count, expired_years(completed_before, death, death)))
else:
var key: String = str(death)
var batch: Dictionary = cached.deaths.get(key, {})
if not batch.is_empty() and not E.eq(batch.end_year, death): batch = {}
if batch.is_empty():
var birth_start: Variant = E.maximum(0, E.subtract(E.subtract(death, lifespan), width))
batch = {"created_from": birth_start, "created_to": E.add(birth_start, width), "start_year": death, "end_year": death, "amount": 0}
order_changed = order_changed or (not batches.is_empty() and E.lt(batch.created_from, batches.back().get("created_from", -INF)))
batches.append(batch)
cached.deaths[key] = batch
batch.amount = E.add(batch.amount, count)
if newborn_adjustment != null and E.finite(newborn_until):
newborn_adjustment.past_years = E.add(newborn_adjustment.get("past_years", 0), E.multiply(count, expired_years(newborn_until, death, death)))
remaining = E.subtract(remaining, count)
if E.eq(duration, 0) or E.ge(part_end, created_to): break
index = E.add(index, 1)
if order_changed:
batches.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return E.lt(a.get("created_from", -INF), b.get("created_from", -INF)))
cached.count = batches.size()
_batch_indices.erase(id)
_instant_appends.erase(id)
return E.maximum(0, integral) if E.finite(integral) else integral
func effective_lifespan(target: Dictionary, id: String) -> Variant: func effective_lifespan(target: Dictionary, id: String) -> Variant:
var entity: OwniverseEntity = world.catalog.entities[id] var entity: OwniverseEntity = world.catalog.entities[id]
@@ -128,6 +258,7 @@ func scale_batches(target: Dictionary, id: String, factor: Variant) -> void:
func append_batch(target: Dictionary, id: String, amount: Variant, created_from: Variant, created_to: Variant, completed: Dictionary = { }, completed_before: Variant = - INF, budget: RefCounted = null, newborn_adjustment: Variant = null, newborn_until: Variant = NAN) -> Variant: func append_batch(target: Dictionary, id: String, amount: Variant, created_from: Variant, created_to: Variant, completed: Dictionary = { }, completed_before: Variant = - INF, budget: RefCounted = null, newborn_adjustment: Variant = null, newborn_until: Variant = NAN) -> Variant:
var _keep_alive: RefCounted = self var _keep_alive: RefCounted = self
if Global.CivilizationRules.has(id): return NAN
if ((E.le(amount, 0) or (not world.catalog.entities.has(id))) or E.le(world.catalog.entities[id].lifespan, 0)): if ((E.le(amount, 0) or (not world.catalog.entities.has(id))) or E.le(world.catalog.entities[id].lifespan, 0)):
return NAN return NAN
if E.lt(created_to, created_from): if E.lt(created_to, created_from):
@@ -139,6 +270,8 @@ func append_batch(target: Dictionary, id: String, amount: Variant, created_from:
target.batch_origins = {} target.batch_origins = {}
var batches: Array = target.batches[id] var batches: Array = target.batches[id]
var lifespan: Variant = effective_lifespan(target, id) var lifespan: Variant = effective_lifespan(target, id)
if uses_cycle_star(target, id, lifespan):
return await append_cycle_star(target, id, amount, created_from, created_to, lifespan, completed, completed_before, budget, newborn_adjustment, newborn_until)
var batch_duration: Variant = window_duration(target, lifespan) var batch_duration: Variant = window_duration(target, lifespan)
if E.le(batch_duration, 0): if E.le(batch_duration, 0):
return NAN return NAN
+5 -1
View File
@@ -101,7 +101,11 @@ func credit(target: Dictionary, id: String, amount: Variant) -> Variant:
if is_discrete(id): if is_discrete(id):
if (not target.has("production_remainders")): if (not target.has("production_remainders")):
target.production_remainders = {} target.production_remainders = {}
var expected: Variant = E.add(amount, target.production_remainders.get(id, 0)) var carry: Variant = target.production_remainders.get(id, 0)
if E.eq(amount, E.floor_value(amount)) and E.ge(carry, 0) and E.lt(carry, 1):
target.production_remainders[id] = carry
else:
var expected: Variant = E.add(amount, carry)
amount = E.floor_value(expected) amount = E.floor_value(expected)
target.production_remainders[id] = E.subtract(expected, amount) target.production_remainders[id] = E.subtract(expected, amount)
target.amount[id] = E.add(target.amount.get(id, 0), amount) target.amount[id] = E.add(target.amount.get(id, 0), amount)
+8 -33
View File
@@ -14,6 +14,7 @@ var stellar = preload("res://world/owniverse/stellar_rules.gd").new(self)
var life = preload("res://world/owniverse/life_evolution.gd").new(self) var life = preload("res://world/owniverse/life_evolution.gd").new(self)
var environment = preload("res://world/owniverse/universe_environment.gd").new(self) var environment = preload("res://world/owniverse/universe_environment.gd").new(self)
var evolution = preload("res://world/owniverse/evolution_coordinator.gd").new(self) var evolution = preload("res://world/owniverse/evolution_coordinator.gd").new(self)
var civilization_flow = preload("res://world/owniverse/civilization_flow.gd").new(self)
var profile_enabled: bool = false var profile_enabled: bool = false
var profile: Dictionary = {} var profile: Dictionary = {}
@@ -65,27 +66,7 @@ var _requested_speed: int = 0
var _queued_productions: Array = [] var _queued_productions: Array = []
var frame_active_clock: Dictionary = {} var frame_active_clock: Dictionary = {}
var _resume_clock: Dictionary = {} var _resume_clock: Dictionary = {}
var frame_step_seconds: float = 0.1 const frame_step_seconds: float = 0.2
var _frame_step_speed: int = 0
var _frame_started_usec: int = 0
var _frame_duration_seconds: float = 0.0
var _frame_cost_ratio: float = 0.0
## Hysteresis prevents alternating step sizes around one queue threshold.
## Only scheduling uses float; both clocks and the accumulated debt stay exact.
func update_frame_step() -> float:
if _frame_step_speed != speed:
_frame_step_speed = speed
frame_step_seconds = 0.1
_frame_cost_ratio = 0.0
if speed < 4: return 1.0 / 60.0
var debt: Variant = E.maximum(0, E.subtract(frame_clock.get("datetime", state.time.datetime), state.time.datetime))
var seconds: float = E.approx(E.divide(debt, state.speed_in_year))
if seconds > frame_step_seconds * 2.0 or (_frame_cost_ratio > 0.85 and seconds > frame_step_seconds):
frame_step_seconds = 0.2 if frame_step_seconds < 0.2 else 0.5
elif seconds < frame_step_seconds * 0.5:
frame_step_seconds = 0.2 if frame_step_seconds > 0.2 else 0.1
return frame_step_seconds
## Keep the requested clock advancing while a transaction spans render frames. ## Keep the requested clock advancing while a transaction spans render frames.
func evolute_frame(delta: float, tree: SceneTree) -> void: func evolute_frame(delta: float, tree: SceneTree) -> void:
@@ -93,18 +74,15 @@ func evolute_frame(delta: float, tree: SceneTree) -> void:
if frame_clock.is_empty(): frame_clock = state.time.duplicate() if frame_clock.is_empty(): frame_clock = state.time.duplicate()
frame_clock = SimulationTime.advance(frame_clock, delta, speed) frame_clock = SimulationTime.advance(frame_clock, delta, speed)
if frame_busy: return if frame_busy: return
if E.le(frame_clock.datetime, state.time.datetime) and _resume_clock.is_empty(): return var next_clock: Dictionary = _resume_clock
if next_clock.is_empty():
next_clock = SimulationTime.advance(state.time, frame_step_seconds, speed)
# Start only after a full 0.2 seconds of simulation time is requested.
if E.gt(next_clock.datetime, frame_clock.datetime): return
frame_busy = true frame_busy = true
frame_budget = preload("res://world/owniverse/frame_budget.gd").new(tree) frame_budget = preload("res://world/owniverse/frame_budget.gd").new(tree)
frame_active_clock = _resume_clock if not _resume_clock.is_empty() else frame_clock.duplicate() frame_active_clock = next_clock
# Bound catch-up work; never turn the entire accumulated debt into one step.
if _resume_clock.is_empty():
var normal_clock: Dictionary = SimulationTime.advance(state.time, update_frame_step(), speed)
if E.lt(normal_clock.datetime, frame_clock.datetime):
frame_active_clock = normal_clock
_resume_clock = {} _resume_clock = {}
_frame_duration_seconds = E.approx(E.divide(E.subtract(frame_active_clock.datetime, state.time.datetime), state.speed_in_year))
_frame_started_usec = Time.get_ticks_usec()
evolution.run_frame(frame_active_clock, frame_budget) evolution.run_frame(frame_active_clock, frame_budget)
func request_production(id: String, amount: Variant, resource: bool = false) -> void: func request_production(id: String, amount: Variant, resource: bool = false) -> void:
@@ -129,9 +107,6 @@ func restore_frame_checkpoint(checkpoint: Dictionary) -> void:
_requested_speed = checkpoint.get("speed", state.speed) _requested_speed = checkpoint.get("speed", state.speed)
func finish_frame() -> void: func finish_frame() -> void:
if _frame_started_usec > 0 and _frame_duration_seconds > 0:
var ratio: float = (Time.get_ticks_usec() - _frame_started_usec) / 1000000.0 / _frame_duration_seconds
_frame_cost_ratio = ratio if _frame_cost_ratio == 0.0 else lerpf(_frame_cost_ratio, ratio, 0.25)
frame_busy = false frame_busy = false
frame_commits += 1 frame_commits += 1
if _requested_speed != 0: if _requested_speed != 0:
+67 -7
View File
@@ -45,6 +45,41 @@ static func allocate_whole(requests: Dictionary, limit: Variant) -> Dictionary:
total = E.add(total, result[id]) total = E.add(total, result[id])
return _allocate_whole_with_total(requests, limit, result, total) return _allocate_whole_with_total(requests, limit, result, total)
## Whole stocks apportioned using float shares. Both the total and every stock
## ceiling are enforced exactly after quota rounding; no quantity becomes float.
static func allocate_profiled(stocks: Dictionary, limit: Variant) -> Dictionary:
var total: Variant = sum(stocks)
var available: Variant = E.floor_value(E.minimum(E.maximum(0, limit), total))
if E.eq(available, total): return stocks.duplicate()
var ids: Array = stocks.keys()
var shares: Array = []
var result: Dictionary = {}
var only: Variant = null
var positives: int = 0
for id in ids:
result[id] = 0
if E.gt(stocks[id], 0):
only = id
positives += 1
if E.le(available, 0): return result
if positives == 1:
result[only] = available
return result
for id in ids: shares.append(E.ratio_approx(stocks[id], total))
result = split_profile({}, "stock", available, ids, shares)
var assigned: Variant = 0
for id in ids:
result[id] = E.minimum(stocks[id], result[id])
assigned = E.add(assigned, result[id])
var missing: Variant = E.subtract(available, assigned)
for id in ids:
if E.le(missing, 0): break
var extra: Variant = E.minimum(missing, E.subtract(stocks[id], result[id]))
result[id] = E.add(result[id], extra)
missing = E.subtract(missing, extra)
assert(E.eq(missing, 0), "Whole stock allocation must exhaust the available quota")
return result
## Variant for callers that already maintain the sum of floored request values. ## Variant for callers that already maintain the sum of floored request values.
## The supplied total must equal sum(floor(max(0, requests[id]))). ## The supplied total must equal sum(floor(max(0, requests[id]))).
@@ -61,6 +96,15 @@ static func _allocate_whole_with_total(requests: Dictionary, limit: Variant, res
if E.le(available, 0): if E.le(available, 0):
for id in result: result[id] = 0 for id in result: result[id] = 0
return result return result
var only: Variant = null
var positive_count: int = 0
for id in result:
if E.gt(result[id], 0):
only = id
positive_count += 1
if positive_count == 1:
result[only] = available
return result
var ids: Array = requests.keys() var ids: Array = requests.keys()
var rests: Array = [] var rests: Array = []
var order: Array = [] var order: Array = []
@@ -154,21 +198,23 @@ static func split_profile(remainders: Dictionary, key: String, count: Variant, i
if _split_keys.size() >= 512: _split_keys.clear() if _split_keys.size() >= 512: _split_keys.clear()
cached = {"ids": ids.duplicate(), "keys": keys} cached = {"ids": ids.duplicate(), "keys": keys}
_split_keys[key] = cached _split_keys[key] = cached
var expected: Array = E.mul_div_many(count, weights, SCALE) var quotas: Array = E.whole_quotas(count, weights, SCALE)
var rests: Array = [] var rests: Array = []
var order: Array = [] var order: Array = []
var total: Variant = 0 var total: Variant = 0
for i in ids.size(): for i in ids.size():
expected[i] = E.add(expected[i], remainders.get(cached.keys[i], 0)) var fraction: float = quotas[i][1] + E.approx(remainders.get(cached.keys[i], 0))
result[ids[i]] = E.floor_value(E.maximum(0, expected[i])) var adjustment: int = floori(fraction)
result[ids[i]] = E.maximum(0, E.add(quotas[i][0], adjustment))
total = E.add(total, result[ids[i]]) total = E.add(total, result[ids[i]])
rests.append(E.subtract(expected[i], result[ids[i]])) rests.append(fraction - adjustment if E.ge(quotas[i][0], -adjustment) else fraction)
order.append(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])) order.sort_custom(func(a: int, b: int) -> bool: return rests[a] > rests[b] if rests[a] != rests[b] else str(ids[a]) < str(ids[b]))
if E.lt(total, count): if E.lt(total, count):
for i in order: for i in order:
if E.ge(total, count): break if E.ge(total, count): break
result[ids[i]] = E.add(result[ids[i]], 1) result[ids[i]] = E.add(result[ids[i]], 1)
rests[i] -= 1.0
total = E.add(total, 1) total = E.add(total, 1)
elif E.gt(total, count): elif E.gt(total, count):
order.reverse() order.reverse()
@@ -176,9 +222,10 @@ static func split_profile(remainders: Dictionary, key: String, count: Variant, i
if E.le(total, count): break if E.le(total, count): break
if E.le(result[ids[i]], 0): continue if E.le(result[ids[i]], 0): continue
result[ids[i]] = E.subtract(result[ids[i]], 1) result[ids[i]] = E.subtract(result[ids[i]], 1)
rests[i] += 1.0
total = E.subtract(total, 1) total = E.subtract(total, 1)
for i in ids.size(): for i in ids.size():
remainders[cached.keys[i]] = E.clamp_value(E.subtract(expected[i], result[ids[i]]), E.neg(1), 1) remainders[cached.keys[i]] = clampf(rests[i], -1.0, 1.0)
return result 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:
@@ -256,7 +303,20 @@ static func split_arrays(remainders: Dictionary, key: String, count: Variant, id
static func accumulate_rate(remainders: Dictionary, key: String, count: Variant, probability: Variant) -> Variant: static func accumulate_rate(remainders: Dictionary, key: String, count: Variant, probability: Variant) -> Variant:
var expected: Variant = E.add(E.multiply(count, E.maximum(0, probability)), remainders.get(key, 0)) var product: Variant = E.multiply(count, E.maximum(0, probability))
var carry: float = E.approx(remainders.get(key, 0))
if carry >= 0.0 and carry < 1.0 and E.ge(product, 0):
if product is int:
remainders[key] = carry
return product
if product is E.Value:
var fraction: float = product.fraction + carry
if product.fraction > 0.0 and carry > 0.0 and fraction > 0.5 and absf(fraction - roundf(fraction)) <= 1e-12:
fraction = roundf(fraction)
var extra: int = floori(fraction)
remainders[key] = fraction - extra
return E.add(E.floor_value(product), extra)
var expected: Variant = E.add(product, remainders.get(key, 0))
var actual: Variant = E.floor_value(expected) var actual: Variant = E.floor_value(expected)
remainders[key] = E.maximum(0, E.subtract(expected, actual)) remainders[key] = E.maximum(0, E.subtract(expected, actual))
return actual return actual