From da03ba11c004c3e114338d9bad22262307493a77 Mon Sep 17 00:00:00 2001 From: Kirill Date: Tue, 29 Sep 2026 19:55:20 +0300 Subject: [PATCH] Refactor Owniverse modules for quantity mathematics and entity catalog - Introduced `quantity_math.gd` for arithmetic operations including allocation, normalization, and accumulation of quantities without scene tree dependencies. - Created `entity_catalog.gd` to manage entity definitions, groups, and event indexes, ensuring separation from simulation state and UI. - Implemented `item_presentation.gd` for read-only projections of simulation state, handling amounts and unlock conditions for UI elements. - Updated `civilization_evolution.gd` and `civilization_state.gd` to utilize new quantity math functions, improving code clarity and maintainability. - Enhanced tests in `test_quantity_catalog.gd` to validate new functionality and ensure correctness of mathematical operations. - Revised documentation to reflect changes in module structure and functionality. --- autoload/global.gd | 6 +- stage/stage.gd | 47 ++----- tests/README.md | 8 ++ tests/test_async_save.gd | 5 +- tests/test_life_icons.gd | 17 +++ tests/test_quantity_catalog.gd | 63 +++++++++ tests/test_quantity_catalog.gd.uid | 1 + tests/test_time.gd | 3 +- world/owniverse/README.md | 26 ++++ world/owniverse/civilization_evolution.gd | 54 +++----- world/owniverse/civilization_state.gd | 8 +- world/owniverse/entity_catalog.gd | 47 +++++++ world/owniverse/entity_catalog.gd.uid | 1 + world/owniverse/item_description.gd | 2 +- world/owniverse/item_presentation.gd | 75 +++++++++++ world/owniverse/item_presentation.gd.uid | 1 + world/owniverse/owniverse.gd | 155 +++++----------------- world/owniverse/quantity_math.gd | 111 ++++++++++++++++ world/owniverse/quantity_math.gd.uid | 1 + 19 files changed, 424 insertions(+), 207 deletions(-) create mode 100644 tests/test_quantity_catalog.gd create mode 100644 tests/test_quantity_catalog.gd.uid create mode 100644 world/owniverse/README.md create mode 100644 world/owniverse/entity_catalog.gd create mode 100644 world/owniverse/entity_catalog.gd.uid create mode 100644 world/owniverse/item_presentation.gd create mode 100644 world/owniverse/item_presentation.gd.uid create mode 100644 world/owniverse/quantity_math.gd create mode 100644 world/owniverse/quantity_math.gd.uid diff --git a/autoload/global.gd b/autoload/global.gd index 4122daf..50e31e5 100644 --- a/autoload/global.gd +++ b/autoload/global.gd @@ -66,15 +66,15 @@ func format_number(value: float, digits: int = 0) -> String: var fixed: String = "%.*f" % [digits, value] var parts: PackedStringArray = fixed.split(".", true) var integer: String = parts[0] - var sign: String = "" + var sign_prefix: String = "" if integer.begins_with("-") or integer.begins_with("+"): - sign = integer.left(1) + sign_prefix = integer.left(1) integer = integer.substr(1) var grouped: String = "" while integer.length() > 3: grouped = " " + integer.right(3) + grouped integer = integer.left(integer.length() - 3) - grouped = sign + integer + grouped + grouped = sign_prefix + integer + grouped return grouped + ("." + parts[1] if parts.size() > 1 else "") var exp_value = int(floor(log(abs(value)) / log(10.0))) diff --git a/stage/stage.gd b/stage/stage.gd index d07f70e..795cb2e 100644 --- a/stage/stage.gd +++ b/stage/stage.gd @@ -116,25 +116,11 @@ func set_owniverse_item_value() -> void: func _unlock_items(): - if locked_items.size() == 0: return - var _new_locked_items = [] - var display_totals: Dictionary = owniverse.get_item_amounts(true) - for _item_id in locked_items: - #print(_item_id ' | ', _entity.unlock) - var _flag_to_unlock = true - for _elem in owniverse.entities[_item_id].unlock: - if maxf(owniverse.state.stats.total_amount.get(_elem, 0.0), display_totals.get(_elem, 0.0)) < owniverse.entities[_item_id].unlock[_elem]: - _flag_to_unlock = false - break - - if _flag_to_unlock: - for item_id in [_item_id, owniverse.entities[_item_id].count_as]: - if owniverse_items.has(item_id): - owniverse_items[item_id].set_visible(true) - else: - _new_locked_items.append(_item_id) - locked_items = _new_locked_items - + var result: Dictionary = owniverse.presentation.unlocks(owniverse.state, owniverse.catalog, locked_items) + for item_id in result.visible: + if owniverse_items.has(item_id): + owniverse_items[item_id].set_visible(true) + locked_items = result.remaining func _on_indicator_pressed(indicator_name: String, page: int = 0): _set_description(indicator_name, page) @@ -228,7 +214,7 @@ func item_activated(item: OwniverseItem) -> void: if item.item_id == "S" or item.item_id == "H": var _value = owniverse.state.amount[item.item_id] - _value = 10 * _value if _value > 0 else 1 + _value = 9 * _value if _value > 0 else 1 owniverse.produce_hs(item.item_id, _value) @@ -504,9 +490,9 @@ func init_enitities() -> Dictionary: var _result := { 'locked_items': [], } - for entity_id in Global.OwniverseEntities: - var entity = OwniverseEntity.new(Global.OwniverseEntities[entity_id]) - owniverse.entities[entity_id] = entity + owniverse.catalog.load_definitions(Global.OwniverseEntities) + for entity_id in owniverse.entities: + var entity: OwniverseEntity = owniverse.entities[entity_id] owniverse.state.stats.total_amount[entity_id] = 0 owniverse.state.amount[entity_id] = 0 @@ -519,22 +505,7 @@ func init_enitities() -> Dictionary: owniverse.state.stats.total_amount[entity.group_as] = 0 if entity.unlock != {}: - owniverse.locked_entities.append(entity) _result.locked_items.append(entity.id) - - if entity.product_per_year_type != "": - owniverse.auto_production.append(entity) - - # groups - if entity.group_as != "": - if !owniverse.entity_groups.has(entity.group_as): - owniverse.entity_groups[entity.group_as] = [] - owniverse.entity_groups[entity.group_as].append(entity_id) - - if entity.event_group != "": - if !owniverse.entity_event_groups.has(entity.event_group): - owniverse.entity_event_groups[entity.event_group] = [] - owniverse.entity_event_groups[entity.event_group].append(entity_id) return _result diff --git a/tests/README.md b/tests/README.md index f9c40b8..69d6605 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,7 +1,15 @@ # Проверки поэтапного переноса из Owniverse.U +## Математика распределений и каталог + +`godot --headless --path Owniverse.GI --script res://tests/test_quantity_catalog.gd` + +Проверяются целочисленность и границы распределения, сохранение суммы, разрешение равных остатков, накопление дробей и долгов округления, нормализация весов, размер производственной партии, загрузка каталога и его индексы. Архитектура описана в `../world/owniverse/README.md`. Сохранение игрока не используется. + ## Иконки стадий жизни +Тест также проверяет модуль `item_presentation.gd`: отсутствие изменений входного состояния и списка закрытых иконок, решения по разблокировке, отложенную и принудительную публикацию количеств. + `godot --headless --path Owniverse.GI --script res://tests/test_life_icons.gd` Проверяются одновременная разблокировка подробных и общих иконок EON/LP и C0.0/C0, количества свободных и связанных планет без двойного учёта, переключение подпанелей и очистка счётчика при исчезновении стадии. Сохранение игрока не используется. diff --git a/tests/test_async_save.gd b/tests/test_async_save.gd index ab12c34..d133564 100644 --- a/tests/test_async_save.gd +++ b/tests/test_async_save.gd @@ -22,6 +22,7 @@ func run() -> void: manager.save_completed.connect(func(filename, success, error): completions.append([filename, success, error])) var world = load("res://world/owniverse/owniverse.gd").new() var data: Dictionary = {"state": world.state, "ui": {"current_page": 1}} + var initial_energy: float = world.state.energy var global: Node = root.get_node("Global") var old_plaintext: bool = global.save_plaintext_copy global.save_plaintext_copy = true @@ -35,9 +36,9 @@ func run() -> void: await process_frame check(not manager.is_saving() and frames > 0, "Frames continue while save is pending") var saved: Dictionary = manager.load_data("snapshot", "test-password") - check(saved.state.energy == 1e14 and saved.ui.current_page == 1, "Worker owns a deep snapshot") + check(saved.state.energy == initial_energy and saved.ui.current_page == 1, "Worker owns a deep snapshot") var plain: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(directory.path_join("snapshot.adventure.json"))) - check(plain.state.energy == 1e14, "Diagnostic JSON uses same snapshot") + check(plain.state.energy == initial_energy, "Diagnostic JSON uses same snapshot") check(completions.size() == 1 and completions[0][1], "Completion reports successful write") completions.clear() diff --git a/tests/test_life_icons.gd b/tests/test_life_icons.gd index 238abb4..1df4e9a 100644 --- a/tests/test_life_icons.gd +++ b/tests/test_life_icons.gd @@ -33,6 +33,23 @@ func run() -> void: check(counts["C0.0"] == 3.0 and counts.C0 == 3.0 and counts.Planet == 19.0, "Civilization detail does not double the planetary total") check(world.get_item_amounts(true).EON1 == 12.0 and world.get_item_amounts(true).LP == 16.0, "Historical counts retain individual and aggregate values") check(world.state == before, "Presentation does not modify simulation state") + var pending: Array = ["EON1", "EON2", "EON3", "C0.0"] + var pending_before: Array = pending.duplicate() + var decision: Dictionary = world.presentation.unlocks(world.state, world.catalog, pending) + check(decision.remaining == ["EON3"], "Only unmet requirements remain pending") + for id in ["EON1", "EON2", "LP", "C0.0", "C0"]: + check(decision.visible.has(id), "Unlock projection includes detail and alias: " + id) + check(pending == pending_before and world.state == before, "Unlock evaluation leaves inputs untouched") + var snapshot: Dictionary = world.items_to_update.duplicate() + world.defer_item_updates = true + world.state.amount.EON1 += 1.0 + world.refresh_item_amounts() + check(world.item_amounts_dirty and world.items_to_update == snapshot, "Deferred refresh retains pending snapshot") + world.refresh_item_amounts(true) + check(not world.item_amounts_dirty and world.items_to_update.EON1 == 13.0, "Forced refresh publishes latest amounts") + world.state.amount.EON1 -= 1.0 + world.defer_item_updates = false + world.refresh_item_amounts() for id in ["EON1", "EON2", "LP", "C0.0", "C0"]: check(stage.owniverse_items[id].visible, "Unlock both levels: " + id) check(stage.owniverse_items[id].description.text != "", "Populate the icon counter: " + id) diff --git a/tests/test_quantity_catalog.gd b/tests/test_quantity_catalog.gd new file mode 100644 index 0000000..85358bf --- /dev/null +++ b/tests/test_quantity_catalog.gd @@ -0,0 +1,63 @@ +extends SceneTree + +const Math = preload("res://world/owniverse/quantity_math.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 run() -> void: + var Catalog = load("res://world/owniverse/entity_catalog.gd") + check(Math.allocate_whole({}, 10.0) == {}, "Empty allocation") + check(Math.allocate_whole({"b": 1.0, "a": 1.0}, 1.0) == {"a": 1.0, "b": 0.0}, "Stable lexical tie") + for limit in range(30): + var requests: Dictionary = {"a": 3.9, "b": 9.1, "c": -2.0, "d": 7.0} + var result: Dictionary = Math.allocate_whole(requests, limit) + check(Math.sum(result) == minf(limit, 19.0), "Conserve bounded allocation") + for id in result: + check(result[id] == floorf(result[id]) and result[id] >= 0.0 and result[id] <= floorf(maxf(0.0, requests[id])), "Whole bounded branch") + check(Math.normalize_distribution({"a": -1.0, "b": 0.0}) == {}, "Nonpositive weights") + check(Math.normalize_distribution({"a": 2.0, "b": 6.0}) == {"a": 0.25, "b": 0.75}, "Normalize weights") + check(Math.production_step([0.25, 0.1, 2.0]) == 20.0, "Common whole production step") + check(Math.production_step([]) == 1.0, "Free production step") + var target: Dictionary = {} + var produced: float = 0.0 + for i in 10: + produced += Math.accumulate_whole(target, "remainders", "a", 0.1) + check(produced == 1.0, "Fractional production carries") + var remainders: Dictionary = {} + var totals: Dictionary = {"a": 0.0, "b": 0.0, "c": 0.0} + for i in 30: + var result: Dictionary = Math.split(remainders, "split", 1.0, {"a": 1.0, "b": 1.0, "c": 1.0}) + check(Math.sum(result) == 1.0, "Split conserves count") + for id in result: totals[id] += result[id] + check(totals == {"a": 10.0, "b": 10.0, "c": 10.0}, "Rounding debt distributes over time") + produced = 0.0 + for i in 10: produced += Math.accumulate_rate(remainders, "rate", 1.0, 0.1) + check(produced == 1.0, "Rate carries") + var catalog = Catalog.new() + var rows: Dictionary = root.get_node("Global").OwniverseEntities + catalog.load_definitions(rows) + check(catalog.entities.size() == rows.size(), "Catalog loads all definitions") + for id in catalog.entities: + var entity = catalog.entities[id] + check(catalog.automatic_producers.has(entity) == (entity.product_per_year_type != ""), "Producer index") + check(catalog.unlockable_entities.has(entity) == (not entity.unlock.is_empty()), "Unlock index") + if entity.group_as != "": + check(catalog.groups[entity.group_as].has(id) and catalog.members(entity.group_as).has(id), "Group index") + if entity.event_group != "": + check(catalog.event_groups[entity.event_group].has(id), "Event index") + var members: Array = catalog.members("Star") + members.clear() + check(not catalog.members("Star").is_empty(), "Group results are independent") + check(Catalog.new().entities.is_empty(), "Catalog instances are independent") + for entity in catalog.entities.values(): entity.free() + print("Quantity and catalog: %d checks, %d failures" % [checks, failures]) + quit(1 if failures else 0) diff --git a/tests/test_quantity_catalog.gd.uid b/tests/test_quantity_catalog.gd.uid new file mode 100644 index 0000000..fda7447 --- /dev/null +++ b/tests/test_quantity_catalog.gd.uid @@ -0,0 +1 @@ +uid://bvri1enpsjisk diff --git a/tests/test_time.gd b/tests/test_time.gd index 36e40cc..88358ea 100644 --- a/tests/test_time.gd +++ b/tests/test_time.gd @@ -70,7 +70,8 @@ func run() -> void: world.evolute(0.005) check(world.state.time.year == 0.1, "Speed change preserves accumulated fraction") world.speed = 100 - check(world.speed == 5 and world.state.speed_in_year == 10000.0, "Upper speed limit unchanged") + var speed_limit: int = root.get_node("Global").SPEED_LIMIT + check(world.speed == speed_limit and world.state.speed_in_year == pow(10.0, speed_limit - 1), "Configured upper speed limit") world.speed = -10 check(world.speed == 1 and world.state.speed_in_year == 1.0, "Lower speed limit unchanged") dispose(world) diff --git a/world/owniverse/README.md b/world/owniverse/README.md new file mode 100644 index 0000000..eb62182 --- /dev/null +++ b/world/owniverse/README.md @@ -0,0 +1,26 @@ +# Модули Owniverse + +Первый этап разделения Owniverse выделяет два модуля: + +- `quantity_math.gd`: нормализация положительных весов, ограниченное целочисленное распределение методом наибольших остатков, распределение с накоплением ошибки округления, накопление дробного выпуска и минимальный целый размер производственной партии. +- `entity_catalog.gd`: загрузка определений, индексы групп и событий, списки автоматических производителей и разблокируемых сущностей. + +Математика не обращается к дереву сцены, Global или Owniverse. Методы накопления изменяют переданный словарь остатков; остальные методы возвращают результат. Для распределения `split` количество должно быть целым, веса — неотрицательными. Равные остатки разрешаются по строковому ID. Сохранены прежние точности округления: 1e-12 для выпуска и 1e-10 для событий цивилизаций. + +Каталог получает словарь исходных строк через `load_definitions(rows)`, вызываемый один раз для нового экземпляра. Конструктор OwniverseEntity по-прежнему использует настройки Global. Каталог не хранит количества, партии или состояние интерфейса. Определения пока остаются Node: освобождает их владелец мира, как до рефакторинга. + +Метод `members(id)` возвращает копию списка. После изменения group_as нужно вызвать `rebuild_group_index()`; добавление или удаление определения автоматически обнаруживается по изменению размера словаря. Полная перезагрузка определений выполняется созданием нового каталога. + +Owniverse сохраняет совместимые свойства entities/entity_groups/entity_event_groups и методы-обёртки. Они используют те же данные каталога, без дублирования. Stage загружает каталог, затем отдельно инициализирует количество, статистику и UI. Эволюция цивилизаций обращается непосредственно к QuantityMath. + +Формулы, порядок обхода определений, формат сохранения и правила симуляции не изменены. Проверки модулей: `tests/test_quantity_catalog.gd`; интеграционные проверки перечислены в `tests/README.md`. + +## Представление количеств и разблокировка иконок + +`item_presentation.gd` получает состояние и каталог, не изменяет симуляцию и не обращается к UI или Global. + +- `amounts(state, catalog, total_produced)` строит текущие или исторические количества. Текущие планеты включают свободные и связанные объекты. Подробные стадии сохраняют собственные значения одновременно с суммой count_as; готовые групповые счётчики повторно не суммируются. +- `unlocks(state, catalog, locked)` проверяет условия по историческим количествам и их отображаемым суммам. Возвращает remaining (ещё закрытые сущности) и visible (открываемые сущности и их count_as). Переданный список не изменяется. Уже открытые иконки повторно не блокируются. +- `refresh` управляет снимком pending_amounts и флагом dirty, учитывает отложенное обновление и принудительную публикацию. Сам модуль не запускает таймеры и не меняет частоту обновления. + +Owniverse делегирует существующие get_item_amounts/refresh_item_amounts и свойства обновления экземпляру presentation. Stage применяет результат разблокировки к контролам и хранит список закрытых иконок. Снимки и флаги принадлежат отдельному миру и не включаются в сохранение. diff --git a/world/owniverse/civilization_evolution.gd b/world/owniverse/civilization_evolution.gd index 31c412b..41d4682 100644 --- a/world/owniverse/civilization_evolution.gd +++ b/world/owniverse/civilization_evolution.gd @@ -1,57 +1,33 @@ extends RefCounted +const QuantityMath = preload("res://world/owniverse/quantity_math.gd") + const State = preload("res://world/owniverse/civilization_state.gd") const WorldParameters = preload("res://world/owniverse/world_parameters.gd") const ETHICS = State.ETHICS const OPPOSITE = {"m": "l", "l": "m", "w": "d", "d": "w", "c": "b", "b": "c"} static func total(pop: Dictionary) -> float: - var result: float = 0.0 - for n in pop.values(): result += n - return result + return QuantityMath.sum(pop) 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) + var taken: Dictionary = QuantityMath.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 + return QuantityMath.accumulate_rate(target.civilization_remainders, key, count, probability) # Fixed-size largest-remainder apportionment with saved signed rounding errors. # Runtime depends on the number of branches, never the number of civilizations. static func split(target: Dictionary, key: String, count: 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 count <= 0.0 or QuantityMath.sum(weights) <= 0.0: return {} 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 + return QuantityMath.split(target.civilization_remainders, key, count, weights) static func drift(target: Dictionary, key: String, pop: Dictionary, rows: Dictionary, diplomacy: bool = false, colony: bool = false) -> Dictionary: @@ -132,7 +108,7 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: float, 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]) + var bounded: Dictionary = QuantityMath.allocate_whole({"pro": pro, "deg": deg}, triggered[ethic]) 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 @@ -143,7 +119,7 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: float, 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) + warriors = QuantityMath.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 @@ -153,7 +129,7 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: float, 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 / order, 1.0), total(triggered) + total(remaining[id])) - var sides: Dictionary = world._allocate_whole({"due": total(triggered), "later": total(remaining[id])}, losses) + var sides: Dictionary = QuantityMath.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) @@ -170,7 +146,7 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: float, 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 sides: Dictionary = QuantityMath.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) @@ -223,19 +199,19 @@ static func colonize(world, target: Dictionary, colonies: Array, year: float, ru 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)) + var granted: Dictionary = QuantityMath.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)]) + var population: Dictionary = QuantityMath.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) + var targets: Dictionary = QuantityMath.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) + var taken: Dictionary = QuantityMath.allocate_whole(slots, amount) world._scale_batches(target, id, 1.0 - amount / stock[id]) stock[id] -= amount for owner in taken: diff --git a/world/owniverse/civilization_state.gd b/world/owniverse/civilization_state.gd index b315efc..d430b74 100644 --- a/world/owniverse/civilization_state.gd +++ b/world/owniverse/civilization_state.gd @@ -1,5 +1,7 @@ extends RefCounted +const QuantityMath = preload("res://world/owniverse/quantity_math.gd") + const ETHICS = ["m", "l", "w", "d", "c", "b"] static func is_space_civilization(id: String, rules: Dictionary) -> bool: @@ -19,7 +21,7 @@ static func reconcile(world, target: Dictionary, rules: Dictionary) -> void: current[ethic] = maxf(0.0, current.get(ethic, 0.0)) total += current[ethic] if total > count: - current = world._allocate_whole(current, count) + current = QuantityMath.allocate_whole(current, count) elif total < count: var missing: float = count - total var base: float = floorf(missing / ETHICS.size()) @@ -131,7 +133,7 @@ static func enforce_capacity(world, target: Dictionary, rules: Dictionary, year: 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) + var removed: Dictionary = QuantityMath.allocate_whole(target.ethics[id], excess) if not target.ethics.has(previous): target.ethics[previous] = {} for ethic in ETHICS: @@ -143,7 +145,7 @@ static func enforce_capacity(world, target: Dictionary, rules: Dictionary, year: 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) + var weights: Dictionary = QuantityMath.normalize_distribution(stars) # Worlds are aggregates: a star may host several inhabited planets. var assigned: float = 0.0 var owners: Array = weights.keys() diff --git a/world/owniverse/entity_catalog.gd b/world/owniverse/entity_catalog.gd new file mode 100644 index 0000000..e2d4c55 --- /dev/null +++ b/world/owniverse/entity_catalog.gd @@ -0,0 +1,47 @@ +extends RefCounted +## Definitions and their indexes only: no population, batches or UI state. +## Definitions are Nodes owned by the caller, as before this extraction. + +var entities: Dictionary[String, OwniverseEntity] = {} +var groups: Dictionary[String, Array] = {} +var event_groups: Dictionary[String, Array] = {} +var automatic_producers: Array = [] +var unlockable_entities: Array = [] +var _group_members: Dictionary = {} +var _indexed_entity_count: int = -1 + +## Populate an empty catalog; replacement uses a new catalog to preserve ownership. +func load_definitions(rows: Dictionary) -> void: + assert(entities.is_empty(), "Load definitions into an empty catalog") + for id in rows: + var entity := OwniverseEntity.new(rows[id]) + entities[id] = entity + if not entity.unlock.is_empty(): unlockable_entities.append(entity) + if entity.product_per_year_type != "": automatic_producers.append(entity) + if entity.group_as != "": + if not groups.has(entity.group_as): groups[entity.group_as] = [] + groups[entity.group_as].append(id) + if entity.event_group != "": + if not event_groups.has(entity.event_group): event_groups[entity.event_group] = [] + event_groups[entity.event_group].append(id) + rebuild_group_index() + +## Call explicitly after modifying group_as without changing the entity count. +func rebuild_group_index() -> void: + _group_members.clear() + for id in entities: + var group: String = entities[id].group_as + if not _group_members.has(group): _group_members[group] = [] + _group_members[group].append(id) + _indexed_entity_count = entities.size() + +func _ensure_group_index() -> void: + if _indexed_entity_count != entities.size(): rebuild_group_index() + +func is_group(id: String) -> bool: + _ensure_group_index() + return groups.has(id) or _group_members.has(id) + +func members(id: String) -> Array: + if not is_group(id): return [id] + return _group_members.get(id, []).duplicate() diff --git a/world/owniverse/entity_catalog.gd.uid b/world/owniverse/entity_catalog.gd.uid new file mode 100644 index 0000000..70ebaa8 --- /dev/null +++ b/world/owniverse/entity_catalog.gd.uid @@ -0,0 +1 @@ +uid://6g3m8wmm0ms7 diff --git a/world/owniverse/item_description.gd b/world/owniverse/item_description.gd index 43b11dd..faf27ba 100644 --- a/world/owniverse/item_description.gd +++ b/world/owniverse/item_description.gd @@ -21,7 +21,7 @@ static func matches(world, id: String) -> Array: for key in world.entities: var entity = world.entities[key] if key == id or entity.count_as == id or entity.group_as == id: - if not world._is_group(key): + if not world.catalog.is_group(key): result.append(key) return result diff --git a/world/owniverse/item_presentation.gd b/world/owniverse/item_presentation.gd new file mode 100644 index 0000000..0f66670 --- /dev/null +++ b/world/owniverse/item_presentation.gd @@ -0,0 +1,75 @@ +extends RefCounted +## Read-only projections of simulation state. No controls or simulation mutations. + +var pending_amounts: Dictionary[String, float] = {} +var deferred_updates: bool = false +var dirty: bool = true + +func refresh(state: Dictionary, catalog, force: bool = false) -> void: + dirty = true + if deferred_updates and not force: + return + pending_amounts = amounts(state, catalog) + dirty = false + +## Aliases are presentation-only; group counters are not counted twice. +static func amounts(state: Dictionary, catalog, total_produced: bool = false) -> Dictionary[String, float]: + var source: Dictionary = state.stats.total_amount if total_produced else state.amount + if not total_produced: + source = source.duplicate() + var physical: Dictionary = {} + for id in catalog.entities: + if not catalog.is_group(id): + physical[id] = maxf(0.0, state.amount.get(id, 0.0)) + for bindings: Dictionary in state.binds.values(): + for id in bindings: + if physical.has(id): + physical[id] += maxf(0.0, bindings[id]) + var planets: float = 0.0 + for id in physical: + if catalog.entities[id].group_as == "Planet": + source[id] = physical[id] + planets += physical[id] + source["Planet"] = planets + var result: Dictionary[String, float] = {} + for id in source: + result[id] = 0.0 + for id in source: + # group_as counters are already sums and must not be counted a second time. + if catalog.is_group(id): + continue + var item_id: String = id + if catalog.entities.has(id) and catalog.entities[id].count_as != "": + item_id = catalog.entities[id].count_as + # Detailed icons need their own count as well as the summary alias. + # Aggregate only from source, never from these presentation entries. + if item_id != id: + result[id] = result.get(id, 0.0) + float(source[id]) + result[item_id] = result.get(item_id, 0.0) + float(source[id]) + for id in source: + if catalog.is_group(id): + result[id] = float(source[id]) + return result + + +## Evaluate pending icons against historical counts, returning UI decisions only. +static func unlocks(state: Dictionary, catalog, locked: Array) -> Dictionary: + var remaining: Array = [] + var visible: Array = [] + if locked.is_empty(): + return {"remaining": remaining, "visible": visible} + var display_totals: Dictionary = amounts(state, catalog, true) + for id in locked: + var entity = catalog.entities[id] + var eligible: bool = true + for requirement in entity.unlock: + if maxf(state.stats.total_amount.get(requirement, 0.0), display_totals.get(requirement, 0.0)) < entity.unlock[requirement]: + eligible = false + break + if eligible: + for item_id in [id, entity.count_as]: + if item_id != "" and not visible.has(item_id): + visible.append(item_id) + else: + remaining.append(id) + return {"remaining": remaining, "visible": visible} diff --git a/world/owniverse/item_presentation.gd.uid b/world/owniverse/item_presentation.gd.uid new file mode 100644 index 0000000..37412d4 --- /dev/null +++ b/world/owniverse/item_presentation.gd.uid @@ -0,0 +1 @@ +uid://cdivwab70aqfb diff --git a/world/owniverse/owniverse.gd b/world/owniverse/owniverse.gd index bc3b170..d1b1851 100644 --- a/world/owniverse/owniverse.gd +++ b/world/owniverse/owniverse.gd @@ -4,22 +4,36 @@ const CivilizationEvolution = preload("res://world/owniverse/civilization_evolut const CivilizationState = preload("res://world/owniverse/civilization_state.gd") const SimulationTime = preload("res://world/owniverse/simulation_time.gd") const WorldParameters = preload("res://world/owniverse/world_parameters.gd") +const QuantityMath = preload("res://world/owniverse/quantity_math.gd") +const EntityCatalog = preload("res://world/owniverse/entity_catalog.gd") +const ItemPresentation = preload("res://world/owniverse/item_presentation.gd") #var state: State = State.new() -var entities: Dictionary[String, OwniverseEntity] = {} -var entity_groups: Dictionary[String, Array] = {} -var entity_event_groups: Dictionary[String, Array] = {} +var catalog = EntityCatalog.new() +# Compatibility accessors keep existing callers and fixtures on the same dictionaries. +var entities: Dictionary[String, OwniverseEntity]: + get: return catalog.entities +var entity_groups: Dictionary[String, Array]: + get: return catalog.groups +var entity_event_groups: Dictionary[String, Array]: + get: return catalog.event_groups -var _group_members: Dictionary = {} -var _indexed_entity_count: int = -1 +var presentation = ItemPresentation.new() +var items_to_update: Dictionary[String, float]: + get: return presentation.pending_amounts + set(value): presentation.pending_amounts = value +var defer_item_updates: bool: + get: return presentation.deferred_updates + set(value): presentation.deferred_updates = value +var item_amounts_dirty: bool: + get: return presentation.dirty + set(value): presentation.dirty = value -var items_to_update: Dictionary[String, float] = {} -var defer_item_updates: bool = false -var item_amounts_dirty: bool = true - -var auto_production: Array = [] -var locked_entities: Array = [] +var auto_production: Array: + get: return catalog.automatic_producers +var locked_entities: Array: + get: return catalog.unlockable_entities # ------------------------------------ @@ -123,45 +137,12 @@ func _evolute_to_datetime(datetime: float) -> void: refresh_item_amounts() -## Aggregate only for presentation; never write aliases back into simulation state. func get_item_amounts(total_produced: bool = false) -> Dictionary[String, float]: - 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] = {} - for id in source: - result[id] = 0.0 - for id in source: - # group_as counters are already sums and must not be counted a second time. - if _is_group(id): - continue - var item_id: String = id - if entities.has(id) and entities[id].count_as != "": - item_id = entities[id].count_as - # Detailed icons need their own count as well as the summary alias. - # Aggregate only from source, never from these presentation entries. - if item_id != id: - result[id] = result.get(id, 0.0) + float(source[id]) - result[item_id] = result.get(item_id, 0.0) + float(source[id]) - for id in source: - if _is_group(id): - result[id] = float(source[id]) - return result + return ItemPresentation.amounts(state, catalog, total_produced) func refresh_item_amounts(force: bool = false) -> void: - item_amounts_dirty = true - if defer_item_updates and not force: - return - items_to_update = get_item_amounts() - item_amounts_dirty = false + presentation.refresh(state, catalog, force) func _add_time_delta(delta: float) -> Dictionary: @@ -191,23 +172,11 @@ func modify_world_parameter(key: String, change: float) -> void: ## 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: - _group_members.clear() - for id in entities: - var group: String = entities[id].group_as - if not _group_members.has(group): - _group_members[group] = [] - _group_members[group].append(id) - _indexed_entity_count = entities.size() - - -func _ensure_group_index() -> void: - if _indexed_entity_count != entities.size(): - rebuild_group_index() + catalog.rebuild_group_index() func _is_group(id: String) -> bool: - _ensure_group_index() - return entity_groups.has(id) or _group_members.has(id) + return catalog.is_group(id) func _physical_amounts(source: Dictionary) -> Dictionary: @@ -223,10 +192,7 @@ func _physical_amounts(source: Dictionary) -> Dictionary: func _members(id: String) -> Array: - if not _is_group(id): - return [id] - # Callers may modify the returned array without changing the index. - return _group_members.get(id, []).duplicate() + return catalog.members(id) func _available(source: Dictionary, id: String) -> float: @@ -607,12 +573,7 @@ func stellar_environment(source: Dictionary) -> Dictionary: func _accumulate_whole(target: Dictionary, field: String, key: String, value: float) -> float: - if not target.has(field): - target[field] = {} - var expected: float = maxf(0.0, value) + target[field].get(key, 0.0) - var whole: float = floorf(snappedf(expected, 1.0e-12)) - target[field][key] = maxf(0.0, expected - whole) - return whole + return QuantityMath.accumulate_whole(target, field, key, value) func _create_planets(target: Dictionary, star_id: String, count: float, @@ -990,27 +951,7 @@ func _expired_years(year: float, start: float, end: float) -> float: # Largest-remainder allocation: whole float values, bounded by each request. func _allocate_whole(requests: Dictionary, limit: float) -> Dictionary: - var total: float = 0.0 - for amount in requests.values(): - total += floorf(maxf(0.0, amount)) - var available: float = floorf(minf(maxf(0.0, limit), total)) - var result: Dictionary = {} - var order: Array = [] - var assigned: float = 0.0 - for id in requests: - var expected: float = floorf(maxf(0.0, requests[id])) * available / total if total > 0.0 else 0.0 - result[id] = floorf(expected) - assigned += result[id] - order.append({"id": id, "rest": expected - result[id]}) - order.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: - return a.rest > b.rest if a.rest != b.rest else str(a.id) < str(b.id)) - for item: Dictionary in order: - if assigned >= available: - break - if result[item.id] < floorf(requests[item.id]): - result[item.id] += 1.0 - assigned += 1.0 - return result + return QuantityMath.allocate_whole(requests, limit) # The entity currently exposes type/amount fields; life_product can contain @@ -1366,29 +1307,11 @@ func produce_hs(entity_id: String, value: float) -> float: # Find the smallest whole batch whose cost is whole for every resource. # Decimal costs are represented as integer fractions; combine their denominators. func _production_step(entity: OwniverseEntity) -> float: - var step: float = 1.0 - for value in entity.cost.values() + entity.bind.values(): - var cost: float = maxf(0.0, value) - if cost == 0.0: - continue - var decimal: String = String.num(cost, 12) - var point: int = decimal.find(".") - var digits: int = decimal.length() - point - 1 if point >= 0 else 0 - var denominator: float = pow(10.0, digits) - var numerator: float = roundf(cost * denominator) - if numerator == 0.0: - return INF - denominator /= _whole_gcd(numerator, denominator) - step = step / _whole_gcd(step, denominator) * denominator - return step + return QuantityMath.production_step(entity.cost.values() + entity.bind.values()) func _whole_gcd(a: float, b: float) -> float: - while b > 0.0: - var remainder: float = fmod(a, b) - a = b - b = remainder - return a + return QuantityMath.whole_gcd(a, b) func calculate_max_producible(entity: OwniverseEntity, desired_amount: float = 1.0E150) -> float: @@ -1450,15 +1373,7 @@ func apply_binds(item_id: String, _entity: OwniverseEntity, amount: float) -> bo ## @param values - dictionary containing numeric values ## @return dictionary with normalized values, each ranging from 0.0 to 1.0 func _normalize_distribution(values: Dictionary) -> Dictionary: - var normalized_distribution: Dictionary = {} - var distribution_total: float = 0 - for id in values: - if values[id] > 0: - distribution_total += values[id] - normalized_distribution[id] = values[id] - for id in normalized_distribution: - normalized_distribution[id] /= distribution_total - return normalized_distribution + return QuantityMath.normalize_distribution(values) #endregion diff --git a/world/owniverse/quantity_math.gd b/world/owniverse/quantity_math.gd new file mode 100644 index 0000000..41cb700 --- /dev/null +++ b/world/owniverse/quantity_math.gd @@ -0,0 +1,111 @@ +extends RefCounted +## Quantity arithmetic without scene tree or global settings. + +static func accumulate_whole(target: Dictionary, field: String, key: String, value: float) -> float: + if not target.has(field): + target[field] = {} + var expected: float = maxf(0.0, value) + target[field].get(key, 0.0) + var whole: float = floorf(snappedf(expected, 1.0e-12)) + target[field][key] = maxf(0.0, expected - whole) + return whole + + +static func allocate_whole(requests: Dictionary, limit: float) -> Dictionary: + var total: float = 0.0 + for amount in requests.values(): + total += floorf(maxf(0.0, amount)) + var available: float = floorf(minf(maxf(0.0, limit), total)) + var result: Dictionary = {} + var order: Array = [] + var assigned: float = 0.0 + for id in requests: + var expected: float = floorf(maxf(0.0, requests[id])) * available / total if total > 0.0 else 0.0 + result[id] = floorf(expected) + assigned += result[id] + order.append({"id": id, "rest": expected - result[id]}) + order.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + return a.rest > b.rest if a.rest != b.rest else str(a.id) < str(b.id)) + for item: Dictionary in order: + if assigned >= available: + break + if result[item.id] < floorf(requests[item.id]): + result[item.id] += 1.0 + assigned += 1.0 + return result + + +static func production_step(costs: Array) -> float: + var step: float = 1.0 + for value in costs: + var cost: float = maxf(0.0, value) + if cost == 0.0: + continue + var decimal: String = String.num(cost, 12) + var point: int = decimal.find(".") + var digits: int = decimal.length() - point - 1 if point >= 0 else 0 + var denominator: float = pow(10.0, digits) + var numerator: float = roundf(cost * denominator) + if numerator == 0.0: + return INF + denominator /= whole_gcd(numerator, denominator) + step = step / whole_gcd(step, denominator) * denominator + return step + + +static func whole_gcd(a: float, b: float) -> float: + while b > 0.0: + var remainder: float = fmod(a, b) + a = b + b = remainder + return a + + +static func normalize_distribution(values: Dictionary) -> Dictionary: + var normalized_distribution: Dictionary = {} + var distribution_total: float = 0 + for id in values: + if values[id] > 0: + distribution_total += values[id] + normalized_distribution[id] = values[id] + for id in normalized_distribution: + normalized_distribution[id] /= distribution_total + return normalized_distribution + + +static func sum(pop: Dictionary) -> float: + var result: float = 0.0 + for n in pop.values(): result += n + return result + + +static func split(remainders: Dictionary, key: String, count: float, weights: Dictionary) -> Dictionary: + var result: Dictionary = {} + var sum_weights: float = sum(weights) + if count <= 0.0 or sum_weights <= 0.0: return result + var expected: Dictionary = {} + var positive_sum: float = 0.0 + for id in weights: + expected[id] = count * weights[id] / sum_weights + 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: + remainders[key + ":" + id] = clampf(expected[id] - result[id], -1.0, 1.0) + return result + + +static func accumulate_rate(remainders: Dictionary, key: String, count: float, probability: float) -> float: + var expected: float = count * maxf(0.0, probability) + remainders.get(key, 0.0) + var actual: float = floorf(snappedf(expected, 1e-10)) + remainders[key] = maxf(0.0, expected - actual) + return actual diff --git a/world/owniverse/quantity_math.gd.uid b/world/owniverse/quantity_math.gd.uid new file mode 100644 index 0000000..2c343b4 --- /dev/null +++ b/world/owniverse/quantity_math.gd.uid @@ -0,0 +1 @@ +uid://dfl47j0lvkt2y