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:
@@ -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 применяет результат разблокировки к контролам и хранит список закрытых иконок. Снимки и флаги принадлежат отдельному миру и не включаются в сохранение.
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://6g3m8wmm0ms7
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cdivwab70aqfb
|
||||
+35
-120
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dfl47j0lvkt2y
|
||||
Reference in New Issue
Block a user