diff --git a/autoload/save_manager.gd b/autoload/save_manager.gd index b502ebe..d5c805a 100644 --- a/autoload/save_manager.gd +++ b/autoload/save_manager.gd @@ -7,6 +7,7 @@ const SAVE_PATH = "user://settings.cfg" const FORMAT_VERSION: int = 2 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 var last_error: String = "" var blocked_saves: Dictionary = {} @@ -229,8 +230,25 @@ static func _validate_payload(data: Variant, speed_limit: int, stages: Array) -> "planet_remainders", "blast_remainders", "destruction_remainders", "civilization_remainders", "civilization_stats", "civilization_homes", "ethics", "stats", ] - if saved.size() != required.size(): - return "unsupported state structure" + # Optional additions to the current format. Saves predating indicators keep + # their quantities and receive neutral defaults from Owniverse on load. + for field in saved: + if field not in required and field not in ["world_parameters", "trends"]: + return "unsupported state structure" + if saved.has("world_parameters"): + if not _number_map(saved.world_parameters) or saved.world_parameters.size() != WorldParameters.defaults().size(): + return "invalid state.world_parameters" + for key in WorldParameters.defaults(): + if not saved.world_parameters.has(key) or saved.world_parameters[key] < 1.0: + return "invalid world parameter " + key + if key in ["Bur", "Exp", "Agg"] and saved.world_parameters[key] > 19.0: + return "invalid world scale " + key + if saved.has("trends"): + if not _number_map(saved.trends) or saved.trends.size() != WorldParameters.TRENDS.size(): + return "invalid state.trends" + for key in WorldParameters.TRENDS: + if not saved.trends.has(key) or saved.trends[key] >= 1.0: + return "invalid trend " + key for field in required: if not saved.has(field): return "missing state." + field diff --git a/simulation_rules.md b/simulation_rules.md index a40f3da..97cba5b 100644 --- a/simulation_rules.md +++ b/simulation_rules.md @@ -72,7 +72,7 @@ GI не хранит накопленные счётчики первичной - `density = 1.23456e-7 * sqrt(B + M) / max(1, V)^(1/6)`. - `blast_factor = clamp(free_S / V, 0, 1)`; при нулевом объёме `1`. -Система сил Unity и планетные бонусы в GI пока не перенесены; здесь используются нейтральные множители 1. Металличность для производства фиксируется до расходования ресурсов на звёзды. Среда бласта фиксируется для поколения до добавления его продуктов. +Планетные бонусы сил пока не перенесены. Параметры мира Bur/Exp/Agg имеют собственные сохраняемые шкалы и эффективные множители (см. раздел индикаторов ниже). Металличность для производства фиксируется до расходования ресурсов на звёзды. Среда бласта фиксируется для поколения до добавления его продуктов. ## Разрушение и бласты @@ -102,7 +102,7 @@ GI не хранит накопленные счётчики первичной Прогресс и деградация округляются вниз с сохранением остатков. Если оба накопленных исхода конкурируют за последнюю планету, распределяются только существующие объекты, а неиспользованный остаток переносится на следующую попытку. Деградация распределяется по весам `fall`; знаковые ошибки распределения также сохраняются. -Для всех шести стадий базовые доли за попытку — `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. Для существующих этических цивилизаций пользователь выбрал равномерное распределение между шестью этиками с детерминированным целым остатком. @@ -144,4 +144,56 @@ HP деградирует в RP. У эонов возможен откат к п Размещение выбирает структуры в порядке убывания `civ_bonus`, начиная с соответствующего стадии `claim`. Прямое размещение ограничено числом структур; остальные разрешённые структуры при наличии дают общий бонус, как в Unity. Период новых партий равен `span / средневзвешенный civ_bonus`, для неразмещённой части множитель равен 1. Уже назначенные сроки партий не переписываются при изменении бонуса. -`life_version = 3` добавляет часы существующим C0.2+ с момента загрузки; старые часы HP, эонов и начальных цивилизаций сохраняются. Ct не получает часы. Миграция выполняется однократно. Глобальные силы и их модификаторы не входят в этот перенос: используются нейтральные множители 1. +Ct не получает часы. Инициализация отсутствующих партий жизни выполняется при загрузке и перед продвижением симуляции. Параметры мира и тенденции описаны ниже; интерфейс применения сил в этот перенос не входит. + +## Параметры мира, тенденции и индикаторы + +Источник формул — Unity `OwniverseInstance`, `Life`, `LifeActions`, `Trends` и классы индикаторов. Расчёты находятся в `world_parameters.gd`; показ — в `ui/indicators/world_indicators.gd`. Шкалы обновляются каждые 0,1 реальной секунды и сразу при создании/загрузке вселенной. Подписи используют текущую локаль и шаблоны. + +### Энергия и плотность + +Энергия сохраняет экономику GI: запас расходуется на H/S. Заполнение индикатора — `clamp(energy / 1e14, 0, 1)`. Это не коэффициент производства Unity; дополнительное замедление производства при уменьшении запаса не вводится. В описании выводятся остаток и процент. + +Плотность берётся из `stellar_environment`. Положение указателя ограничено диапазоном 0…1: + +- `density ≤ 1e-5`: шкала разрыва, `(log10(max(density, 1e-7)) + 7) / 2`; +- `1e-5 < density < 0.9`: обычная шкала, `ln(density × 1e5) / ln(9e4)`; +- `density ≥ 0.9`: шкала сжатия, `(density − 0.9) × 10`. + +В Unity в первой формуле было `−7`; это ошибка, не позволяющая указателю двигаться. Здесь исправлено на `+7`. Переключение шкалы меняет и заголовок/описание индикатора. Само наступление апокалипсиса индикатор не запускает. + +### Горючесть, взрывчатость и агрессия + +`world_parameters` хранит Bur, Exp и Agg в диапазоне 1…19, начальное значение — 10. Эффективный множитель `M(x) = 10^((x−10)/9)`: крайние значения 0,1 и 10, нейтральное — 1. + +- Bur делит срок жизни новых звёзд на `M(Bur)`. `modify_world_parameter("Bur", delta)` также пересчитывает оставшееся время существующих звёзд с коэффициентом `старый M / новый M`, сохраняя количество и остатки округления. Производство звёзд умножается на `M`, если `M ≤ 1`, иначе на `sqrt(M)`. +- Exp умножает вероятность альтернативного исхода (с ограничением до 1), мощность `entity.blast` и производство событий группы SNGRBB. Локальный `destroy_level` от него не меняется. +- Для Agg обозначим `a = M(Agg)`. Вероятность прогресса умножается на `a`, деградации — на `(1 − (1−a)/20) / Order`, войны — на `1 − (1−a)/5`, колонизации — на `1 − (1−a)/2`. Если сумма прогресса и деградации превышает 1, они пропорционально нормируются. Вероятности войны/колонизации ограничены единицей. +- Для первичной жизни дополнительный множитель прогресса — Enlightenment. Order дополнительно уменьшает потери в войнах и побочный ущерб. Начальные Enlightenment и Order равны 1. + +Изменять шкалы следует через `owniverse.modify_world_parameter(key, delta)`, чтобы горючесть корректно обновляла партии. Готовых кнопок применения сил этот метод не добавляет. + +### Тенденции + +Пять направлений: Antitheism, Integration, Xenocide, Reconstruction, Spirituality. Участвуют космические стадии с непустым `home`; вес первой — 1, каждой следующей — вдвое больше. Для каждой тенденции: + +`rate = Σ_стадий (вес стадии × Σ_этик(доля этики × вес этики))` + +`прирост = rate × прошедшие игровые годы × 1e-9`. + +Доли берутся на начало интервала, после проверки вместимости. Умножение на время исправляет ошибку Unity, где аргумент period не использовался. Накопление само по себе не зависит от FPS; изменение состава цивилизаций внутри крупных шагов остаётся дискретным, как остальная симуляция. + +Веса этик в порядке пяти направлений: + +| Этика | Антитеизм | Интеграция | Ксеноцид | Реконструкция | Духовность | +|---|---:|---:|---:|---:|---:| +| m | 0 | 2 | 2 | 0 | 6 | +| l | 6 | 1 | 1 | 2 | 0 | +| w | 2 | 0 | 6 | 0 | 2 | +| d | 0 | 6 | 0 | 1 | 3 | +| c | 1 | 3 | 3 | 3 | 0 | +| b | 3 | 0 | 0 | 6 | 1 | + +При достижении 1 учитываются все целые срабатывания, дробный остаток сохраняется. Только Integration имеет последствия в исходной Unity: равновероятно выбирается Federation (+0,1 и Agg −1), Enlightenment (+0,2 и Agg −1) или Order (+0,1). Генератор сохраняет своё состояние. До 256 событий за шаг выбор выполняется по одному; для больших количеств используется нормальная аппроксимация мультиномиального распределения с сохранением точного суммарного числа событий, чтобы высокие скорости не блокировали кадр. Она может менять последовательность исходов при другом разбиении больших интервалов. + +Federation сохраняется и увеличивается как в Unity, но в исходном коде не применяется к дипломатии. Дополнительное правило для него не придумано. Остальные четыре тенденции имеют счётчики срабатываний и сброс, но не последствия — так же, как в исходной реализации. Параметры и остатки тенденций сохраняются; у существующего сохранения текущего формата без этих полей используются нейтральные значения и нулевые тенденции, без изменения ресурсов или возраста. diff --git a/stage/stage.gd b/stage/stage.gd index 1f0e8c4..64e3875 100644 --- a/stage/stage.gd +++ b/stage/stage.gd @@ -5,6 +5,9 @@ const OWNIVERSE_ITEM = preload("uid://d3xjfxwe3w16v") const SPEED_TEXTURE_NAME_TEMPLATE = "res://ui/topmenu/time&speed/time%s.png" const ItemDescription = preload("res://world/owniverse/item_description.gd") +const WorldIndicators = preload("res://ui/indicators/world_indicators.gd") +var world_indicators: RefCounted +var indicator_elapsed: float = 0.0 var description_targets: Dictionary = {} var description_samples: Dictionary = {} var description_rates: Dictionary = {} @@ -317,15 +320,10 @@ func _page_action_pressed(page_id: int, action_amount: float) -> void: func _ready() -> void: get_tree().auto_accept_quit = false - var inited_result = init_enitities() - locked_items = inited_result.locked_items + world_indicators = WorldIndicators.new(self) + create_new_universe() load_data() - _init_owniverse_items() - owniverse.refresh_item_amounts() - _change_speed(0) - set_datetime(owniverse.state.time.year) - set_owniverse_item_value() - _unlock_items() + _refresh_universe_ui() g_counters.swipe.sec = 0.2 @@ -333,10 +331,57 @@ func _ready() -> void: page_actions[pa].action_pressed.connect(_page_action_pressed) page_actions[pa].page_id = pa + +## Starts a fresh universe. Can be connected directly to a button's pressed signal. +## Saving continues through the normal autosave/close path. +func create_new_universe() -> void: + var previous: Owniverse = owniverse + owniverse = Owniverse.new() + var inited_result: Dictionary = init_enitities() + locked_items = inited_result.locked_items + owniverse.new_state = owniverse.state + for entity in previous.entities.values(): + entity.free() + previous.free() + + if is_instance_valid(g_tween): + g_tween.kill() + g_active_items.clear() + description_targets.clear() + description_samples.clear() + description_rates.clear() + description_elapsed = 0.0 + for label in g_item_descriptions: + label.text = "" + for item in owniverse_items.values(): + item.get_parent().remove_child(item) + item.queue_free() + _init_owniverse_items() + for page in range(5, item_pages.size()): + item_pages[page].visible = false + for actions in page_actions.values(): + actions.set_actions("", 0.0) + pending_page = RESOURCE_PAGE + _restore_page.call_deferred() + _refresh_universe_ui() + _on_topmenu_button_pressed("World") + + +func _refresh_universe_ui() -> void: + owniverse.refresh_item_amounts() + _change_speed(0) + set_datetime(owniverse.state.time.year) + set_owniverse_item_value() + _unlock_items() + if world_indicators != null: world_indicators.update(owniverse) func _process(delta: float) -> void: owniverse.evolute(delta) + indicator_elapsed += delta + if indicator_elapsed >= 0.1: + indicator_elapsed = 0.0 + world_indicators.update(owniverse) _update_descriptions(delta) set_owniverse_item_value() set_datetime(owniverse.state.time.year) diff --git a/tests/README.md b/tests/README.md index f718c45..0384678 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,5 +1,13 @@ # Проверки поэтапного переноса из Owniverse.U +## Параметры мира и индикаторы + +```sh +godot --headless --path Owniverse.GI --script res://tests/test_world_indicators.gd +``` + +Проверяются сроки жизни существующих и новых звёзд при смене горючести, вероятности и мощность взрывов, влияние агрессии/порядка/просвещения на жизнь, накопление тенденций по времени и этикам, несколько событий за шаг, продолжение после сериализации, валидация сохранения, границы шкал и описания на двух языках. Сцена использует фикстуру без доступа к сохранению игрока. + ## Прямой расчёт завершившихся сверхновых ```sh diff --git a/tests/test_world_indicators.gd b/tests/test_world_indicators.gd new file mode 100644 index 0000000..7287641 --- /dev/null +++ b/tests/test_world_indicators.gd @@ -0,0 +1,171 @@ +extends SceneTree + +const Parameters = preload("res://world/owniverse/world_parameters.gd") +const Descriptions = preload("res://world/owniverse/item_description.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() + for id in root.get_node("Global").OwniverseEntities: + var entity = load("res://world/owniverse/owniverse_entity.gd").new(root.get_node("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 + return world + +func dispose(world) -> void: + for entity in world.entities.values(): entity.free() + world.free() + +func run() -> void: + var global: Node = root.get_node("Global") + var world = fixture() + world.state.amount.RD = 400.0 + world._append_batch(world.state, "RD", 400.0, 0.0, 0.0) + var end: float = world.state.batches.RD[0].end_year + world.modify_world_parameter("Bur", 9.0) + check(is_equal_approx(world.state.batches.RD[0].end_year, end / 10.0), "Burnability accelerates existing cohorts") + var count: float = world.state.batches.RD[0].amount + world.modify_world_parameter("Bur", -9.0) + check(is_equal_approx(world.state.batches.RD[0].end_year, end) and world.state.batches.RD[0].amount == count, "Changing burnability back preserves population and lifetime") + world.modify_world_parameter("Bur", 9.0) + world._append_batch(world.state, "YD", 10.0, 0.0, 0.0) + check(is_equal_approx(world.state.batches.YD[0].start_year, world.entities.YD.lifespan / 10.0), "New stars use the same burnability") + world.state.time = {"datetime": end / 10.0 - 0.1, "year": end / 10.0 - 0.1} + world._withdraw_batches(world.state, "RD", world.state.time.year) + world.modify_world_parameter("Bur", -18.0) + check(world.state.batches.RD[0].end_year >= world.state.time.year, "Slowing a partially evolved cohort keeps its remaining window in the future") + world.modify_world_parameter("Exp", 9.0) + check(world._evolution_products(world.state, world.entities.SN, 2.0).blast == 180.0, "Explosion power includes world explosiveness") + var products: Dictionary = world._evolution_products(world.state, world.entities.HRG, 1000.0) + check(products.get("GRB", 0.0) == 100.0 and products.get("SN", 0.0) == 900.0, "Explosiveness raises rare-event probability without duplicating stars") + world.entities.HRG.output_or.chance = 50.0 + products = world._evolution_products(world.state, world.entities.HRG, 100.0) + check(products.get("GRB", 0.0) == 100.0 and products.get("SN", 0.0) == 0.0, "Rare-event probability is bounded at one") + dispose(world) + + world = fixture() + var ordinary: Dictionary = world._life_products(world.state, world.entities.HP, 1000000.0) + world.state.life_remainders.clear() + world.modify_world_parameter("Agg", 9.0) + var aggressive: Dictionary = world._life_products(world.state, world.entities.HP, 1000000.0) + check(aggressive.EON1 > ordinary.EON1, "Aggression accelerates primitive evolution") + world.state.life_remainders.clear() + world.state.world_parameters.Order = 10.0 + var orderly: Dictionary = world._life_products(world.state, world.entities.HP, 1000000.0) + check(orderly.RP < aggressive.RP, "Order reduces degradation") + world.state.life_remainders.clear() + world.state.world_parameters.Enlightenment = 10.0 + var enlightened: Dictionary = world._life_products(world.state, world.entities.HP, 1000000.0) + check(enlightened.EON1 > orderly.EON1, "Enlightenment increases primitive progress") + dispose(world) + + world = fixture() + var hotter = fixture() + for simulation in [world, hotter]: + simulation.state.amount.RD = 1000000.0 + simulation._append_batch(simulation.state, "RD", 1000000.0, 0.0, 0.0) + hotter.modify_world_parameter("Bur", 9.0) + world.evolute(1000.0) + hotter.evolute(1000.0) + check(hotter.state.amount.S > world.state.amount.S, "Burnability changes actual space production") + check(hotter.state.energy + hotter.state.amount.S == Parameters.INITIAL_ENERGY, "Enhanced production still spends the GI energy budget") + dispose(world) + dispose(hotter) + + world = fixture() + world.state.amount["C0.2"] = 1000000.0 + world.state.ethics["C0.2"] = {"m": 1000000.0} + var baseline: Dictionary = world.state.duplicate(true) + var advanced: Dictionary = baseline.duplicate(true) + advanced.world_parameters.Agg = 19.0 + var next_stage: String = global.CivilizationRules["C0.2"].next + var outcomes: Array = [] + for target in [baseline, advanced]: + var expired: Dictionary = {"C0.2": [{"amount": 1000000.0, "from": 0.0, "to": 1.0}]} + var budget: Dictionary = world.CivilizationState.stellar_budget(world, target, global.CivilizationRules) + world.CivilizationEvolution.prepare(world, target, expired, 1.0, budget, global.CivilizationRules) + outcomes.append(expired["C0.2"][0].products.get(next_stage, 0.0)) + check(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.0}, "C2.1": {"d": 100.0}} + var source: Dictionary = world.state.duplicate(true) + var single: Dictionary = source.duplicate(true) + var split: Dictionary = source.duplicate(true) + Parameters.advance(source, single, rules, 1.0e6) + for i in 10: Parameters.advance(source, split, rules, 1.0e5) + check(is_equal_approx(single.trends.Integration, 0.018), "Trend integrates elapsed years and doubled stage weights") + for key in Parameters.TRENDS: + check(is_equal_approx(single.trends[key], split.trends[key]), "Trend is independent of frame partition: " + key) + source.ethics["C2.0"].d = 1000000.0 + var larger: Dictionary = world.state.duplicate(true) + Parameters.advance(source, larger, rules, 1.0e6) + check(is_equal_approx(larger.trends.Integration, single.trends.Integration), "Population proportions determine trend influence") + world.state.trends.Integration = 0.99 + Parameters.advance(source, world.state, rules, 1.0e8) + check(world.state.civilization_stats["trend:Integration"] == 2.0, "Long steps process every crossed threshold") + check(is_equal_approx(world.state.trends.Integration, 0.79), "Threshold crossing preserves the fractional remainder") + check(world.state.world_parameters.Federation > 1.0 or world.state.world_parameters.Enlightenment > 1.0 or world.state.world_parameters.Order > 1.0, "Integration events change the world") + var restored: Dictionary = bytes_to_var(var_to_bytes(world.state)) + Parameters.advance(source, world.state, rules, 1.0e8) + Parameters.advance(source, restored, rules, 1.0e8) + check(world.state == restored, "Trend events resume identically after serialization") + Parameters.advance(source, world.state, rules, 1.0e12) + check(world.state.trends.Integration >= 0.0 and world.state.trends.Integration < 1.0, "Very large intervals finish with a valid trend fraction") + check(world.state.world_parameters.Agg >= 1.0, "Repeated integration events cannot reduce aggression below the scale") + var payload: Dictionary = {"format_version": 2, "app_version": "test", "ui": {"current_page": 1}, "state": world.state} + var manager: Node = root.get_node("SaveManager") + check(manager.validate_data(payload) == "", "New parameters and trends pass save validation") + var old: Dictionary = payload.duplicate(true) + old.state.erase("world_parameters") + old.state.erase("trends") + check(manager.validate_data(old) == "", "Existing current-format saves remain readable") + payload.state.world_parameters.Agg = 20.0 + check(manager.validate_data(payload) != "", "Out-of-range scales are rejected") + dispose(world) + + for sample in [[1e-7, 0.0], [1e-6, 0.5], [1e-5, 1.0], [0.9, 0.0], [0.95, 0.5], [1.0, 1.0]]: + check(is_equal_approx(Parameters.density_display(sample[0]).position, sample[1]), "Density boundary " + str(sample[0])) + # The fixture overrides saving/loading; the player's save is never accessed. + var scene = load("res://world/world.tscn").instantiate() + var stage = scene.get_node("Stage") + stage.owniverse.free() + stage.set_script(load("res://tests/fixtures/ui_stage.gd")) + root.add_child(scene) + await process_frame + await process_frame + stage.owniverse.state.energy = Parameters.INITIAL_ENERGY / 4.0 + stage.owniverse.state.world_parameters.Bur = 19.0 + stage.owniverse.state.trends.Integration = 0.75 + stage.world_indicators.update(stage.owniverse) + for bar in stage.world_indicators.energy_bars: + check(bar.scale.x == 0.25, "Energy fill is synchronized on both pages") + var pointer: Control = stage.world_indicators.pointers.Bur + check(is_equal_approx(pointer.position.x + pointer.size.x / 2.0, 304.0), "Burnability pointer reaches the correct scale endpoint") + check(stage.world_indicators.trend_bars[1].scale.y == 0.75, "Trend bar displays its stored level") + for locale in ["en", "ru"]: + global.Locale = locale + for id in ["Energy", "Density", "Burnability", "Explosiveness", "Aggression", "Trends"]: + var description: String = Descriptions.render(stage.owniverse, global, id, "Indicator") + check(not description.contains("—") and not description.contains("%value"), "Resolved indicator description: " + locale + "/" + id) + dispose(stage.owniverse) + scene.free() + print("World indicators: %d checks, %d failures" % [checks, failures]) + quit(0 if failures == 0 else 1) diff --git a/tests/test_world_indicators.gd.uid b/tests/test_world_indicators.gd.uid new file mode 100644 index 0000000..9324e70 --- /dev/null +++ b/tests/test_world_indicators.gd.uid @@ -0,0 +1 @@ +uid://dmldg06dx3utl diff --git a/ui/indicators/world_indicators.gd b/ui/indicators/world_indicators.gd new file mode 100644 index 0000000..ebafd76 --- /dev/null +++ b/ui/indicators/world_indicators.gd @@ -0,0 +1,48 @@ +extends RefCounted + +const Parameters = preload("res://world/owniverse/world_parameters.gd") +var energy_bars: Array = [] +var density_indicators: Array = [] +var pointers: Dictionary = {} +var trend_bars: Array = [] + +func _init(stage: Node) -> void: + var pages: Node = stage.get_node("World/PageScroller/HBoxContainer") + for path in ["ResourcePage/ResourceHeader", "StructurePage/StructureHeader"]: + energy_bars.append(pages.get_node(path + "/EnergyIndicator/Indicator")) + density_indicators.append(pages.get_node(path + "/DensityIndicator")) + pointers.Bur = pages.get_node("StarPage/StarHeader/BurnabilityIndicator/BurnabilityPointer") + pointers.Exp = pages.get_node("StarPage/StarHeader/ExplosivenessIndicator/ExplosivenessPointer") + pointers.Agg = pages.get_node("LifePage/LifeHeader/AggressionIndicator/AggressionPointer") + var trends: Node = pages.get_node("LifePage/LifeHeader/TrendIndicator/HBoxContainer") + for i in 5: + var container: String = "CenterContainer" + (str(i + 1) if i > 0 else "") + trend_bars.append(trends.get_node(container + "/Trend" + str(i + 1))) + # Decorative children must not swallow clicks intended for indicator buttons. + for header_path in ["ResourcePage/ResourceHeader", "StructurePage/StructureHeader", "StarPage/StarHeader", "LifePage/LifeHeader"]: + for child in pages.get_node(header_path).get_children(): + if child is TextureButton: _ignore_mouse_on_children(child) + +func _ignore_mouse_on_children(node: Node) -> void: + for child in node.get_children(): + if child is Control: child.mouse_filter = Control.MOUSE_FILTER_IGNORE + _ignore_mouse_on_children(child) + +func update(world) -> void: + var source: Dictionary = world.state + var energy: float = clampf(source.energy / Parameters.INITIAL_ENERGY, 0.0, 1.0) + for bar in energy_bars: bar.scale.x = energy + var density: Dictionary = Parameters.density_display(world.stellar_environment(source).density) + for indicator in density_indicators: + for mode in ["Density", "DensityRip", "DensityCrunch"]: + indicator.get_node(mode).visible = mode == density.mode + var pointer: Control = indicator.get_node("Pointer") + pointer.position.x = 16.0 + 288.0 * density.position - pointer.size.x / 2.0 + pointer.self_modulate = Color.BLACK if density.mode == "DensityCrunch" else Color.WHITE + for key in pointers: + var pointer: Control = pointers[key] + pointer.position.x = 16.0 * clampf(Parameters.value(source, key), 1.0, 19.0) - pointer.size.x / 2.0 + for i in trend_bars.size(): + var bar: Control = trend_bars[i] + bar.pivot_offset = Vector2(0.0, bar.size.y) + bar.scale.y = clampf(source.get("trends", {}).get(Parameters.TRENDS[i], 0.0), 0.0, 1.0) diff --git a/ui/indicators/world_indicators.gd.uid b/ui/indicators/world_indicators.gd.uid new file mode 100644 index 0000000..b3c7902 --- /dev/null +++ b/ui/indicators/world_indicators.gd.uid @@ -0,0 +1 @@ +uid://brwcqoffc2n1e diff --git a/world/owniverse/civilization_evolution.gd b/world/owniverse/civilization_evolution.gd index 418b6ad..31c412b 100644 --- a/world/owniverse/civilization_evolution.gd +++ b/world/owniverse/civilization_evolution.gd @@ -1,6 +1,7 @@ extends RefCounted 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"} @@ -106,6 +107,8 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: float, var plans: Dictionary = {} var colonies: Array = [] var collateral: Dictionary = {} + var modifiers: Dictionary = WorldParameters.life_modifiers(target) + var order: float = WorldParameters.value(target, "Order") if not target.has("civilization_stats"): target.civilization_stats = {} var ids: Array = expired.keys() ids.sort() @@ -124,14 +127,14 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: float, 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 p: float = maxf(0.0, float(row.progress) * modifiers.progress) + var d: float = maxf(0.0, float(row.degradation) * modifiers.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)) + if rules[id].fights: warriors[ethic] = rate(target, id + ":" + ethic + ":war", triggered[ethic], minf(1.0, float(row.war) * modifiers.war)) + if rules[id].colonizes: settlers[ethic] = rate(target, id + ":" + ethic + ":col", triggered[ethic], minf(1.0, float(row.colonization) * modifiers.colonization)) successes[ethic] = bounded.pro failures[ethic] = bounded.deg triggered[ethic] -= bounded.pro + bounded.deg @@ -146,16 +149,16 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: float, 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)) + var attackers_lost: Dictionary = take(world, warriors, rate(target, id + ":attackers_lost", outcomes.get("deg", 0.0) / order, 1.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 losses: float = minf(rate(target, id + ":loss", loss_expected / order, 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 + var damage: float = rate(target, id + ":collateral", (outcomes.get("pro50", 0.0) * 10.0 + outcomes.get("pro75", 0.0) * 30.0) / order, 1.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) diff --git a/world/owniverse/item_description.gd b/world/owniverse/item_description.gd index e57261c..43b11dd 100644 --- a/world/owniverse/item_description.gd +++ b/world/owniverse/item_description.gd @@ -3,6 +3,7 @@ extends RefCounted const GREEN = "#40cc40" const RED = "#ff6060" const GREY = "#888888" +const Parameters = preload("res://world/owniverse/world_parameters.gd") static func number(value: float, digits: int = 0) -> String: if not is_finite(value): @@ -82,6 +83,19 @@ static func variables(world, global: Node, id: String, rate: float = 0.0) -> Dic static func render(world, global: Node, id: String, category: String = "Entity", rate: float = 0.0) -> String: var values: Dictionary = variables(world, global, id, rate) + if category == "Indicator": + if id == "Density": + id = Parameters.density_display(world.stellar_environment(world.state).density).mode + var scales: Dictionary = {"Burnability": "Bur", "Explosiveness": "Exp", "Aggression": "Agg"} + if scales.has(id): + var level: float = Parameters.value(world.state, scales[id]) + values.amount = number(level, 1 if level != floorf(level) else 0) + values.scale = global.render_template(global.get_template("Scale" + str(clampi(roundi(level), 1, 19)), "Indicator", "Scale"), {}) + if id == "Trends": + for trend in Parameters.TRENDS: + values[trend.to_lower()] = number(world.state.get("trends", {}).get(trend, 0.0) * 100.0, 2) + "%" + if id == "Energy": + values["энергия"] = number(world.state.energy) + " (" + number(clampf(world.state.energy / Parameters.INITIAL_ENERGY, 0.0, 1.0) * 100.0, 2) + "%)" var title: String = global.render_template(global.get_template(id, category, "Name"), values) var body: String = global.render_template(global.get_template(id, category, "Description"), values) return "[b]" + (title if title != "" else id) + "[/b]\n" + body diff --git a/world/owniverse/owniverse.gd b/world/owniverse/owniverse.gd index ccdb7eb..ad25020 100644 --- a/world/owniverse/owniverse.gd +++ b/world/owniverse/owniverse.gd @@ -3,6 +3,7 @@ class_name Owniverse 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") #var state: State = State.new() @@ -30,7 +31,9 @@ var state: Dictionary = { }, 'speed': 1, 'speed_in_year': 1, - 'energy': 1E14, + 'energy': WorldParameters.INITIAL_ENERGY, + 'world_parameters': WorldParameters.defaults(), + 'trends': WorldParameters.trend_defaults(), 'batches': {}, 'batch_origins': {}, 'binds': {}, @@ -110,6 +113,7 @@ func _evolute_to_datetime(datetime: float) -> void: _evolve_batches(next, to_year) _refresh_groups(next) _clamp_auto_resources(next) + WorldParameters.advance(previous, next, Global.CivilizationRules, to_year - from_year) next.time = {"datetime": datetime, "year": to_year} # Publish only after every phase has finished; previous stays untouched. @@ -161,6 +165,25 @@ func _add_time_delta(delta: float) -> Dictionary: return {"datetime": new_datetime, "year": SimulationTime.processed_year(new_datetime)} +## Public entry point for forces: update a world scale and preserve stellar ages. +func modify_world_parameter(key: String, change: float) -> void: + if key not in ["Bur", "Exp", "Agg"] or not is_finite(change): return + var old_multiplier: float = WorldParameters.effective(state, key) + if not state.has("world_parameters"): state.world_parameters = WorldParameters.defaults() + state.world_parameters[key] = clampf(WorldParameters.value(state, key) + change, 1.0, 19.0) + if key == "Bur": + var ratio: float = old_multiplier / WorldParameters.effective(state, key) + var now: float = state.time.year + for id in state.batches: + if not entities.has(id) or entities[id].group_as != "Star": continue + for batch: Dictionary in state.batches[id]: + # Only future survival time changes. Birth dates and the fractional + # withdrawal carry remain intact; already elapsed time is not replayed. + for field in ["start_year", "end_year", "processed_until"]: + if batch.has(field): batch[field] = now + maxf(0.0, batch[field] - now) * ratio + new_state = state + + ## Rebuild after changing group_as or replacing definitions without changing their count. ## Adding/removing definitions is detected automatically during initialization. func rebuild_group_index() -> void: @@ -355,6 +378,8 @@ func _append_batch(target: Dictionary, id: String, amount: float, target.batch_origins = {} var batches: Array = target.batches[id] var lifespan: float = CivilizationState.effective_span(self, target, id, Global.CivilizationRules) + if entities[id].group_as == "Star": + lifespan /= WorldParameters.effective(target, "Bur") var batch_duration: float = lifespan / Global.batch_count if lifespan > 10.0 else lifespan if batch_duration <= 0.0: return NAN @@ -475,11 +500,17 @@ func _add_output_terms(outputs: Dictionary, terms: String, count: float) -> void func _life_products(target: Dictionary, entity: OwniverseEntity, count: float) -> Dictionary: var rule: Dictionary = entity.life_rule var pending: Dictionary = {} + var modifiers: Dictionary = WorldParameters.life_modifiers(target) + var probabilities: Dictionary = { + "progress": float(rule.progress) * modifiers.progress * WorldParameters.value(target, "Enlightenment"), + "degradation": float(rule.degradation) * modifiers.degradation, + } + var probability_sum: float = maxf(1.0, probabilities.progress + probabilities.degradation) if not target.has("life_remainders"): target.life_remainders = {} for channel in ["progress", "degradation"]: var key: String = entity.id + ":" + channel - var expected: float = count * float(rule[channel]) + target.life_remainders.get(key, 0.0) + var expected: float = count * probabilities[channel] / probability_sum + target.life_remainders.get(key, 0.0) target.life_remainders[key] = expected pending[channel] = floorf(snappedf(expected, 1.0e-12)) var assigned: Dictionary = _allocate_whole(pending, count) @@ -530,7 +561,7 @@ func _evolution_products(target: Dictionary, entity: OwniverseEntity, count: flo outputs[id] = count * maxf(0.0, entity.output_and[id]) if not entity.output_or.is_empty(): # The parser stores the percentage after '%', e.g. 0.1 means 0.1 percent. - var chance: float = clampf(entity.output_or.get("chance", 0.0) / 100.0, 0.0, 1.0) + var chance: float = clampf(entity.output_or.get("chance", 0.0) / 100.0 * WorldParameters.effective(target, "Exp"), 0.0, 1.0) if not target.has("evolution_remainders"): target.evolution_remainders = {} var expected: float = count * chance + target.evolution_remainders.get(entity.id, 0.0) @@ -542,12 +573,11 @@ func _evolution_products(target: Dictionary, entity: OwniverseEntity, count: flo # blast:N. Zero disables the event; never add both representations. outputs.erase("blast") if entity.blast > 0: - outputs["blast"] = count * entity.blast + outputs["blast"] = count * entity.blast * WorldParameters.effective(target, "Exp") return outputs # Derived from current concrete stock, including bound objects, never UI aliases. -# GI has no Unity force parameters; stellar effects use their neutral multiplier 1. func stellar_environment(source: Dictionary) -> Dictionary: var quantities: Dictionary = _physical_amounts(source) # SMBH is a self-group in GI; its quantity stores mass, not object count. @@ -618,7 +648,7 @@ func _produce_newborn_lifetime(target: Dictionary, id: String, count: float, object_years -= maxf(0.0, added) * _expired_years(to_year, batch.start_year, batch.end_year) object_years = maxf(0.0, object_years) for resource in products: - var output: float = object_years * products[resource] + var output: float = object_years * products[resource] * WorldParameters.production(target, entities[id]) if resource in ["H", "S"]: output = minf(output, target.energy) var actual: float = _credit(target, resource, output) @@ -1001,7 +1031,7 @@ func _produce_for_period(previous: Dictionary, target: Dictionary, quantities: D var auto_share: float = clampf(entity.auto, 0.0, 1.0) var products: Dictionary = _yearly_products(entity) for product in products: - var amount: float = object_years * products[product] + var amount: float = object_years * products[product] * WorldParameters.production(previous, entity) basic[product] = basic.get(product, 0.0) + amount * (1.0 - auto_share) automatic[product] = automatic.get(product, 0.0) + amount * auto_share # Basic and automatic output each retain their unproduced remainder. diff --git a/world/owniverse/world_parameters.gd b/world/owniverse/world_parameters.gd new file mode 100644 index 0000000..f5ca325 --- /dev/null +++ b/world/owniverse/world_parameters.gd @@ -0,0 +1,102 @@ +extends RefCounted + +const INITIAL_ENERGY = 1.0e14 +const TRENDS = ["Antitheism", "Integration", "Xenocide", "Reconstruction", "Spirituality"] +const WEIGHTS = { + "m": [0, 2, 2, 0, 6], "l": [6, 1, 1, 2, 0], + "w": [2, 0, 6, 0, 2], "d": [0, 6, 0, 1, 3], + "c": [1, 3, 3, 3, 0], "b": [3, 0, 0, 6, 1], +} + +static func defaults() -> Dictionary: + return {"Bur": 10.0, "Exp": 10.0, "Agg": 10.0, + "Federation": 1.0, "Enlightenment": 1.0, "Order": 1.0} + +static func trend_defaults() -> Dictionary: + return {"Antitheism": 0.0, "Integration": 0.0, "Xenocide": 0.0, + "Reconstruction": 0.0, "Spirituality": 0.0} + +static func value(source: Dictionary, key: String) -> float: + return source.get("world_parameters", {}).get(key, defaults().get(key, 1.0)) + +static func effective(source: Dictionary, key: String) -> float: + return pow(10.0, (clampf(value(source, key), 1.0, 19.0) - 10.0) / 9.0) + +static func life_modifiers(source: Dictionary) -> Dictionary: + var aggression: float = effective(source, "Agg") + return {"progress": aggression, + "degradation": (1.0 - (1.0 - aggression) / 20.0) / value(source, "Order"), + "war": 1.0 - (1.0 - aggression) / 5.0, + "colonization": 1.0 - (1.0 - aggression) / 2.0} + +static func production(source: Dictionary, entity) -> float: + if entity.event_group == "SNGRBB": + return effective(source, "Exp") + if entity.group_as == "Star": + var burn: float = effective(source, "Bur") + return sqrt(burn) if burn > 1.0 else burn + return 1.0 + +static func density_display(density: float) -> Dictionary: + if density <= 1.0e-5: + # Unity had log10(density) - 7 here; +7 maps 1e-7..1e-5 to 0..1. + return {"mode": "DensityRip", "position": clampf((log(maxf(density, 1e-7)) / log(10.0) + 7.0) / 2.0, 0.0, 1.0)} + if density >= 0.9: + return {"mode": "DensityCrunch", "position": clampf((density - 0.9) * 10.0, 0.0, 1.0)} + return {"mode": "Density", "position": clampf(log(density * 1.0e5) / log(9.0e4), 0.0, 1.0)} + +## Integrate the population present at the start of the simulation interval. +## Thresholds use whole event counts; no per-year loop at high speeds. +static func advance(previous: Dictionary, target: Dictionary, rules: Dictionary, years: float) -> void: + if years <= 0.0: return + if not target.has("world_parameters"): target.world_parameters = defaults() + if not target.has("trends"): target.trends = trend_defaults() + var rates: Array = [0.0, 0.0, 0.0, 0.0, 0.0] + var stages: Array = [] + for id in rules: + if str(rules[id].home) != "": stages.append(id) + stages.sort() + var weight: float = 1.0 + for id in stages: + var population: Dictionary = previous.get("ethics", {}).get(id, {}) + var count: float = 0.0 + for n in population.values(): count += n + if count > 0.0: + for ethic in population: + for i in TRENDS.size(): + rates[i] += population[ethic] / count * WEIGHTS[ethic][i] * weight + weight *= 2.0 + for i in TRENDS.size(): + var name: String = TRENDS[i] + var accumulated: float = target.trends[name] + rates[i] * years * 1.0e-9 + var events: float = floorf(snappedf(accumulated, 1.0e-12)) + target.trends[name] = maxf(0.0, accumulated - events) + if events <= 0.0: continue + var key: String = "trend:" + name + target.civilization_stats[key] = target.civilization_stats.get(key, 0.0) + events + if name == "Integration": _integration(target, events) + +static func _integration(target: Dictionary, events: float) -> void: + # Equal-probability outcomes in Unity. Use a saved RNG and bounded aggregate + # sampling for large steps, so thousands of triggers do not stall a frame. + var rng := RandomNumberGenerator.new() + rng.seed = 76123 + if target.civilization_stats.has("trend_rng"): + rng.state = int(target.civilization_stats.trend_rng) + var counts: Array = [0.0, 0.0, 0.0] + if events <= 256.0: + for i in int(events): counts[rng.randi_range(0, 2)] += 1.0 + else: + counts[0] = clampf(roundf(rng.randfn(events / 3.0, sqrt(events * 2.0 / 9.0))), 0.0, events) + var rest: float = events - counts[0] + counts[1] = clampf(roundf(rng.randfn(rest / 2.0, sqrt(rest / 4.0))), 0.0, rest) + counts[2] = rest - counts[1] + target.civilization_stats.trend_rng = rng.state + var parameters: Dictionary = target.world_parameters + parameters.Agg = maxf(1.0, parameters.Agg - counts[0] - counts[1]) + parameters.Federation += counts[0] * 0.1 + parameters.Enlightenment += counts[1] * 0.2 + parameters.Order += counts[2] * 0.1 + for i in 3: + var key: String = ["Federation", "Enlightenment", "Order"][i] + target.civilization_stats[key] = target.civilization_stats.get(key, 0.0) + counts[i] diff --git a/world/owniverse/world_parameters.gd.uid b/world/owniverse/world_parameters.gd.uid new file mode 100644 index 0000000..783d53b --- /dev/null +++ b/world/owniverse/world_parameters.gd.uid @@ -0,0 +1 @@ +uid://brsauq1r3xn87 diff --git a/world/world.tscn b/world/world.tscn index 134e5f5..e78e882 100644 --- a/world/world.tscn +++ b/world/world.tscn @@ -1110,6 +1110,7 @@ autostart = true [connection signal="pressed" from="Stage/World/PageScroller/HBoxContainer/LifePage/LifeHeader/AggressionIndicator" to="Stage" method="_on_indicator_pressed" binds= ["Aggression", 4]] [connection signal="pressed" from="Stage/World/PageScroller/HBoxContainer/LifePage/LifeHeader/TrendIndicator" to="Stage" method="_on_indicator_pressed" binds= ["Trends", 4]] [connection signal="pressed" from="Stage/MainMenu/TopMenu/BackButton" to="Stage" method="_on_topmenu_button_pressed" binds= ["World"]] +[connection signal="pressed" from="Stage/MainMenu/CreateOwniverse" to="Stage" method="create_new_universe"] [connection signal="pressed" from="Stage/MainMenu/Achievements" to="Stage" method="_on_topmenu_button_pressed" binds= ["Achievements"]] [connection signal="pressed" from="Stage/MainMenu/Credits" to="Stage" method="_on_topmenu_button_pressed" binds= ["Credits"]] [connection signal="pressed" from="Stage/Report/TopMenu/BackButton" to="Stage" method="_on_topmenu_button_pressed" binds= ["World"]]