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.
This commit is contained in:
Kirill
2026-09-29 19:55:20 +03:00
parent c2313e9163
commit da03ba11c0
19 changed files with 424 additions and 207 deletions
+3 -3
View File
@@ -66,15 +66,15 @@ func format_number(value: float, digits: int = 0) -> String:
var fixed: String = "%.*f" % [digits, value] var fixed: String = "%.*f" % [digits, value]
var parts: PackedStringArray = fixed.split(".", true) var parts: PackedStringArray = fixed.split(".", true)
var integer: String = parts[0] var integer: String = parts[0]
var sign: String = "" var sign_prefix: String = ""
if integer.begins_with("-") or integer.begins_with("+"): if integer.begins_with("-") or integer.begins_with("+"):
sign = integer.left(1) sign_prefix = integer.left(1)
integer = integer.substr(1) integer = integer.substr(1)
var grouped: String = "" var grouped: String = ""
while integer.length() > 3: while integer.length() > 3:
grouped = " " + integer.right(3) + grouped grouped = " " + integer.right(3) + grouped
integer = integer.left(integer.length() - 3) integer = integer.left(integer.length() - 3)
grouped = sign + integer + grouped grouped = sign_prefix + integer + grouped
return grouped + ("." + parts[1] if parts.size() > 1 else "") return grouped + ("." + parts[1] if parts.size() > 1 else "")
var exp_value = int(floor(log(abs(value)) / log(10.0))) var exp_value = int(floor(log(abs(value)) / log(10.0)))
+7 -36
View File
@@ -116,25 +116,11 @@ func set_owniverse_item_value() -> void:
func _unlock_items(): func _unlock_items():
if locked_items.size() == 0: return var result: Dictionary = owniverse.presentation.unlocks(owniverse.state, owniverse.catalog, locked_items)
var _new_locked_items = [] for item_id in result.visible:
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): if owniverse_items.has(item_id):
owniverse_items[item_id].set_visible(true) owniverse_items[item_id].set_visible(true)
else: locked_items = result.remaining
_new_locked_items.append(_item_id)
locked_items = _new_locked_items
func _on_indicator_pressed(indicator_name: String, page: int = 0): func _on_indicator_pressed(indicator_name: String, page: int = 0):
_set_description(indicator_name, page) _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": if item.item_id == "S" or item.item_id == "H":
var _value = owniverse.state.amount[item.item_id] 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) owniverse.produce_hs(item.item_id, _value)
@@ -504,9 +490,9 @@ func init_enitities() -> Dictionary:
var _result := { var _result := {
'locked_items': [], 'locked_items': [],
} }
for entity_id in Global.OwniverseEntities: owniverse.catalog.load_definitions(Global.OwniverseEntities)
var entity = OwniverseEntity.new(Global.OwniverseEntities[entity_id]) for entity_id in owniverse.entities:
owniverse.entities[entity_id] = entity var entity: OwniverseEntity = owniverse.entities[entity_id]
owniverse.state.stats.total_amount[entity_id] = 0 owniverse.state.stats.total_amount[entity_id] = 0
owniverse.state.amount[entity_id] = 0 owniverse.state.amount[entity_id] = 0
@@ -519,23 +505,8 @@ func init_enitities() -> Dictionary:
owniverse.state.stats.total_amount[entity.group_as] = 0 owniverse.state.stats.total_amount[entity.group_as] = 0
if entity.unlock != {}: if entity.unlock != {}:
owniverse.locked_entities.append(entity)
_result.locked_items.append(entity.id) _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 return _result
#endregion #endregion
+8
View File
@@ -1,7 +1,15 @@
# Проверки поэтапного переноса из Owniverse.U # Проверки поэтапного переноса из 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` `godot --headless --path Owniverse.GI --script res://tests/test_life_icons.gd`
Проверяются одновременная разблокировка подробных и общих иконок EON/LP и C0.0/C0, количества свободных и связанных планет без двойного учёта, переключение подпанелей и очистка счётчика при исчезновении стадии. Сохранение игрока не используется. Проверяются одновременная разблокировка подробных и общих иконок EON/LP и C0.0/C0, количества свободных и связанных планет без двойного учёта, переключение подпанелей и очистка счётчика при исчезновении стадии. Сохранение игрока не используется.
+3 -2
View File
@@ -22,6 +22,7 @@ func run() -> void:
manager.save_completed.connect(func(filename, success, error): completions.append([filename, success, error])) manager.save_completed.connect(func(filename, success, error): completions.append([filename, success, error]))
var world = load("res://world/owniverse/owniverse.gd").new() var world = load("res://world/owniverse/owniverse.gd").new()
var data: Dictionary = {"state": world.state, "ui": {"current_page": 1}} 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 global: Node = root.get_node("Global")
var old_plaintext: bool = global.save_plaintext_copy var old_plaintext: bool = global.save_plaintext_copy
global.save_plaintext_copy = true global.save_plaintext_copy = true
@@ -35,9 +36,9 @@ func run() -> void:
await process_frame await process_frame
check(not manager.is_saving() and frames > 0, "Frames continue while save is pending") check(not manager.is_saving() and frames > 0, "Frames continue while save is pending")
var saved: Dictionary = manager.load_data("snapshot", "test-password") 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"))) 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") check(completions.size() == 1 and completions[0][1], "Completion reports successful write")
completions.clear() completions.clear()
+17
View File
@@ -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(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.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") 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"]: for id in ["EON1", "EON2", "LP", "C0.0", "C0"]:
check(stage.owniverse_items[id].visible, "Unlock both levels: " + id) check(stage.owniverse_items[id].visible, "Unlock both levels: " + id)
check(stage.owniverse_items[id].description.text != "", "Populate the icon counter: " + id) check(stage.owniverse_items[id].description.text != "", "Populate the icon counter: " + id)
+63
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://bvri1enpsjisk
+2 -1
View File
@@ -70,7 +70,8 @@ func run() -> void:
world.evolute(0.005) world.evolute(0.005)
check(world.state.time.year == 0.1, "Speed change preserves accumulated fraction") check(world.state.time.year == 0.1, "Speed change preserves accumulated fraction")
world.speed = 100 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 world.speed = -10
check(world.speed == 1 and world.state.speed_in_year == 1.0, "Lower speed limit unchanged") check(world.speed == 1 and world.state.speed_in_year == 1.0, "Lower speed limit unchanged")
dispose(world) dispose(world)
+26
View File
@@ -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 применяет результат разблокировки к контролам и хранит список закрытых иконок. Снимки и флаги принадлежат отдельному миру и не включаются в сохранение.
+15 -39
View File
@@ -1,57 +1,33 @@
extends RefCounted extends RefCounted
const QuantityMath = preload("res://world/owniverse/quantity_math.gd")
const State = preload("res://world/owniverse/civilization_state.gd") const State = preload("res://world/owniverse/civilization_state.gd")
const WorldParameters = preload("res://world/owniverse/world_parameters.gd") const WorldParameters = preload("res://world/owniverse/world_parameters.gd")
const ETHICS = State.ETHICS const ETHICS = State.ETHICS
const OPPOSITE = {"m": "l", "l": "m", "w": "d", "d": "w", "c": "b", "b": "c"} const OPPOSITE = {"m": "l", "l": "m", "w": "d", "d": "w", "c": "b", "b": "c"}
static func total(pop: Dictionary) -> float: static func total(pop: Dictionary) -> float:
var result: float = 0.0 return QuantityMath.sum(pop)
for n in pop.values(): result += n
return result
static func add(to: Dictionary, from: Dictionary) -> void: static func add(to: Dictionary, from: Dictionary) -> void:
for key in from: to[key] = to.get(key, 0.0) + from[key] for key in from: to[key] = to.get(key, 0.0) + from[key]
static func take(world, pop: Dictionary, count: float) -> Dictionary: 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] for key in taken: pop[key] -= taken[key]
return taken return taken
static func rate(target: Dictionary, key: String, count: float, probability: float) -> float: static func rate(target: Dictionary, key: String, count: float, probability: float) -> float:
if not target.has("civilization_remainders"): target.civilization_remainders = {} if not target.has("civilization_remainders"): target.civilization_remainders = {}
var expected: float = count * maxf(0.0, probability) + target.civilization_remainders.get(key, 0.0) return QuantityMath.accumulate_rate(target.civilization_remainders, key, count, probability)
var actual: float = floorf(snappedf(expected, 1e-10))
target.civilization_remainders[key] = maxf(0.0, expected - actual)
return actual
# Fixed-size largest-remainder apportionment with saved signed rounding errors. # Fixed-size largest-remainder apportionment with saved signed rounding errors.
# Runtime depends on the number of branches, never the number of civilizations. # Runtime depends on the number of branches, never the number of civilizations.
static func split(target: Dictionary, key: String, count: float, weights: Dictionary) -> Dictionary: static func split(target: Dictionary, key: String, count: float, weights: Dictionary) -> Dictionary:
var result: Dictionary = {} if count <= 0.0 or QuantityMath.sum(weights) <= 0.0: return {}
var sum_weights: float = total(weights)
if count <= 0.0 or sum_weights <= 0.0: return result
if not target.has("civilization_remainders"): target.civilization_remainders = {} if not target.has("civilization_remainders"): target.civilization_remainders = {}
var expected: Dictionary = {} return QuantityMath.split(target.civilization_remainders, key, count, weights)
var positive_sum: float = 0.0
for id in weights:
expected[id] = count * weights[id] / sum_weights + target.civilization_remainders.get(key + ":" + id, 0.0)
positive_sum += maxf(0.0, expected[id])
var order: Array = []
var assigned: float = 0.0
for id in weights:
var normalized: float = maxf(0.0, expected[id]) * count / positive_sum if positive_sum > 0.0 else 0.0
result[id] = floorf(normalized)
assigned += result[id]
order.append({"id": id, "rest": normalized - result[id]})
order.sort_custom(func(a, b): return a.rest > b.rest if a.rest != b.rest else str(a.id) < str(b.id))
for entry in order:
if assigned >= count: break
result[entry.id] += 1.0
assigned += 1.0
for id in weights:
target.civilization_remainders[key + ":" + id] = clampf(expected[id] - result[id], -1.0, 1.0)
return result
static func drift(target: Dictionary, key: String, pop: Dictionary, rows: Dictionary, static func drift(target: Dictionary, key: String, pop: Dictionary, rows: Dictionary,
diplomacy: bool = false, colony: bool = false) -> 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 scale: float = maxf(1.0, p + d)
var pro: float = rate(target, id + ":" + ethic + ":pro", triggered[ethic], p / scale) 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 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].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)) 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 successes[ethic] = bounded.pro
@@ -143,7 +119,7 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: float,
if rules[id].fights: if rules[id].fights:
for ethic in warriors: warriors[ethic] = minf(warriors[ethic], triggered[ethic]) 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)) 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] 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 outcomes: Dictionary = split(target, id + ":wars", wars, {"deg": 1.0, "pro25": 2.0, "pro50": 3.0, "pro75": 3.0})
var win_expected: float = outcomes.get("pro25", 0.0) * 0.25 + outcomes.get("pro50", 0.0) * 0.5 + outcomes.get("pro75", 0.0) * 0.75 var 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)) add(triggered, progress(world, target, rules, budget, plan, winners))
fall(target, plan, rules, attackers_lost) 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 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) var victims: Dictionary = take(world, triggered, sides.due)
add(victims, consume_untriggered(world, target, plan, remaining[id], sides.later, year)) add(victims, consume_untriggered(world, target, plan, remaining[id], sides.later, year))
fall(target, plan, rules, victims) 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 plan: Dictionary = plans[id]
var retry: Dictionary = plan.outputs.get(id, {}) var retry: Dictionary = plan.outputs.get(id, {})
var amount: float = minf(collateral[id], total(retry) + total(remaining[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) var victims: Dictionary = take(world, retry, sides.due)
add(victims, consume_untriggered(world, target, plan, remaining[id], sides.later, year)) add(victims, consume_untriggered(world, target, plan, remaining[id], sides.later, year))
fall(target, plan, rules, victims) 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) for id in ["GG", "AB", "RP", "HP"]: stock[id] = physical.get(id, 0.0)
var requests: Dictionary = {} var requests: Dictionary = {}
for i in colonies.size(): requests[str(i)] = total(colonies[i].population) 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(): for i in colonies.size():
var colony: Dictionary = colonies[i] 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) population = drift(target, colony.source + ":colonies", population, rules[colony.source].ethics, false, true)
var count: float = total(population) 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: for id in targets:
var amount: float = targets[id] var amount: float = targets[id]
if amount <= 0.0: continue if amount <= 0.0: continue
var slots: Dictionary = {"": target.amount.get(id, 0.0)} var slots: Dictionary = {"": target.amount.get(id, 0.0)}
for owner in target.binds: slots[owner] = target.binds[owner].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]) world._scale_batches(target, id, 1.0 - amount / stock[id])
stock[id] -= amount stock[id] -= amount
for owner in taken: for owner in taken:
+5 -3
View File
@@ -1,5 +1,7 @@
extends RefCounted extends RefCounted
const QuantityMath = preload("res://world/owniverse/quantity_math.gd")
const ETHICS = ["m", "l", "w", "d", "c", "b"] const ETHICS = ["m", "l", "w", "d", "c", "b"]
static func is_space_civilization(id: String, rules: Dictionary) -> bool: 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)) current[ethic] = maxf(0.0, current.get(ethic, 0.0))
total += current[ethic] total += current[ethic]
if total > count: if total > count:
current = world._allocate_whole(current, count) current = QuantityMath.allocate_whole(current, count)
elif total < count: elif total < count:
var missing: float = count - total var missing: float = count - total
var base: float = floorf(missing / ETHICS.size()) 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 target.amount[id] = supported
world._credit(target, previous, excess) world._credit(target, previous, excess)
world._append_batch(target, previous, excess, year, year) 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): if not target.ethics.has(previous):
target.ethics[previous] = {} target.ethics[previous] = {}
for ethic in ETHICS: for ethic in ETHICS:
@@ -143,7 +145,7 @@ static func enforce_capacity(world, target: Dictionary, rules: Dictionary, year:
for star in physical: for star in physical:
if world.entities[star].event_group == "DwStar" and physical[star] > 0.0: if world.entities[star].event_group == "DwStar" and physical[star] > 0.0:
stars[star] = physical[star] 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. # Worlds are aggregates: a star may host several inhabited planets.
var assigned: float = 0.0 var assigned: float = 0.0
var owners: Array = weights.keys() var owners: Array = weights.keys()
+47
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://6g3m8wmm0ms7
+1 -1
View File
@@ -21,7 +21,7 @@ static func matches(world, id: String) -> Array:
for key in world.entities: for key in world.entities:
var entity = world.entities[key] var entity = world.entities[key]
if key == id or entity.count_as == id or entity.group_as == id: 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) result.append(key)
return result return result
+75
View File
@@ -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}
+1
View File
@@ -0,0 +1 @@
uid://cdivwab70aqfb
+35 -120
View File
@@ -4,22 +4,36 @@ const CivilizationEvolution = preload("res://world/owniverse/civilization_evolut
const CivilizationState = preload("res://world/owniverse/civilization_state.gd") const CivilizationState = preload("res://world/owniverse/civilization_state.gd")
const SimulationTime = preload("res://world/owniverse/simulation_time.gd") const SimulationTime = preload("res://world/owniverse/simulation_time.gd")
const WorldParameters = preload("res://world/owniverse/world_parameters.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 state: State = State.new()
var entities: Dictionary[String, OwniverseEntity] = {} var catalog = EntityCatalog.new()
var entity_groups: Dictionary[String, Array] = {} # Compatibility accessors keep existing callers and fixtures on the same dictionaries.
var entity_event_groups: Dictionary[String, Array] = {} 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 presentation = ItemPresentation.new()
var _indexed_entity_count: int = -1 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 auto_production: Array:
var defer_item_updates: bool = false get: return catalog.automatic_producers
var item_amounts_dirty: bool = true var locked_entities: Array:
get: return catalog.unlockable_entities
var auto_production: Array = []
var locked_entities: Array = []
# ------------------------------------ # ------------------------------------
@@ -123,45 +137,12 @@ func _evolute_to_datetime(datetime: float) -> void:
refresh_item_amounts() 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]: func get_item_amounts(total_produced: bool = false) -> Dictionary[String, float]:
var source: Dictionary = state.stats.total_amount if total_produced else state.amount return ItemPresentation.amounts(state, catalog, total_produced)
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
func refresh_item_amounts(force: bool = false) -> void: func refresh_item_amounts(force: bool = false) -> void:
item_amounts_dirty = true presentation.refresh(state, catalog, force)
if defer_item_updates and not force:
return
items_to_update = get_item_amounts()
item_amounts_dirty = false
func _add_time_delta(delta: float) -> Dictionary: 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. ## Rebuild after changing group_as or replacing definitions without changing their count.
## Adding/removing definitions is detected automatically during initialization. ## Adding/removing definitions is detected automatically during initialization.
func rebuild_group_index() -> void: func rebuild_group_index() -> void:
_group_members.clear() catalog.rebuild_group_index()
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: func _is_group(id: String) -> bool:
_ensure_group_index() return catalog.is_group(id)
return entity_groups.has(id) or _group_members.has(id)
func _physical_amounts(source: Dictionary) -> Dictionary: func _physical_amounts(source: Dictionary) -> Dictionary:
@@ -223,10 +192,7 @@ func _physical_amounts(source: Dictionary) -> Dictionary:
func _members(id: String) -> Array: func _members(id: String) -> Array:
if not _is_group(id): return catalog.members(id)
return [id]
# Callers may modify the returned array without changing the index.
return _group_members.get(id, []).duplicate()
func _available(source: Dictionary, id: String) -> float: 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: func _accumulate_whole(target: Dictionary, field: String, key: String, value: float) -> float:
if not target.has(field): return QuantityMath.accumulate_whole(target, field, key, value)
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
func _create_planets(target: Dictionary, star_id: String, count: float, 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. # Largest-remainder allocation: whole float values, bounded by each request.
func _allocate_whole(requests: Dictionary, limit: float) -> Dictionary: func _allocate_whole(requests: Dictionary, limit: float) -> Dictionary:
var total: float = 0.0 return QuantityMath.allocate_whole(requests, limit)
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
# The entity currently exposes type/amount fields; life_product can contain # 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. # Find the smallest whole batch whose cost is whole for every resource.
# Decimal costs are represented as integer fractions; combine their denominators. # Decimal costs are represented as integer fractions; combine their denominators.
func _production_step(entity: OwniverseEntity) -> float: func _production_step(entity: OwniverseEntity) -> float:
var step: float = 1.0 return QuantityMath.production_step(entity.cost.values() + entity.bind.values())
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
func _whole_gcd(a: float, b: float) -> float: func _whole_gcd(a: float, b: float) -> float:
while b > 0.0: return QuantityMath.whole_gcd(a, b)
var remainder: float = fmod(a, b)
a = b
b = remainder
return a
func calculate_max_producible(entity: OwniverseEntity, desired_amount: float = 1.0E150) -> float: 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 ## @param values - dictionary containing numeric values
## @return dictionary with normalized values, each ranging from 0.0 to 1.0 ## @return dictionary with normalized values, each ranging from 0.0 to 1.0
func _normalize_distribution(values: Dictionary) -> Dictionary: func _normalize_distribution(values: Dictionary) -> Dictionary:
var normalized_distribution: Dictionary = {} return QuantityMath.normalize_distribution(values)
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
#endregion #endregion
+111
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dfl47j0lvkt2y