Compare commits
2 Commits
e48d4bbe88
...
3fcc7211ce
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fcc7211ce | |||
| 6ee66a627d |
+1
-1
@@ -34,7 +34,7 @@ var ExtraParams: Dictionary = {}
|
||||
var Music: 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:
|
||||
|
||||
@@ -6,7 +6,7 @@ const E = preload("res://world/owniverse/math/exact.gd")
|
||||
const SETTING_SECTION = "Settings"
|
||||
const SAVE_PATH = "user://settings.cfg"
|
||||
|
||||
const FORMAT_VERSION : int = 5
|
||||
const FORMAT_VERSION : int = 6
|
||||
const SimulationTime = preload("res://world/owniverse/simulation_time.gd")
|
||||
const WorldParameters = preload("res://world/owniverse/world_parameters.gd")
|
||||
const STATE_VERSION : String = SimulationTime.STATE_VERSION
|
||||
@@ -348,11 +348,19 @@ static func _validate_payload(data: Variant, speed_limit: int, stages: Array, en
|
||||
if ((not (saved.ethics is Dictionary)) or (not (saved.civilization_homes is Dictionary))):
|
||||
return "invalid civilization state"
|
||||
for id in saved.ethics:
|
||||
if (not E.contains(stages, id) or (not _number_map(saved.ethics[id], true))):
|
||||
if (not E.contains(stages, id) or (not (saved.ethics[id] is Dictionary))):
|
||||
return E.add("invalid state.ethics.", str(id))
|
||||
if E.ne(saved.ethics[id].keys().size(), 6):
|
||||
return E.add("invalid ethic profile size for ", str(id))
|
||||
var share_sum: float = 0.0
|
||||
for ethic in saved.ethics[id]:
|
||||
if not E.contains(["m", "l", "w", "d", "c", "b"], ethic):
|
||||
var share: Variant = saved.ethics[id][ethic]
|
||||
var share_value: float = 0.0 + share if share is int or share is float else -1.0
|
||||
if not E.contains(["m", "l", "w", "d", "c", "b"], ethic) or not is_finite(share_value) or share_value < 0.0 or share_value > 1.0:
|
||||
return E.add("invalid ethic ", str(ethic))
|
||||
share_sum += share_value
|
||||
if absf(share_sum - 1.0) > 0.000001:
|
||||
return E.add("invalid ethic profile total for ", str(id))
|
||||
for id in saved.civilization_homes:
|
||||
if (not _number_map(saved.civilization_homes[id], true)):
|
||||
return "invalid civilization homes"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[runnable_presets]
|
||||
|
||||
"Windows Desktop"="Windows Desktop"
|
||||
Android="Android"
|
||||
|
||||
[preset.0]
|
||||
|
||||
|
||||
+18
-24
@@ -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
|
||||
@@ -56,7 +62,7 @@ converted = floor(expected)
|
||||
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: конечная стадия без новых жизненных партий.
|
||||
|
||||
Состояние `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
|
||||
|
||||
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`.
|
||||
В `civilization_remainders` сохраняются квоты и дробные остатки потоков; в `civilization_stats` — войны, колонии, побочный ущерб и поклонение. Возрастные партии сохраняются для звёзд, HP, эонов, C0.0 и C0.1.
|
||||
|
||||
### Структуры и периоды
|
||||
|
||||
Прямой `claim` задаёт структуру стадии и норму 1; каждая промежуточная стадия умножает норму на 10 до следующего `claim`. Начиная с C3.0 требуется соответствующий запас структур, общий для использующих его стадий. При потере запаса избыток деградирует. Этот бюджет действует одновременно со звёздным.
|
||||
|
||||
Размещение выбирает структуры в порядке убывания `civ_bonus`, начиная с соответствующего стадии `claim`. Прямое размещение ограничено числом структур; остальные разрешённые структуры при наличии дают общий бонус, как в Unity. Период новых партий равен `span / средневзвешенный civ_bonus`, для неразмещённой части множитель равен 1. Уже назначенные сроки партий не переписываются при изменении бонуса.
|
||||
Размещение выбирает структуры в порядке убывания `civ_bonus`, начиная с соответствующего стадии `claim`. Прямое размещение ограничено числом структур; остальные разрешённые структуры при наличии дают общий бонус, как в Unity. Эффективный средний интервал попыток равен `span / средневзвешенный civ_bonus`, для неразмещённой части множитель равен 1. Бонус входит в оператор следующего расчётного интервала.
|
||||
|
||||
Ct не получает часы. Инициализация отсутствующих партий жизни выполняется при загрузке и перед продвижением симуляции. Параметры мира и тенденции описаны ниже; интерфейс применения сил в этот перенос не входит.
|
||||
|
||||
|
||||
+42
-17
@@ -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%). Парсер планет сохраняет дроби и поддерживает один элемент без `&`.
|
||||
|
||||
@@ -209,8 +243,7 @@ godot --path Owniverse.GI --script res://tests/benchmark_civilizations.gd -- ui
|
||||
Stage вызывает `evolute_frame`: транзакция получает бюджет 40 мс и уступает
|
||||
следующему кадру внутри создания партий и обработки стадий. Расчёт остаётся
|
||||
на главном потоке, состояние публикуется только после полного завершения.
|
||||
Длительность одной транзакции ограничена обычным шагом 1/60 секунды выбранной
|
||||
скорости. Запрошенное время хранится отдельно, поэтому отставание не превращается
|
||||
Одна транзакция обрабатывает 0,2 реальной секунды на любой скорости. Запрошенное время хранится отдельно, поэтому отставание не превращается
|
||||
в один всё более тяжёлый шаг. При предельной нагрузке время симуляции может
|
||||
отставать от запрошенного; это не ограничение FPS интерфейса и не удаление событий.
|
||||
|
||||
@@ -259,20 +292,12 @@ Stage вызывает `evolute_frame`: транзакция получает б
|
||||
1.057. Замер включает сокращение накопленного отставания сохранения;
|
||||
это результат конкретного сценария на Mac, а не гарантия для Android.
|
||||
|
||||
Адаптивный шаг на скоростях 4–6 выбирает 0.1, 0.2 или 0.5 реальной секунды.
|
||||
Долг больше двух шагов повышает ступень, меньше половины шага понижает её.
|
||||
Шаг ограничен доступным запрошенным временем. Скорости 1–3 сохраняют 1/60;
|
||||
смена скорости сбрасывает ступень. Возобновляемая транзакция сохраняет точную
|
||||
границу из checkpoint, адаптация применяется к следующей транзакции.
|
||||
Бюджет кадра остаётся 40 мс; ширина временных партий не меняется.
|
||||
`test_frame_evolution` проверяет ступени, гистерезис, сохранение долга,
|
||||
переключение скорости, возобновление и совпадение с прямым расчётом того же шага.
|
||||
На свежей диагностической копии сохранения с долгом 407.79 млн лет:
|
||||
600 кадров реального UI на M4 Pro дали 18.05 FPS (p95 92.91 мс,
|
||||
максимум 122.45 мс), поступило 3.335 млн лет, обработано 6.480 млн:
|
||||
пропускная способность 1.943 вместо 0.787 при фиксированном шаге 0.1.
|
||||
Разбиение времени влияет на дискретные производство и глобальные параметры;
|
||||
адаптивные шаги не обещают совпадения результата с последовательностью 0.1.
|
||||
Полный шаг фиксирован: 0,2 реальной секунды на всех скоростях, пять циклов
|
||||
на секунду запрошенного времени. Неполный интервал накапливается. При отставании
|
||||
шаг сохраняется, долг не отбрасывается. Возобновляемая транзакция сохраняет
|
||||
границу из checkpoint. Бюджет кадра остаётся 40 мс. `test_frame_evolution`
|
||||
проверяет расписание всех шести скоростей, сохранение долга и пять циклов
|
||||
на одну секунду.
|
||||
|
||||
Дипломатический drift использует шесть взвешенных вкладов вместо матрицы
|
||||
пар этик: вес = сумма вкладов + 195 × свой вклад − противоположный вклад.
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
extends Control
|
||||
|
||||
const E = preload("res://world/owniverse/math/exact.gd")
|
||||
const RESULT_PATH := "user://android_benchmark_result.txt"
|
||||
const FRAME_COUNT := 120
|
||||
|
||||
var status_label: Label
|
||||
|
||||
func _ready() -> void:
|
||||
status_label = Label.new()
|
||||
status_label.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
status_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
status_label.add_theme_font_size_override("font_size", 32)
|
||||
add_child(status_label)
|
||||
call_deferred("_run")
|
||||
|
||||
func _set_status(message: String) -> void:
|
||||
status_label.text = message
|
||||
var file := FileAccess.open(RESULT_PATH, FileAccess.WRITE)
|
||||
if file != null:
|
||||
file.store_line(message)
|
||||
file.flush()
|
||||
|
||||
func _restore(value):
|
||||
if value is float and value == floorf(value) and absf(value) <= 9007199254740991:
|
||||
return int(value)
|
||||
if value is Dictionary:
|
||||
for key in value:
|
||||
value[key] = _restore(value[key])
|
||||
elif value is Array:
|
||||
for index in value.size():
|
||||
value[index] = _restore(value[index])
|
||||
return value
|
||||
|
||||
func _load_payload_threaded() -> Dictionary:
|
||||
var file := FileAccess.open("res://tests/artifacts/all_objects_speed6.adventure.json", FileAccess.READ)
|
||||
if file == null:
|
||||
return {"error": "Cannot open benchmark save: %s" % FileAccess.get_open_error()}
|
||||
var parser := JSON.new()
|
||||
var error := parser.parse(file.get_as_text())
|
||||
if error != OK:
|
||||
return {"error": "JSON parse error at line %d: %s" % [parser.get_error_line(), parser.get_error_message()]}
|
||||
var data = E.decode(_restore(parser.data))
|
||||
if not data is Dictionary or not data.has("state"):
|
||||
return {"error": "Benchmark save has no state"}
|
||||
return {"payload": data}
|
||||
|
||||
func _run() -> void:
|
||||
_set_status("Reading and decoding benchmark save on a worker thread…")
|
||||
var worker := Thread.new()
|
||||
var start_error := worker.start(Callable(self, "_load_payload_threaded"))
|
||||
if start_error != OK:
|
||||
_set_status("ERROR: could not start loader thread (%s)" % start_error)
|
||||
return
|
||||
while worker.is_alive():
|
||||
await get_tree().process_frame
|
||||
var loaded: Dictionary = worker.wait_to_finish()
|
||||
if loaded.has("error"):
|
||||
_set_status("ERROR: " + str(loaded.error))
|
||||
return
|
||||
_set_status("Save decoded. Initializing UI scene…")
|
||||
var payload: Dictionary = loaded.payload
|
||||
var scene: Node = load("res://world/world.tscn").instantiate()
|
||||
var stage = scene.get_node("Stage")
|
||||
stage.owniverse.free()
|
||||
stage.set_script(load("res://tests/fixtures/benchmark_ui_stage.gd"))
|
||||
get_tree().root.add_child(scene)
|
||||
await get_tree().process_frame
|
||||
stage.owniverse.state = payload.state.duplicate(true)
|
||||
stage.owniverse.restore_frame_checkpoint(payload.get("simulation", {}))
|
||||
stage.owniverse.presentation.refresh(stage.owniverse.state, stage.owniverse.catalog)
|
||||
stage.set_owniverse_item_value()
|
||||
stage.stage_page_scroller.scroll_horizontal = 4 * stage.PAGE_WIDTH
|
||||
var initial_requested = stage.owniverse.frame_clock.get("datetime", stage.owniverse.state.time.datetime)
|
||||
var initial_published = stage.owniverse.state.time.datetime
|
||||
stage.owniverse.profile_enabled = true
|
||||
stage.measuring = true
|
||||
_set_status("Running speed 6: 0/%d frames" % FRAME_COUNT)
|
||||
for frame in FRAME_COUNT:
|
||||
await get_tree().process_frame
|
||||
if frame % 5 == 4:
|
||||
_set_status("Running speed 6: %d/%d frames; commits=%d" % [frame + 1, FRAME_COUNT, stage.owniverse.frame_commits])
|
||||
stage.measuring = false
|
||||
var samples: Array = stage.frame_samples
|
||||
if samples.is_empty():
|
||||
_set_status("ERROR: no rendered frame samples; frames=%d" % stage.frame_samples.size())
|
||||
return
|
||||
var total: float = 0.0
|
||||
for sample in samples:
|
||||
total += sample
|
||||
samples.sort()
|
||||
var requested_advance = E.subtract(stage.owniverse.frame_clock.datetime, initial_requested)
|
||||
var published_advance = E.subtract(stage.owniverse.state.time.datetime, initial_published)
|
||||
var process_samples: Array = stage.process_samples
|
||||
process_samples.sort()
|
||||
var process_total: float = 0.0
|
||||
for sample in process_samples:
|
||||
process_total += sample
|
||||
var process_summary := "no _process samples"
|
||||
if not process_samples.is_empty():
|
||||
process_summary = "mean=%.2fms p95=%.2fms max=%.2fms samples=%d" % [process_total / process_samples.size(), process_samples[int(ceil(process_samples.size() * 0.95)) - 1], process_samples.back(), process_samples.size()]
|
||||
var budget: Variant = stage.owniverse.frame_budget
|
||||
var max_slice_summary: String = "none"
|
||||
if budget != null:
|
||||
max_slice_summary = "%.2fms (%s)" % [budget.max_slice_usec / 1000.0, budget.max_slice_phase]
|
||||
var result := "DONE\nFPS=%.2f mean=%.2fms p95=%.2fms max=%.2fms frames=%d commits=%d\nStage _process CPU: %s\nMax uninterrupted simulation slice: %s\nRequested=%s published=%s throughput=%s\nFinal adaptive step=%s\nProfile=%s" % [1000.0 / (total / samples.size()), total / samples.size(), samples[int(ceil(samples.size() * 0.95)) - 1], samples.back(), samples.size(), stage.owniverse.frame_commits, process_summary, max_slice_summary, str(requested_advance), str(published_advance), str(E.divide(published_advance, requested_advance)), str(stage.owniverse.frame_step_seconds), str(stage.owniverse.profile)]
|
||||
_set_status(result)
|
||||
get_tree().quit()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dkbyhj8ib0eyf
|
||||
@@ -0,0 +1,12 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tests/android_benchmark.gd" id="1"]
|
||||
|
||||
[node name="AndroidBenchmark" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1")
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -61,3 +61,321 @@ godot --headless --path Owniverse.GI --script res://tests/create_full_benchmark_
|
||||
godot --headless --path Owniverse.GI --script res://tests/benchmark_saved_state.gd -- Owniverse.GI/tests/artifacts/all_objects_speed6.adventure.json 0.5 profile
|
||||
godot --path Owniverse.GI --script res://tests/benchmark_full_ui.gd
|
||||
```
|
||||
|
||||
## Проверка на Android Note8 (2026-10-06)
|
||||
|
||||
Сборка benchmark установлена отдельным пакетом `com.KIDGameProduction.Owniverse.AndroidBench`, сохранение основной игры не использовалось. Подготовка и декодирование сохранения выполнялись в фоновом потоке; ход и результат записывались в файл приложения. Устройство: Samsung SM-N950F, Android 9. Сценарий: все объекты и цивилизации, скорость 6, 120 кадров (119 интервалов).
|
||||
|
||||
| Метрика | Результат |
|
||||
|---|---:|
|
||||
| FPS UI | 1,76 |
|
||||
| Средний / p95 / максимальный кадр | 567,28 / 817,54 / 1278,33 мс |
|
||||
| Завершенные транзакции за прогон | 1 |
|
||||
| Запрошено / опубликовано игрового времени | 1599938,57 / 10000 лет |
|
||||
| Доля опубликованного от запрошенного | 0,00625 |
|
||||
| Итоговый динамический шаг | 0,2 с |
|
||||
|
||||
Самый дорогой участок профиля — `generations`: 5,350 с суммарно за единственную завершенную транзакцию; внутри него `civilization_prepare` занял 3,186 с, `wars` — 2,217 с, `civilization_transitions` — 1,034 с. Интервалы вложены, поэтому их нельзя складывать с `generations`. Этот экстремальный сценарий на Note8 не достигает целевых 5 FPS и сильно отстает по игровому времени. Это замер именно полного стресс-сохранения, а не обычного игрового сохранения.
|
||||
|
||||
### Покадровая уступка в расчётах цивилизаций
|
||||
|
||||
Добавлены контрольные точки внутри обработки этик, проигравших в войне, падения популяции и выходов между стадиями. Математика и порядок переходов не меняются. На том же Note8 повторный прогон дал 1,87 FPS против 1,76 FPS до изменения (прирост около 6%). Средний кадр сократился с 567 до 535 мс, но p95 остался 630 мс, максимум — 1281 мс. Это слишком малое улучшение для цели 5 FPS.
|
||||
|
||||
В повторном профиле `generations` заняли 5,166 с, `civilization_prepare` — 3,040 с, войны — 0,985 с, переходы между стадиями — 0,640 с. Профиль не показывает явного трёхкратного ускорения: контрольные точки лучше дробят отдельные подциклы, но главный синхронный участок остается слишком дорогим. Диагностическая сборка для измерения максимального непрерывного участка не завершила прогон на телефоне; при следующем проходе сначала нужно локализовать этот участок, а затем оптимизировать его отдельно.
|
||||
|
||||
Контрольный запуск UI-бенчмарка на Apple M4 Pro после изменения: 21,51 FPS, средний / p95 / максимальный кадр — 46,49 / 89,62 / 107,19 мс. Этот результат подтверждает отсутствие регрессии на компьютере, но не заменяет Android-замер.
|
||||
|
||||
### После перехода этик на доли (2026-10-06)
|
||||
|
||||
Этическое состояние теперь хранит шесть долей `float`, а точное целое население берётся из реестра объектов. Исходы одной стадии сначала собираются по назначениям; доли состояния обновляются после агрегации потоков. Формат сохранения повышен до версии 6; миграции прежних сохранений нет.
|
||||
|
||||
Финальная APK повторно проверена на Samsung SM-N950F, Android 9, тем же полным сценарием и 120 кадрами. Два последовательных прогона дали **14,57 и 15,02 FPS**, средний кадр — **68,61 и 66,58 мс**, p95 — **113,03 и 123,03 мс**, по одной завершённой транзакции из 119 интервалов. Опубликовано 10 000 лет из 647 331 и 634 134 запрошенных; пропускная способность — 0,01545 и 0,01577. В последнем профиле `generations` заняли 5,120 с, `civilization_prepare` — 3,162 с, войны — 1,026 с, переходы — 0,728 с, дрейф этик — 0,474 с. Самый длинный непрерывный участок составил **458,14 мс** в `deep_copy_and_coarsen`, максимальный кадр — 976 мс. Предыдущий одиночный запуск старой benchmark-сборки показал 1,83 FPS, поэтому сравнивать его с этой парой напрямую нельзя; оба финальных прогона выполнены на одной APK и подтверждают результат около 15 FPS.
|
||||
|
||||
На компьютере прошли тесты состояния (70), эволюции (555), оптимизаций симуляции (364), индикаторов (63) и жизни (25). Полный набор всё ещё сообщает о двух отказах в `test_descriptions.gd` при обновлении выбранного описания и смене языка; оба относятся к UI-описаниям, а не к модели этик. В логах Android ошибок GDScript/Godot за прогон не найдено.
|
||||
|
||||
|
||||
## Непрерывная модель цивилизаций — 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
|
||||
}
|
||||
@@ -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
|
||||
@@ -40,7 +40,7 @@ func run():
|
||||
for id in stage.owniverse.catalog.entities:
|
||||
if E.gt(stage.owniverse.ledger.physical_amount(stage.owniverse.state, id), 0): remaining_objects += 1
|
||||
print("Remaining objects: ", remaining_objects)
|
||||
print("Final adaptive step: ", stage.owniverse.frame_step_seconds)
|
||||
print("Simulation step: ", stage.owniverse.frame_step_seconds)
|
||||
for entity in stage.owniverse.catalog.entities.values(): entity.free()
|
||||
stage.owniverse.free()
|
||||
scene.free()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://d0plagt7blmcf
|
||||
@@ -109,5 +109,103 @@ func run() -> void:
|
||||
world.batches.withdraw_batches(world.state, "N", 1)
|
||||
check(world.state.batches.N.is_empty(), "Scaling invalidates readiness and removes zero cohorts")
|
||||
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]))
|
||||
quit((0 if E.eq(failures, 0) else 1))
|
||||
|
||||
@@ -1,165 +1,136 @@
|
||||
extends SceneTree
|
||||
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 failures: int = 0
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("run")
|
||||
|
||||
func check(condition: bool, message: String) -> void:
|
||||
checks = E.add(checks, 1)
|
||||
if (not condition):
|
||||
failures = E.add(failures, 1)
|
||||
func _initialize(): call_deferred("run")
|
||||
func check(value: bool, message: String) -> void:
|
||||
checks += 1
|
||||
if not value:
|
||||
failures += 1
|
||||
push_error(message)
|
||||
|
||||
func fixture():
|
||||
var world = load("res://world/owniverse/owniverse.gd").new()
|
||||
var global: Node = root.get_node("Global")
|
||||
for id in global.OwniverseEntities:
|
||||
var entity = load("res://world/owniverse/owniverse_entity.gd").new(global.OwniverseEntities[id])
|
||||
world.catalog.entities[id] = entity
|
||||
world.catalog.load_definitions(root.get_node("Global").OwniverseEntities)
|
||||
for id in world.catalog.entities:
|
||||
world.state.amount[id] = 0
|
||||
world.state.stats.total_amount[id] = 0
|
||||
if E.ne(entity.group_as, ""):
|
||||
world.state.amount[entity.group_as] = 0
|
||||
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
|
||||
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"]:
|
||||
if world.state.amount.has(id): world.state.amount[id] = E.of("1e60")
|
||||
return world
|
||||
|
||||
func dispose(world) -> void:
|
||||
for entity in world.catalog.entities.values():
|
||||
entity.free()
|
||||
func dispose(world):
|
||||
for entity in world.catalog.entities.values(): entity.free()
|
||||
world.free()
|
||||
|
||||
func seed_population(world, id: String, count: Variant, time: Variant = 1.0) -> void:
|
||||
world.state.amount.N = 0
|
||||
world.state.amount[id] = count
|
||||
world.state.batches[id] = [{"amount": count, "start_year": time, "end_year": time}]
|
||||
world.evolution.initialize_life_batches()
|
||||
|
||||
func sum_ethics(world, id: String) -> Variant:
|
||||
func population(world) -> Variant:
|
||||
var result: Variant = 0
|
||||
for n in world.state.ethics.get(id, {}).values(): result = E.add(result, n)
|
||||
for id in world.ledger.physical_amounts(world.state):
|
||||
if root.get_node("Global").CivilizationRules.has(id) or world.catalog.entities[id].group_as == "Planet":
|
||||
result = E.add(result, world.ledger.physical_amount(world.state, id))
|
||||
return result
|
||||
|
||||
func invariants(world) -> void:
|
||||
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:
|
||||
check(E.eq(sum_ethics(world, id), physical.get(id, 0)), E.add("Ethics equal physical population for ", id))
|
||||
for n in world.state.ethics[id].values():
|
||||
if (E.lt(n, 0) or E.ne(n, E.floor_value(n))): check(false, "Invalid ethic amount")
|
||||
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Civilization state validates")
|
||||
var total: float = 0.0
|
||||
for value in world.state.ethics[id].values():
|
||||
check(is_finite(value) and value >= 0.0 and value <= 1.0, "Finite normalized ethic shares")
|
||||
total += value
|
||||
check(absf(total - 1.0) < 1e-12, "Ethic shares sum to one: " + id)
|
||||
|
||||
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 baseline: Dictionary = global.CivilizationRules.duplicate(true)
|
||||
configure(global.CivilizationRules, 1.0, 0.0)
|
||||
var world = fixture()
|
||||
seed_population(world, "C0.2", 60000)
|
||||
world.evolution.evolve_batches(world.state, 1)
|
||||
check(E.gt(world.state.amount["C0.3"], 0), "C0.2 actually progresses")
|
||||
check(E.gt(world.state.amount["C0.1"], 0), "C0.2 actually degrades")
|
||||
check(E.eq(world.state.civilization_stats.get("wars", 0), 0), "Table disables early wars")
|
||||
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(E.eq(world.state.stats.total_amount["C0.2"], 0), "Survivors do not count as new civilizations")
|
||||
check(E.gt(world.state.civilization_stats.worship, 0), "Worship derived from surviving ethics")
|
||||
var count: Variant = E.of("10000000000000000000000000000000000000007")
|
||||
world.state.amount["C0.2"] = count
|
||||
world.state.ethics["C0.2"] = State.empty_profile()
|
||||
world.civilization_flow.evolve(world.state, 0, 100)
|
||||
check(E.eq(population(world), count), "Continuous chain conserves arbitrary-size whole 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.gt(world.state.amount["C0.4"], 0), "One interval includes multiple successive stages")
|
||||
check(not world.state.batches.has("C0.2") and not world.state.batches.has("C0.3"), "Civilizations do not create age batches")
|
||||
invariants(world)
|
||||
var restored = fixture()
|
||||
restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state))))
|
||||
world.evolution.evolve_batches(world.state, 2100)
|
||||
restored.evolution.evolve_batches(restored.state, 2100)
|
||||
check(E.eq(world.state, restored.state), "Save resumes deterministic ethical evolution")
|
||||
invariants(world)
|
||||
dispose(restored)
|
||||
var half = fixture()
|
||||
half.state.amount["C0.2"] = count
|
||||
half.state.ethics["C0.2"] = State.empty_profile()
|
||||
half.civilization_flow.evolve(half.state, 0, 50)
|
||||
half.civilization_flow.evolve(half.state, 50, 100)
|
||||
for id in global.CivilizationRules:
|
||||
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)
|
||||
|
||||
configure(global.CivilizationRules, 0.0, 0.0)
|
||||
world = fixture()
|
||||
world.state.amount.RD = 100000000
|
||||
seed_population(world, "C2.0", 60000)
|
||||
world.state.amount["C1.9"] = 10000
|
||||
world.evolution.initialize_life_batches()
|
||||
world.evolution.evolve_batches(world.state, 1)
|
||||
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")
|
||||
world.state.amount["C0.2"] = 10001
|
||||
world.state.ethics["C0.2"] = {"m": 1.0, "l": 0.0, "w": 0.0, "d": 0.0, "c": 0.0, "b": 0.0}
|
||||
world.civilization_flow.evolve(world.state, 0, 100)
|
||||
check(E.eq(world.state.amount["C0.2"], 10001), "Disabled stage movement retains every individual")
|
||||
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")
|
||||
invariants(world)
|
||||
dispose(world)
|
||||
|
||||
configure(global.CivilizationRules, 1.0, 0.0)
|
||||
world = fixture()
|
||||
world.state.amount.RD = 1
|
||||
world.state.binds.RD = {"HP": 5, "GG": 5, "AB": 5, "RP": 5}
|
||||
seed_population(world, "C1.6", 60000)
|
||||
for row : Dictionary in global.CivilizationRules["C1.6"].ethics.values(): row.degradation = "0"
|
||||
world.evolution.evolve_batches(world.state, 1)
|
||||
var after: Dictionary = world.ledger.physical_amounts(world.state)
|
||||
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")
|
||||
check(E.eq(world.state.binds.RD.get("C1.4", 0), 20), "Colonies retain target host and use two-stages-earlier level")
|
||||
var totals: Variant = 0
|
||||
for id in after:
|
||||
if E.eq(world.catalog.entities[id].group_as, "Planet"): totals = E.add(totals, after[id])
|
||||
check(E.eq(totals, 60020), "Colonization replaces planets, does not create extra worlds")
|
||||
for id in ["RD", "OD", "YD", "WYD"]: world.state.amount[id] = 0
|
||||
world.state.amount["C1.9"] = 101
|
||||
world.civilization_flow.evolve(world.state, 0, 100)
|
||||
check(E.eq(world.state.amount["C1.9"], 101), "Unfunded interstellar flows return to their source")
|
||||
check(E.eq(population(world), 101), "Resource admission conserves exact population")
|
||||
dispose(world)
|
||||
|
||||
configure(global.CivilizationRules, 0.0, 0.0, 1)
|
||||
world = fixture()
|
||||
world.state.amount["C1.6"] = 60000
|
||||
world.state.binds.RD = {"HP": 5, "RP": 5, "GG": 5, "AB": 5}
|
||||
global.CivilizationRules["C1.6"].colonizes = true
|
||||
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)
|
||||
dispose(world)
|
||||
|
||||
global.CivilizationRules = baseline.duplicate(true)
|
||||
world = fixture()
|
||||
world.state.amount.RD = E.of("1e25")
|
||||
world.state.amount.BG = E.of("1e25")
|
||||
world.state.amount.TWD = E.of("1e25")
|
||||
world.state.amount.SNS = E.of("1e25")
|
||||
world.state.amount.MGT = E.of("1e25")
|
||||
world.state.amount.BH25 = E.of("1e25")
|
||||
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")
|
||||
world.state.amount["C2.0"] = 60000
|
||||
world.state.amount["C1.9"] = 10000
|
||||
world.civilization_flow.evolve(world.state, 0, 20000)
|
||||
check(E.gt(world.state.civilization_stats.wars, 0), "War rewards integrate encounters during the interval")
|
||||
check(E.gt(world.state.civilization_stats.collateral, 0), "Collateral damage reaches previous stages")
|
||||
check(E.eq(population(world), 70000), "War outcomes and collateral conserve occupied-world counts")
|
||||
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)
|
||||
global.CivilizationRules = baseline
|
||||
|
||||
world = fixture()
|
||||
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))
|
||||
print("Civilization flows: %d checks, %d failures" % [checks, failures])
|
||||
quit(1 if failures else 0)
|
||||
|
||||
@@ -48,18 +48,23 @@ func run() -> void:
|
||||
world.evolution.initialize_life_batches()
|
||||
check((E.eq(world.state.amount["C2.0"], 12) and E.eq(world.state.binds.RD["C2.0"], 0)), "Old space civilizations detach without loss")
|
||||
check(E.eq(world.state.binds.RD["C1.9"], 3), "Planetary civilizations retain their host")
|
||||
check(E.eq(world.state.ethics["C0.2"], {"m": 2, "l": 2, "w": 1, "d": 1, "c": 1, "b": 1}), "Migration uses equal ethics and deterministic remainder")
|
||||
check(world.state.ethics["C2.0"].values().all(func ( n ) : return E.eq(n, 2)), "Bound civilization ethics count physical population")
|
||||
check(world.state.ethics["C0.2"].values().all(func ( n ) : return E.near(n, 1.0 / 6.0)), "Migration initializes equal ethic shares")
|
||||
check(world.state.ethics["C2.0"].values().all(func ( n ) : return E.near(n, 1.0 / 6.0)), "Bound civilization ethics are population-independent shares")
|
||||
var before: Dictionary = world.state.duplicate(true)
|
||||
world.evolution.initialize_life_batches()
|
||||
check(E.eq(world.state, before), "Repeated initialization preserves ethics")
|
||||
var profiles_stable: bool = true
|
||||
for ethic_id in before.ethics:
|
||||
for ethic in before.ethics[ethic_id]:
|
||||
profiles_stable = profiles_stable and E.near(world.state.ethics[ethic_id][ethic], before.ethics[ethic_id][ethic])
|
||||
check(profiles_stable, "Repeated initialization preserves ethics")
|
||||
world.state.ethics["C0.2"] = {"m": 8, "l": 0, "w": 0, "d": 0, "c": 0, "b": 0}
|
||||
world.evolution.initialize_life_batches()
|
||||
check(E.eq(world.state.ethics["C0.2"].m, 8), "Existing unequal distribution is retained")
|
||||
check(E.eq(world.state.ethics["C0.2"].m, 1.0), "Existing unequal distribution is retained")
|
||||
world.state.amount["C0.2"] = 3
|
||||
world.evolution.initialize_life_batches()
|
||||
check(E.eq(world.state.ethics["C0.2"].m, 3), "Losses reduce ethics without inventing population")
|
||||
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Ethics state validates")
|
||||
check(E.eq(world.state.ethics["C0.2"].m, 1.0), "Losses retain normalized ethical composition")
|
||||
check(E.eq(world.ledger.physical_amounts(world.state)["C0.2"], 3), "Exact population remains in the object ledger")
|
||||
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 6, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Ethics state validates")
|
||||
var restored = fixture()
|
||||
restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state))))
|
||||
restored.evolution.initialize_life_batches()
|
||||
@@ -74,7 +79,8 @@ func run() -> void:
|
||||
row.colonization = "0"
|
||||
world.state.binds.RD = {"C1.9": 10}
|
||||
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")
|
||||
dispose(world)
|
||||
var capacity = load("res://world/owniverse/civilization_state.gd")
|
||||
@@ -117,7 +123,7 @@ func run() -> void:
|
||||
world.evolution.initialize_life_batches()
|
||||
check(E.eq(world.state.amount["C4.2"], 2), "SMBH capacity uses stellar mass equivalent")
|
||||
check(E.eq(world.ledger.physical_amounts(world.state)["C1.9"], 1), "Unsupported civilization degrades through missing habitat families")
|
||||
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Capacity-limited state validates")
|
||||
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 6, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Capacity-limited state validates")
|
||||
dispose(world)
|
||||
|
||||
world = fixture()
|
||||
@@ -128,7 +134,7 @@ func run() -> void:
|
||||
row.colonization = "0"
|
||||
world.state.binds.RD = {"C1.9": 5}
|
||||
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")
|
||||
dispose(world)
|
||||
print(("Civilization state: %d checks, %d failures" % [checks, failures]))
|
||||
|
||||
@@ -53,11 +53,11 @@ func run():
|
||||
check(E.eq(physical.get("C0.1", 0), 38300), "C0.1 attempts evolution once")
|
||||
check(E.eq(physical.get("C0.2", 0), 700), "C0.1 successor does not immediately attempt again")
|
||||
else:
|
||||
check(world.profile.civilization_transitions[1] == 1, "Ethical civilization is processed only once per birth bucket")
|
||||
check(world.profile.civilization_flow_plan[1] == 1, "Ethical civilization uses one continuous interval plan")
|
||||
var before: Dictionary = world.state.duplicate(true)
|
||||
world.evolution.evolute_to_datetime(10000)
|
||||
check(E.eq(world.state, before), "Repeating completed clock does not repeat transitions")
|
||||
var validation: String = root.get_node("SaveManager").validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state})
|
||||
var validation: String = root.get_node("SaveManager").validate_data({"format_version": 6, "app_version": "", "ui": {"current_page": 0}, "state": world.state})
|
||||
check(validation.is_empty(), "Coarse state validates: " + validation)
|
||||
world.evolution.evolute_to_datetime(20000)
|
||||
if id in ["C0.0", "C0.1"]:
|
||||
|
||||
@@ -94,7 +94,7 @@ func run():
|
||||
check(str(world.state.auto_star_deficits).length() < 500, "true", "fraction storage stays bounded")
|
||||
print("100 changing-composition cycles: %d ms" % (Time.get_ticks_msec() - started))
|
||||
var saves = root.get_node("SaveManager")
|
||||
check(saves.validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), "", "long-running state saves")
|
||||
check(saves.validate_data({"format_version": 6, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), "", "long-running state saves")
|
||||
for entity in world.catalog.entities.values(): entity.free()
|
||||
world.free()
|
||||
print("Exact math: %d checks, %d failures" % [checks, failures])
|
||||
|
||||
@@ -35,7 +35,7 @@ func run():
|
||||
direct = fixture()
|
||||
sliced = fixture()
|
||||
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(E.eq(sliced.state.time.year, 0), "Published clock stays at completed transaction")
|
||||
sliced.request_production("H", 1, true)
|
||||
@@ -45,7 +45,7 @@ func run():
|
||||
sliced.speed = 3
|
||||
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
|
||||
direct.evolute(1.0 / 60.0)
|
||||
direct.evolute(0.2)
|
||||
direct.production.produce_hs("H", 1)
|
||||
direct.speed = 3
|
||||
check(E.eq(direct.state, sliced.state), "Queued action and speed change survive publication")
|
||||
@@ -60,7 +60,7 @@ func run():
|
||||
dispose(direct)
|
||||
dispose(sliced)
|
||||
var original = fixture()
|
||||
original.evolute_frame(1.0 / 60.0, self)
|
||||
original.evolute_frame(0.2, self)
|
||||
original.request_production("H", 123, true)
|
||||
original.evolute_frame(0.01, self)
|
||||
original.speed = 3
|
||||
@@ -84,7 +84,7 @@ func run():
|
||||
dispose(resumed)
|
||||
dispose(expected)
|
||||
var cancelled = fixture()
|
||||
cancelled.evolute_frame(0.133333333333, self)
|
||||
cancelled.evolute_frame(0.2, self)
|
||||
check(cancelled.frame_busy, "Cancellation fixture has suspended work")
|
||||
var budget_ref: RefCounted = cancelled.frame_budget
|
||||
dispose(cancelled)
|
||||
@@ -96,25 +96,26 @@ func run():
|
||||
invalid.evolute(1e308)
|
||||
check(not invalid.frame_busy and invalid.frame_clock.is_empty() and E.eq(before, invalid.state), "Overflowing elapsed float cannot enter either clock API")
|
||||
dispose(invalid)
|
||||
var adaptive = fixture()
|
||||
adaptive.frame_clock = Clock.advance(adaptive.state.time, 10.0, 6)
|
||||
var debt_clock: Dictionary = adaptive.frame_clock.duplicate(true)
|
||||
check(adaptive.update_frame_step() == 0.2, "Growing debt increases step to 0.2 seconds")
|
||||
check(adaptive.update_frame_step() == 0.5, "Large debt increases step to bounded 0.5 seconds")
|
||||
check(adaptive.update_frame_step() == 0.5 and E.eq(adaptive.frame_clock, debt_clock), "Maximum step preserves requested time")
|
||||
adaptive.frame_clock = Clock.advance(adaptive.state.time, 0.3, 6)
|
||||
check(adaptive.update_frame_step() == 0.5, "Hysteresis keeps step between thresholds")
|
||||
adaptive.frame_clock = Clock.advance(adaptive.state.time, 0.05, 6)
|
||||
check(adaptive.update_frame_step() == 0.2, "Small debt reduces step")
|
||||
check(adaptive.update_frame_step() == 0.1, "Caught-up simulation returns to base step")
|
||||
adaptive.frame_clock = Clock.advance(adaptive.state.time, 0.15, 6)
|
||||
adaptive._frame_cost_ratio = 1.1
|
||||
check(adaptive.update_frame_step() == 0.2, "Measured slow transactions increase step before two-step debt")
|
||||
adaptive.frame_clock = Clock.advance(adaptive.state.time, 0.05, 6)
|
||||
adaptive.speed = 3
|
||||
check(adaptive.update_frame_step() == 1.0 / 60.0, "Low speeds retain 60 Hz scheduling")
|
||||
adaptive.speed = 6
|
||||
check(adaptive.update_frame_step() == 0.1, "Speed switch resets adaptive step")
|
||||
dispose(adaptive)
|
||||
for selected_speed in range(1, 7):
|
||||
var scheduled = fixture()
|
||||
scheduled.speed = selected_speed
|
||||
scheduled.evolute_frame(0.1, self)
|
||||
check(not scheduled.frame_busy and scheduled.frame_commits == 0, "Wait for a full step at speed %d" % selected_speed)
|
||||
scheduled.evolute_frame(0.1, self)
|
||||
while scheduled.frame_busy: await process_frame
|
||||
var cadence_clock: Dictionary = Clock.advance({"datetime": 0, "year": 0}, 0.2, selected_speed)
|
||||
check(scheduled.frame_commits == 1 and E.eq(scheduled.state.time, cadence_clock), "One complete 0.2 second cycle at speed %d" % selected_speed)
|
||||
scheduled.evolute_frame(0.0, self)
|
||||
check(not scheduled.frame_busy and scheduled.frame_commits == 1, "No extra cycle without elapsed time")
|
||||
dispose(scheduled)
|
||||
var behind = fixture()
|
||||
behind.evolute_frame(1.0, self)
|
||||
while behind.frame_busy: await process_frame
|
||||
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")
|
||||
for cycle in 4:
|
||||
behind.evolute_frame(0.0, self)
|
||||
while behind.frame_busy: await process_frame
|
||||
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])
|
||||
quit(1 if failures else 0)
|
||||
|
||||
+4
-4
@@ -46,7 +46,7 @@ func run() -> void:
|
||||
var before: Dictionary = world.state.duplicate(true)
|
||||
world.evolution.initialize_life_batches()
|
||||
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.RP, 100), "HP degradation produces RP")
|
||||
check(E.eq(world.state.amount.HP, 39890), "Unchanged planets retry, not disappear")
|
||||
@@ -54,7 +54,7 @@ func run() -> void:
|
||||
check(E.gt(world.state.batches.HP.size(), 0), "Survivors get next attempt")
|
||||
var physical: Dictionary = world.ledger.physical_amounts(world.state)
|
||||
check(E.eq(E.add(E.add(physical.HP, physical.EON1), physical.RP), 40000), "Life conserves planets")
|
||||
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Life save validates")
|
||||
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 6, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Life save validates")
|
||||
var restored = fixture()
|
||||
restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state))))
|
||||
world.evolution.evolve_batches(world.state, 11000000)
|
||||
@@ -69,7 +69,7 @@ func run() -> void:
|
||||
# Test host preservation with a fixed rate, independent of game balance.
|
||||
world.catalog.entities.EON5.life_rule.progress = "0.00025"
|
||||
world.evolution.initialize_life_batches()
|
||||
world.evolution.evolve_batches(world.state, E.add(500000, E.divide(500000, 40)))
|
||||
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.amount["C0.0"], 0), "Life transition keeps planet bound")
|
||||
check(world.state.batches.has("C0.0"), "New C0.0 starts civilization evolution")
|
||||
@@ -120,7 +120,7 @@ func run() -> void:
|
||||
world.evolution.evolve_batches(world.state, 2050)
|
||||
check(E.eq(world.state.binds.RD["C0.2"], 700), "C0.1 reaches ethical civilization without losing host")
|
||||
check(E.eq(world.state.binds.RD["C0.0"], 1000), "C0.1 degradation follows Unity")
|
||||
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Extended life save validates")
|
||||
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 6, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Extended life save validates")
|
||||
dispose(world)
|
||||
print(("Life: %d checks, %d failures" % [checks, failures]))
|
||||
quit((0 if E.eq(failures, 0) else 1))
|
||||
|
||||
@@ -17,7 +17,7 @@ func run() -> void:
|
||||
var World = load("res://world/owniverse/owniverse.gd")
|
||||
var first = 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 = []
|
||||
for name in names:
|
||||
check(E.ne(first.get(name), second.get(name)), E.add("World has its own ", name))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
extends SceneTree
|
||||
const E = preload("res://world/owniverse/math/exact.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")
|
||||
var checks: int = 0
|
||||
var failures: int = 0
|
||||
@@ -11,52 +12,36 @@ func check(value: bool, message: String):
|
||||
failures += 1
|
||||
push_error(message)
|
||||
|
||||
func reference_drift(target: Dictionary, pop: Dictionary, rows: Dictionary, diplomacy: bool, colony: bool) -> Dictionary:
|
||||
var weights: Dictionary = {}
|
||||
for other in Civ.ETHICS: weights[other] = 0
|
||||
for ethic in pop:
|
||||
var contribution: Variant = E.multiply(pop[ethic], E.of(rows[ethic].diplomacy_rate) if diplomacy else 1)
|
||||
if E.eq(contribution, 0): continue
|
||||
for other in Civ.ETHICS:
|
||||
var weight: int = 9 if colony else 1
|
||||
if other == ethic: weight = 960 if colony else 196
|
||||
elif other == Civ.OPPOSITE[ethic]: weight = 4 if colony else 0
|
||||
weights[other] = E.add(weights[other], E.multiply(contribution, weight))
|
||||
if E.le(Civ.total(weights), 0): return pop.duplicate()
|
||||
return Civ.split(target, "test", Civ.total(pop), weights)
|
||||
|
||||
func run():
|
||||
var rows: Dictionary = {}
|
||||
for ethic in Civ.ETHICS: rows[ethic] = {"diplomacy_rate": "0.125" if ethic == "m" else "1.75"}
|
||||
for count in [0, 1, 7, 999999999, E.of("1e40")]:
|
||||
for diplomacy in [false, true]:
|
||||
for colony in [false, true]:
|
||||
var actual: Dictionary = {}
|
||||
var reference: Dictionary = {}
|
||||
for round_index in 5:
|
||||
var pop: Dictionary = {"m": count, "l": round_index, "w": 9, "d": 0, "c": 137, "b": 2}
|
||||
var result: Dictionary = Civ.drift(actual, "test", pop, rows, diplomacy, colony)
|
||||
if diplomacy:
|
||||
check(E.eq(result, reference_drift(reference, pop, rows, diplomacy, colony)), "Diplomatic drift preserves allocation")
|
||||
var carries_valid: bool = true
|
||||
for value in actual.get("civilization_remainders", {}).values():
|
||||
carries_valid = carries_valid and E.ge(value, -1) and E.le(value, 1)
|
||||
check(carries_valid, "Diplomatic drift carries stay bounded")
|
||||
else:
|
||||
check(E.eq(Civ.total(result), Civ.total(pop)), "Integer flow drift conserves population")
|
||||
var valid_whole: bool = true
|
||||
for ethic in Civ.ETHICS:
|
||||
valid_whole = valid_whole and E.ge(result[ethic], 0) and E.eq(result[ethic], E.floor_value(result[ethic]))
|
||||
check(valid_whole, "Integer flow drift has nonnegative whole counts")
|
||||
var decimal_rows: Dictionary = {}
|
||||
for ethic in Civ.ETHICS: decimal_rows[ethic] = {"diplomacy_rate": "0.333333333333" if ethic == "m" else "0.017"}
|
||||
var decimal_target: Dictionary = {}
|
||||
for round_index in 20:
|
||||
var pop: Dictionary = {"m": E.of("1e40"), "l": round_index, "w": 17, "d": 4, "c": 91, "b": 2}
|
||||
var result: Dictionary = Civ.drift(decimal_target, "decimal", pop, decimal_rows, true)
|
||||
check(E.eq(Civ.total(result), Civ.total(pop)), "Decimal diplomatic drift preserves whole population")
|
||||
for ethic in result:
|
||||
check(E.ge(result[ethic], 0) and E.eq(result[ethic], E.floor_value(result[ethic])), "Decimal drift returns nonnegative whole counts")
|
||||
for count in [0, 1, 7, 999999999, E.of("1e40"), E.of("1e400")]:
|
||||
var carry: Dictionary = {}
|
||||
for round_index in 20:
|
||||
var weights: Dictionary = {"m": round_index + 1.0, "l": 0.0, "w": 9.0, "d": 4.0, "c": 137.0, "b": 2.0}
|
||||
var profile: Dictionary = Civ.State.normalize_profile(weights)
|
||||
var shares: Array = []
|
||||
for ethic in Civ.ETHICS: shares.append(profile[ethic])
|
||||
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]:
|
||||
var world = load("res://world/owniverse/owniverse.gd").new()
|
||||
world.catalog.load_definitions(root.get_node("Global").OwniverseEntities)
|
||||
@@ -70,39 +55,53 @@ func run():
|
||||
var row: Dictionary = rules[id].ethics.m
|
||||
var p: Variant = E.maximum(0, E.multiply(E.of(row.progress), 2))
|
||||
var d: Variant = E.maximum(0, E.of(row.degradation))
|
||||
check(E.eq(changed.m.pro, E.divide(p, E.maximum(1, E.add(p, d)))), "Coefficient cache follows modifiers")
|
||||
check(E.near(changed.m.pro, E.divide(p, E.maximum(1, E.add(p, d)))), "Coefficient cache follows modifiers")
|
||||
modifiers.progress = 1
|
||||
check(E.eq(Civ.coefficients(world, rules, id, modifiers), original), "Coefficient cache restores original rates")
|
||||
var local_rules: Dictionary = rules.duplicate(true)
|
||||
local_rules[id].ethics.m.progress = "0.375"
|
||||
changed = Civ.coefficients(world, local_rules, id, modifiers)
|
||||
check(E.eq(changed.m.pro, E.divide(E.of("0.375"), E.maximum(1, E.add(E.of("0.375"), d)))), "Coefficient cache follows rule edits")
|
||||
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": {}}
|
||||
target.amount = {}
|
||||
target.binds = {}
|
||||
target.stats = {"total_amount": {}}
|
||||
target.amount[id] = 60
|
||||
target.stats.total_amount[id] = 60
|
||||
Civ.State.reconcile(world, target, rules, {id: 60})
|
||||
target.ethics[id].m = 100
|
||||
target.ethics[id] = {"m": 1.0, "l": 0.0, "w": 0.0, "d": 0.0, "c": 0.0, "b": 0.0}
|
||||
Civ.State.reconcile(world, target, rules, {id: 60})
|
||||
check(E.eq(Civ.total(target.ethics[id]), 60), "Reconcile cache detects changed ethics")
|
||||
check(E.eq(target.ethics[id].m, 1.0), "Reconcile cache detects changed ethics")
|
||||
Civ.State.reconcile(world, target, rules, {id: 120})
|
||||
check(E.eq(Civ.total(target.ethics[id]), 120), "Reconcile cache detects changed population")
|
||||
check(E.eq(target.ethics[id].m, 1.0), "Reconcile cache detects changed population")
|
||||
target.amount[id] = 120
|
||||
Civ.worship(world, target, rules)
|
||||
target.ethics[id].m = E.add(target.ethics[id].m, 5)
|
||||
var expected: Variant = 0
|
||||
target.ethics[id].m = 0.5
|
||||
target.ethics[id].l = 0.5
|
||||
var expected: float = 0.0
|
||||
for stage in rules:
|
||||
if rules[stage].worships:
|
||||
for ethic in target.ethics[stage]:
|
||||
expected = E.add(expected, E.multiply(target.ethics[stage][ethic], E.of(rules[stage].ethics[ethic].worship)))
|
||||
check(E.eq(Civ.worship(world, target, rules), expected), "Worship cache detects changed ethics")
|
||||
var n: float = (0.0 + target.ethics[stage][ethic]) * E.approx(E.of(rules[stage].ethics[ethic].worship))
|
||||
n *= E.approx(target.amount.get(stage, 0))
|
||||
expected += n
|
||||
check(E.near(Civ.worship(world, target, rules), expected), "Worship cache detects changed ethics")
|
||||
var worship_rules: Dictionary = rules.duplicate(true)
|
||||
for stage in worship_rules:
|
||||
if worship_rules[stage].worships:
|
||||
worship_rules[stage].ethics.m.worship = "0.375"
|
||||
expected = 0
|
||||
expected = 0.0
|
||||
for stage in worship_rules:
|
||||
if worship_rules[stage].worships:
|
||||
for ethic in target.ethics[stage]:
|
||||
expected = E.add(expected, E.multiply(target.ethics[stage][ethic], E.of(worship_rules[stage].ethics[ethic].worship)))
|
||||
check(E.eq(Civ.worship(world, target, worship_rules), expected), "Worship cache detects changed rules")
|
||||
var n: float = (0.0 + target.ethics[stage][ethic]) * E.approx(E.of(worship_rules[stage].ethics[ethic].worship))
|
||||
n *= E.approx(target.amount.get(stage, 0))
|
||||
expected += n
|
||||
check(E.near(Civ.worship(world, target, worship_rules), expected), "Worship cache detects changed rules")
|
||||
world.ledger = load("res://tests/fixtures/audit_rounding_ledger.gd").new(world)
|
||||
# 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
|
||||
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)
|
||||
|
||||
@@ -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")
|
||||
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.RD = 4
|
||||
world.batches.append_batch(world.state, "RD", 4, 0, 0)
|
||||
@@ -96,7 +96,7 @@ func run() -> void:
|
||||
check((E.eq(world.state.binds.RD.GG, 16) and E.eq(world.state.binds.RD.AB, 120)), "Planet multiplicity applied")
|
||||
check((E.eq(world.state.amount.HP, 0) and E.eq(world.state.stats.total_amount.HP, 1)), "Created planets are bound, counted once")
|
||||
var saves: Node = root.get_node("SaveManager")
|
||||
check(E.eq(saves.validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Planet carry save validates")
|
||||
check(E.eq(saves.validate_data({"format_version": 6, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Planet carry save validates")
|
||||
restored = fixture()
|
||||
restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state))))
|
||||
world.stellar.create_planets(world.state, "RD", 37, 0, 0, E.of("0.7"))
|
||||
@@ -118,7 +118,7 @@ func run() -> void:
|
||||
check((E.eq(world.state.amount.RP, 0) and E.eq(world.state.amount["C1.3"], 0)), "Inherited planets never become free")
|
||||
world.evolution.evolve_batches(world.state, E.add(lifespan, width))
|
||||
check((E.eq(world.state.binds.RD["C1.3"], 0) and E.eq(world.state.binds.TWD["C1.3"], 4)), "No planets left at extinct parent type")
|
||||
check(E.eq(saves.validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Inherited state validates")
|
||||
check(E.eq(saves.validate_data({"format_version": 6, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Inherited state validates")
|
||||
dispose(world)
|
||||
|
||||
world = fixture()
|
||||
@@ -158,7 +158,7 @@ func run() -> void:
|
||||
check((E.eq(world.state.amount.SN, 0) and E.eq(world.state.amount.LNS, 1)), "Supernova produces a remnant")
|
||||
check(((E.eq(world.state.amount.H, 8) and E.eq(world.state.amount.He, 50)) and E.eq(world.state.amount.Met, 11)), "Supernova returns table resources")
|
||||
check((not world.state.amount.has("blast")), "Supernova dispatches blast output")
|
||||
check(E.eq(saves.validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Blast state validates")
|
||||
check(E.eq(saves.validate_data({"format_version": 6, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Blast state validates")
|
||||
dispose(world)
|
||||
|
||||
world = fixture()
|
||||
|
||||
+2
-2
@@ -51,10 +51,10 @@ func run() -> void:
|
||||
for invalid in [0, E.neg(1), NAN, INF, 1e308]:
|
||||
world.evolute(invalid)
|
||||
check(E.eq(world.state, before), "Invalid/overflow interval rejected")
|
||||
check(E.eq(saves.validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Fractional save is valid")
|
||||
check(E.eq(saves.validate_data({"format_version": 6, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Fractional save is valid")
|
||||
var invalid_save: Dictionary = world.state.duplicate(true)
|
||||
invalid_save.time.year = E.of("0.25")
|
||||
check(E.ne(saves.validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": invalid_save}), ""), "Reject inconsistent fractional clock")
|
||||
check(E.ne(saves.validate_data({"format_version": 6, "app_version": "", "ui": {"current_page": 0}, "state": invalid_save}), ""), "Reject inconsistent fractional clock")
|
||||
dispose(world)
|
||||
|
||||
for fps in [30, 60, 144]:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
extends SceneTree
|
||||
const E = preload("res://world/owniverse/math/exact.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 Descriptions = preload("res://world/owniverse/item_description.gd")
|
||||
@@ -91,24 +90,24 @@ func run() -> void:
|
||||
|
||||
world = fixture()
|
||||
world.state.amount["C0.2"] = 1000000
|
||||
world.state.ethics["C0.2"] = {"m": 1000000}
|
||||
world.state.ethics["C0.2"] = {"m": 1.0, "l": 0.0, "w": 0.0, "d": 0.0, "c": 0.0, "b": 0.0}
|
||||
var baseline: Dictionary = world.state.duplicate(true)
|
||||
var advanced: Dictionary = baseline.duplicate(true)
|
||||
advanced.world_parameters.Agg = 19
|
||||
var next_stage: String = global.CivilizationRules["C0.2"].next
|
||||
var outcomes: Array = []
|
||||
for target in [baseline, advanced]:
|
||||
var expired: Dictionary = {"C0.2": [{"amount": 1000000, "from": 0, "to": 1}]}
|
||||
var budget: Dictionary = CivilizationState.stellar_budget(world, target, global.CivilizationRules)
|
||||
CivilizationEvolution.prepare(world, target, expired, 1, budget, global.CivilizationRules)
|
||||
outcomes.append(expired["C0.2"][0].products.get(next_stage, 0))
|
||||
world.civilization_flow.evolve(target, 0, 2000)
|
||||
outcomes.append(target.amount.get(next_stage, 0))
|
||||
check(E.gt(outcomes[1], outcomes[0]), "Aggression changes actual ethical civilization transitions")
|
||||
dispose(world)
|
||||
|
||||
# Trends use ethical proportions and stage weights, not population magnitude.
|
||||
world = fixture()
|
||||
var rules: Dictionary = {"C2.0": {"home": "DwStar"}, "C2.1": {"home": "DwStar"}}
|
||||
world.state.ethics = {"C2.0": {"d": 100}, "C2.1": {"d": 100}}
|
||||
world.state.ethics = {"C2.0": {"m": 0.0, "l": 0.0, "w": 0.0, "d": 1.0, "c": 0.0, "b": 0.0}, "C2.1": {"m": 0.0, "l": 0.0, "w": 0.0, "d": 1.0, "c": 0.0, "b": 0.0}}
|
||||
world.state.amount["C2.1"] = 10
|
||||
world.state.binds["RD"] = {"C2.0": 10}
|
||||
var source: Dictionary = world.state.duplicate(true)
|
||||
var single: Dictionary = source.duplicate(true)
|
||||
var split: Dictionary = source.duplicate(true)
|
||||
@@ -117,10 +116,17 @@ func run() -> void:
|
||||
check(E.near(single.trends.Integration, E.of("0.018")), "Trend integrates elapsed years and doubled stage weights")
|
||||
for key in Parameters.TRENDS:
|
||||
check(E.near(single.trends[key], split.trends[key]), E.add("Trend is independent of frame partition: ", key))
|
||||
source.ethics["C2.0"].d = 1000000
|
||||
source.ethics["C2.0"].d = 1.0
|
||||
var larger: Dictionary = world.state.duplicate(true)
|
||||
larger.amount["C2.1"] = 1000000
|
||||
larger.binds["RD"]["C2.0"] = 1000000
|
||||
Parameters.advance(source, larger, rules, 1000000)
|
||||
check(E.near(larger.trends.Integration, single.trends.Integration), "Population proportions determine trend influence")
|
||||
var vacant: Dictionary = world.state.duplicate(true)
|
||||
vacant.amount["C2.1"] = 0
|
||||
vacant.binds["RD"]["C2.0"] = 0
|
||||
Parameters.advance(vacant, vacant, rules, 1000000)
|
||||
check(vacant.trends.values().all(func (value): return E.eq(value, 0)), "Empty saved ethic profiles do not generate trends")
|
||||
world.state.trends.Integration = E.of("0.99")
|
||||
Parameters.advance(source, world.state, rules, 100000000)
|
||||
check(E.eq(world.state.civilization_stats["trend:Integration"], 2), "Long steps process every crossed threshold")
|
||||
@@ -133,7 +139,7 @@ func run() -> void:
|
||||
Parameters.advance(source, world.state, rules, E.of("1.0e12"))
|
||||
check((E.ge(world.state.trends.Integration, 0) and E.lt(world.state.trends.Integration, 1)), "Very large intervals finish with a valid trend fraction")
|
||||
check(E.ge(world.state.world_parameters.Agg, 1), "Repeated integration events cannot reduce aggression below the scale")
|
||||
var payload: Dictionary = {"format_version": 5, "app_version": "test", "ui": {"current_page": 1}, "state": world.state}
|
||||
var payload: Dictionary = {"format_version": 6, "app_version": "test", "ui": {"current_page": 1}, "state": world.state}
|
||||
var manager: Node = root.get_node("SaveManager")
|
||||
check(E.eq(manager.validate_data(payload), ""), "New parameters and trends pass save validation")
|
||||
var old: Dictionary = payload.duplicate(true)
|
||||
|
||||
@@ -35,7 +35,7 @@ Stage обращается непосредственно к `world.presentation
|
||||
| `automatic_production.gd` / `automatic` | Выпуск за период и жизнь новорождённых объектов, разделение ресурсов на обычные и автоматические, энергия, квоты звёзд и бурых карликов |
|
||||
| `stellar_rules.gd` / `stellar` | Продукты звёздной эволюции, создание планет, выбор звёздных преемников, два уровня разрушения и глобальные бласты |
|
||||
| `life_evolution.gd` / `life` | Прогресс, деградация и повторная попытка планетарной жизни |
|
||||
| `civilization_evolution.gd`, `civilization_state.gd` | Существующие правила цивилизаций: этики, войны, колонизация, лимиты и места обитания |
|
||||
| `civilization_flow.gd`, `civilization_state.gd` | Непрерывные потоки цивилизаций, доли этик, целые квоты, лимиты и места обитания |
|
||||
| `universe_environment.gd` / `environment` | Плотность, металличность, доля попаданий бластов, изменение параметров мира с сохранением возраста звёзд |
|
||||
| `evolution_coordinator.gd` / `evolution` | Порядок фаз шага, последовательные поколения и публикация нового состояния |
|
||||
|
||||
@@ -50,7 +50,7 @@ Stage обращается непосредственно к `world.presentation
|
||||
|
||||
1. Определить обработанную десятую года. При продвижении времени восстановить недостающие партии жизни и проверить вместимость.
|
||||
2. Скопировать состояние, посчитать физические количества и производство за период.
|
||||
3. Обработать поколения до конца интервала. Для каждого поколения: локальные разрушения → выбытие партий всех типов → план переходов → резервирование передачи привязок → зачисление преемников и их партий → производство новорождённых → применение привязок и округление → завершение переходов цивилизаций → глобальные бласты → проверка вместимости.
|
||||
3. Интегрировать непрерывные потоки цивилизаций за весь интервал, затем обработать поколения звёзд и биологической жизни до конца интервала. Для каждого поколения: локальные разрушения → выбытие партий всех типов → план переходов → резервирование передачи привязок → зачисление преемников и их партий → производство новорождённых → применение привязок и округление → глобальные бласты → проверка вместимости.
|
||||
4. Обновить групповые количества, ограничить автоматические ресурсы и продвинуть параметры мира.
|
||||
5. Опубликовать новое состояние и обновить представление количеств.
|
||||
|
||||
|
||||
@@ -1,205 +1,27 @@
|
||||
extends RefCounted
|
||||
## Shared civilization coefficients and worship derived from float profiles.
|
||||
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 WorldParameters = preload("res://world/owniverse/world_parameters.gd")
|
||||
const ETHICS = State.ETHICS
|
||||
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:
|
||||
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)
|
||||
|
||||
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.
|
||||
# Cache the weighted float worship rate multiplied by the exact population.
|
||||
static func worship(world, target: Dictionary, rules: Dictionary) -> Variant:
|
||||
var result: Variant = 0
|
||||
var physical: Dictionary = world.ledger.physical_amounts(target)
|
||||
for id in rules:
|
||||
if not rules[id].worships: continue
|
||||
var pop: Dictionary = target.ethics[id]
|
||||
var pop: Dictionary = State.normalize_profile(target.ethics.get(id, {}))
|
||||
var coefficients: Dictionary = {}
|
||||
for ethic in pop: coefficients[ethic] = rules[id].ethics[ethic].worship
|
||||
var cached: Dictionary = world.ledger.worship_products.get(id, {})
|
||||
if cached.is_empty() or not E.eq(cached.population, pop) or not E.eq(cached.coefficients, coefficients):
|
||||
var products: Dictionary = {}
|
||||
for ethic in pop: products[ethic] = E.multiply(pop[ethic], E.of(coefficients[ethic]))
|
||||
cached = {"population": pop.duplicate(), "coefficients": coefficients, "products": products}
|
||||
var count: Variant = physical.get(id, 0)
|
||||
if cached.is_empty() or not E.eq(cached.population, pop) or not E.eq(cached.coefficients, coefficients) or not E.eq(cached.count, count):
|
||||
var worship_rate: float = 0.0
|
||||
for ethic in pop: worship_rate += (0.0 + pop[ethic]) * E.approx(E.of(coefficients[ethic]))
|
||||
cached = {"population": pop.duplicate(), "coefficients": coefficients, "count": count, "product": E.multiply(count, worship_rate)}
|
||||
world.ledger.worship_products[id] = cached
|
||||
for ethic in pop: result = E.add(result, cached.products[ethic])
|
||||
result = E.add(result, cached.product)
|
||||
return result
|
||||
|
||||
static func coefficients(world, rules: Dictionary, id: String, modifiers: Dictionary) -> Dictionary:
|
||||
@@ -215,215 +37,11 @@ static func coefficients(world, rules: Dictionary, id: String, modifiers: Dictio
|
||||
var rates: Dictionary = {}
|
||||
for ethic in rules[id].ethics:
|
||||
var row: Dictionary = rules[id].ethics[ethic]
|
||||
var p: Variant = E.maximum(0, E.multiply(E.of(row.progress), modifiers.progress))
|
||||
var d: Variant = E.maximum(0, E.multiply(E.of(row.degradation), modifiers.degradation))
|
||||
var scale: Variant = E.maximum(1, E.add(p, d))
|
||||
rates[ethic] = {"pro": E.divide(p, scale), "deg": E.divide(d, scale),
|
||||
"war": E.minimum(1, E.multiply(E.of(row.war), modifiers.war)),
|
||||
"col": E.minimum(1, E.multiply(E.of(row.colonization), modifiers.colonization))}
|
||||
var p: float = maxf(0.0, E.approx(E.of(row.progress)) * E.approx(modifiers.progress))
|
||||
var d: float = maxf(0.0, E.approx(E.of(row.degradation)) * E.approx(modifiers.degradation))
|
||||
var scale: float = maxf(1.0, p + d)
|
||||
rates[ethic] = {"pro": p / scale, "deg": d / scale,
|
||||
"war": clampf(E.approx(E.of(row.war)) * E.approx(modifiers.war), 0.0, 1.0),
|
||||
"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}
|
||||
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)
|
||||
State.reconcile(world, target, rules, physical, reconcile_ids)
|
||||
var remaining: Dictionary = target.ethics.duplicate()
|
||||
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 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:
|
||||
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))
|
||||
fall(target, plan, rules, failures)
|
||||
world.profile_end("civilization_transitions", profile_transitions)
|
||||
if rules[id].fights:
|
||||
var profile_wars: int = world.profile_start()
|
||||
for ethic in warriors: warriors[ethic] = E.minimum(warriors[ethic], triggered[ethic])
|
||||
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)
|
||||
var outcomes: Dictionary = split(target, E.add(id, ":wars"), wars, {"deg": 1, "pro25": 2, "pro50": 3, "pro75": 3})
|
||||
var win_expected: Variant = E.add(E.add(E.multiply(outcomes.get("pro25", 0), E.of("0.25")), E.multiply(outcomes.get("pro50", 0), E.of("0.5"))), E.multiply(outcomes.get("pro75", 0), E.of("0.75")))
|
||||
var loss_expected: Variant = E.add(E.add(E.add(E.multiply(outcomes.get("deg", 0), 2), outcomes.get("pro25", 0)), E.multiply(outcomes.get("pro50", 0), E.of("1.5"))), E.multiply(outcomes.get("pro75", 0), 2))
|
||||
var wins_requested: Variant = rate(target, E.add(id, ":wins"), win_expected, 1)
|
||||
var winners: Dictionary = take(world, warriors, wins_requested, wars)
|
||||
var winner_total: Variant = E.floor_value(E.minimum(E.maximum(0, wins_requested), wars))
|
||||
warriors_total = E.subtract(wars, winner_total)
|
||||
var losses_requested: Variant = rate(target, E.add(id, ":attackers_lost"), E.divide(outcomes.get("deg", 0), order), 1)
|
||||
var 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)
|
||||
fall(target, plan, rules, attackers_lost)
|
||||
remaining_total = total(remaining[id])
|
||||
var losses: Variant = E.minimum(rate(target, E.add(id, ":loss"), E.divide(loss_expected, order), 1), E.add(triggered_total, remaining_total))
|
||||
var 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))
|
||||
fall(target, plan, rules, victims)
|
||||
add(triggered, warriors)
|
||||
triggered_total = E.add(triggered_total, warriors_total)
|
||||
var damage: Variant = rate(target, E.add(id, ":collateral"), E.divide((E.add(E.multiply(outcomes.get("pro50", 0), 10), E.multiply(outcomes.get("pro75", 0), 30))), order), 1)
|
||||
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))
|
||||
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 and not await budget_timer.pause(): return {}
|
||||
var plan: Dictionary = plans[id]
|
||||
var products: Dictionary = {}
|
||||
for destination in plan.outputs:
|
||||
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}
|
||||
|
||||
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
|
||||
target.ethics = transaction.remaining
|
||||
for id in transaction.incoming:
|
||||
if (not target.ethics.has(id)): target.ethics[id] = {}
|
||||
add(target.ethics[id], transaction.incoming[id])
|
||||
# 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))
|
||||
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 = {"": target.amount.get(id, 0)}
|
||||
for owner in target.binds: slots[owner] = target.binds[owner].get(id, 0)
|
||||
var taken: Dictionary = QuantityMath.allocate_whole(slots, amount)
|
||||
world.batches.scale_batches(target, id, E.subtract(1, E.divide(amount, stock[id])))
|
||||
stock[id] = E.subtract(stock[id], amount)
|
||||
for owner in taken:
|
||||
if E.le(taken[owner], 0): continue
|
||||
if E.eq(owner, ""): target.amount[id] = E.subtract(target.amount[id], taken[owner])
|
||||
else: target.binds[owner][id] = E.subtract(target.binds[owner][id], taken[owner])
|
||||
var created: Variant = world.ledger.credit(target, colony.stage, taken[owner])
|
||||
if E.ne(owner, ""):
|
||||
target.amount[colony.stage] = E.subtract(target.amount[colony.stage], created)
|
||||
target.binds[owner][colony.stage] = E.add(target.binds[owner].get(colony.stage, 0), created)
|
||||
world.batches.append_batch(target, colony.stage, amount, year, year)
|
||||
if (not target.ethics.has(colony.stage)): target.ethics[colony.stage] = {}
|
||||
add(target.ethics[colony.stage], population)
|
||||
target.civilization_stats.colonies = E.add(target.civilization_stats.get("colonies", 0), count)
|
||||
world.profile_end("colonization", profile_colonies)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://clon18ixi6yqx
|
||||
@@ -5,11 +5,46 @@ const QuantityMath = preload("res://world/owniverse/quantity_math.gd")
|
||||
|
||||
const ETHICS = ["m", "l", "w", "d", "c", "b"]
|
||||
|
||||
static func empty_profile() -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
for ethic in ETHICS: result[ethic] = 1.0 / ETHICS.size()
|
||||
return result
|
||||
|
||||
static func normalize_profile(profile: Dictionary) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
var total: float = 0.0
|
||||
for ethic in ETHICS:
|
||||
var value: float = maxf(0.0, E.approx(profile.get(ethic, 0.0)))
|
||||
result[ethic] = value
|
||||
total += value
|
||||
if not is_finite(total) or total <= 0.0: return empty_profile()
|
||||
if profile.size() == ETHICS.size() and absf(total - 1.0) <= 0.000000000001:
|
||||
return result
|
||||
var assigned: float = 0.0
|
||||
for i in ETHICS.size() - 1:
|
||||
var ethic: String = ETHICS[i]
|
||||
result[ethic] = (0.0 + result[ethic]) / total
|
||||
assigned += result[ethic]
|
||||
result[ETHICS.back()] = maxf(0.0, 1.0 - assigned)
|
||||
return result
|
||||
|
||||
static func merge_profiles(base: Dictionary, base_count: Variant, incoming_profile: Dictionary, incoming_count: Variant) -> Dictionary:
|
||||
var total: Variant = E.add(base_count, incoming_count)
|
||||
if E.le(total, 0): return empty_profile()
|
||||
var base_ratio: float = E.ratio_approx(base_count, total) if E.gt(base_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 result: Dictionary = {}
|
||||
for ethic in ETHICS:
|
||||
var incoming_share: float = E.approx(incoming_profile.get(ethic, 0.0)) if E.gt(incoming_count, 0) else 0.0
|
||||
result[ethic] = (0.0 + normalized_base[ethic]) * base_ratio + incoming_share * incoming_ratio
|
||||
return normalize_profile(result)
|
||||
|
||||
static func is_space_civilization(id: String, rules: Dictionary) -> bool:
|
||||
return (rules.has(id) and E.ne(str(rules[id].home), ""))
|
||||
|
||||
# Preserve any existing ethics. New populations start equally distributed;
|
||||
# reductions (including blasts) are proportional and never leave negative counts.
|
||||
# Keep one six-value float profile per stage. Absolute population remains in
|
||||
# the exact physical object ledger; flow calculations use shares directly.
|
||||
static func reconcile(world, target: Dictionary, rules: Dictionary, physical: Dictionary = {}, only_ids: Array = []) -> void:
|
||||
if (not target.has("ethics")):
|
||||
target.ethics = {}
|
||||
@@ -19,22 +54,10 @@ static func reconcile(world, target: Dictionary, rules: Dictionary, physical: Di
|
||||
for id in ids:
|
||||
if not rules.has(id): continue
|
||||
var count: Variant = physical.get(id, 0)
|
||||
var current: Dictionary = target.ethics.get(id, {})
|
||||
var current: Dictionary = normalize_profile(target.ethics.get(id, {}))
|
||||
var cached: Dictionary = world.ledger.reconciled_ethics.get(id, {})
|
||||
if not cached.is_empty() and E.eq(cached.count, count) and E.eq(cached.population, current):
|
||||
continue
|
||||
var total: Variant = 0
|
||||
for ethic in ETHICS:
|
||||
current[ethic] = E.maximum(0, current.get(ethic, 0))
|
||||
total = E.add(total, current[ethic])
|
||||
if E.gt(total, count):
|
||||
current = QuantityMath.allocate_whole(current, count)
|
||||
elif E.lt(total, count):
|
||||
var missing: Variant = E.subtract(count, total)
|
||||
var base: Variant = E.floor_value(E.divide(missing, ETHICS.size()))
|
||||
var rest: int = E.integer(E.subtract(missing, E.multiply(base, ETHICS.size())))
|
||||
for i in ETHICS.size():
|
||||
current[ETHICS[i]] = E.add(current[ETHICS[i]], E.add(base, ((1 if E.lt(i, rest) else 0))))
|
||||
target.ethics[id] = current
|
||||
world.ledger.reconciled_ethics[id] = {"count": count, "population": current.duplicate()}
|
||||
|
||||
@@ -115,6 +138,7 @@ static func enforce_capacity(world, target: Dictionary, rules: Dictionary, year:
|
||||
detach_space_civilizations(target, rules)
|
||||
var physical: Dictionary = world.ledger.physical_amounts(target)
|
||||
reconcile(world, target, rules, physical)
|
||||
var ethical_totals: Dictionary = physical.duplicate()
|
||||
var budget: Dictionary = stellar_budget(world, target, rules, physical)
|
||||
var remaining: Dictionary = budget.capacity.duplicate()
|
||||
var remaining_homes: Dictionary = budget.habitats.capacity.duplicate()
|
||||
@@ -141,12 +165,13 @@ static func enforce_capacity(world, target: Dictionary, rules: Dictionary, year:
|
||||
target.amount[id] = supported
|
||||
world.ledger.credit(target, previous, excess)
|
||||
world.batches.append_batch(target, previous, excess, year, year)
|
||||
var removed: Dictionary = QuantityMath.allocate_whole(target.ethics[id], excess)
|
||||
if (not target.ethics.has(previous)):
|
||||
target.ethics[previous] = {}
|
||||
for ethic in ETHICS:
|
||||
target.ethics[id][ethic] = E.subtract(target.ethics[id][ethic], removed[ethic])
|
||||
target.ethics[previous][ethic] = E.add(target.ethics[previous].get(ethic, 0), removed[ethic])
|
||||
var previous_count: Variant = ethical_totals.get(previous, physical.get(previous, target.amount.get(previous, 0)))
|
||||
var previous_profile: Dictionary = target.ethics.get(previous, empty_profile())
|
||||
var source_profile: Dictionary = target.ethics.get(id, empty_profile())
|
||||
target.ethics[previous] = merge_profiles(previous_profile, previous_count, source_profile, excess)
|
||||
ethical_totals[id] = supported
|
||||
ethical_totals[previous] = E.add(previous_count, excess)
|
||||
# Capacity losses are proportional, so the surviving source keeps its profile.
|
||||
if E.eq(previous, "C1.9"):
|
||||
var stars: Dictionary = {}
|
||||
var current_physical: Dictionary = world.ledger.physical_amounts(target)
|
||||
@@ -230,7 +255,7 @@ static func effective_span(world, target: Dictionary, id: String, rules: Diction
|
||||
if E.le(population, 0): return base
|
||||
var homes: Dictionary = stage_homes(world, target, id, world.catalog.civilization_layout(rules))
|
||||
target.civilization_homes[id] = homes
|
||||
var weighted: Variant = population
|
||||
var multiplier: float = 1.0
|
||||
for home in homes:
|
||||
weighted = E.add(weighted, E.multiply(homes[home], (E.subtract(E.maximum(0, world.catalog.entities[home].civ_bonus), 1))))
|
||||
return (E.mul_div(base, population, weighted) if E.gt(weighted, 0) else base)
|
||||
multiplier += E.ratio_approx(homes[home], population) * (maxf(0.0, E.approx(world.catalog.entities[home].civ_bonus)) - 1.0)
|
||||
return E.approx(base) / multiplier if multiplier > 0.0 else base
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
extends "res://world/owniverse/simulation_module.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 SimulationTime = preload("res://world/owniverse/simulation_time.gd")
|
||||
const WorldParameters = preload("res://world/owniverse/world_parameters.gd")
|
||||
const QuantityMath = preload("res://world/owniverse/quantity_math.gd")
|
||||
|
||||
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.enforce_capacity(world, world.state, Global.CivilizationRules, world.state.time.datetime)
|
||||
var physical: Dictionary = world.ledger.physical_amounts(world.state)
|
||||
@@ -30,10 +32,12 @@ func evolute_to_datetime(datetime: Variant, clock: Dictionary = {}, budget: RefC
|
||||
# Fractional frame updates cannot change capacity or populations.
|
||||
var processed_year: Variant = SimulationTime.processed_year(datetime)
|
||||
if E.gt(processed_year, world.state.time.year):
|
||||
if budget != null: budget.call("set_phase", "initialize_life_batches")
|
||||
var profile_initialize: int = world.profile_start()
|
||||
initialize_life_batches()
|
||||
world.profile_end("initialize", profile_initialize)
|
||||
var previous: Dictionary = world.state
|
||||
if budget != null: budget.call("set_phase", "deep_copy_and_coarsen")
|
||||
var next: Dictionary = previous.duplicate_deep()
|
||||
next.version = SimulationTime.STATE_VERSION
|
||||
next.speed = clampi(E.integer(previous.speed), 1, Global.SPEED_LIMIT)
|
||||
@@ -49,6 +53,12 @@ func evolute_to_datetime(datetime: Variant, clock: Dictionary = {}, budget: RefC
|
||||
var profile_production: int = world.profile_start()
|
||||
world.automatic.produce_for_period(previous, next, quantities, distribution, from_year, to_year)
|
||||
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()
|
||||
if budget != null:
|
||||
await evolve_batches(next, to_year, budget)
|
||||
@@ -84,9 +94,10 @@ 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:
|
||||
var _keep_alive: RefCounted = self
|
||||
# Apply direct completions at the original next-generation boundary, so
|
||||
# binding transfers, the blast environment and capacity checks keep order.
|
||||
# Apply direct completions at their original generation boundaries, so
|
||||
# binding transfers, blast environment and capacity checks keep order.
|
||||
if budget != null and not await budget.pause(): return false
|
||||
if budget != null: budget.call("set_phase", "generation_setup_and_withdrawal")
|
||||
var completed_now: Dictionary = completed.duplicate()
|
||||
completed.clear()
|
||||
if context.is_empty():
|
||||
@@ -94,7 +105,9 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
|
||||
var quantities: Dictionary = world.ledger.physical_amounts(target)
|
||||
var expired: 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 profile_withdraw: int = world.profile_start()
|
||||
for id in batch_ids:
|
||||
@@ -122,8 +135,8 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
|
||||
break
|
||||
if (not has_expired):
|
||||
return false
|
||||
# This per-generation summary is valid until the next transition mutates
|
||||
# the cohort set. Reuse it for civilization planning and transfer ratios.
|
||||
# Aggregate quantities are used to calculate the type-level transfer ratio;
|
||||
# transition products remain calculated for each expiring cohort.
|
||||
var expired_totals: Dictionary = {}
|
||||
for id in expired:
|
||||
var total: Variant = 0
|
||||
@@ -132,29 +145,28 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
|
||||
var fractions: Dictionary = {}
|
||||
var outputs: Dictionary = {}
|
||||
var current_quantities: Dictionary = quantities
|
||||
if budget != null: budget.call("set_phase", "civilization_stellar_budget")
|
||||
var star_budget: Dictionary = CivilizationState.stellar_budget(world, target, Global.CivilizationRules, current_quantities)
|
||||
var civilization_transaction: Dictionary = await CivilizationEvolution.prepare(world, target, expired, to_year, star_budget, Global.CivilizationRules, current_quantities, budget, expired_totals)
|
||||
if budget != null and budget.cancelled: return false
|
||||
if budget != null: budget.call("set_phase", "civilization_prepare")
|
||||
var profile_products: int = world.profile_start()
|
||||
for id in expired:
|
||||
if budget != null and not await budget.pause(): return false
|
||||
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)
|
||||
outputs[id] = {}
|
||||
var combined: Dictionary = {}
|
||||
for part : Dictionary in expired[id]:
|
||||
if (not part.has("products")):
|
||||
for part: Dictionary in expired[id]:
|
||||
if not part.has("products"):
|
||||
part.products = world.stellar.evolution_products(target, world.catalog.entities[id], part.amount, context)
|
||||
for successor in part.products.keys():
|
||||
if Global.CivilizationRules.has(id):
|
||||
continue
|
||||
if Global.CivilizationRules.has(id): continue
|
||||
var requested: Variant = part.products[successor]
|
||||
var admitted: Variant = CivilizationState.admit_transition(star_budget, Global.CivilizationRules, id, successor, requested)
|
||||
part.products[successor] = admitted
|
||||
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:
|
||||
combined[successor] = E.add(combined.get(successor, 0), part.products[successor])
|
||||
outputs[id] = {}
|
||||
if E.gt(total, 0):
|
||||
for successor in combined:
|
||||
outputs[id][successor] = E.divide(combined[successor], total)
|
||||
@@ -166,40 +178,34 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
|
||||
affected[id] = true
|
||||
for held in target.binds.get(id, {}): affected[held] = true
|
||||
for successor in outputs[id]: affected[successor] = true
|
||||
if budget != null: budget.call("set_phase", "transfer_evolution")
|
||||
var incoming: Dictionary = world.ledger.transfer_evolution(target, fractions, outputs)
|
||||
|
||||
var new_owners: Dictionary = {}
|
||||
var born_civilizations: Dictionary = {}
|
||||
var blast_power: Variant = 0
|
||||
for parts: Array in expired.values():
|
||||
for part: Dictionary in parts:
|
||||
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 resolution: Variant = world.batches.temporal_resolution(target)
|
||||
if budget != null: budget.call("set_phase", "transfer_and_newborns")
|
||||
for id in expired:
|
||||
for part : Dictionary in expired[id]:
|
||||
for part: Dictionary in expired[id]:
|
||||
for successor in part.products:
|
||||
if budget != null and not await budget.pause(): return false
|
||||
var amount: Variant = part.products[successor]
|
||||
if E.le(amount, 0): continue
|
||||
if E.eq(successor, "blast"):
|
||||
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)))):
|
||||
if E.le(amount, 0) or E.eq(successor, "blast") or 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)
|
||||
else:
|
||||
amount = world.ledger.credit(target, successor, amount)
|
||||
var produces: bool = not world.automatic.yearly_products(world.catalog.entities[successor]).is_empty()
|
||||
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_to: Variant = part.to
|
||||
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_from = born_to
|
||||
var profile_newborn: int = world.profile_start()
|
||||
@@ -213,17 +219,18 @@ 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.profile_end("newborn_batches", profile_newborn)
|
||||
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)
|
||||
if budget != null: budget.call("set_phase", "finish_and_capacity")
|
||||
world.ledger.bind_new_owners(target, new_owners)
|
||||
for owner in new_owners:
|
||||
for resource in world.catalog.entities[owner].bind:
|
||||
for member in world.catalog.members(resource): affected[member] = true
|
||||
world.ledger.round_object_allocations(target, affected)
|
||||
if budget != null:
|
||||
await CivilizationEvolution.finish(world, target, civilization_transaction, to_year, Global.CivilizationRules, budget)
|
||||
if budget.cancelled: return false
|
||||
else:
|
||||
CivilizationEvolution.finish(world, target, civilization_transaction, to_year, Global.CivilizationRules)
|
||||
for id in born_civilizations:
|
||||
var current: Variant = world.ledger.physical_amount(target, id)
|
||||
var born: Variant = born_civilizations[id]
|
||||
target.ethics[id] = CivilizationState.merge_profiles(target.ethics.get(id, {}), E.maximum(0, E.subtract(current, born)), CivilizationState.empty_profile(), born)
|
||||
if E.gt(blast_power, 0): world.stellar.apply_blast(target, blast_power, environment)
|
||||
CivilizationState.enforce_capacity(world, target, Global.CivilizationRules, to_year)
|
||||
return true
|
||||
|
||||
@@ -5,18 +5,32 @@ var slice_usec: int = 40000
|
||||
var deadline: int
|
||||
var cancelled: bool = false
|
||||
var waiting_usec: int = 0
|
||||
var checkpoint_usec: int = 0
|
||||
var max_slice_usec: int = 0
|
||||
var max_slice_phase: String = "startup"
|
||||
var phase: String = "startup"
|
||||
|
||||
func _init(scene_tree: SceneTree, milliseconds: int = 40):
|
||||
tree = scene_tree
|
||||
slice_usec = milliseconds * 1000
|
||||
deadline = Time.get_ticks_usec() + slice_usec
|
||||
checkpoint_usec = deadline - slice_usec
|
||||
|
||||
func set_phase(value: String) -> void:
|
||||
phase = value
|
||||
|
||||
func pause() -> bool:
|
||||
var keep_alive: RefCounted = self
|
||||
if cancelled: return false
|
||||
var now: int = Time.get_ticks_usec()
|
||||
var work_usec: int = now - checkpoint_usec
|
||||
if work_usec > max_slice_usec:
|
||||
max_slice_usec = work_usec
|
||||
max_slice_phase = phase
|
||||
if Time.get_ticks_usec() >= deadline:
|
||||
var waiting_from: int = Time.get_ticks_usec()
|
||||
await tree.process_frame
|
||||
keep_alive.waiting_usec += Time.get_ticks_usec() - waiting_from
|
||||
deadline = Time.get_ticks_usec() + slice_usec
|
||||
checkpoint_usec = Time.get_ticks_usec()
|
||||
return not keep_alive.cancelled
|
||||
|
||||
@@ -67,20 +67,15 @@ static func divmod(a: Array, b: Array) -> Array:
|
||||
assert(b != [0], "Division by zero")
|
||||
if b == [1]: return [a.duplicate(), [0]]
|
||||
if compare(a, b) < 0: return [[0], a.duplicate()]
|
||||
# Decimal ratios often share entire base-1e9 factors. Cancel them before
|
||||
# long division; restore the remainder's original scale afterwards.
|
||||
var shared: int = 0
|
||||
while shared < mini(a.size(), b.size()) - 1 and a[shared] == 0 and b[shared] == 0:
|
||||
shared += 1
|
||||
if shared:
|
||||
var reduced: Array = divmod(a.slice(shared), b.slice(shared))
|
||||
if reduced[1] != [0]:
|
||||
var restored: Array = []
|
||||
restored.resize(shared)
|
||||
restored.fill(0)
|
||||
restored.append_array(reduced[1])
|
||||
reduced[1] = restored
|
||||
return reduced
|
||||
# A divisor with trailing base limbs can divide the high part directly.
|
||||
# The low numerator limbs belong entirely to the remainder.
|
||||
var shift: int = 0
|
||||
while shift < b.size() - 1 and b[shift] == 0: shift += 1
|
||||
if shift > 0:
|
||||
var divided: Array = divmod(a.slice(shift), b.slice(shift))
|
||||
var restored: Array = a.slice(0, shift)
|
||||
if divided[1] != [0]: restored.append_array(divided[1])
|
||||
return [divided[0], trim(restored)]
|
||||
if b.size() == 1:
|
||||
var quotient: Array = []
|
||||
quotient.resize(a.size())
|
||||
|
||||
@@ -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
|
||||
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:
|
||||
if a == null: return b == null
|
||||
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])
|
||||
|
||||
## 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:
|
||||
var result: Array = []
|
||||
if not finite(a) or not finite(divisor):
|
||||
|
||||
@@ -4,6 +4,70 @@ const CivilizationState = preload("res://world/owniverse/civilization_state.gd")
|
||||
const SimulationTime = preload("res://world/owniverse/simulation_time.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:
|
||||
var speed: int = int(target.get("speed", 1))
|
||||
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:
|
||||
if target.batches[id].size() <= 1: 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)
|
||||
if E.le(lifespan, 10): continue
|
||||
var width: Variant = window_duration(target, lifespan)
|
||||
@@ -58,6 +125,69 @@ var _instant_target: Dictionary = {}
|
||||
var _instant_appends: Dictionary = {}
|
||||
var _static_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:
|
||||
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:
|
||||
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)):
|
||||
return NAN
|
||||
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 = {}
|
||||
var batches: Array = target.batches[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)
|
||||
if E.le(batch_duration, 0):
|
||||
return NAN
|
||||
|
||||
@@ -101,9 +101,13 @@ func credit(target: Dictionary, id: String, amount: Variant) -> Variant:
|
||||
if is_discrete(id):
|
||||
if (not target.has("production_remainders")):
|
||||
target.production_remainders = {}
|
||||
var expected: Variant = E.add(amount, target.production_remainders.get(id, 0))
|
||||
amount = E.floor_value(expected)
|
||||
target.production_remainders[id] = E.subtract(expected, amount)
|
||||
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)
|
||||
target.production_remainders[id] = E.subtract(expected, amount)
|
||||
target.amount[id] = E.add(target.amount.get(id, 0), amount)
|
||||
target.stats.total_amount[id] = E.add(target.stats.total_amount.get(id, 0), amount)
|
||||
if ((world.catalog.entities.has(id) and E.ne(world.catalog.entities[id].group_as, "")) and E.ne(world.catalog.entities[id].group_as, id)):
|
||||
|
||||
@@ -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 environment = preload("res://world/owniverse/universe_environment.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: Dictionary = {}
|
||||
|
||||
@@ -65,27 +66,7 @@ var _requested_speed: int = 0
|
||||
var _queued_productions: Array = []
|
||||
var frame_active_clock: Dictionary = {}
|
||||
var _resume_clock: Dictionary = {}
|
||||
var frame_step_seconds: float = 0.1
|
||||
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
|
||||
const frame_step_seconds: float = 0.2
|
||||
|
||||
## Keep the requested clock advancing while a transaction spans render frames.
|
||||
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()
|
||||
frame_clock = SimulationTime.advance(frame_clock, delta, speed)
|
||||
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_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()
|
||||
# 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
|
||||
frame_active_clock = next_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)
|
||||
|
||||
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)
|
||||
|
||||
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_commits += 1
|
||||
if _requested_speed != 0:
|
||||
|
||||
@@ -45,6 +45,41 @@ static func allocate_whole(requests: Dictionary, limit: Variant) -> Dictionary:
|
||||
total = E.add(total, result[id])
|
||||
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.
|
||||
## 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):
|
||||
for id in result: result[id] = 0
|
||||
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 rests: Array = []
|
||||
var order: Array = []
|
||||
@@ -123,11 +167,72 @@ static func sum(pop: Dictionary) -> Variant:
|
||||
|
||||
static var _split_keys: Dictionary = {}
|
||||
|
||||
## Split a whole population using normalized float shares. Quantizing the six
|
||||
## shares to 15 decimal places matches their stored float precision, while the
|
||||
## integer denominator avoids a second expensive exact normalization pass.
|
||||
static func split_profile(remainders: Dictionary, key: String, count: Variant, ids: Array, shares: Array) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
if E.le(count, 0): return result
|
||||
assert(E.eq(count, E.floor_value(count)) and ids.size() == shares.size() and not ids.is_empty())
|
||||
const SCALE: int = 1000000000000000
|
||||
var weights: Array = []
|
||||
var assigned_weight: int = 0
|
||||
for i in ids.size() - 1:
|
||||
var weight: int = clampi(roundi(clampf(float(shares[i]), 0.0, 1.0) * SCALE), 0, SCALE)
|
||||
weights.append(weight)
|
||||
assigned_weight += weight
|
||||
var last_weight: int = SCALE - assigned_weight
|
||||
if last_weight < 0:
|
||||
var excess: int = -last_weight
|
||||
for i in range(weights.size() - 1, -1, -1):
|
||||
var reduction: int = mini(excess, weights[i])
|
||||
weights[i] -= reduction
|
||||
excess -= reduction
|
||||
if excess == 0: break
|
||||
last_weight = 0
|
||||
weights.append(last_weight)
|
||||
var cached: Dictionary = _split_keys.get(key, {})
|
||||
if cached.is_empty() or cached.ids != ids:
|
||||
var keys: Array = []
|
||||
for id in ids: keys.append(key + ":" + str(id))
|
||||
if _split_keys.size() >= 512: _split_keys.clear()
|
||||
cached = {"ids": ids.duplicate(), "keys": keys}
|
||||
_split_keys[key] = cached
|
||||
var quotas: Array = E.whole_quotas(count, weights, SCALE)
|
||||
var rests: Array = []
|
||||
var order: Array = []
|
||||
var total: Variant = 0
|
||||
for i in ids.size():
|
||||
var fraction: float = quotas[i][1] + E.approx(remainders.get(cached.keys[i], 0))
|
||||
var adjustment: int = floori(fraction)
|
||||
result[ids[i]] = E.maximum(0, E.add(quotas[i][0], adjustment))
|
||||
total = E.add(total, result[ids[i]])
|
||||
rests.append(fraction - adjustment if E.ge(quotas[i][0], -adjustment) else fraction)
|
||||
order.append(i)
|
||||
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):
|
||||
for i in order:
|
||||
if E.ge(total, count): break
|
||||
result[ids[i]] = E.add(result[ids[i]], 1)
|
||||
rests[i] -= 1.0
|
||||
total = E.add(total, 1)
|
||||
elif E.gt(total, count):
|
||||
order.reverse()
|
||||
for i in order:
|
||||
if E.le(total, count): break
|
||||
if E.le(result[ids[i]], 0): continue
|
||||
result[ids[i]] = E.subtract(result[ids[i]], 1)
|
||||
rests[i] += 1.0
|
||||
total = E.subtract(total, 1)
|
||||
for i in ids.size():
|
||||
remainders[cached.keys[i]] = clampf(rests[i], -1.0, 1.0)
|
||||
return result
|
||||
|
||||
static func split(remainders: Dictionary, key: String, count: Variant, weights: Dictionary) -> Dictionary:
|
||||
if E.le(count, 0): return {}
|
||||
return split_arrays(remainders, key, count, weights.keys(), weights.values())
|
||||
|
||||
static func split_arrays(remainders: Dictionary, key: String, count: Variant, ids: Array, weights: Array, population_factor: int = 0) -> Dictionary:
|
||||
static func split_arrays(remainders: Dictionary, key: String, count: Variant, ids: Array, weights: Array, population_factor: int = 0, skip_conserved_identity: bool = false) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
if E.le(count, 0): return result
|
||||
var sum_weights: Variant = 0
|
||||
@@ -177,7 +282,7 @@ static func split_arrays(remainders: Dictionary, key: String, count: Variant, id
|
||||
# Fractional identity ratios still round-trip through parts/make in the
|
||||
# original algorithm. Retain them to preserve ties and saved errors exactly.
|
||||
var normalized: Array = positives
|
||||
if E.gt(positive_sum, 0) and (E.ne(positive_sum, count) or not whole_quotas):
|
||||
if E.gt(positive_sum, 0) and (E.ne(positive_sum, count) or (not whole_quotas and not (skip_conserved_identity and E.eq(positive_sum, count)))):
|
||||
normalized = E.mul_div_many(count, positives, positive_sum)
|
||||
var order: Array = []
|
||||
var rests: Array = []
|
||||
@@ -198,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:
|
||||
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)
|
||||
remainders[key] = E.maximum(0, E.subtract(expected, actual))
|
||||
return actual
|
||||
|
||||
@@ -62,16 +62,26 @@ static func advance(previous: Dictionary, target: Dictionary, rules: Dictionary,
|
||||
for id in rules:
|
||||
if E.ne(str(rules[id].home), ""): stages.append(id)
|
||||
stages.sort()
|
||||
# Profiles remain in the save after a stage becomes empty. Only living
|
||||
# populations (including populations bound to a host object) contribute.
|
||||
var populated: Dictionary = {}
|
||||
var amounts: Dictionary = previous.get("amount", {})
|
||||
for id in stages:
|
||||
if E.gt(amounts.get(id, 0), 0): populated[id] = true
|
||||
for owner in previous.get("binds", {}):
|
||||
for id in previous.binds[owner]:
|
||||
if rules.has(id) and E.ne(str(rules[id].home), "") and E.gt(previous.binds[owner][id], 0):
|
||||
populated[id] = true
|
||||
var weight: Variant = 1
|
||||
for id in stages:
|
||||
if not populated.has(id):
|
||||
weight = E.multiply(weight, 2)
|
||||
continue
|
||||
var population: Dictionary = previous.get("ethics", {}).get(id, {})
|
||||
var count: Variant = 0
|
||||
for n in population.values(): count = E.add(count, n)
|
||||
if E.gt(count, 0):
|
||||
for ethic in population:
|
||||
var proportion: Variant = E.divide(population[ethic], count)
|
||||
for i in TRENDS.size():
|
||||
rates[i] = E.add(rates[i], E.multiply(E.multiply(proportion, WEIGHTS[ethic][i]), weight))
|
||||
for ethic in population:
|
||||
var proportion: float = maxf(0.0, float(population[ethic]))
|
||||
for i in TRENDS.size():
|
||||
rates[i] = E.add(rates[i], E.multiply(E.multiply(proportion, WEIGHTS[ethic][i]), weight))
|
||||
weight = E.multiply(weight, 2)
|
||||
for i in TRENDS.size():
|
||||
var name: String = TRENDS[i]
|
||||
|
||||
Reference in New Issue
Block a user