Add comprehensive civilization evolution tests and enhance civilization state management
- Introduced a new test script for civilization evolution (`test_civilization_evolution.gd`) to validate the evolution mechanics, including population dynamics, ethical transitions, and war impacts. - Updated `README.md` to include instructions for running the new civilization evolution tests. - Modified `test_civilization_state.gd` to accommodate changes in civilization rules and ensure proper validation of ethical stages. - Enhanced `test_descriptions.gd` to check for accurate population counters and state validation. - Adjusted `test_life.gd` to reflect updates in life version management. - Improved `civilization_state.gd` to include habitat budget calculations and transition management. - Added `civilization_evolution.gd` to handle the logic for civilization evolution, including population drift, warfare, and colonization mechanics. - Updated `owniverse.gd` and `owniverse_entity.gd` to integrate new evolution logic and ensure proper initialization of entities.
This commit is contained in:
+10
-1
@@ -69,10 +69,19 @@ func format_number(value: float, digits: int = 0) -> String:
|
|||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
CivilizationRules.clear()
|
||||||
for row: Dictionary in _read_from_csv("res://data/civilization_rules.txt").values():
|
for row: Dictionary in _read_from_csv("res://data/civilization_rules.txt").values():
|
||||||
if not CivilizationRules.has(row.stage):
|
if not CivilizationRules.has(row.stage):
|
||||||
CivilizationRules[row.stage] = {"home": row.home, "span": float(row.span), "stars": float(row.stars), "ethics": {}}
|
CivilizationRules[row.stage] = {"home": row.home, "span": float(row.span), "stars": float(row.stars), "ethics": {},
|
||||||
|
"colonizes": row.colonizes == "1", "fights": row.fights == "1", "diplomacy": row.diplomacy == "1", "worships": row.worships == "1"}
|
||||||
CivilizationRules[row.stage].ethics[row.ethic] = row
|
CivilizationRules[row.stage].ethics[row.ethic] = row
|
||||||
|
var stages: Array = CivilizationRules.keys()
|
||||||
|
stages.sort()
|
||||||
|
for i in stages.size():
|
||||||
|
CivilizationRules[stages[i]].next = stages[i + 1] if i + 1 < stages.size() else "Ct"
|
||||||
|
CivilizationRules[stages[i]].previous = stages[i - 1] if i > 0 else "C0.1"
|
||||||
|
CivilizationRules[stages[i]].colony = stages[maxi(0, i - 2)]
|
||||||
|
CivilizationRules.Ct = {"home": "", "span": 0.0, "stars": 0.0, "ethics": {}, "next": "Ct", "previous": "C4.4", "colonizes": false, "fights": false, "diplomacy": false, "worships": false}
|
||||||
LifeRules = _read_from_csv("res://data/life_rules.txt")
|
LifeRules = _read_from_csv("res://data/life_rules.txt")
|
||||||
OwniverseEntities = _read_from_csv("res://data/owniverse_entities.txt")
|
OwniverseEntities = _read_from_csv("res://data/owniverse_entities.txt")
|
||||||
OwniverseItems = _read_from_csv("res://data/owniverse_items.txt")
|
OwniverseItems = _read_from_csv("res://data/owniverse_items.txt")
|
||||||
|
|||||||
@@ -190,8 +190,18 @@ func validate_data(data: Variant) -> String:
|
|||||||
for ethic in saved.ethics[id]:
|
for ethic in saved.ethics[id]:
|
||||||
if ethic not in ["m", "l", "w", "d", "c", "b"]:
|
if ethic not in ["m", "l", "w", "d", "c", "b"]:
|
||||||
return "invalid ethic " + str(ethic)
|
return "invalid ethic " + str(ethic)
|
||||||
if saved.has("life_version") and saved.life_version not in [1, 2]:
|
if saved.has("life_version") and saved.life_version not in [1, 2, 3]:
|
||||||
return "invalid state.life_version"
|
return "invalid state.life_version"
|
||||||
|
if saved.has("civilization_remainders") and not _number_map(saved.civilization_remainders, false, false):
|
||||||
|
return "invalid state.civilization_remainders"
|
||||||
|
if saved.has("civilization_stats") and not _number_map(saved.civilization_stats):
|
||||||
|
return "invalid state.civilization_stats"
|
||||||
|
if saved.has("civilization_homes"):
|
||||||
|
if not saved.civilization_homes is Dictionary:
|
||||||
|
return "invalid state.civilization_homes"
|
||||||
|
for id in saved.civilization_homes:
|
||||||
|
if not _number_map(saved.civilization_homes[id], true):
|
||||||
|
return "invalid civilization homes"
|
||||||
if saved.has("life_remainders") and not _number_map(saved.life_remainders, false, false):
|
if saved.has("life_remainders") and not _number_map(saved.life_remainders, false, false):
|
||||||
return "invalid state.life_remainders"
|
return "invalid state.life_remainders"
|
||||||
if saved.has("resource_remainders"):
|
if saved.has("resource_remainders"):
|
||||||
|
|||||||
+26
-6
@@ -64,7 +64,7 @@ GI не хранит накопленные счётчики первичной
|
|||||||
|
|
||||||
## Границы этапа
|
## Границы этапа
|
||||||
|
|
||||||
Рассчитываются агрегаты по типам, а не отдельные звёздные системы; конкретная принадлежность каждой планеты отдельной звезде не моделируется. Распределения целых объектов детерминированы. Порядок внутри длинного шага — по поколениям, поэтому нелинейное воздействие бластов и среды не обязано совпадать с серией более коротких шагов. Эволюция HP и эонов подключена ниже; развитие цивилизаций после появления C0.0, их бонусы и силы остаются отдельными этапами переноса.
|
Рассчитываются агрегаты по типам, а не отдельные звёздные системы; конкретная принадлежность каждой планеты отдельной звезде не моделируется. Распределения целых объектов детерминированы. Порядок внутри длинного шага — по поколениям, поэтому нелинейное воздействие бластов и среды не обязано совпадать с серией более коротких шагов. Эволюция HP и эонов подключена ниже; эволюция цивилизаций и бонусы структур описаны ниже. Глобальная система сил остаётся отдельной механикой.
|
||||||
|
|
||||||
Проверки: `tests/test_time.gd`, `tests/test_stellar_rules.gd`. Сохранения игрока тестами не затрагиваются.
|
Проверки: `tests/test_time.gd`, `tests/test_stellar_rules.gd`. Сохранения игрока тестами не затрагиваются.
|
||||||
|
|
||||||
@@ -80,24 +80,44 @@ GI не хранит накопленные счётчики первичной
|
|||||||
|
|
||||||
Для всех шести стадий базовые доли за попытку — `progress=0.00025`, `degradation=0.0025`. Интервалы HP/EON1/EON2/EON3/EON4/EON5: 5 млн, 10 млн, 10 млн, 1 млн, 1 млн, 500 тыс. лет. Используются постепенные окна GI шириной `span / 40`. Случайные множители Unity не применяются; агрессия, порядок и просвещение пока имеют нейтральное влияние.
|
Для всех шести стадий базовые доли за попытку — `progress=0.00025`, `degradation=0.0025`. Интервалы HP/EON1/EON2/EON3/EON4/EON5: 5 млн, 10 млн, 10 млн, 1 млн, 1 млн, 500 тыс. лет. Используются постепенные окна GI шириной `span / 40`. Случайные множители Unity не применяются; агрессия, порядок и просвещение пока имеют нейтральное влияние.
|
||||||
|
|
||||||
HP деградирует в RP. У эонов возможен откат к предыдущим стадиям по таблице. RP не развивается сам. C0.0 и C0.1 также развиваются: период 2000 лет, прогресс 0.0175, деградация 0.025. Переходы C0.0 → C0.1 → C0.2, откат C0.0 → EON5 и C0.1 → C0.0. Развитие C0.2 и выше ещё не подключено. Конфликт civ_bonus устранён повторным импортом: бонусы структур совпадают с Unity. Для существующих этических цивилизаций пользователь выбрал равномерное распределение между шестью этиками с детерминированным целым остатком.
|
HP деградирует в RP. У эонов возможен откат к предыдущим стадиям по таблице. RP не развивается сам. C0.0 и C0.1 также развиваются: период 2000 лет, прогресс 0.0175, деградация 0.025. Переходы C0.0 → C0.1 → C0.2, откат C0.0 → EON5 и C0.1 → C0.0. Развитие C0.2 и выше подключено через этические правила. Конфликт civ_bonus устранён повторным импортом: бонусы структур совпадают с Unity. Для существующих этических цивилизаций пользователь выбрал равномерное распределение между шестью этиками с детерминированным целым остатком.
|
||||||
|
|
||||||
Переходы сохраняют количество планет и их хозяев, включая передачу преемнику звезды и разрушение бластом. Повторная попытка той же стадии не увеличивает статистику созданных объектов. Партии новых планет регистрируются при создании; недостающие партии старых HP/эонов — однократно при загрузке с текущим возрастом вселенной, без выдумывания их прошлого возраста. Состояние содержит `life_version=2` (версия 1 также загружается) и `life_remainders`.
|
Переходы сохраняют количество планет и их хозяев, включая передачу преемнику звезды и разрушение бластом. Повторная попытка той же стадии не увеличивает статистику созданных объектов. Партии новых планет регистрируются при создании; недостающие партии старых HP/эонов — однократно при загрузке с текущим возрастом вселенной, без выдумывания их прошлого возраста. Состояние содержит `life_version=2` (версия 1 также загружается) и `life_remainders`.
|
||||||
|
|
||||||
Миграция версии жизни 1 → 2 добавляет только недостающие партии C0.0/C0.1, сохраняя уже существующие партии HP и эонов.
|
Миграция версии жизни 1 → 2 добавляет только недостающие партии C0.0/C0.1, сохраняя уже существующие партии HP и эонов.
|
||||||
|
|
||||||
## Подготовка этических и космических цивилизаций
|
## Этические и космические цивилизации
|
||||||
|
|
||||||
`civilization_rules.txt` содержит 43 стадии по шесть этик из Unity. Они загружаются в `Global.CivilizationRules`, но расчёт их прогресса, деградации, войн и колонизации ещё не подключён.
|
`civilization_rules.txt` содержит 43 стадии по шесть этик из Unity. Они загружаются в `Global.CivilizationRules` и участвуют в расчёте партий. После C4.4 следует Ct: конечная стадия без новых жизненных партий.
|
||||||
|
|
||||||
Состояние `ethics[stage][ethic]` хранит целые количества. Для существующих цивилизаций без этик количество распределяется поровну в порядке m, l, w, d, c, b; остаток распределяется по одному в этом порядке. Существующее распределение сохраняется. При внешних потерях оно пропорционально сокращается.
|
Состояние `ethics[stage][ethic]` хранит целые количества. Для существующих цивилизаций без этик количество распределяется поровну в порядке m, l, w, d, c, b; остаток распределяется по одному в этом порядке. Существующее распределение сохраняется. При внешних потерях оно пропорционально сокращается.
|
||||||
|
|
||||||
Цивилизации с непустым `home` (C2.0–C4.4) освобождаются от прежних привязок без изменения общего количества. Общий механизм переходов больше не переносит планетную привязку на такого преемника. Назначение структур и обратное размещение при деградации ещё не подключены.
|
Цивилизации с непустым `home` (C2.0–C4.4) освобождаются от прежних привязок без изменения общего количества. Общий механизм переходов больше не переносит планетную привязку на такого преемника. Структуры назначаются отдельно в `civilization_homes`; при возвращении к планетным стадиям цивилизации размещаются у имеющихся карликовых звёзд.
|
||||||
|
|
||||||
Правило среды согласовано: число космических цивилизаций ограничено звёздным ресурсом. Для стадии `i` расход ёмкости — `population[i] * max(1, Stars[i])`. Каждое семейство `Home` имеет общий бюджет для всех использующих его стадий; звёзды внутри структур также учитываются, но повторно не суммируются. SMBH учитывается по суммарной массе в эквиваленте звёзд, согласно решению пользователя.
|
Правило среды согласовано: число космических цивилизаций ограничено звёздным ресурсом. Для стадии `i` расход ёмкости — `population[i] * max(1, Stars[i])`. Каждое семейство `Home` имеет общий бюджет для всех использующих его стадий; звёзды внутри структур также учитываются, но повторно не суммируются. SMBH учитывается по суммарной массе в эквиваленте звёзд, согласно решению пользователя.
|
||||||
|
|
||||||
Переход переиспользует резерв исходной стадии. При нехватке ёмкости неподдержанные переходы остаются на исходной стадии. Лимит применяется к общему механизму выходов партий и ручному созданию; это само по себе не включает пока отсутствующий расчёт прогресса C0.2+.
|
Переход переиспользует резерв исходной стадии. При нехватке ёмкости неподдержанные переходы остаются на исходной стадии. Лимит применяется к общему механизму выходов партий и ручному созданию; расчёт прогресса C0.2+ использует тот же бюджет.
|
||||||
|
|
||||||
При загрузке и после эволюционных поколений избыток деградирует последовательно до поддерживаемой стадии. Приоритет получают существующие более развитые цивилизации; остаток бюджета достаётся младшим. Деградация переносит количество и этики, сокращает старые партии и регистрирует новые, не уничтожая цивилизации. C2.0 при избытке становится C1.9 и распределяется по имеющимся звёздам-карликам. Если карликов нет, C1.9 остаётся свободной — отсутствующая звезда не создаётся. Планетные стадии до C2.0 не расходуют отдельную звёздную ёмкость: у одной звезды могут быть несколько планет.
|
При загрузке и после эволюционных поколений избыток деградирует последовательно до поддерживаемой стадии. Приоритет получают существующие более развитые цивилизации; остаток бюджета достаётся младшим. Деградация переносит количество и этики, сокращает старые партии и регистрирует новые, не уничтожая цивилизации. C2.0 при избытке становится C1.9 и распределяется по имеющимся звёздам-карликам. Если карликов нет, C1.9 остаётся свободной — отсутствующая звезда не создаётся. Планетные стадии до C2.0 не расходуют отдельную звёздную ёмкость: у одной звезды могут быть несколько планет.
|
||||||
|
|
||||||
Резерв ёмкости логический: звёзды не расходуются и продолжают производить ресурсы. Общий бюджет пересчитывается из физических количеств, отдельная копия бронирований в сохранении не хранится.
|
Резерв ёмкости логический: звёзды не расходуются и продолжают производить ресурсы. Общий бюджет пересчитывается из физических количеств, отдельная копия бронирований в сохранении не хранится.
|
||||||
|
|
||||||
|
|
||||||
|
### Расчёт поколения 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`), бласты и ограничения среды. Число операций зависит от числа типов и партий, а не от числа цивилизаций.
|
||||||
|
|
||||||
|
Окна исходных партий сохраняют время переходов; несколько частей одного типа обрабатываются агрегированно между минимальным и максимальным временем. Побочный ущерб неистёкшим партиям применяется в текущий момент. Все остатки округления сохраняются в `civilization_remainders`, счётчики — в `civilization_stats`.
|
||||||
|
|
||||||
|
### Структуры и периоды
|
||||||
|
|
||||||
|
Прямой `claim` задаёт структуру стадии и норму 1; каждая промежуточная стадия умножает норму на 10 до следующего `claim`. Начиная с C3.0 требуется соответствующий запас структур, общий для использующих его стадий. При потере запаса избыток деградирует. Этот бюджет действует одновременно со звёздным.
|
||||||
|
|
||||||
|
Размещение выбирает структуры в порядке убывания `civ_bonus`, начиная с соответствующего стадии `claim`. Прямое размещение ограничено числом структур; остальные разрешённые структуры при наличии дают общий бонус, как в Unity. Период новых партий равен `span / средневзвешенный civ_bonus`, для неразмещённой части множитель равен 1. Уже назначенные сроки партий не переписываются при изменении бонуса.
|
||||||
|
|
||||||
|
`life_version = 3` добавляет часы существующим C0.2+ с момента загрузки; старые часы HP, эонов и начальных цивилизаций сохраняются. Ct не получает часы. Миграция выполняется однократно. Глобальные силы и их модификаторы не входят в этот перенос: используются нейтральные множители 1.
|
||||||
|
|||||||
@@ -62,3 +62,12 @@ godot --headless --path Owniverse.GI --script res://tests/test_civilization_stat
|
|||||||
```
|
```
|
||||||
|
|
||||||
Тест цивилизаций дополнительно проверяет общий звёздный бюджет, повторное использование резерва при переходе, звёзды в структурах, массовый эквивалент SMBH, блокировку неподдержанного прогресса, каскад деградации и повторное применение ограничений без потерь.
|
Тест цивилизаций дополнительно проверяет общий звёздный бюджет, повторное использование резерва при переходе, звёзды в структурах, массовый эквивалент SMBH, блокировку неподдержанного прогресса, каскад деградации и повторное применение ограничений без потерь.
|
||||||
|
|
||||||
|
|
||||||
|
## Полная эволюция цивилизаций
|
||||||
|
|
||||||
|
```sh
|
||||||
|
godot --headless --path Owniverse.GI --script res://tests/test_civilization_evolution.gd
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверяются реальные коэффициенты C0.2, войны и побочный ущерб C2.0, замена планет колониями с сохранением хозяина, дипломатическое влияние, бонус периода от структур и деградация при их отсутствии. Ускоренная модель проходит все стадии C0.2 → C4.4 → Ct. Проверки контролируют сохранение населения, целочисленность этик, валидацию сохранения и детерминированное продолжение после сериализации. Миграция часов цивилизаций использует `life_version = 3`.
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
extends SceneTree
|
||||||
|
const Clock = preload("res://world/owniverse/simulation_time.gd")
|
||||||
|
var checks: int = 0
|
||||||
|
var failures: int = 0
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
call_deferred("run")
|
||||||
|
|
||||||
|
func check(condition: bool, message: String) -> void:
|
||||||
|
checks += 1
|
||||||
|
if not condition:
|
||||||
|
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.entities[id] = entity
|
||||||
|
world.state.amount[id] = 0.0
|
||||||
|
world.state.stats.total_amount[id] = 0.0
|
||||||
|
if entity.group_as != "":
|
||||||
|
world.state.amount[entity.group_as] = 0.0
|
||||||
|
world.state.stats.total_amount[entity.group_as] = 0.0
|
||||||
|
world.entities["N"].life_product = {}
|
||||||
|
world.entities["N"].product_per_year_type = "H"
|
||||||
|
world.entities["N"].product_per_year_amount = 10.0
|
||||||
|
world.entities["N"].auto = 0.0
|
||||||
|
world.state.amount.N = 1.0
|
||||||
|
return world
|
||||||
|
|
||||||
|
func dispose(world) -> void:
|
||||||
|
for entity in world.entities.values():
|
||||||
|
entity.free()
|
||||||
|
world.free()
|
||||||
|
|
||||||
|
func seed_population(world, id: String, count: float, time: float = 1.0) -> void:
|
||||||
|
world.state.amount.N = 0.0
|
||||||
|
world.state.amount[id] = count
|
||||||
|
world.state.batches[id] = [{"amount": count, "start_year": time, "end_year": time}]
|
||||||
|
world.initialize_life_batches()
|
||||||
|
|
||||||
|
func sum_ethics(world, id: String) -> float:
|
||||||
|
var result: float = 0.0
|
||||||
|
for n in world.state.ethics.get(id, {}).values(): result += n
|
||||||
|
return result
|
||||||
|
|
||||||
|
func invariants(world) -> void:
|
||||||
|
var physical: Dictionary = world._physical_amounts(world.state)
|
||||||
|
for id in world.state.ethics:
|
||||||
|
check(sum_ethics(world, id) == physical.get(id, 0.0), "Ethics equal physical population for " + id)
|
||||||
|
for n in world.state.ethics[id].values():
|
||||||
|
if n < 0.0 or n != floorf(n): check(false, "Invalid ethic amount")
|
||||||
|
check(root.get_node("SaveManager").validate_data({"state": world.state}) == "", "Civilization state validates")
|
||||||
|
|
||||||
|
func run() -> void:
|
||||||
|
var global: Node = root.get_node("Global")
|
||||||
|
var baseline: Dictionary = global.CivilizationRules.duplicate(true)
|
||||||
|
var world = fixture()
|
||||||
|
seed_population(world, "C0.2", 60000.0)
|
||||||
|
world._evolve_batches(world.state, 1.0)
|
||||||
|
check(world.state.amount["C0.3"] > 0.0, "C0.2 actually progresses")
|
||||||
|
check(world.state.amount["C0.1"] > 0.0, "C0.2 actually degrades")
|
||||||
|
check(world.state.civilization_stats.get("wars", 0.0) == 0.0, "Table disables early wars")
|
||||||
|
check(world.state.amount["C0.2"] + world.state.amount["C0.3"] + world.state.amount["C0.1"] == 60000.0, "Ordinary evolution conserves population")
|
||||||
|
check(world.state.stats.total_amount["C0.2"] == 0.0, "Survivors do not count as new civilizations")
|
||||||
|
check(world.state.civilization_stats.worship > 0.0, "Worship derived from surviving ethics")
|
||||||
|
invariants(world)
|
||||||
|
var restored = fixture()
|
||||||
|
restored.state = bytes_to_var(var_to_bytes(world.state))
|
||||||
|
world._evolve_batches(world.state, 2100.0)
|
||||||
|
restored._evolve_batches(restored.state, 2100.0)
|
||||||
|
check(world.state == restored.state, "Save resumes deterministic ethical evolution")
|
||||||
|
invariants(world)
|
||||||
|
dispose(restored)
|
||||||
|
dispose(world)
|
||||||
|
|
||||||
|
world = fixture()
|
||||||
|
world.state.amount.RD = 1e8
|
||||||
|
seed_population(world, "C2.0", 60000.0)
|
||||||
|
world.state.amount["C1.9"] = 10000.0
|
||||||
|
world.initialize_life_batches()
|
||||||
|
world._evolve_batches(world.state, 1.0)
|
||||||
|
check(world.state.civilization_stats.wars > 0.0, "Wars execute when enabled")
|
||||||
|
check(world.state.civilization_stats.collateral > 0.0, "Wars affect previous-stage populations")
|
||||||
|
check(world.state.amount["C2.1"] > 0.0, "Winners and peaceful progress reach next stage")
|
||||||
|
invariants(world)
|
||||||
|
dispose(world)
|
||||||
|
|
||||||
|
world = fixture()
|
||||||
|
world.state.amount.RD = 1.0
|
||||||
|
world.state.binds.RD = {"HP": 5.0, "GG": 5.0, "AB": 5.0, "RP": 5.0}
|
||||||
|
seed_population(world, "C1.6", 60000.0)
|
||||||
|
for row: Dictionary in global.CivilizationRules["C1.6"].ethics.values(): row.degradation = "0"
|
||||||
|
world._evolve_batches(world.state, 1.0)
|
||||||
|
var after: Dictionary = world._physical_amounts(world.state)
|
||||||
|
check(world.state.civilization_stats.colonies == 20.0, "Colonization limited to real targets")
|
||||||
|
check(after.HP + after.GG + after.AB + after.RP == 0.0, "Colonized objects consumed once")
|
||||||
|
check(world.state.binds.RD.get("C1.4", 0.0) == 20.0, "Colonies retain target host and use two-stages-earlier level")
|
||||||
|
var totals: float = 0.0
|
||||||
|
for id in after:
|
||||||
|
if world.entities[id].group_as == "Planet": totals += after[id]
|
||||||
|
check(totals == 60020.0, "Colonization replaces planets, does not create extra worlds")
|
||||||
|
invariants(world)
|
||||||
|
dispose(world)
|
||||||
|
|
||||||
|
global.CivilizationRules = baseline.duplicate(true)
|
||||||
|
world = fixture()
|
||||||
|
world.state.amount.RD = 1e25
|
||||||
|
world.state.amount.BG = 1e25
|
||||||
|
world.state.amount.TWD = 1e25
|
||||||
|
world.state.amount.SNS = 1e25
|
||||||
|
world.state.amount.MGT = 1e25
|
||||||
|
world.state.amount.BH25 = 1e25
|
||||||
|
world.state.amount.SMBH = 1e26
|
||||||
|
for id in world.entities:
|
||||||
|
if world.entities[id].claim != "": world.state.amount[id] = 1e12
|
||||||
|
# Accelerated deterministic fixture traverses every enabled stage, including Ct.
|
||||||
|
for id in global.CivilizationRules:
|
||||||
|
if 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.entities[id].lifespan = 1.0
|
||||||
|
world.entities[id].lifespan_delta = 1.0
|
||||||
|
global.CivilizationRules[id].span = 1.0
|
||||||
|
seed_population(world, "C0.2", 6.0)
|
||||||
|
world._evolve_batches(world.state, 1000.0)
|
||||||
|
check(world.state.amount.Ct == 6.0, "Complete C0.2 -> C4.4 -> Ct chain runs")
|
||||||
|
check(not world.state.batches.has("Ct"), "Transcendence is terminal")
|
||||||
|
invariants(world)
|
||||||
|
dispose(world)
|
||||||
|
global.CivilizationRules = baseline
|
||||||
|
|
||||||
|
world = fixture()
|
||||||
|
world.state.amount.RD = 10000.0
|
||||||
|
world.state.amount.SCS = 1.0
|
||||||
|
world.state.amount["C2.4"] = 1.0
|
||||||
|
world.initialize_life_batches()
|
||||||
|
var span: float = world.state.batches["C2.4"][0].start_year
|
||||||
|
check(is_equal_approx(span, 20000.0 / 3.6), "Structure bonus shortens newly registered lifespan")
|
||||||
|
dispose(world)
|
||||||
|
var evolution = load("res://world/owniverse/civilization_evolution.gd")
|
||||||
|
var balanced: Dictionary = {"m": 10000.0, "l": 10000.0, "w": 10000.0, "d": 10000.0, "c": 10000.0, "b": 10000.0}
|
||||||
|
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(neutral == balanced, "Ordinary drift preserves balanced ethics")
|
||||||
|
check(diplomatic.d > diplomatic.w, "Diplomacy uses per-ethic influence")
|
||||||
|
check(evolution.total(diplomatic) == 60000.0, "Diplomacy conserves population")
|
||||||
|
|
||||||
|
world = fixture()
|
||||||
|
world.state.amount.RD = 1e15
|
||||||
|
world.state.amount.BG = 1e15
|
||||||
|
world.state.amount["C3.0"] = 6.0
|
||||||
|
world.initialize_life_batches()
|
||||||
|
check(world.state.amount["C3.0"] == 0.0 and world.state.amount["C2.9"] == 6.0, "Missing mandatory structures degrade existing civilizations")
|
||||||
|
invariants(world)
|
||||||
|
dispose(world)
|
||||||
|
print("Civilization evolution: %d checks, %d failures" % [checks, failures])
|
||||||
|
quit(0 if failures == 0 else 1)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://u4ujq17gu2l5
|
||||||
@@ -38,9 +38,9 @@ func dispose(world) -> void:
|
|||||||
func run() -> void:
|
func run() -> void:
|
||||||
var world = fixture()
|
var world = fixture()
|
||||||
var global: Node = root.get_node("Global")
|
var global: Node = root.get_node("Global")
|
||||||
check(global.CivilizationRules.size() == 43, "Load all ethical civilization stages")
|
check(global.CivilizationRules.size() == 44, "Load all ethical civilization stages")
|
||||||
for row: Dictionary in global.CivilizationRules.values():
|
for row: Dictionary in global.CivilizationRules.values():
|
||||||
check(row.ethics.size() == 6, "Every ethical stage has six rules")
|
check(row.ethics.size() == 6 or row.span == 0.0, "Every ethical stage has six rules")
|
||||||
world.state.amount.RD = 12.0
|
world.state.amount.RD = 12.0
|
||||||
world.state.amount["C0.2"] = 8.0
|
world.state.amount["C0.2"] = 8.0
|
||||||
world.state.binds.RD = {"C2.0": 12.0, "C1.9": 3.0}
|
world.state.binds.RD = {"C2.0": 12.0, "C1.9": 3.0}
|
||||||
@@ -67,7 +67,10 @@ func run() -> void:
|
|||||||
dispose(world)
|
dispose(world)
|
||||||
world = fixture()
|
world = fixture()
|
||||||
world.state.amount.RD = 10.0
|
world.state.amount.RD = 10.0
|
||||||
world.entities["C1.9"].output_and = {"C2.0": 1.0}
|
for row: Dictionary in global.CivilizationRules["C1.9"].ethics.values():
|
||||||
|
row.progress = "1"
|
||||||
|
row.degradation = "0"
|
||||||
|
row.colonization = "0"
|
||||||
world.state.binds.RD = {"C1.9": 10.0}
|
world.state.binds.RD = {"C1.9": 10.0}
|
||||||
world.state.batches["C1.9"] = [{"amount": 10.0, "start_year": 1.0, "end_year": 1.0}]
|
world.state.batches["C1.9"] = [{"amount": 10.0, "start_year": 1.0, "end_year": 1.0}]
|
||||||
world._evolve_batches(world.state, 1.0)
|
world._evolve_batches(world.state, 1.0)
|
||||||
@@ -107,6 +110,7 @@ func run() -> void:
|
|||||||
dispose(world)
|
dispose(world)
|
||||||
|
|
||||||
world = fixture()
|
world = fixture()
|
||||||
|
world.state.amount.LGW = 20.0
|
||||||
world.state.amount.SMBH = 2.0e22
|
world.state.amount.SMBH = 2.0e22
|
||||||
world.state.amount["C4.2"] = 3.0
|
world.state.amount["C4.2"] = 3.0
|
||||||
world.initialize_life_batches()
|
world.initialize_life_batches()
|
||||||
@@ -117,7 +121,10 @@ func run() -> void:
|
|||||||
|
|
||||||
world = fixture()
|
world = fixture()
|
||||||
world.state.amount.RD = 2.0
|
world.state.amount.RD = 2.0
|
||||||
world.entities["C1.9"].output_and = {"C2.0": 1.0}
|
for row: Dictionary in global.CivilizationRules["C1.9"].ethics.values():
|
||||||
|
row.progress = "1"
|
||||||
|
row.degradation = "0"
|
||||||
|
row.colonization = "0"
|
||||||
world.state.binds.RD = {"C1.9": 5.0}
|
world.state.binds.RD = {"C1.9": 5.0}
|
||||||
world.state.batches["C1.9"] = [{"amount": 5.0, "start_year": 1.0, "end_year": 1.0}]
|
world.state.batches["C1.9"] = [{"amount": 5.0, "start_year": 1.0, "end_year": 1.0}]
|
||||||
world._evolve_batches(world.state, 1.0)
|
world._evolve_batches(world.state, 1.0)
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ func run() -> void:
|
|||||||
check(values.amount.contains("#ff6060") and values.amount.contains("−2.0"), "Real-time decrease red")
|
check(values.amount.contains("#ff6060") and values.amount.contains("−2.0"), "Real-time decrease red")
|
||||||
values = description.variables(world, global, "HP")
|
values = description.variables(world, global, "HP")
|
||||||
check(values.amount == "7", "Planet description includes bound planets")
|
check(values.amount == "7", "Planet description includes bound planets")
|
||||||
|
world.state.amount.EON1 = 2.0
|
||||||
|
world.state.binds.G.EON2 = 3.0
|
||||||
|
var saved_state: Dictionary = world.state.duplicate(true)
|
||||||
|
var counters: Dictionary = world.get_item_amounts()
|
||||||
|
check(counters.HP == 7.0 and counters.LP == 5.0 and counters.Planet == 12.0, "Planet counters include bound objects and aggregate eons")
|
||||||
|
check(counters.RD == 2.0, "Star counters retain available quantity")
|
||||||
|
check(world.state == saved_state, "Reading counters does not mutate simulation")
|
||||||
|
check(world.get_item_amounts(true).HP == 0.0, "Produced counters remain independent of current population")
|
||||||
values = description.variables(world, global, "RD", 2.0)
|
values = description.variables(world, global, "RD", 2.0)
|
||||||
check(values.amount.contains("#40cc40") and values.amount.contains("+2.0"), "Increase green")
|
check(values.amount.contains("#40cc40") and values.amount.contains("+2.0"), "Increase green")
|
||||||
var stage_script = load("res://stage/stage.gd")
|
var stage_script = load("res://stage/stage.gd")
|
||||||
|
|||||||
+1
-1
@@ -103,7 +103,7 @@ func run() -> void:
|
|||||||
world.state.life_version = 1
|
world.state.life_version = 1
|
||||||
world.state.amount["C0.0"] = 40000.0
|
world.state.amount["C0.0"] = 40000.0
|
||||||
world.initialize_life_batches()
|
world.initialize_life_batches()
|
||||||
check(world.state.life_version == 2 and world.state.batches["C0.0"][0].created_from == 123.4, "Previous life saves receive missing civilization cohorts")
|
check(world.state.life_version == 3 and world.state.batches["C0.0"][0].created_from == 123.4, "Previous life saves receive missing civilization cohorts")
|
||||||
before = world.state.duplicate(true)
|
before = world.state.duplicate(true)
|
||||||
world.initialize_life_batches()
|
world.initialize_life_batches()
|
||||||
check(world.state == before, "Second life migration is idempotent")
|
check(world.state == before, "Second life migration is idempotent")
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const State = preload("res://world/owniverse/civilization_state.gd")
|
||||||
|
const ETHICS = State.ETHICS
|
||||||
|
const OPPOSITE = {"m": "l", "l": "m", "w": "d", "d": "w", "c": "b", "b": "c"}
|
||||||
|
|
||||||
|
static func total(pop: Dictionary) -> float:
|
||||||
|
var result: float = 0.0
|
||||||
|
for n in pop.values(): result += n
|
||||||
|
return result
|
||||||
|
|
||||||
|
static func add(to: Dictionary, from: Dictionary) -> void:
|
||||||
|
for key in from: to[key] = to.get(key, 0.0) + from[key]
|
||||||
|
|
||||||
|
static func take(world, pop: Dictionary, count: float) -> Dictionary:
|
||||||
|
var taken: Dictionary = world._allocate_whole(pop, count)
|
||||||
|
for key in taken: pop[key] -= taken[key]
|
||||||
|
return taken
|
||||||
|
|
||||||
|
static func rate(target: Dictionary, key: String, count: float, probability: float) -> float:
|
||||||
|
if not target.has("civilization_remainders"): target.civilization_remainders = {}
|
||||||
|
var expected: float = count * maxf(0.0, probability) + target.civilization_remainders.get(key, 0.0)
|
||||||
|
var actual: float = floorf(snappedf(expected, 1e-10))
|
||||||
|
target.civilization_remainders[key] = maxf(0.0, expected - actual)
|
||||||
|
return actual
|
||||||
|
|
||||||
|
# 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: float, weights: Dictionary) -> Dictionary:
|
||||||
|
var result: Dictionary = {}
|
||||||
|
var sum_weights: float = total(weights)
|
||||||
|
if count <= 0.0 or sum_weights <= 0.0: return result
|
||||||
|
if not target.has("civilization_remainders"): target.civilization_remainders = {}
|
||||||
|
var expected: Dictionary = {}
|
||||||
|
var positive_sum: float = 0.0
|
||||||
|
for id in weights:
|
||||||
|
expected[id] = count * weights[id] / sum_weights + target.civilization_remainders.get(key + ":" + id, 0.0)
|
||||||
|
positive_sum += maxf(0.0, expected[id])
|
||||||
|
var order: Array = []
|
||||||
|
var assigned: float = 0.0
|
||||||
|
for id in weights:
|
||||||
|
var normalized: float = maxf(0.0, expected[id]) * count / positive_sum if positive_sum > 0.0 else 0.0
|
||||||
|
result[id] = floorf(normalized)
|
||||||
|
assigned += result[id]
|
||||||
|
order.append({"id": id, "rest": normalized - result[id]})
|
||||||
|
order.sort_custom(func(a, b): return a.rest > b.rest if a.rest != b.rest else str(a.id) < str(b.id))
|
||||||
|
for entry in order:
|
||||||
|
if assigned >= count: break
|
||||||
|
result[entry.id] += 1.0
|
||||||
|
assigned += 1.0
|
||||||
|
for id in weights:
|
||||||
|
target.civilization_remainders[key + ":" + id] = clampf(expected[id] - result[id], -1.0, 1.0)
|
||||||
|
return result
|
||||||
|
|
||||||
|
static func drift(target: Dictionary, key: String, pop: Dictionary, rows: Dictionary,
|
||||||
|
diplomacy: bool = false, colony: bool = false) -> Dictionary:
|
||||||
|
var weights: Dictionary = {}
|
||||||
|
for ethic in pop:
|
||||||
|
var influence: float = float(rows[ethic].diplomacy_rate) if diplomacy else 1.0
|
||||||
|
for other in ETHICS:
|
||||||
|
var weight: float = 9.0 if colony else 1.0
|
||||||
|
if other == ethic: weight = 960.0 if colony else 196.0
|
||||||
|
elif other == OPPOSITE[ethic]: weight = 4.0 if colony else 0.0
|
||||||
|
weights[other] = weights.get(other, 0.0) + pop[ethic] * influence * weight
|
||||||
|
if total(weights) <= 0.0: return pop.duplicate()
|
||||||
|
return split(target, key, total(pop), 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:
|
||||||
|
var weights: Dictionary = {}
|
||||||
|
for term in str(rules[plan.id].ethics[ethic].fall).split("^", false):
|
||||||
|
var pair: PackedStringArray = term.split(":")
|
||||||
|
weights[pair[0]] = float(pair[1])
|
||||||
|
if weights.is_empty(): weights[rules[plan.id].previous] = 1.0
|
||||||
|
var shares: Dictionary = split(target, "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) -> Dictionary:
|
||||||
|
var successor: String = rules[plan.id].next
|
||||||
|
var admitted: float = State.admit_transition(budget, rules, plan.id, successor, total(pop))
|
||||||
|
var accepted: Dictionary = take(world, pop, admitted)
|
||||||
|
emit(plan, successor, accepted)
|
||||||
|
return pop
|
||||||
|
|
||||||
|
static func new_plan(id: String, year: float) -> Dictionary:
|
||||||
|
return {"id": id, "from": year, "to": -INF, "amount": 0.0, "outputs": {}}
|
||||||
|
|
||||||
|
static func consume_untriggered(world, target: Dictionary, plan: Dictionary, pop: Dictionary, count: float, year: float) -> Dictionary:
|
||||||
|
var old_total: float = total(pop)
|
||||||
|
var removed: Dictionary = take(world, pop, count)
|
||||||
|
var actual: float = total(removed)
|
||||||
|
if actual > 0.0:
|
||||||
|
world._scale_batches(target, plan.id, (old_total - actual) / old_total)
|
||||||
|
plan.amount += actual
|
||||||
|
plan.to = maxf(plan.to, year)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
static func prepare(world, target: Dictionary, expired: Dictionary, year: float, budget: Dictionary, rules: Dictionary) -> Dictionary:
|
||||||
|
State.reconcile(world, target, rules)
|
||||||
|
var remaining: Dictionary = target.ethics.duplicate(true)
|
||||||
|
var plans: Dictionary = {}
|
||||||
|
var colonies: Array = []
|
||||||
|
var collateral: Dictionary = {}
|
||||||
|
if not target.has("civilization_stats"): target.civilization_stats = {}
|
||||||
|
var ids: Array = expired.keys()
|
||||||
|
ids.sort()
|
||||||
|
for id in ids:
|
||||||
|
if not rules.has(id) or expired[id].is_empty(): continue
|
||||||
|
var plan: Dictionary = new_plan(id, year)
|
||||||
|
plans[id] = plan
|
||||||
|
for part: Dictionary in expired[id]:
|
||||||
|
plan.amount += part.amount
|
||||||
|
plan.from = minf(plan.from, part.from)
|
||||||
|
plan.to = maxf(plan.to, part.to)
|
||||||
|
var triggered: Dictionary = take(world, remaining[id], plan.amount)
|
||||||
|
var successes: Dictionary = {}
|
||||||
|
var failures: Dictionary = {}
|
||||||
|
var warriors: Dictionary = {}
|
||||||
|
var settlers: Dictionary = {}
|
||||||
|
for ethic in triggered:
|
||||||
|
var row: Dictionary = rules[id].ethics[ethic]
|
||||||
|
var p: float = maxf(0.0, float(row.progress))
|
||||||
|
var d: float = maxf(0.0, float(row.degradation))
|
||||||
|
var scale: float = maxf(1.0, p + d)
|
||||||
|
var pro: float = rate(target, id + ":" + ethic + ":pro", triggered[ethic], p / scale)
|
||||||
|
var deg: float = rate(target, id + ":" + ethic + ":deg", triggered[ethic], d / scale)
|
||||||
|
var bounded: Dictionary = world._allocate_whole({"pro": pro, "deg": deg}, triggered[ethic])
|
||||||
|
if rules[id].fights: warriors[ethic] = rate(target, id + ":" + ethic + ":war", triggered[ethic], float(row.war))
|
||||||
|
if rules[id].colonizes: settlers[ethic] = rate(target, id + ":" + ethic + ":col", triggered[ethic], float(row.colonization))
|
||||||
|
successes[ethic] = bounded.pro
|
||||||
|
failures[ethic] = bounded.deg
|
||||||
|
triggered[ethic] -= bounded.pro + bounded.deg
|
||||||
|
add(triggered, progress(world, target, rules, budget, plan, successes))
|
||||||
|
fall(target, plan, rules, failures)
|
||||||
|
if rules[id].fights:
|
||||||
|
for ethic in warriors: warriors[ethic] = minf(warriors[ethic], triggered[ethic])
|
||||||
|
var wars: float = minf(total(warriors), floorf((total(triggered) + total(remaining[id])) / 2.0))
|
||||||
|
warriors = world._allocate_whole(warriors, wars)
|
||||||
|
for ethic in warriors: triggered[ethic] -= warriors[ethic]
|
||||||
|
var outcomes: Dictionary = split(target, id + ":wars", wars, {"deg": 1.0, "pro25": 2.0, "pro50": 3.0, "pro75": 3.0})
|
||||||
|
var win_expected: float = outcomes.get("pro25", 0.0) * 0.25 + outcomes.get("pro50", 0.0) * 0.5 + outcomes.get("pro75", 0.0) * 0.75
|
||||||
|
var loss_expected: float = outcomes.get("deg", 0.0) * 2.0 + outcomes.get("pro25", 0.0) + outcomes.get("pro50", 0.0) * 1.5 + outcomes.get("pro75", 0.0) * 2.0
|
||||||
|
var winners: Dictionary = take(world, warriors, rate(target, id + ":wins", win_expected, 1.0))
|
||||||
|
var attackers_lost: Dictionary = take(world, warriors, outcomes.get("deg", 0.0))
|
||||||
|
add(triggered, progress(world, target, rules, budget, plan, winners))
|
||||||
|
fall(target, plan, rules, attackers_lost)
|
||||||
|
var losses: float = minf(rate(target, id + ":loss", loss_expected, 1.0), total(triggered) + total(remaining[id]))
|
||||||
|
var sides: Dictionary = world._allocate_whole({"due": total(triggered), "later": total(remaining[id])}, losses)
|
||||||
|
var victims: Dictionary = take(world, triggered, sides.due)
|
||||||
|
add(victims, consume_untriggered(world, target, plan, remaining[id], sides.later, year))
|
||||||
|
fall(target, plan, rules, victims)
|
||||||
|
add(triggered, warriors)
|
||||||
|
var damage: float = outcomes.get("pro50", 0.0) * 10.0 + outcomes.get("pro75", 0.0) * 30.0
|
||||||
|
collateral[rules[id].previous] = collateral.get(rules[id].previous, 0.0) + damage
|
||||||
|
target.civilization_stats.wars = target.civilization_stats.get("wars", 0.0) + wars
|
||||||
|
emit(plan, id, triggered)
|
||||||
|
if total(settlers) > 0.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 plans.has(id): plans[id] = new_plan(id, year)
|
||||||
|
var plan: Dictionary = plans[id]
|
||||||
|
var retry: Dictionary = plan.outputs.get(id, {})
|
||||||
|
var amount: float = minf(collateral[id], total(retry) + total(remaining[id]))
|
||||||
|
var sides: Dictionary = world._allocate_whole({"due": total(retry), "later": total(remaining[id])}, amount)
|
||||||
|
var victims: Dictionary = take(world, retry, sides.due)
|
||||||
|
add(victims, consume_untriggered(world, target, plan, remaining[id], sides.later, year))
|
||||||
|
fall(target, plan, rules, victims)
|
||||||
|
target.civilization_stats.collateral = target.civilization_stats.get("collateral", 0.0) + total(victims)
|
||||||
|
var incoming: Dictionary = {}
|
||||||
|
var planetary: Dictionary = {}
|
||||||
|
for id in plans:
|
||||||
|
var plan: Dictionary = plans[id]
|
||||||
|
var products: Dictionary = {}
|
||||||
|
for destination in plan.outputs:
|
||||||
|
var pop: Dictionary = plan.outputs[destination]
|
||||||
|
if rules.has(destination):
|
||||||
|
if destination != "Ct":
|
||||||
|
pop = drift(target, id + ":" + destination + ":drift", pop, rules[id].ethics, destination == id and rules[id].diplomacy)
|
||||||
|
if not incoming.has(destination): incoming[destination] = {}
|
||||||
|
add(incoming[destination], pop)
|
||||||
|
products[destination] = total(pop)
|
||||||
|
if State.is_space_civilization(id, rules) and world.entities[destination].group_as == "Planet":
|
||||||
|
planetary[destination] = planetary.get(destination, 0.0) + total(pop)
|
||||||
|
expired[id] = [{"amount": plan.amount, "from": plan.from, "to": maxf(plan.from, plan.to), "products": products}]
|
||||||
|
return {"remaining": remaining, "incoming": incoming, "planetary": planetary, "colonies": colonies}
|
||||||
|
|
||||||
|
static func finish(world, target: Dictionary, transaction: Dictionary, year: float, rules: Dictionary) -> void:
|
||||||
|
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._physical_amounts(target)
|
||||||
|
var hosts: Dictionary = {}
|
||||||
|
for id in physical:
|
||||||
|
if world.entities[id].event_group == "DwStar" and physical[id] > 0.0: hosts[id] = physical[id]
|
||||||
|
for id in transaction.planetary:
|
||||||
|
var shares: Dictionary = split(target, "return:" + id, minf(transaction.planetary[id], target.amount.get(id, 0.0)), hosts)
|
||||||
|
for owner in shares:
|
||||||
|
if not target.binds.has(owner): target.binds[owner] = {}
|
||||||
|
target.binds[owner][id] = target.binds[owner].get(id, 0.0) + shares[owner]
|
||||||
|
target.amount[id] -= shares[owner]
|
||||||
|
colonize(world, target, transaction.colonies, year, rules)
|
||||||
|
State.reconcile(world, target, rules)
|
||||||
|
var worship: float = 0.0
|
||||||
|
for id in rules:
|
||||||
|
if not rules[id].worships: continue
|
||||||
|
for ethic in target.ethics[id]: worship += target.ethics[id][ethic] * float(rules[id].ethics[ethic].worship)
|
||||||
|
target.civilization_stats.worship = worship
|
||||||
|
|
||||||
|
static func colonize(world, target: Dictionary, colonies: Array, year: float, rules: Dictionary) -> void:
|
||||||
|
var physical: Dictionary = world._physical_amounts(target)
|
||||||
|
var stock: Dictionary = {}
|
||||||
|
for id in ["GG", "AB", "RP", "HP"]: stock[id] = physical.get(id, 0.0)
|
||||||
|
var requests: Dictionary = {}
|
||||||
|
for i in colonies.size(): requests[str(i)] = total(colonies[i].population)
|
||||||
|
var granted: Dictionary = world._allocate_whole(requests, total(stock))
|
||||||
|
for i in colonies.size():
|
||||||
|
var colony: Dictionary = colonies[i]
|
||||||
|
var population: Dictionary = world._allocate_whole(colony.population, granted[str(i)])
|
||||||
|
population = drift(target, colony.source + ":colonies", population, rules[colony.source].ethics, false, true)
|
||||||
|
var count: float = total(population)
|
||||||
|
var targets: Dictionary = world._allocate_whole(stock, count)
|
||||||
|
for id in targets:
|
||||||
|
var amount: float = targets[id]
|
||||||
|
if amount <= 0.0: continue
|
||||||
|
var slots: Dictionary = {"": target.amount.get(id, 0.0)}
|
||||||
|
for owner in target.binds: slots[owner] = target.binds[owner].get(id, 0.0)
|
||||||
|
var taken: Dictionary = world._allocate_whole(slots, amount)
|
||||||
|
world._scale_batches(target, id, 1.0 - amount / stock[id])
|
||||||
|
stock[id] -= amount
|
||||||
|
for owner in taken:
|
||||||
|
if taken[owner] <= 0.0: continue
|
||||||
|
if owner == "": target.amount[id] -= taken[owner]
|
||||||
|
else: target.binds[owner][id] -= taken[owner]
|
||||||
|
var created: float = world._credit(target, colony.stage, taken[owner])
|
||||||
|
if owner != "":
|
||||||
|
target.amount[colony.stage] -= created
|
||||||
|
target.binds[owner][colony.stage] = target.binds[owner].get(colony.stage, 0.0) + created
|
||||||
|
world._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 = target.civilization_stats.get("colonies", 0.0) + count
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cypckxhb2n2r8
|
||||||
@@ -59,7 +59,7 @@ static func stellar_budget(world, target: Dictionary, rules: Dictionary) -> Dict
|
|||||||
var free: Dictionary = {}
|
var free: Dictionary = {}
|
||||||
for home in capacity:
|
for home in capacity:
|
||||||
free[home] = maxf(0.0, capacity[home] - requested.get(home, 0.0))
|
free[home] = maxf(0.0, capacity[home] - requested.get(home, 0.0))
|
||||||
return {"capacity": capacity, "requested": requested, "free": free, "populations": populations}
|
return {"capacity": capacity, "requested": requested, "free": free, "populations": populations, "habitats": habitat_budget(world, target, rules)}
|
||||||
|
|
||||||
# Account for the source's existing reservation when changing stages. A failed
|
# Account for the source's existing reservation when changing stages. A failed
|
||||||
# transition retains its population at the source, rather than losing objects.
|
# transition retains its population at the source, rather than losing objects.
|
||||||
@@ -81,6 +81,17 @@ static func admit_transition(budget: Dictionary, rules: Dictionary, source: Stri
|
|||||||
var admitted: float = floorf(maxf(0.0, requested))
|
var admitted: float = floorf(maxf(0.0, requested))
|
||||||
if net_cost > 0.0:
|
if net_cost > 0.0:
|
||||||
admitted = minf(admitted, floorf(budget.free.get(home, 0.0) / net_cost))
|
admitted = minf(admitted, floorf(budget.free.get(home, 0.0) / net_cost))
|
||||||
|
if budget.has("habitats"):
|
||||||
|
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_units: float = new_req.units - (old_req.units if old_req.get("id", "") == new_req.id else 0.0)
|
||||||
|
if net_units > 0.0:
|
||||||
|
var free_units: float = maxf(0.0, housing.capacity.get(new_req.id, 0.0) - housing.requested.get(new_req.id, 0.0))
|
||||||
|
admitted = minf(admitted, floorf(free_units / net_units))
|
||||||
|
if not old_req.is_empty(): housing.requested[old_req.id] = maxf(0.0, housing.requested.get(old_req.id, 0.0) - admitted * old_req.units)
|
||||||
|
if not new_req.is_empty(): housing.requested[new_req.id] = housing.requested.get(new_req.id, 0.0) + admitted * new_req.units
|
||||||
if old_home != "":
|
if old_home != "":
|
||||||
budget.requested[old_home] = maxf(0.0, budget.requested.get(old_home, 0.0) - admitted * old_cost)
|
budget.requested[old_home] = maxf(0.0, budget.requested.get(old_home, 0.0) - admitted * old_cost)
|
||||||
budget.free[old_home] = maxf(0.0, budget.capacity.get(old_home, 0.0) - budget.requested[old_home])
|
budget.free[old_home] = maxf(0.0, budget.capacity.get(old_home, 0.0) - budget.requested[old_home])
|
||||||
@@ -95,6 +106,7 @@ static func enforce_capacity(world, target: Dictionary, rules: Dictionary, year:
|
|||||||
reconcile(world, target, rules)
|
reconcile(world, target, rules)
|
||||||
var budget: Dictionary = stellar_budget(world, target, rules)
|
var budget: Dictionary = stellar_budget(world, target, rules)
|
||||||
var remaining: Dictionary = budget.capacity.duplicate()
|
var remaining: Dictionary = budget.capacity.duplicate()
|
||||||
|
var remaining_homes: Dictionary = budget.habitats.capacity.duplicate()
|
||||||
var stages: Array = []
|
var stages: Array = []
|
||||||
for id in rules:
|
for id in rules:
|
||||||
if is_space_civilization(id, rules):
|
if is_space_civilization(id, rules):
|
||||||
@@ -106,6 +118,10 @@ static func enforce_capacity(world, target: Dictionary, rules: Dictionary, year:
|
|||||||
var cost: float = maxf(1.0, rules[id].stars)
|
var cost: float = maxf(1.0, rules[id].stars)
|
||||||
var count: float = target.amount.get(id, 0.0)
|
var count: float = target.amount.get(id, 0.0)
|
||||||
var supported: float = minf(count, floorf(remaining.get(home, 0.0) / cost))
|
var supported: float = minf(count, floorf(remaining.get(home, 0.0) / cost))
|
||||||
|
var requirement: Dictionary = budget.habitats.requirements.get(id, {})
|
||||||
|
if not requirement.is_empty():
|
||||||
|
supported = minf(supported, floorf(remaining_homes.get(requirement.id, 0.0) / requirement.units))
|
||||||
|
remaining_homes[requirement.id] = maxf(0.0, remaining_homes.get(requirement.id, 0.0) - supported * requirement.units)
|
||||||
remaining[home] = maxf(0.0, remaining.get(home, 0.0) - supported * cost)
|
remaining[home] = maxf(0.0, remaining.get(home, 0.0) - supported * cost)
|
||||||
var excess: float = count - supported
|
var excess: float = count - supported
|
||||||
if excess <= 0.0:
|
if excess <= 0.0:
|
||||||
@@ -141,3 +157,79 @@ static func enforce_capacity(world, target: Dictionary, rules: Dictionary, year:
|
|||||||
target.binds[owner][previous] = target.binds[owner].get(previous, 0.0) + amount
|
target.binds[owner][previous] = target.binds[owner].get(previous, 0.0) + amount
|
||||||
target.amount[previous] -= amount
|
target.amount[previous] -= amount
|
||||||
reconcile(world, target, rules)
|
reconcile(world, target, rules)
|
||||||
|
assign_homes(world, target, rules)
|
||||||
|
|
||||||
|
# Unity's structure chain: direct Claim resets the required structure count;
|
||||||
|
# intervening stages require ten times more units of the preceding structure.
|
||||||
|
static func habitat_budget(world, target: Dictionary, rules: Dictionary) -> Dictionary:
|
||||||
|
var physical: Dictionary = world._physical_amounts(target)
|
||||||
|
var structures: Array = []
|
||||||
|
var capacity: Dictionary = {}
|
||||||
|
for id in world.entities:
|
||||||
|
if world.entities[id].claim != "":
|
||||||
|
structures.append(id)
|
||||||
|
capacity[id] = physical.get(id, 0.0)
|
||||||
|
structures.sort_custom(func(a, b):
|
||||||
|
var left: float = world.entities[a].civ_bonus
|
||||||
|
var right: float = world.entities[b].civ_bonus
|
||||||
|
return left > right if left != right else str(a) < str(b))
|
||||||
|
var stages: Array = []
|
||||||
|
for id in rules:
|
||||||
|
if is_space_civilization(id, rules): stages.append(id)
|
||||||
|
stages.sort()
|
||||||
|
var requirements: Dictionary = {}
|
||||||
|
var anchors: Dictionary = {}
|
||||||
|
var anchor: String = ""
|
||||||
|
var units: float = 1.0
|
||||||
|
for id in stages:
|
||||||
|
var direct: String = ""
|
||||||
|
for structure in structures:
|
||||||
|
if world.entities[structure].claim == id: direct = structure
|
||||||
|
if direct != "":
|
||||||
|
anchor = direct
|
||||||
|
units = 1.0
|
||||||
|
else:
|
||||||
|
units *= 10.0
|
||||||
|
anchors[id] = anchor
|
||||||
|
if id.begins_with("C3.") or id.begins_with("C4."):
|
||||||
|
requirements[id] = {"id": anchor, "units": units}
|
||||||
|
var requested: Dictionary = {}
|
||||||
|
for id in requirements:
|
||||||
|
var req: Dictionary = requirements[id]
|
||||||
|
requested[req.id] = requested.get(req.id, 0.0) + physical.get(id, 0.0) * req.units
|
||||||
|
return {"capacity": capacity, "requested": requested, "requirements": requirements, "structures": structures, "anchors": anchors}
|
||||||
|
|
||||||
|
static func assign_homes(world, target: Dictionary, rules: Dictionary) -> void:
|
||||||
|
var budget: Dictionary = habitat_budget(world, target, rules)
|
||||||
|
var used: Dictionary = {}
|
||||||
|
target.civilization_homes = {}
|
||||||
|
var stages: Array = budget.anchors.keys()
|
||||||
|
stages.sort()
|
||||||
|
stages.reverse()
|
||||||
|
for id in stages:
|
||||||
|
var left: float = target.amount.get(id, 0.0)
|
||||||
|
var homes: Dictionary = {}
|
||||||
|
var allowed: bool = budget.anchors[id] == ""
|
||||||
|
for structure in budget.structures:
|
||||||
|
if structure == budget.anchors[id]: allowed = true
|
||||||
|
if not allowed or budget.capacity[structure] <= 0.0: continue
|
||||||
|
var amount: float = left
|
||||||
|
if world.entities[structure].claim == id:
|
||||||
|
amount = minf(amount, maxf(0.0, budget.capacity[structure] - used.get(structure, 0.0)))
|
||||||
|
used[structure] = used.get(structure, 0.0) + amount
|
||||||
|
if amount > 0.0: homes[structure] = amount
|
||||||
|
left -= amount
|
||||||
|
if left <= 0.0: break
|
||||||
|
target.civilization_homes[id] = homes
|
||||||
|
|
||||||
|
static func effective_span(world, target: Dictionary, id: String, rules: Dictionary) -> float:
|
||||||
|
if not rules.has(id): return world.entities[id].lifespan
|
||||||
|
var base: float = rules[id].span
|
||||||
|
if not is_space_civilization(id, rules): return base
|
||||||
|
assign_homes(world, target, rules)
|
||||||
|
var population: float = target.amount.get(id, 0.0)
|
||||||
|
if population <= 0.0: return base
|
||||||
|
var weighted: float = population
|
||||||
|
for home in target.civilization_homes.get(id, {}):
|
||||||
|
weighted += target.civilization_homes[id][home] * (maxf(0.0, world.entities[home].civ_bonus) - 1.0)
|
||||||
|
return base * population / weighted if weighted > 0.0 else base
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
extends Node
|
extends Node
|
||||||
class_name Owniverse
|
class_name Owniverse
|
||||||
|
const CivilizationEvolution = preload("res://world/owniverse/civilization_evolution.gd")
|
||||||
const CivilizationState = preload("res://world/owniverse/civilization_state.gd")
|
const CivilizationState = preload("res://world/owniverse/civilization_state.gd")
|
||||||
const SimulationTime = preload("res://world/owniverse/simulation_time.gd")
|
const SimulationTime = preload("res://world/owniverse/simulation_time.gd")
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ func migrate_time_precision() -> void:
|
|||||||
func initialize_life_batches() -> void:
|
func initialize_life_batches() -> void:
|
||||||
CivilizationState.detach_space_civilizations(state, Global.CivilizationRules)
|
CivilizationState.detach_space_civilizations(state, Global.CivilizationRules)
|
||||||
CivilizationState.enforce_capacity(self, state, Global.CivilizationRules, state.time.datetime)
|
CivilizationState.enforce_capacity(self, state, Global.CivilizationRules, state.time.datetime)
|
||||||
if state.get("life_version", 0) >= 2:
|
if state.get("life_version", 0) >= 3:
|
||||||
return
|
return
|
||||||
var physical: Dictionary = _physical_amounts(state)
|
var physical: Dictionary = _physical_amounts(state)
|
||||||
for id in entities:
|
for id in entities:
|
||||||
@@ -77,7 +78,7 @@ func initialize_life_batches() -> void:
|
|||||||
for batch: Dictionary in state.batches.get(id, []):
|
for batch: Dictionary in state.batches.get(id, []):
|
||||||
tracked += batch.amount
|
tracked += batch.amount
|
||||||
_append_batch(state, id, maxf(0.0, physical.get(id, 0.0) - tracked), state.time.datetime, state.time.datetime)
|
_append_batch(state, id, maxf(0.0, physical.get(id, 0.0) - tracked), state.time.datetime, state.time.datetime)
|
||||||
state.life_version = 2
|
state.life_version = 3
|
||||||
new_state = state
|
new_state = state
|
||||||
|
|
||||||
|
|
||||||
@@ -111,6 +112,15 @@ func _evolute_to_datetime(datetime: float) -> void:
|
|||||||
## Aggregate only for presentation; never write aliases back into simulation state.
|
## Aggregate only for presentation; never write aliases back into simulation state.
|
||||||
func get_item_amounts(total_produced: bool = false) -> Dictionary[String, float]:
|
func get_item_amounts(total_produced: bool = false) -> Dictionary[String, float]:
|
||||||
var source: Dictionary = state.stats.total_amount if total_produced else state.amount
|
var source: Dictionary = state.stats.total_amount if total_produced else state.amount
|
||||||
|
if not total_produced:
|
||||||
|
source = source.duplicate()
|
||||||
|
var physical: Dictionary = _physical_amounts(state)
|
||||||
|
var planets: float = 0.0
|
||||||
|
for id in physical:
|
||||||
|
if entities[id].group_as == "Planet":
|
||||||
|
source[id] = physical[id]
|
||||||
|
planets += physical[id]
|
||||||
|
source["Planet"] = planets
|
||||||
var result: Dictionary[String, float] = {}
|
var result: Dictionary[String, float] = {}
|
||||||
for id in source:
|
for id in source:
|
||||||
result[id] = 0.0
|
result[id] = 0.0
|
||||||
@@ -317,8 +327,8 @@ func _append_batch(target: Dictionary, id: String, amount: float,
|
|||||||
if not target.has("batch_origins"):
|
if not target.has("batch_origins"):
|
||||||
target.batch_origins = {}
|
target.batch_origins = {}
|
||||||
var batches: Array = target.batches[id]
|
var batches: Array = target.batches[id]
|
||||||
var lifespan: float = entities[id].lifespan
|
var lifespan: float = CivilizationState.effective_span(self, target, id, Global.CivilizationRules)
|
||||||
var batch_duration: float = entities[id].lifespan_delta
|
var batch_duration: float = lifespan / Global.batch_count if lifespan > 10.0 else lifespan
|
||||||
if batch_duration <= 0.0:
|
if batch_duration <= 0.0:
|
||||||
return
|
return
|
||||||
if not target.batch_origins.has(id):
|
if not target.batch_origins.has(id):
|
||||||
@@ -344,7 +354,7 @@ func _append_batch(target: Dictionary, id: String, amount: float,
|
|||||||
part_amount = floorf(amount * (part_end - created_from) / duration) - (amount - remaining_amount)
|
part_amount = floorf(amount * (part_end - created_from) / duration) - (amount - remaining_amount)
|
||||||
var batch: Dictionary = {}
|
var batch: Dictionary = {}
|
||||||
for existing: Dictionary in batches:
|
for existing: Dictionary in batches:
|
||||||
if existing.get("created_from", -INF) == window_start:
|
if existing.get("created_from", -INF) == window_start and existing.start_year == window_start + lifespan and existing.end_year == window_end + lifespan:
|
||||||
batch = existing
|
batch = existing
|
||||||
break
|
break
|
||||||
if batch.is_empty():
|
if batch.is_empty():
|
||||||
@@ -637,6 +647,7 @@ func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool:
|
|||||||
var fractions: Dictionary = {}
|
var fractions: Dictionary = {}
|
||||||
var outputs: Dictionary = {}
|
var outputs: Dictionary = {}
|
||||||
var star_budget: Dictionary = CivilizationState.stellar_budget(self, target, Global.CivilizationRules)
|
var star_budget: Dictionary = CivilizationState.stellar_budget(self, target, Global.CivilizationRules)
|
||||||
|
var civilization_transaction: Dictionary = CivilizationEvolution.prepare(self, target, expired, to_year, star_budget, Global.CivilizationRules)
|
||||||
for id in expired:
|
for id in expired:
|
||||||
var total: float = 0.0
|
var total: float = 0.0
|
||||||
for part: Dictionary in expired[id]:
|
for part: Dictionary in expired[id]:
|
||||||
@@ -644,8 +655,11 @@ func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool:
|
|||||||
fractions[id] = clampf(total / quantities[id], 0.0, 1.0) if quantities.get(id, 0.0) > 0.0 else 0.0
|
fractions[id] = clampf(total / quantities[id], 0.0, 1.0) if quantities.get(id, 0.0) > 0.0 else 0.0
|
||||||
var combined: Dictionary = {}
|
var combined: Dictionary = {}
|
||||||
for part: Dictionary in expired[id]:
|
for part: Dictionary in expired[id]:
|
||||||
part.products = _evolution_products(target, entities[id], part.amount)
|
if not part.has("products"):
|
||||||
|
part.products = _evolution_products(target, entities[id], part.amount)
|
||||||
for successor in part.products.keys():
|
for successor in part.products.keys():
|
||||||
|
if Global.CivilizationRules.has(id):
|
||||||
|
continue
|
||||||
var requested: float = part.products[successor]
|
var requested: float = part.products[successor]
|
||||||
var admitted: float = CivilizationState.admit_transition(star_budget, Global.CivilizationRules, id, successor, requested)
|
var admitted: float = CivilizationState.admit_transition(star_budget, Global.CivilizationRules, id, successor, requested)
|
||||||
part.products[successor] = admitted
|
part.products[successor] = admitted
|
||||||
@@ -719,6 +733,7 @@ func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool:
|
|||||||
target.amount[id] = maxf(0.0, target.amount.get(id, 0.0) - transferred)
|
target.amount[id] = maxf(0.0, target.amount.get(id, 0.0) - transferred)
|
||||||
_bind_new_owners(target, new_owners)
|
_bind_new_owners(target, new_owners)
|
||||||
_round_object_allocations(target)
|
_round_object_allocations(target)
|
||||||
|
CivilizationEvolution.finish(self, target, civilization_transaction, to_year, Global.CivilizationRules)
|
||||||
_apply_blast(target, blast_power, environment)
|
_apply_blast(target, blast_power, environment)
|
||||||
CivilizationState.enforce_capacity(self, target, Global.CivilizationRules, to_year)
|
CivilizationState.enforce_capacity(self, target, Global.CivilizationRules, to_year)
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ func _init(values: Dictionary) -> void:
|
|||||||
mass = float(values.mass)
|
mass = float(values.mass)
|
||||||
space = int(values.space)
|
space = int(values.space)
|
||||||
life_rule = Global.LifeRules.get(id, {}).duplicate(true)
|
life_rule = Global.LifeRules.get(id, {}).duplicate(true)
|
||||||
|
if Global.CivilizationRules.has(id):
|
||||||
|
life_rule = {"ethical": true, "span": Global.CivilizationRules[id].span}
|
||||||
lifespan = float(life_rule.span) if not life_rule.is_empty() else float(values.lifespan)
|
lifespan = float(life_rule.span) if not life_rule.is_empty() else float(values.lifespan)
|
||||||
lifespan_delta = lifespan / Global.batch_count if lifespan > 10 else lifespan
|
lifespan_delta = lifespan / Global.batch_count if lifespan > 10 else lifespan
|
||||||
auto = float(values.auto)
|
auto = float(values.auto)
|
||||||
|
|||||||
Reference in New Issue
Block a user