From 5ea65a7e9bde8ab17a982079546c51c7c353ce69 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 16 Sep 2026 07:58:19 +0300 Subject: [PATCH] Update export presets, project version, and enhance civilization simulation rules - Updated export path and version code in export_presets.cfg - Changed project version in project.godot to 0.7.0 - Expanded simulation rules in simulation_rules.md to clarify resource management - Improved testing framework in tests/README.md to include new civilization budget checks - Added new tests for batch boundaries and civilization state transitions - Enhanced civilization_state.gd with shared budget and transition logic - Refactored owniverse.gd to enforce capacity during life batch initialization --- export_presets.cfg | 4 +- project.godot | 2 +- simulation_rules.md | 8 +- tests/README.md | 2 + tests/test_batch_boundaries.gd | 57 ++++++++++++++ tests/test_batch_boundaries.gd.uid | 1 + tests/test_civilization_state.gd | 52 +++++++++++++ world/owniverse/civilization_state.gd | 107 ++++++++++++++++++++++++++ world/owniverse/owniverse.gd | 25 +++++- 9 files changed, 250 insertions(+), 8 deletions(-) create mode 100644 tests/test_batch_boundaries.gd create mode 100644 tests/test_batch_boundaries.gd.uid diff --git a/export_presets.cfg b/export_presets.cfg index 4f75529..f72109d 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -11,7 +11,7 @@ custom_features="" export_filter="all_resources" include_filter="" exclude_filter="" -export_path="../../../Documents/Owad/Owniverse.I/Builds/owniverse.0.6.29-145.console.exe" +export_path="../../../Documents/Owad/Owniverse.I/Builds/owniverse.0.7.0-146.console.exe" patches=PackedStringArray() patch_delta_encoding=false patch_delta_compression_level_zstd=19 @@ -112,7 +112,7 @@ architectures/armeabi-v7a=false architectures/arm64-v8a=true architectures/x86=false architectures/x86_64=false -version/code=145 +version/code=146 version/name="" package/unique_name="com.KIDGameProduction.Owniverse" package/name="Owniverse" diff --git a/project.godot b/project.godot index 8955410..b087449 100644 --- a/project.godot +++ b/project.godot @@ -15,7 +15,7 @@ general/updates_per_second=10 [application] config/name="Owniverse" -config/version="0.6.29" +config/version="0.7.0" run/main_scene="uid://bv384dpmvjv8o" config/features=PackedStringArray("4.7", "Mobile") boot_splash/show_image=false diff --git a/simulation_rules.md b/simulation_rules.md index e3f708e..e83c602 100644 --- a/simulation_rules.md +++ b/simulation_rules.md @@ -94,4 +94,10 @@ HP деградирует в RP. У эонов возможен откат к п Цивилизации с непустым `home` (C2.0–C4.4) освобождаются от прежних привязок без изменения общего количества. Общий механизм переходов больше не переносит планетную привязку на такого преемника. Назначение структур и обратное размещение при деградации ещё не подключены. -Открытое правило среды: Unity читает `Stars`, но не использует эту норму в ограничении количества цивилизаций. Требуется выбор между фактическим поведением Unity и количественным ограничением доступными звёздами. +Правило среды согласовано: число космических цивилизаций ограничено звёздным ресурсом. Для стадии `i` расход ёмкости — `population[i] * max(1, Stars[i])`. Каждое семейство `Home` имеет общий бюджет для всех использующих его стадий; звёзды внутри структур также учитываются, но повторно не суммируются. SMBH учитывается по суммарной массе в эквиваленте звёзд, согласно решению пользователя. + +Переход переиспользует резерв исходной стадии. При нехватке ёмкости неподдержанные переходы остаются на исходной стадии. Лимит применяется к общему механизму выходов партий и ручному созданию; это само по себе не включает пока отсутствующий расчёт прогресса C0.2+. + +При загрузке и после эволюционных поколений избыток деградирует последовательно до поддерживаемой стадии. Приоритет получают существующие более развитые цивилизации; остаток бюджета достаётся младшим. Деградация переносит количество и этики, сокращает старые партии и регистрирует новые, не уничтожая цивилизации. C2.0 при избытке становится C1.9 и распределяется по имеющимся звёздам-карликам. Если карликов нет, C1.9 остаётся свободной — отсутствующая звезда не создаётся. Планетные стадии до C2.0 не расходуют отдельную звёздную ёмкость: у одной звезды могут быть несколько планет. + +Резерв ёмкости логический: звёзды не расходуются и продолжают производить ресурсы. Общий бюджет пересчитывается из физических количеств, отдельная копия бронирований в сохранении не хранится. diff --git a/tests/README.md b/tests/README.md index 447c638..417677e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -60,3 +60,5 @@ godot --headless --path Owniverse.GI --script res://tests/test_life.gd ```sh godot --headless --path Owniverse.GI --script res://tests/test_civilization_state.gd ``` + +Тест цивилизаций дополнительно проверяет общий звёздный бюджет, повторное использование резерва при переходе, звёзды в структурах, массовый эквивалент SMBH, блокировку неподдержанного прогресса, каскад деградации и повторное применение ограничений без потерь. diff --git a/tests/test_batch_boundaries.gd b/tests/test_batch_boundaries.gd new file mode 100644 index 0000000..b61dfb2 --- /dev/null +++ b/tests/test_batch_boundaries.gd @@ -0,0 +1,57 @@ +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 run() -> void: + var world = fixture() + world.state.batch_origins.LGRB = 10357034.80000000074505806 + var boundary: float = 41215289.79999999701976776 + for interval in [[boundary - 2.5, boundary + 2.25], [boundary, boundary + 2.0]]: + world.state.batches.LGRB = [] + world._append_batch(world.state, "LGRB", 100.0, interval[0], interval[1]) + var total: float = 0.0 + var last: float = -INF + for batch: Dictionary in world.state.batches.LGRB: + total += batch.amount + check(batch.created_from > last, "Batch windows advance monotonically") + check(batch.created_to > batch.created_from, "Positive window duration") + check(batch.amount >= 0.0, "No negative batch amounts") + last = batch.created_from + check(total == 100.0, "Boundary correction preserves all objects") + check(world.state.batches.LGRB.size() <= 7, "Finite number of windows") + dispose(world) + print("Batch boundaries: %d checks, %d failures" % [checks, failures]) + quit(0 if failures == 0 else 1) diff --git a/tests/test_batch_boundaries.gd.uid b/tests/test_batch_boundaries.gd.uid new file mode 100644 index 0000000..f67cf9d --- /dev/null +++ b/tests/test_batch_boundaries.gd.uid @@ -0,0 +1 @@ +uid://dq86k8bto42xl diff --git a/tests/test_civilization_state.gd b/tests/test_civilization_state.gd index e3050bf..ebe8583 100644 --- a/tests/test_civilization_state.gd +++ b/tests/test_civilization_state.gd @@ -41,6 +41,7 @@ func run() -> void: check(global.CivilizationRules.size() == 43, "Load all ethical civilization stages") for row: Dictionary in global.CivilizationRules.values(): check(row.ethics.size() == 6, "Every ethical stage has six rules") + world.state.amount.RD = 12.0 world.state.amount["C0.2"] = 8.0 world.state.binds.RD = {"C2.0": 12.0, "C1.9": 3.0} world.initialize_life_batches() @@ -65,11 +66,62 @@ func run() -> void: dispose(restored) dispose(world) world = fixture() + world.state.amount.RD = 10.0 world.entities["C1.9"].output_and = {"C2.0": 1.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._evolve_batches(world.state, 1.0) check(world.state.amount["C2.0"] == 10.0 and world.state.binds.RD.get("C2.0", 0.0) == 0.0, "Space successor does not inherit planetary binding") dispose(world) + var capacity = load("res://world/owniverse/civilization_state.gd") + world = fixture() + world.state.amount.RD = 10.0 + world.state.amount["C2.0"] = 8.0 + var budget: Dictionary = capacity.stellar_budget(world, world.state, global.CivilizationRules) + check(capacity.admit_transition(budget, global.CivilizationRules, "C1.9", "C2.0", 5.0) == 2.0, "Growth consumes only remaining stellar capacity") + check(capacity.admit_transition(budget, global.CivilizationRules, "C1.9", "C2.0", 5.0) == 0.0, "A reservation cannot be spent twice") + world.state.amount["C2.0"] = 1.0 + budget = capacity.stellar_budget(world, world.state, global.CivilizationRules) + check(capacity.admit_transition(budget, global.CivilizationRules, "C2.0", "C2.1", 1.0) == 1.0, "A transition reuses the source reservation") + check(budget.free.DwStar == 0.0, "C2.1 reserves ten dwarf stars") + world.state.amount["C2.0"] = 0.0 + world.state.binds.SC = {"RD": 10.0} + budget = capacity.stellar_budget(world, world.state, global.CivilizationRules) + check(budget.capacity.DwStar == 20.0, "Stars inside structures are counted once") + dispose(world) + + world = fixture() + world.state.amount.RD = 12.0 + world.state.amount["C2.1"] = 2.0 + world.state.amount["C2.0"] = 5.0 + world.initialize_life_batches() + check(world.state.amount["C2.1"] == 1.0 and world.state.amount["C2.0"] == 2.0, "Shared budget supports advanced civilizations first") + check(world.state.binds.RD["C1.9"] == 4.0, "Excess cascades to supported planetary stage") + var snapshot: Dictionary = world.state.duplicate(true) + world.initialize_life_batches() + check(world.state == snapshot, "Capacity enforcement is idempotent") + world.state.amount.RD = 0.0 + world.initialize_life_batches() + check(world.state.amount["C2.1"] == 0.0 and world.state.amount["C2.0"] == 0.0, "Vanished stars force all unsupported space civilizations down") + check(world._physical_amounts(world.state)["C1.9"] == 7.0, "Forced degradation conserves civilizations") + dispose(world) + + world = fixture() + world.state.amount.SMBH = 2.0e22 + world.state.amount["C4.2"] = 3.0 + world.initialize_life_batches() + check(world.state.amount["C4.2"] == 2.0, "SMBH capacity uses stellar mass equivalent") + check(world._physical_amounts(world.state)["C1.9"] == 1.0, "Unsupported civilization degrades through missing habitat families") + check(root.get_node("SaveManager").validate_data({"state": world.state}) == "", "Capacity-limited state validates") + dispose(world) + + world = fixture() + world.state.amount.RD = 2.0 + world.entities["C1.9"].output_and = {"C2.0": 1.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._evolve_batches(world.state, 1.0) + check(world.state.amount["C2.0"] == 2.0 and world.state.binds.RD["C1.9"] == 3.0, "Unfunded transition retains the original planetary civilization") + dispose(world) print("Civilization state: %d checks, %d failures" % [checks, failures]) quit(0 if failures == 0 else 1) diff --git a/world/owniverse/civilization_state.gd b/world/owniverse/civilization_state.gd index 8cb2400..a43d299 100644 --- a/world/owniverse/civilization_state.gd +++ b/world/owniverse/civilization_state.gd @@ -34,3 +34,110 @@ static func detach_space_civilizations(target: Dictionary, rules: Dictionary) -> if is_space_civilization(id, rules): target.amount[id] = target.amount.get(id, 0.0) + held[id] held[id] = 0.0 + +# A shared budget per stellar family, including stars bound into structures. +# Reservations affect capacity, not the physical stock or its production. +static func stellar_budget(world, target: Dictionary, rules: Dictionary) -> Dictionary: + var physical: Dictionary = world._physical_amounts(target) + var capacity: Dictionary = {} + for id in physical: + var entity = world.entities[id] + for home in ["DwStar", "GiStar", "WD", "NS", "MGT", "BH"]: + if entity.event_group == home or entity.count_as == home or id == home: + capacity[home] = capacity.get(home, 0.0) + physical[id] + capacity["SMBH"] = maxf(0.0, target.amount.get("SMBH", 0.0)) + for held: Dictionary in target.binds.values(): + capacity["SMBH"] += maxf(0.0, held.get("SMBH", 0.0)) + var populations: Dictionary = {} + var requested: Dictionary = {} + for id in rules: + if not is_space_civilization(id, rules): + continue + populations[id] = physical.get(id, 0.0) + var home: String = rules[id].home + requested[home] = requested.get(home, 0.0) + populations[id] * maxf(1.0, rules[id].stars) + var free: Dictionary = {} + for home in capacity: + free[home] = maxf(0.0, capacity[home] - requested.get(home, 0.0)) + return {"capacity": capacity, "requested": requested, "free": free, "populations": populations} + +# Account for the source's existing reservation when changing stages. A failed +# transition retains its population at the source, rather than losing objects. +static func admit_transition(budget: Dictionary, rules: Dictionary, source: String, + destination: String, requested: float) -> float: + if source == destination or not is_space_civilization(destination, rules): + return requested + var home: String = rules[destination].home + var cost: float = maxf(1.0, rules[destination].stars) + var old_home: String = "" + var old_cost: float = 0.0 + if is_space_civilization(source, rules): + old_home = rules[source].home + old_cost = maxf(1.0, rules[source].stars) + # Existing overcapacity is handled by the separate population policy. + if budget.requested.get(home, 0.0) > budget.capacity.get(home, 0.0): + return 0.0 + var net_cost: float = cost - old_cost if old_home == home else cost + var admitted: float = floorf(maxf(0.0, requested)) + if net_cost > 0.0: + admitted = minf(admitted, floorf(budget.free.get(home, 0.0) / net_cost)) + if old_home != "": + 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.requested[home] = budget.requested.get(home, 0.0) + admitted * cost + budget.free[home] = maxf(0.0, budget.capacity.get(home, 0.0) - budget.requested[home]) + return admitted + +# Resolve shortages from the most advanced stages downwards. This gives existing +# advanced populations priority and lets a demotion cascade in the same cycle. +static func enforce_capacity(world, target: Dictionary, rules: Dictionary, year: float) -> void: + detach_space_civilizations(target, rules) + reconcile(world, target, rules) + var budget: Dictionary = stellar_budget(world, target, rules) + var remaining: Dictionary = budget.capacity.duplicate() + var stages: Array = [] + for id in rules: + if is_space_civilization(id, rules): + stages.append(id) + stages.sort() + for index in range(stages.size() - 1, -1, -1): + var id: String = stages[index] + var home: String = rules[id].home + var cost: float = maxf(1.0, rules[id].stars) + var count: float = target.amount.get(id, 0.0) + var supported: float = minf(count, floorf(remaining.get(home, 0.0) / cost)) + remaining[home] = maxf(0.0, remaining.get(home, 0.0) - supported * cost) + var excess: float = count - supported + if excess <= 0.0: + continue + var previous: String = stages[index - 1] if index > 0 else "C1.9" + world._scale_batches(target, id, supported / count) + target.amount[id] = supported + world._credit(target, previous, excess) + world._append_batch(target, previous, excess, year, year) + var removed: Dictionary = world._allocate_whole(target.ethics[id], excess) + if not target.ethics.has(previous): + target.ethics[previous] = {} + for ethic in ETHICS: + target.ethics[id][ethic] -= removed[ethic] + target.ethics[previous][ethic] = target.ethics[previous].get(ethic, 0.0) + removed[ethic] + if previous == "C1.9": + var stars: Dictionary = {} + var physical: Dictionary = world._physical_amounts(target) + for star in physical: + if world.entities[star].event_group == "DwStar" and physical[star] > 0.0: + stars[star] = physical[star] + var weights: Dictionary = world._normalize_distribution(stars) + # Worlds are aggregates: a star may host several inhabited planets. + var assigned: float = 0.0 + var owners: Array = weights.keys() + owners.sort() + for i in owners.size(): + var owner: String = owners[i] + var amount: float = floorf(excess * weights[owner]) if i + 1 < owners.size() else excess - assigned + assigned += amount + if not target.binds.has(owner): + target.binds[owner] = {} + target.binds[owner][previous] = target.binds[owner].get(previous, 0.0) + amount + target.amount[previous] -= amount + reconcile(world, target, rules) diff --git a/world/owniverse/owniverse.gd b/world/owniverse/owniverse.gd index d8f4462..e4c46e2 100644 --- a/world/owniverse/owniverse.gd +++ b/world/owniverse/owniverse.gd @@ -66,7 +66,7 @@ func migrate_time_precision() -> void: # Old saves have counts but no life ages. Start only missing cohorts at load time. func initialize_life_batches() -> void: CivilizationState.detach_space_civilizations(state, Global.CivilizationRules) - CivilizationState.reconcile(self, state, Global.CivilizationRules) + CivilizationState.enforce_capacity(self, state, Global.CivilizationRules, state.time.datetime) if state.get("life_version", 0) >= 2: return var physical: Dictionary = _physical_amounts(state) @@ -327,10 +327,17 @@ func _append_batch(target: Dictionary, id: String, amount: float, var cursor: float = created_from var duration: float = created_to - created_from var remaining_amount: float = amount + # Compute the index once. Recomputing floor from a rounded boundary can + # select the preceding window forever (e.g. fractional origins at large ages). + var window_index: float = floorf((cursor - origin) / batch_duration) + if origin + (window_index + 1.0) * batch_duration <= cursor: + window_index += 1.0 while true: - var window_index: float = floorf((cursor - origin) / batch_duration) var window_start: float = origin + window_index * batch_duration var window_end: float = origin + (window_index + 1.0) * batch_duration + if window_end <= cursor: + push_error("Batch window exceeds floating-point time precision: " + id) + return var part_end: float = minf(created_to, window_end) var part_amount: float = remaining_amount if duration > 0.0 and part_end < created_to: @@ -354,6 +361,7 @@ func _append_batch(target: Dictionary, id: String, amount: float, if duration == 0.0 or part_end >= created_to: break cursor = window_end + window_index += 1.0 batches.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.created_from < b.created_from) @@ -628,6 +636,7 @@ func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool: return false var fractions: Dictionary = {} var outputs: Dictionary = {} + var star_budget: Dictionary = CivilizationState.stellar_budget(self, target, Global.CivilizationRules) for id in expired: var total: float = 0.0 for part: Dictionary in expired[id]: @@ -636,6 +645,12 @@ func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool: var combined: Dictionary = {} for part: Dictionary in expired[id]: part.products = _evolution_products(target, entities[id], part.amount) + for successor in part.products.keys(): + var requested: float = part.products[successor] + var admitted: float = CivilizationState.admit_transition(star_budget, Global.CivilizationRules, id, successor, requested) + part.products[successor] = admitted + if admitted < requested: + part.products[id] = part.products.get(id, 0.0) + requested - admitted for successor in part.products: combined[successor] = combined.get(successor, 0.0) + part.products[successor] outputs[id] = {} @@ -687,7 +702,7 @@ func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool: continue if not entities.has(successor): continue - if successor == id and not entities[id].life_rule.is_empty(): + if successor == id and (not entities[id].life_rule.is_empty() or Global.CivilizationRules.has(id)): target.amount[id] = target.amount.get(id, 0.0) + amount else: amount = _credit(target, successor, amount) @@ -705,7 +720,7 @@ func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool: _bind_new_owners(target, new_owners) _round_object_allocations(target) _apply_blast(target, blast_power, environment) - CivilizationState.reconcile(self, target, Global.CivilizationRules) + CivilizationState.enforce_capacity(self, target, Global.CivilizationRules, to_year) return true @@ -1116,6 +1131,8 @@ func produce(item_id: String, desired_amount: float) -> float: return 0.0 var max_producible = floorf(calculate_max_producible(entity, desired_amount)) + var budget: Dictionary = CivilizationState.stellar_budget(self, state, Global.CivilizationRules) + max_producible = CivilizationState.admit_transition(budget, Global.CivilizationRules, "", item_id, max_producible) if max_producible <= 0: return 0.0