Implement civilization flow mechanics and profiling for resource allocation

- Added `civilization_flow.gd` to manage continuous expected flows and resource allocation within civilizations.
- Introduced `allocate_profiled` and `whole_quotas` functions in `quantity_math.gd` for precise stock allocation based on float shares.
- Enhanced `credit` logic in `object_ledger.gd` to handle production remainders more effectively.
- Updated `append_batch` in `object_batches.gd` to prevent batch creation for invalid IDs.
- Introduced `ratio_approx` function in `exact.gd` for better handling of float profiles.
- Added a benchmark script `benchmark_civilization_flows.gd` to evaluate the performance of civilization flow mechanics.
- Refactored various functions to improve clarity and efficiency in handling resource flows and allocations.
This commit is contained in:
Kirill
2026-10-07 22:51:55 +03:00
parent 6ee66a627d
commit 3fcc7211ce
41 changed files with 247959 additions and 817 deletions
+2 -2
View File
@@ -35,7 +35,7 @@ Stage обращается непосредственно к `world.presentation
| `automatic_production.gd` / `automatic` | Выпуск за период и жизнь новорождённых объектов, разделение ресурсов на обычные и автоматические, энергия, квоты звёзд и бурых карликов |
| `stellar_rules.gd` / `stellar` | Продукты звёздной эволюции, создание планет, выбор звёздных преемников, два уровня разрушения и глобальные бласты |
| `life_evolution.gd` / `life` | Прогресс, деградация и повторная попытка планетарной жизни |
| `civilization_evolution.gd`, `civilization_state.gd` | Существующие правила цивилизаций: этики, войны, колонизация, лимиты и места обитания |
| `civilization_flow.gd`, `civilization_state.gd` | Непрерывные потоки цивилизаций, доли этик, целые квоты, лимиты и места обитания |
| `universe_environment.gd` / `environment` | Плотность, металличность, доля попаданий бластов, изменение параметров мира с сохранением возраста звёзд |
| `evolution_coordinator.gd` / `evolution` | Порядок фаз шага, последовательные поколения и публикация нового состояния |
@@ -50,7 +50,7 @@ Stage обращается непосредственно к `world.presentation
1. Определить обработанную десятую года. При продвижении времени восстановить недостающие партии жизни и проверить вместимость.
2. Скопировать состояние, посчитать физические количества и производство за период.
3. Обработать поколения до конца интервала. Для каждого поколения: локальные разрушения → выбытие партий всех типов → план переходов → резервирование передачи привязок → зачисление преемников и их партий → производство новорождённых → применение привязок и округление → завершение переходов цивилизаций → глобальные бласты → проверка вместимости.
3. Интегрировать непрерывные потоки цивилизаций за весь интервал, затем обработать поколения звёзд и биологической жизни до конца интервала. Для каждого поколения: локальные разрушения → выбытие партий всех типов → план переходов → резервирование передачи привязок → зачисление преемников и их партий → производство новорождённых → применение привязок и округление → глобальные бласты → проверка вместимости.
4. Обновить групповые количества, ограничить автоматические ресурсы и продвинуть параметры мира.
5. Опубликовать новое состояние и обновить представление количеств.
+8 -448
View File
@@ -1,191 +1,11 @@
extends RefCounted
## Shared civilization coefficients and worship derived from float profiles.
const E = preload("res://world/owniverse/math/exact.gd")
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"}
const DIPLOMACY_SCALE: int = 1000000000
static var _diplomacy_rates: Dictionary = {}
static func total(pop: Dictionary) -> Variant:
return QuantityMath.sum(pop)
static func add(to: Dictionary, from: Dictionary) -> void:
for key in from: to[key] = E.add(to.get(key, 0), from[key])
static func take(world, pop: Dictionary, count: Variant, population_total: Variant = null) -> Dictionary:
var taken: Dictionary = QuantityMath.allocate_whole_with_total(pop, count, population_total) if population_total != null else QuantityMath.allocate_whole(pop, count)
for key in taken: pop[key] = E.subtract(pop[key], taken[key])
return taken
static func rate(target: Dictionary, key: String, count: Variant, probability: Variant) -> Variant:
if (not target.has("civilization_remainders")): target.civilization_remainders = {}
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: Variant, weights: Dictionary) -> Dictionary:
if E.le(count, 0): return {}
if (not target.has("civilization_remainders")): target.civilization_remainders = {}
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, population: Variant = null) -> Dictionary:
if population == null: population = total(pop)
if E.le(population, 0): return pop.duplicate()
if not diplomacy and E.eq(population, E.floor_value(population)):
return direct_drift(target, key, pop, colony, population)
var weights: Array = []
if not diplomacy:
# Integer populations: exact algebraic reduction of the 6 x 6 matrix.
var common: Variant = E.multiply(population, 9) if colony else population
for ethic in ETHICS:
weights.append(E.subtract(E.add(common, E.multiply(pop.get(ethic, 0), 951 if colony else 195)), E.multiply(pop.get(OPPOSITE[ethic], 0), 5 if colony else 1)))
else:
# Algebraic reduction of the diplomatic 6 x 6 matrix.
var rates: Array = diplomacy_rates(rows)
var contributions: Array = [0, 0, 0, 0, 0, 0]
var combined: Variant = 0
for ethic in pop:
var index: int = ETHICS.find(ethic)
var contribution: Variant = E.multiply(pop[ethic], rates[index])
contributions[index] = contribution
combined = E.add(combined, contribution)
var common: Variant = E.multiply(combined, 9) if colony else combined
for ethic in ETHICS:
weights.append(E.maximum(0, E.subtract(E.add(common, E.multiply(contributions[ETHICS.find(ethic)], 951 if colony else 195)), E.multiply(contributions[ETHICS.find(OPPOSITE[ethic])], 5 if colony else 1))))
if E.le(QuantityMath.sum_array(weights), 0): return pop.duplicate()
if not target.has("civilization_remainders"): target.civilization_remainders = {}
return QuantityMath.split_arrays(target.civilization_remainders, key, population, ETHICS, weights, 0, true)
static func diplomacy_rates(rows: Dictionary) -> Array:
var signature: String = ""
for ethic in ETHICS: signature += str(rows[ethic].diplomacy_rate) + ";"
if _diplomacy_rates.has(signature): return _diplomacy_rates[signature]
var rates: Array = []
for ethic in ETHICS:
var exact_rate: Variant = E.of(rows[ethic].diplomacy_rate)
rates.append(E.integer(E.round_value(E.multiply(exact_rate, DIPLOMACY_SCALE))))
if _diplomacy_rates.size() >= 256: _diplomacy_rates.clear()
rates.make_read_only()
_diplomacy_rates[signature] = rates
return rates
static func direct_drift(target: Dictionary, key: String, pop: Dictionary, colony: bool, population: Variant) -> Dictionary:
if not target.has("civilization_remainders"): target.civilization_remainders = {}
var carries: Dictionary = target.civilization_remainders
var result: Dictionary = {}
for ethic in ETHICS: result[ethic] = 0
for ethic in ETHICS:
if E.ne(pop.get(ethic, 0), E.floor_value(pop.get(ethic, 0))): return fractional_direct_fallback(target, key, pop, colony, population)
var divisor: int = 25 if colony else 50
for source in ETHICS:
var count: Variant = pop.get(source, 0)
if E.le(count, 0): continue
var whole: Variant = E.floor_value(E.divide(count, divisor))
var count_remainder: int = E.integer(E.subtract(count, E.multiply(whole, divisor)))
var remainder_key: String = key + ":flow:" + source
var pending: int = count_remainder + int(carries.get(remainder_key, 0))
var outgoing: Variant = E.add(whole, pending / divisor)
carries[remainder_key] = pending % divisor
result[source] = E.add(result[source], E.subtract(count, outgoing))
if E.le(outgoing, 0): continue
if not colony:
var neighbor_base: Variant = E.floor_value(E.divide(outgoing, 4))
var extras: int = E.integer(E.subtract(outgoing, E.multiply(neighbor_base, 4)))
var neighbors: Array = []
for destination in ETHICS:
if destination != source and destination != OPPOSITE[source]: neighbors.append(destination)
var cursor_key: String = key + ":flow_cursor:" + source
var cursor: int = int(carries.get(cursor_key, 0))
for neighbor_index in 4:
var destination: String = neighbors[neighbor_index]
var add: Variant = E.add(neighbor_base, 1 if ((neighbor_index - cursor + 4) % 4) < extras else 0)
result[destination] = E.add(result[destination], add)
carries[cursor_key] = (cursor + extras) % 4
else:
distribute_colonial_flow(result, carries, key, source, outgoing)
assert(E.eq(total(result), population), "Direct ethic drift must conserve population")
return result
static func distribute_colonial_flow(result: Dictionary, carries: Dictionary, key: String, source: String, outgoing: Variant) -> void:
# Colonial flow is 9:9:9:9:4 among the other ethics (total weight 40).
var base: Variant = E.floor_value(E.divide(outgoing, 40))
var tail: int = E.integer(E.subtract(outgoing, E.multiply(base, 40)))
var destinations: Array = []
var weights: Array = []
for destination in ETHICS:
if destination == source: continue
destinations.append(destination)
weights.append(4 if destination == OPPOSITE[source] else 9)
var assigned: Variant = 0
var rests: Array = []
var quotas: Array = []
for i in destinations.size():
var carry_key: String = key + ":col_flow:" + source + ":" + destinations[i]
var numerator: int = tail * weights[i] + int(carries.get(carry_key, 0))
var extra: int = floori(float(numerator) / 40.0)
var rest: int = numerator - extra * 40
quotas.append(E.add(E.multiply(base, weights[i]), extra))
rests.append(rest)
assigned = E.add(assigned, quotas[i])
carries[carry_key] = rest
var missing: int = E.integer(E.subtract(outgoing, assigned))
var order: Array = [0, 1, 2, 3, 4]
order.sort_custom(func(a: int, b: int) -> bool: return rests[a] > rests[b] if rests[a] != rests[b] else destinations[a] < destinations[b])
for i in order:
if missing <= 0: break
quotas[i] = E.add(quotas[i], 1)
var carry_key: String = key + ":col_flow:" + source + ":" + destinations[i]
carries[carry_key] = int(carries[carry_key]) - 40
missing -= 1
for i in destinations.size(): result[destinations[i]] = E.add(result[destinations[i]], quotas[i])
static func fractional_direct_fallback(target: Dictionary, key: String, pop: Dictionary, colony: bool, population: Variant) -> Dictionary:
var weights: Dictionary = {}
for ethic in ETHICS:
var common: Variant = E.multiply(population, 9) if colony else population
weights[ethic] = E.subtract(E.add(common, E.multiply(pop.get(ethic, 0), 951 if colony else 195)), E.multiply(pop.get(OPPOSITE[ethic], 0), 5 if colony else 1))
if not target.has("civilization_remainders"): target.civilization_remainders = {}
return QuantityMath.split(target.civilization_remainders, key, population, weights)
static func emit(plan: Dictionary, destination: String, pop: Dictionary) -> void:
if (not plan.outputs.has(destination)): plan.outputs[destination] = {}
add(plan.outputs[destination], pop)
static func fall(target: Dictionary, plan: Dictionary, rules: Dictionary, pop: Dictionary) -> void:
for ethic in pop:
if E.le(pop[ethic], 0): continue
var weights: Dictionary = QuantityMath.rule_weights(str(rules[plan.id].ethics[ethic].fall))
if weights.is_empty(): weights = {rules[plan.id].previous: 1}
var shares: Dictionary = split(target, E.add(E.add(E.add("fall:", plan.id), ":"), ethic), pop[ethic], weights)
for stage in shares: emit(plan, stage, {ethic: shares[stage]})
static func progress(world, target: Dictionary, rules: Dictionary, budget: Dictionary, plan: Dictionary, pop: Dictionary, population_total: Variant = null) -> Dictionary:
var successor: String = rules[plan.id].next
if population_total == null: population_total = total(pop)
var admitted: Variant = State.admit_transition(budget, rules, plan.id, successor, population_total)
var accepted: Dictionary = take(world, pop, admitted, population_total)
emit(plan, successor, accepted)
return pop
static func new_plan(id: String, year: Variant) -> Dictionary:
return {"id": id, "from": year, "to": E.neg(INF), "amount": 0, "outputs": {}}
static func consume_untriggered(world, target: Dictionary, plan: Dictionary, pop: Dictionary, count: Variant, year: Variant, population_total: Variant = null) -> Dictionary:
var old_total: Variant = total(pop) if population_total == null else population_total
var actual: Variant = E.floor_value(E.minimum(E.maximum(0, count), old_total))
var removed: Dictionary = take(world, pop, actual, old_total)
if E.gt(actual, 0):
world.batches.scale_batches(target, plan.id, E.divide((E.subtract(old_total, actual)), old_total))
plan.amount = E.add(plan.amount, actual)
plan.to = E.maximum(plan.to, year)
return removed
# Cache products per ethic, then sum them in the original stage/ethic order.
# Keeping the summation order preserves float fractional rounding exactly.
# Cache the weighted float worship rate multiplied by the exact population.
static func worship(world, target: Dictionary, rules: Dictionary) -> Variant:
var result: Variant = 0
var physical: Dictionary = world.ledger.physical_amounts(target)
@@ -217,271 +37,11 @@ static func coefficients(world, rules: Dictionary, id: String, modifiers: Dictio
var rates: Dictionary = {}
for ethic in rules[id].ethics:
var row: Dictionary = rules[id].ethics[ethic]
var p: Variant = E.maximum(0, E.multiply(E.of(row.progress), modifiers.progress))
var d: Variant = E.maximum(0, E.multiply(E.of(row.degradation), modifiers.degradation))
var scale: Variant = E.maximum(1, E.add(p, d))
rates[ethic] = {"pro": E.divide(p, scale), "deg": E.divide(d, scale),
"war": E.minimum(1, E.multiply(E.of(row.war), modifiers.war)),
"col": E.minimum(1, E.multiply(E.of(row.colonization), modifiers.colonization))}
var p: float = maxf(0.0, E.approx(E.of(row.progress)) * E.approx(modifiers.progress))
var d: float = maxf(0.0, E.approx(E.of(row.degradation)) * E.approx(modifiers.degradation))
var scale: float = maxf(1.0, p + d)
rates[ethic] = {"pro": p / scale, "deg": d / scale,
"war": clampf(E.approx(E.of(row.war)) * E.approx(modifiers.war), 0.0, 1.0),
"col": clampf(E.approx(E.of(row.colonization)) * E.approx(modifiers.colonization), 0.0, 1.0)}
world.ledger.civilization_coefficients[id] = {"signature": signature, "rates": rates}
return rates
static func prepare(world, target: Dictionary, expired: Dictionary, year: Variant, budget: Dictionary, rules: Dictionary, physical: Dictionary = {}, budget_timer: RefCounted = null, expired_totals: Dictionary = {}) -> Dictionary:
var profile_started: int = world.profile_start()
var ids: Array = []
for id in expired:
if rules.has(id) and not expired[id].is_empty(): ids.append(id)
if ids.is_empty():
world.profile_end("civilization_prepare", profile_started)
return {"active": false}
var reconcile_ids: Array = []
for id in ids:
reconcile_ids.append(id)
if rules[id].fights and not reconcile_ids.has(rules[id].previous): reconcile_ids.append(rules[id].previous)
if budget_timer != null: budget_timer.call("set_phase", "civilization_reconcile")
State.reconcile(world, target, rules, physical, reconcile_ids)
if physical.is_empty(): physical = world.ledger.physical_amounts(target)
var remaining: Dictionary = {}
for id in reconcile_ids:
if rules.has(id):
remaining[id] = State.materialize_profile(target, id, target.ethics.get(id, {}), physical.get(id, 0))
var copied: Dictionary = {}
var plans: Dictionary = {}
var colonies: Array = []
var collateral: Dictionary = {}
var modifiers: Dictionary = WorldParameters.life_modifiers(target)
var order: Variant = WorldParameters.value(target, "Order")
if (not target.has("civilization_stats")): target.civilization_stats = {}
ids.sort()
for id in ids:
if budget_timer != null: budget_timer.call("set_phase", "civilization_stage:" + id)
if budget_timer != null and not await budget_timer.pause(): return {}
if ((not rules.has(id)) or expired[id].is_empty()): continue
var plan: Dictionary = new_plan(id, year)
plans[id] = plan
plan.amount = expired_totals.get(id, 0)
for part : Dictionary in expired[id]:
if not expired_totals.has(id): plan.amount = E.add(plan.amount, part.amount)
plan.from = E.minimum(plan.from, part.from)
plan.to = E.maximum(plan.to, part.to)
remaining[id] = remaining[id].duplicate()
copied[id] = true
var triggered: Dictionary = take(world, remaining[id], plan.amount)
var successes: Dictionary = {}
var failures: Dictionary = {}
var warriors: Dictionary = {}
var settlers: Dictionary = {}
var stage_rates: Dictionary = coefficients(world, rules, id, modifiers)
for ethic in triggered:
if budget_timer != null: budget_timer.call("set_phase", "civilization_ethic:" + id)
if budget_timer != null and not await budget_timer.pause(): return {}
var probability: Dictionary = stage_rates[ethic]
var prefix: String = id + ":" + ethic
var pro: Variant = rate(target, prefix + ":pro", triggered[ethic], probability.pro)
var deg: Variant = rate(target, prefix + ":deg", triggered[ethic], probability.deg)
var bounded: Dictionary = QuantityMath.allocate_whole({"pro": pro, "deg": deg}, triggered[ethic])
if rules[id].fights: warriors[ethic] = rate(target, prefix + ":war", triggered[ethic], probability.war)
if rules[id].colonizes: settlers[ethic] = rate(target, prefix + ":col", triggered[ethic], probability.col)
successes[ethic] = bounded.pro
failures[ethic] = bounded.deg
triggered[ethic] = E.subtract(triggered[ethic], E.add(bounded.pro, bounded.deg))
var profile_transitions: int = world.profile_start()
add(triggered, progress(world, target, rules, budget, plan, successes))
if budget_timer != null and not await budget_timer.pause(): return {}
fall(target, plan, rules, failures)
world.profile_end("civilization_transitions", profile_transitions)
if rules[id].fights:
var profile_wars: int = world.profile_start()
for ethic in warriors:
if budget_timer != null: budget_timer.call("set_phase", "civilization_war:" + id)
if budget_timer != null and not await budget_timer.pause(): return {}
warriors[ethic] = E.minimum(warriors[ethic], triggered[ethic])
var warriors_total: Variant = total(warriors)
var triggered_total: Variant = total(triggered)
var remaining_total: Variant = total(remaining[id])
var wars: Variant = E.minimum(warriors_total, E.floor_value(E.divide(E.add(triggered_total, remaining_total), 2)))
warriors = QuantityMath.allocate_whole_with_total(warriors, wars, warriors_total)
for ethic in warriors: triggered[ethic] = E.subtract(triggered[ethic], warriors[ethic])
triggered_total = E.subtract(triggered_total, wars)
# Integrate the four equally weighted battle outcomes directly.
# Their expected coefficients are wins=17/36, deaths=1/9,
# total losses=29/18, and collateral=40/3 per battle. Rate
# remainders preserve fractional results between generations.
var wins_requested: Variant = rate(target, E.add(id, ":wins"), wars, E.of("17/36"))
var winners: Dictionary = take(world, warriors, wins_requested, wars)
var winner_total: Variant = E.floor_value(E.minimum(E.maximum(0, wins_requested), wars))
warriors_total = E.subtract(wars, winner_total)
var deaths_expected: Variant = E.divide(wars, 9)
var losses_requested: Variant = rate(target, E.add(id, ":attackers_lost"), E.divide(deaths_expected, order), 1)
var attackers_lost: Dictionary = take(world, warriors, losses_requested, warriors_total)
var attackers_lost_total: Variant = E.floor_value(E.minimum(E.maximum(0, losses_requested), warriors_total))
warriors_total = E.subtract(warriors_total, attackers_lost_total)
add(triggered, progress(world, target, rules, budget, plan, winners, winner_total))
triggered_total = total(triggered)
if budget_timer != null and not await budget_timer.pause(): return {}
fall(target, plan, rules, attackers_lost)
remaining_total = total(remaining[id])
var losses_expected: Variant = E.multiply(wars, E.of("29/18"))
var losses: Variant = E.minimum(rate(target, E.add(id, ":loss"), E.divide(losses_expected, order), 1), E.add(triggered_total, remaining_total))
var sides: Dictionary = QuantityMath.allocate_whole_with_total({"due": triggered_total, "later": remaining_total}, losses, E.add(triggered_total, remaining_total))
var victims: Dictionary = take(world, triggered, sides.due, triggered_total)
triggered_total = E.subtract(triggered_total, sides.due)
add(victims, consume_untriggered(world, target, plan, remaining[id], sides.later, year, remaining_total))
if budget_timer != null and not await budget_timer.pause(): return {}
fall(target, plan, rules, victims)
add(triggered, warriors)
triggered_total = E.add(triggered_total, warriors_total)
var damage_expected: Variant = E.multiply(wars, E.of("40/3"))
var damage: Variant = rate(target, E.add(id, ":collateral"), E.divide(damage_expected, order), 1)
collateral[rules[id].previous] = E.add(collateral.get(rules[id].previous, 0), damage)
target.civilization_stats.wars = E.add(target.civilization_stats.get("wars", 0), wars)
world.profile_end("wars", profile_wars)
emit(plan, id, triggered)
if E.gt(total(settlers), 0): colonies.append({"stage": rules[id].colony, "source": id, "population": settlers})
# Collateral is applied after all stages planned their own attempts.
for id in collateral:
if (not rules.has(id)): continue
if not copied.has(id):
remaining[id] = remaining[id].duplicate()
copied[id] = true
if (not plans.has(id)): plans[id] = new_plan(id, year)
var plan: Dictionary = plans[id]
var retry: Dictionary = plan.outputs.get(id, {})
var retry_total: Variant = total(retry)
var remaining_total: Variant = total(remaining[id])
var amount: Variant = E.minimum(collateral[id], E.add(retry_total, remaining_total))
var sides: Dictionary = QuantityMath.allocate_whole_with_total({"due": retry_total, "later": remaining_total}, amount, E.add(retry_total, remaining_total))
var victims: Dictionary = take(world, retry, sides.due, retry_total)
add(victims, consume_untriggered(world, target, plan, remaining[id], sides.later, year, remaining_total))
if budget_timer != null and not await budget_timer.pause(): return {}
fall(target, plan, rules, victims)
target.civilization_stats.collateral = E.add(target.civilization_stats.get("collateral", 0), total(victims))
var incoming: Dictionary = {}
var planetary: Dictionary = {}
for id in plans:
if budget_timer != null: budget_timer.call("set_phase", "civilization_products:" + id)
if budget_timer != null and not await budget_timer.pause(): return {}
var plan: Dictionary = plans[id]
var products: Dictionary = {}
for destination in plan.outputs:
if budget_timer != null: budget_timer.call("set_phase", "civilization_drift:" + id)
if budget_timer != null and not await budget_timer.pause(): return {}
var pop: Dictionary = plan.outputs[destination]
var count: Variant = total(pop)
if rules.has(destination):
if E.ne(destination, "Ct") and E.gt(count, 0):
var profile_drift: int = world.profile_start()
pop = drift(target, E.add(E.add(E.add(id, ":"), destination), ":drift"), pop, rules[id].ethics, (E.eq(destination, id) and rules[id].diplomacy), false, count)
world.profile_end("ethics_drift", profile_drift)
if (not incoming.has(destination)): incoming[destination] = {}
add(incoming[destination], pop)
products[destination] = count
if (State.is_space_civilization(id, rules) and E.eq(world.catalog.entities[destination].group_as, "Planet")):
planetary[destination] = E.add(planetary.get(destination, 0), count)
expired[id] = [{"amount": plan.amount, "from": plan.from, "to": E.maximum(plan.from, plan.to), "products": products}]
world.profile_end("civilization_prepare", profile_started)
return {"active": true, "remaining": remaining, "incoming": incoming, "planetary": planetary, "colonies": colonies, "base_physical": physical.duplicate()}
static func finish(world, target: Dictionary, transaction: Dictionary, year: Variant, rules: Dictionary, budget_timer: RefCounted = null) -> void:
var profile_started: int = world.profile_start()
if not transaction.get("active", true):
State.reconcile(world, target, rules)
if not target.has("civilization_stats"): target.civilization_stats = {}
target.civilization_stats.worship = worship(world, target, rules)
world.profile_end("civilization_finish", profile_started)
return
var remaining: Dictionary = transaction.remaining
var incoming: Dictionary = transaction.incoming
for id in remaining:
var counts: Dictionary = remaining[id].duplicate()
add(counts, incoming.get(id, {}))
target.ethics[id] = State.profile_from_counts(counts)
for id in incoming:
if not remaining.has(id):
target.ethics[id] = State.merge_profile(target.ethics.get(id, {}), transaction.base_physical.get(id, 0), incoming[id])
# Space civilizations that fall back to planetary stages need a stellar host.
var physical: Dictionary = world.ledger.physical_amounts(target)
var hosts: Dictionary = {}
for id in physical:
if (E.eq(world.catalog.entities[id].event_group, "DwStar") and E.gt(physical[id], 0)): hosts[id] = physical[id]
for id in transaction.planetary:
var shares: Dictionary = split(target, E.add("return:", id), E.minimum(transaction.planetary[id], target.amount.get(id, 0)), hosts)
for owner in shares:
if (not target.binds.has(owner)): target.binds[owner] = {}
target.binds[owner][id] = E.add(target.binds[owner].get(id, 0), shares[owner])
target.amount[id] = E.subtract(target.amount[id], shares[owner])
if budget_timer != null:
await colonize(world, target, transaction.colonies, year, rules, physical, budget_timer)
if budget_timer.cancelled: return
else:
colonize(world, target, transaction.colonies, year, rules, physical)
State.reconcile(world, target, rules, physical if transaction.colonies.is_empty() else {})
target.civilization_stats.worship = worship(world, target, rules)
world.profile_end("civilization_finish", profile_started)
static func colonize(world, target: Dictionary, colonies: Array, year: Variant, rules: Dictionary, physical: Dictionary = {}, budget_timer: RefCounted = null) -> void:
if colonies.is_empty(): return
var profile_colonies: int = world.profile_start()
if physical.is_empty(): physical = world.ledger.physical_amounts(target)
var stock: Dictionary = {}
for id in ["GG", "AB", "RP", "HP"]: stock[id] = physical.get(id, 0)
var requests: Dictionary = {}
for i in colonies.size(): requests[str(i)] = total(colonies[i].population)
var granted: Dictionary = QuantityMath.allocate_whole(requests, total(stock))
var additions: Dictionary = {}
var credit_events: Array = []
var batch_additions: Dictionary = {}
var debits: Dictionary = {}
var slots_by_resource: Dictionary = {}
for id in stock:
var slots: Dictionary = {"": target.amount.get(id, 0)}
for owner in target.binds: slots[owner] = target.binds[owner].get(id, 0)
slots_by_resource[id] = slots
debits[id] = {}
for i in colonies.size():
if budget_timer != null and not await budget_timer.pause(): return
var colony: Dictionary = colonies[i]
var population: Dictionary = QuantityMath.allocate_whole(colony.population, granted[str(i)])
population = drift(target, E.add(colony.source, ":colonies"), population, rules[colony.source].ethics, false, true)
var count: Variant = total(population)
var targets: Dictionary = QuantityMath.allocate_whole(stock, count)
for id in targets:
var amount: Variant = targets[id]
if E.le(amount, 0): continue
var slots: Dictionary = slots_by_resource[id]
var taken: Dictionary = QuantityMath.allocate_whole(slots, amount)
stock[id] = E.subtract(stock[id], amount)
for owner in taken:
if E.le(taken[owner], 0): continue
slots[owner] = E.subtract(slots.get(owner, 0), taken[owner])
debits[id][owner] = E.add(debits[id].get(owner, 0), taken[owner])
credit_events.append({"stage": colony.stage, "owner": owner, "amount": taken[owner]})
batch_additions[colony.stage] = E.add(batch_additions.get(colony.stage, 0), taken[owner])
if not additions.has(colony.stage): additions[colony.stage] = {}
add(additions[colony.stage], population)
target.civilization_stats.colonies = E.add(target.civilization_stats.get("colonies", 0), count)
# Commit each resource and owner once after all colonies have consumed their
# sequentially allocated shares. This keeps allocation order exact while
# avoiding repeated batch scaling and ledger mutations in the inner loop.
for id in debits:
var removed: Variant = E.subtract(physical.get(id, 0), stock[id])
if E.gt(physical.get(id, 0), 0):
world.batches.scale_batches(target, id, E.subtract(1, E.divide(removed, physical[id])))
for owner in debits[id]:
if E.eq(owner, ""):
target.amount[id] = E.subtract(target.amount.get(id, 0), debits[id][owner])
elif target.binds.has(owner):
target.binds[owner][id] = E.subtract(target.binds[owner].get(id, 0), debits[id][owner])
for event: Dictionary in credit_events:
var stage: String = event.stage
var owner: String = event.owner
var created: Variant = world.ledger.credit(target, stage, event.amount)
if E.ne(owner, ""):
target.amount[stage] = E.subtract(target.amount[stage], created)
if not target.binds.has(owner): target.binds[owner] = {}
target.binds[owner][stage] = E.add(target.binds[owner].get(stage, 0), created)
for stage in batch_additions:
world.batches.append_batch(target, stage, batch_additions[stage], year, year)
for id in additions:
target.ethics[id] = State.merge_profile(target.ethics.get(id, {}), physical.get(id, 0), additions[id])
world.profile_end("colonization", profile_colonies)
+480
View File
@@ -0,0 +1,480 @@
extends "res://world/owniverse/simulation_module.gd"
## Continuous expected flows. Only ledger quantities and rounding carries are exact.
const E = preload("res://world/owniverse/math/exact.gd")
const State = preload("res://world/owniverse/civilization_state.gd")
const Q = preload("res://world/owniverse/quantity_math.gd")
const Civ = preload("res://world/owniverse/civilization_evolution.gd")
const Parameters = preload("res://world/owniverse/world_parameters.gd")
const ETHICS = State.ETHICS
var _generator_key: String = ""
var _generator: Dictionary = {}
var _solutions: Dictionary = {}
func _edges(target: Dictionary, rules: Dictionary) -> Dictionary:
var modifiers: Dictionary = Parameters.life_modifiers(target)
var order: float = maxf(1e-12, E.approx(Parameters.value(target, "Order")))
var stages: Array = rules.keys()
stages.sort()
var rates: Dictionary = {}
var spans: Dictionary = {}
var progression: Dictionary = {}
var budget: Dictionary = State.stellar_budget(world, target, rules)
var signature: String = str(modifiers) + ":" + str(order)
for id in stages.duplicate():
if rules[id].ethics.is_empty(): continue
rates[id] = Civ.coefficients(world, rules, id, modifiers)
progression[id] = _can_progress(id, rules[id].next, budget, rules)
spans[id] = maxf(1e-12, E.approx(State.effective_span(world, target, id, rules)))
signature += id + str(spans[id]) + str(rates[id]) + str(rules[id]) + str(progression[id])
for row: Dictionary in rules[id].ethics.values():
for destination in Q.rule_weights(str(row.fall)):
if not stages.has(destination): stages.append(destination)
if signature == _generator_key: return _generator
var build_started: int = world.profile_start()
var index: Dictionary = {}
for i in stages.size(): index[stages[i]] = i
var rows: Array = []
var rewards: Array = []
var maximum: float = 0.0
for id in stages:
for e in 6:
var edges: Dictionary = {}
var reward: Dictionary = {"war": 0.0, "col": 0.0, "colony": "", "previous": ""}
if rates.has(id):
var ethic: String = ETHICS[e]
var r: Dictionary = rates[id][ethic]
var span: float = spans[id]
var war: float = minf(0.5, E.approx(r.war)) if rules[id].fights else 0.0
var progress: float = (E.approx(r.pro) + war * 17.0 / 36.0) / span if progression[id] else 0.0
var degradation: float = (E.approx(r.deg) + war * 31.0 / 18.0 / order) / span
var next_node: int = index[rules[id].next] * 6 + e
edges[next_node] = progress
var falls: Dictionary = Q.rule_weights(str(rules[id].ethics[ethic].fall))
if falls.is_empty(): falls = {rules[id].previous: 1}
var fall_total: float = 0.0
for weight in falls.values(): fall_total += E.approx(weight)
for destination in falls:
var node: int = index[destination] * 6 + e
edges[node] = float(edges.get(node, 0.0)) + degradation * E.approx(falls[destination]) / fall_total
reward = {"war": war / span, "col": E.approx(r.col) / span if rules[id].colonizes else 0.0,
"colony": rules[id].colony, "previous": rules[id].previous}
var outgoing: float = 0.0
var self_node: int = index[id] * 6 + e
edges.erase(self_node)
for rate_value in edges.values(): outgoing += rate_value
maximum = maxf(maximum, outgoing)
rows.append({"edges": edges, "outgoing": outgoing})
rewards.append(reward)
_generator_key = signature
_solutions.clear()
var targets: Array = []
var probabilities: Array = []
for node in rows.size():
var destinations: PackedInt32Array = []
var weights: PackedFloat64Array = []
destinations.append(node)
weights.append(maxf(0.0, 1.0 - rows[node].outgoing / maximum) if maximum > 0.0 else 1.0)
for destination in rows[node].edges:
destinations.append(destination)
weights.append(rows[node].edges[destination] / maximum if maximum > 0.0 else 0.0)
targets.append(destinations)
probabilities.append(weights)
_generator = {"stages": stages, "index": index, "rows": rows, "rewards": rewards, "lambda": maximum, "order": order, "targets": targets, "probabilities": probabilities}
world.profile_end("civilization_operator_build", build_started)
return _generator
func _can_progress(source: String, destination: String, budget: Dictionary, rules: Dictionary) -> bool:
var home: String = rules[destination].home
if home != "":
var old_cost: Variant = E.maximum(1, rules[source].stars) if rules[source].home == home else 0
var net: Variant = E.subtract(E.maximum(1, rules[destination].stars), old_cost)
if E.gt(net, 0) and E.lt(budget.free.get(home, 0), net): return false
var new_req: Dictionary = budget.habitats.requirements.get(destination, {})
var old_req: Dictionary = budget.habitats.requirements.get(source, {})
if not new_req.is_empty():
var net: Variant = E.subtract(new_req.units, old_req.get("units", 0) if old_req.get("id", "") == new_req.id else 0)
var free: Variant = E.subtract(budget.habitats.capacity.get(new_req.id, 0), budget.habitats.requested.get(new_req.id, 0))
if E.gt(net, 0) and E.lt(free, net): return false
return true
## Uniformization computes exp(Q*t) through a Poisson series of sparse steps.
## Absorbing biological stages and Ct retain their mass. No one-transition cap.
func _solve(generator: Dictionary, source: String, profile: Dictionary, years: float) -> Dictionary:
var key: String = source + ":" + str(years)
var basis: Dictionary = _solutions.get(key, {})
if basis.is_empty():
var started: int = world.profile_start()
basis = _solve_basis(generator, source, years)
world.profile_end("civilization_operator_solve", started)
if _solutions.size() >= 512: _solutions.clear()
_solutions[key] = basis
var mass: PackedFloat64Array = basis.mass.duplicate()
var occupation: PackedFloat64Array = basis.occupation.duplicate()
var shares: PackedFloat64Array = []
for ethic in ETHICS: shares.append(profile[ethic])
for node in mass.size():
mass[node] *= shares[node % 6]
occupation[node] *= shares[node % 6]
return {"mass": mass, "occupation": occupation, "occupation_scale": 1.0}
func _solve_basis(generator: Dictionary, source: String, years: float) -> Dictionary:
var size: int = generator.rows.size()
var values: PackedFloat64Array = []
values.resize(size)
var active: PackedInt32Array = []
var offset: int = generator.index[source] * 6
for e in 6:
values[offset + e] = 1.0
active.append(offset + e)
var mass: PackedFloat64Array = []
var occupation: PackedFloat64Array = []
mass.resize(size)
occupation.resize(size)
var frequency: float = generator.lambda
if frequency <= 0.0:
mass = values.duplicate()
for node in active: occupation[node] = years
return {"mass": mass, "occupation": occupation}
var mean: float = frequency * years
var weight: float = exp(-mean)
var cumulative: float = 0.0
var limit: int = maxi(32, ceili(mean + 12.0 * sqrt(mean) + 32.0))
for step in limit:
cumulative = minf(1.0, cumulative + weight)
var time_weight: float = maxf(0.0, 1.0 - cumulative) / frequency
for node in active:
mass[node] += weight * values[node]
occupation[node] += time_weight * values[node]
if step > mean and weight < 1e-18: break
var next: PackedFloat64Array = []
next.resize(size)
var next_active: PackedInt32Array = []
for node in active:
var destinations: PackedInt32Array = generator.targets[node]
var probabilities: PackedFloat64Array = generator.probabilities[node]
for i in destinations.size():
var contribution: float = values[node] * probabilities[i]
if contribution <= 0.0: continue
var destination: int = destinations[i]
if next[destination] == 0.0: next_active.append(destination)
next[destination] += contribution
values = next
active = next_active
weight *= mean / float(step + 1)
var integrated: PackedFloat64Array = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
for node in size: integrated[node % 6] += occupation[node]
for e in 6:
if integrated[e] <= 0.0:
occupation[offset + e] = years
else:
var factor: float = years / integrated[e]
for node in range(e, size, 6): occupation[node] *= factor
return {"mass": mass, "occupation": occupation}
func _drift(target: Dictionary, rules: Dictionary, generator: Dictionary, years: float) -> void:
for id in rules:
if rules[id].ethics.is_empty() or not target.ethics.has(id): continue
var profile: Dictionary = State.normalize_profile(target.ethics[id])
var span: float = maxf(1e-12, E.approx(State.effective_span(world, target, id, rules)))
var duration: float = years / span
var pair_decay: float = exp(-0.03 * duration)
var opposite_decay: float = exp(-0.02 * duration)
var result: Dictionary = {}
for ethic in ETHICS:
var opposite: float = profile[Civ.OPPOSITE[ethic]]
result[ethic] = 1.0 / 6.0 + pair_decay * ((profile[ethic] + opposite) * 0.5 - 1.0 / 6.0) + opposite_decay * (profile[ethic] - opposite) * 0.5
if rules[id].diplomacy:
var weighted: Dictionary = {}
for ethic in ETHICS: weighted[ethic] = result[ethic] * maxf(0.0, E.approx(E.of(rules[id].ethics[ethic].diplomacy_rate)))
weighted = State.normalize_profile(weighted)
var fraction: float = 1.0 - exp(-duration)
for ethic in ETHICS: result[ethic] = lerpf(result[ethic], weighted[ethic], fraction)
target.ethics[id] = State.normalize_profile(result)
func _profile(vector: Array) -> Dictionary:
var result: Dictionary = {}
for e in 6: result[ETHICS[e]] = vector[e]
return State.normalize_profile(result)
func _merge(plan: Dictionary, destination: String, count: Variant, profile: Dictionary) -> void:
if E.le(count, 0): return
var previous: Variant = plan.counts.get(destination, 0)
plan.profiles[destination] = State.merge_profiles(plan.profiles.get(destination, {}), previous, profile, count)
plan.counts[destination] = E.add(previous, count)
func _plans(target: Dictionary, physical: Dictionary, rules: Dictionary, generator: Dictionary, years: float) -> Dictionary:
var plans: Dictionary = {}
var colonies: Dictionary = {}
var damages: Dictionary = {}
var wars: Variant = 0
for source in rules:
var count: Variant = physical.get(source, 0)
if E.le(count, 0) or rules[source].ethics.is_empty(): continue
var solved: Dictionary = _solve(generator, source, State.normalize_profile(target.ethics.get(source, {})), years)
var vectors: Dictionary = {}
for node in solved.mass.size():
if solved.mass[node] <= 0.0: continue
var destination: String = generator.stages[int(node) / 6]
if not vectors.has(destination): vectors[destination] = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
vectors[destination][int(node) % 6] += solved.mass[node]
var ids: Array = []
var shares: Array = []
var profiles: Dictionary = {}
var retained: float = 0.0
for destination in vectors:
var share: float = 0.0
for value in vectors[destination]: share += value
profiles[destination] = _profile(vectors[destination])
if destination == source or share < 0.5e-15:
retained += share
else:
ids.append(destination)
shares.append(share)
ids.append(source)
shares.append(retained)
if not profiles.has(source): profiles[source] = State.normalize_profile(target.ethics.get(source, {}))
var counts: Dictionary = Q.split_profile(target.civilization_remainders, "flow:" + source, count, ids, shares)
plans[source] = {"counts": counts, "profiles": profiles}
var colony_vectors: Dictionary = {}
var damage: Dictionary = {}
var war_reward: float = 0.0
for node in solved.occupation.size():
if solved.occupation[node] <= 0.0: continue
var reward: Dictionary = generator.rewards[node]
var duration: float = solved.occupation[node] * solved.occupation_scale
war_reward += duration * reward.war
if reward.war > 0.0:
damage[reward.previous] = float(damage.get(reward.previous, 0.0)) + duration * reward.war * 40.0 / 3.0 / generator.order
if reward.col <= 0.0: continue
if not colony_vectors.has(reward.colony): colony_vectors[reward.colony] = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
colony_vectors[reward.colony][int(node) % 6] += duration * reward.col
wars = E.add(wars, Q.accumulate_rate(target.civilization_remainders, "flow:wars:" + source, count, war_reward))
for destination in damage:
damages[destination] = E.add(damages.get(destination, 0), Q.accumulate_rate(target.civilization_remainders, "flow:damage:" + source + ":" + destination, count, damage[destination]))
for destination in colony_vectors:
var reward: float = 0.0
for value in colony_vectors[destination]: reward += value
var quantity: Variant = Q.accumulate_rate(target.civilization_remainders, "flow:colony:" + source + ":" + destination, count, reward)
if not colonies.has(destination): colonies[destination] = {"counts": {}, "profiles": {}}
_merge(colonies[destination], destination, quantity, _profile(colony_vectors[destination]))
return {"plans": plans, "colonies": colonies, "damages": damages, "wars": wars}
## Positive resource costs share the same frozen free budget. Rejected movement
## stays at its source. Releases become available after this transaction commits.
func _limit(plans: Dictionary, budget: Dictionary, rules: Dictionary) -> void:
var demands: Dictionary = {}
var costs: Dictionary = {}
for source in plans:
costs[source] = {}
for destination in plans[source].counts:
if destination == source or not rules.has(destination): continue
var amount: Variant = plans[source].counts[destination]
if E.le(amount, 0): continue
var resource_costs: Dictionary = {}
var home: String = rules[destination].home
if home != "":
var old_cost: Variant = rules[source].stars if rules[source].home == home else 0
var net: Variant = E.maximum(0, E.subtract(E.maximum(1, rules[destination].stars), old_cost))
if E.gt(net, 0): resource_costs["star:" + home] = net
var housing: Dictionary = budget.habitats
var new_req: Dictionary = housing.requirements.get(destination, {})
var old_req: Dictionary = housing.requirements.get(source, {})
if not new_req.is_empty():
var net: Variant = E.maximum(0, E.subtract(new_req.units, old_req.get("units", 0) if old_req.get("id", "") == new_req.id else 0))
if E.gt(net, 0): resource_costs["house:" + new_req.id] = net
costs[source][destination] = resource_costs
for resource in resource_costs:
demands[resource] = E.add(demands.get(resource, 0), E.multiply(amount, resource_costs[resource]))
var factors: Dictionary = {}
for resource in demands:
var available: Variant
if resource.begins_with("star:"):
available = budget.free.get(resource.trim_prefix("star:"), 0)
else:
var id: String = resource.trim_prefix("house:")
available = E.maximum(0, E.subtract(budget.habitats.capacity.get(id, 0), budget.habitats.requested.get(id, 0)))
factors[resource] = [available, demands[resource]]
for source in costs:
for destination in costs[source]:
var requested: Variant = plans[source].counts[destination]
var allowed: Variant = requested
for resource in costs[source][destination]:
var ratio: Array = factors[resource]
if E.lt(ratio[0], ratio[1]): allowed = E.minimum(allowed, E.floor_value(E.mul_div(requested, ratio[0], ratio[1])))
var rejected: Variant = E.subtract(requested, allowed)
if E.le(rejected, 0): continue
plans[source].counts[destination] = allowed
_merge(plans[source], source, rejected, plans[source].profiles[destination])
func _commit(target: Dictionary, physical: Dictionary, plans: Dictionary, rules: Dictionary, year: Variant) -> void:
var credits: Dictionary = {}
var contributions: Dictionary = {}
var births: Dictionary = {}
for source in plans:
var plan: Dictionary = plans[source]
var slots: Dictionary = {"": target.amount.get(source, 0)}
for owner in target.binds:
var held: Variant = target.binds[owner].get(source, 0)
if E.gt(held, 0): slots[owner] = held
for destination in plan.counts:
var amount: Variant = plan.counts[destination]
if E.le(amount, 0): continue
if not contributions.has(destination): contributions[destination] = []
contributions[destination].append({"amount": amount, "profile": plan.profiles[destination]})
if destination == source: continue
var taken: Dictionary = Q.allocate_profiled(slots, amount)
for owner in taken:
if E.le(taken[owner], 0): continue
slots[owner] = E.subtract(slots[owner], taken[owner])
var host: String = owner
if State.is_space_civilization(destination, rules) or destination == "Ct": host = ""
elif owner == "" and State.is_space_civilization(source, rules): host = "@host"
if not credits.has(destination): credits[destination] = {}
credits[destination][host] = E.add(credits[destination].get(host, 0), taken[owner])
target.amount[source] = slots[""]
for owner in slots:
if owner != "": target.binds[owner][source] = slots[owner]
var hosts: Dictionary = {}
for id in physical:
if world.catalog.entities[id].event_group == "DwStar" and E.gt(physical[id], 0): hosts[id] = physical[id]
for destination in credits:
var created: Variant = world.ledger.credit(target, destination, Q.sum(credits[destination]))
for owner in credits[destination]:
var count: Variant = credits[destination][owner]
if owner == "" or E.le(count, 0): continue
if owner == "@host":
if hosts.is_empty(): continue
var ownership: Dictionary = Q.split_profile(target.civilization_remainders, "flow:hosts:" + destination, count, hosts.keys(), _host_shares(hosts))
for host in ownership:
if E.le(ownership[host], 0): continue
if not target.binds.has(host): target.binds[host] = {}
target.binds[host][destination] = E.add(target.binds[host].get(destination, 0), ownership[host])
target.amount[destination] = E.subtract(target.amount[destination], count)
else:
target.binds[owner][destination] = E.add(target.binds[owner].get(destination, 0), count)
target.amount[destination] = E.subtract(target.amount[destination], count)
if not rules.has(destination): births[destination] = created
var final: Dictionary = world.ledger.physical_amounts(target)
for destination in contributions:
if not rules.has(destination) or E.le(final.get(destination, 0), 0): continue
var profile: Dictionary = {}
for ethic in ETHICS: profile[ethic] = 0.0
var existing: Variant = physical.get(destination, 0) if not plans.has(destination) else 0
if E.gt(existing, 0):
var original: Dictionary = State.normalize_profile(target.ethics.get(destination, {}))
var ratio: float = E.ratio_approx(existing, final[destination])
for ethic in ETHICS: profile[ethic] += original[ethic] * ratio
for entry: Dictionary in contributions[destination]:
var ratio: float = E.ratio_approx(entry.amount, final[destination])
for ethic in ETHICS: profile[ethic] += entry.profile[ethic] * ratio
target.ethics[destination] = State.normalize_profile(profile)
for destination in births: world.batches.append_batch(target, destination, births[destination], year, year)
func _host_shares(hosts: Dictionary) -> Array:
var total: Variant = Q.sum(hosts)
var result: Array = []
for value in hosts.values(): result.append(E.ratio_approx(value, total))
return result
func _colonize(target: Dictionary, colonies: Dictionary, year: Variant, rules: Dictionary) -> void:
if colonies.is_empty(): return
var physical: Dictionary = world.ledger.physical_amounts(target)
var stocks: Dictionary = {}
for id in ["GG", "AB", "RP", "HP"]: stocks[id] = physical.get(id, 0)
var requests: Dictionary = {}
for id in colonies: requests[id] = colonies[id].counts.get(id, 0)
var granted: Dictionary = Q.allocate_profiled(requests, Q.sum(stocks))
var total: Variant = Q.sum(granted)
if E.le(total, 0): return
var taken_resources: Dictionary = Q.allocate_profiled(stocks, total)
var remaining: Dictionary = granted.duplicate()
for resource in taken_resources:
var removed: Variant = taken_resources[resource]
if E.le(removed, 0): continue
var owners: Dictionary = {"": target.amount.get(resource, 0)}
for owner in target.binds:
if E.gt(target.binds[owner].get(resource, 0), 0): owners[owner] = target.binds[owner][resource]
var taken: Dictionary = Q.allocate_profiled(owners, removed)
world.batches.scale_batches(target, resource, E.subtract(1, E.divide(removed, stocks[resource])))
for owner in taken:
var amount: Variant = taken[owner]
if E.le(amount, 0): continue
if owner == "": target.amount[resource] = E.subtract(target.amount[resource], amount)
else: target.binds[owner][resource] = E.subtract(target.binds[owner][resource], amount)
var allocation: Dictionary = Q.allocate_profiled(remaining, amount)
for destination in allocation:
var count: Variant = allocation[destination]
if E.le(count, 0): continue
remaining[destination] = E.subtract(remaining[destination], count)
var created: Variant = world.ledger.credit(target, destination, count)
if owner != "" and not State.is_space_civilization(destination, rules):
target.amount[destination] = E.subtract(target.amount[destination], created)
target.binds[owner][destination] = E.add(target.binds[owner].get(destination, 0), created)
for id in granted:
if E.le(granted[id], 0): continue
var profile: Dictionary = colonies[id].profiles[id]
var drifted: Dictionary = {}
for ethic in ETHICS:
drifted[ethic] = 0.0
for source in ETHICS:
drifted[ethic] += profile[source] * (0.96 if source == ethic else (0.004 if Civ.OPPOSITE[source] == ethic else 0.009))
target.ethics[id] = State.merge_profiles(target.ethics.get(id, {}), physical.get(id, 0), drifted, granted[id])
target.civilization_stats.colonies = E.add(target.civilization_stats.get("colonies", 0), total)
func evolve(target: Dictionary, from_year: Variant, to_year: Variant, timer: RefCounted = null) -> void:
var keep_alive: RefCounted = self
var years: float = E.approx(E.subtract(to_year, from_year))
if years <= 0.0: return
var rules: Dictionary = Global.CivilizationRules
var generator: Dictionary = {}
# Bound the Poisson mean, processing the entire requested interval.
var maximum_years: float = years
var cursor: Variant = from_year
while E.lt(cursor, to_year):
if timer != null and not await timer.pause(): return
generator = _edges(target, rules)
maximum_years = 32.0 / generator.lambda if generator.lambda > 0.0 else years
var end: Variant = E.minimum(to_year, E.add(cursor, E.of(maximum_years)))
var physical: Dictionary = world.ledger.physical_amounts(target)
var started: int = world.profile_start()
var transaction: Dictionary = _plans(target, physical, rules, generator, E.approx(E.subtract(end, cursor)))
world.profile_end("civilization_flow_plan", started)
if timer != null and not await timer.pause(): return
var plans: Dictionary = transaction.plans
var damage_total: Variant = 0
for source in transaction.damages:
if not plans.has(source): continue
var retained: Variant = plans[source].counts.get(source, 0)
var amount: Variant = E.minimum(retained, transaction.damages[source])
if E.le(amount, 0): continue
var profile: Dictionary = plans[source].profiles[source]
var weights: Dictionary = {}
for ethic in ETHICS:
var falls: Dictionary = Q.rule_weights(str(rules[source].ethics[ethic].fall))
if falls.is_empty(): falls = {rules[source].previous: 1}
var sum: float = 0.0
for weight in falls.values(): sum += E.approx(weight)
for destination in falls: weights[destination] = float(weights.get(destination, 0.0)) + profile[ethic] * E.approx(falls[destination]) / sum
var split: Dictionary = Q.split_profile(target.civilization_remainders, "flow:collateral:" + source, amount, weights.keys(), weights.values())
plans[source].counts[source] = E.subtract(retained, amount)
for destination in split: _merge(plans[source], destination, split[destination], profile)
damage_total = E.add(damage_total, amount)
started = world.profile_start()
_limit(plans, State.stellar_budget(world, target, rules, physical), rules)
world.profile_end("civilization_flow_capacity", started)
started = world.profile_start()
_commit(target, physical, plans, rules, end)
world.profile_end("civilization_flow_ledger", started)
if timer != null and not await timer.pause(): return
started = world.profile_start()
_colonize(target, transaction.colonies, end, rules)
world.profile_end("civilization_flow_colonies", started)
started = world.profile_start()
_drift(target, rules, generator, E.approx(E.subtract(end, cursor)))
target.civilization_stats.wars = E.add(target.civilization_stats.get("wars", 0), transaction.wars)
target.civilization_stats.collateral = E.add(target.civilization_stats.get("collateral", 0), damage_total)
State.enforce_capacity(world, target, rules, end)
State.reconcile(world, target, rules)
target.civilization_stats.worship = Civ.worship(world, target, rules)
world.profile_end("civilization_flow_finish", started)
cursor = end
+1
View File
@@ -0,0 +1 @@
uid://clon18ixi6yqx
+6 -28
View File
@@ -28,33 +28,11 @@ static func normalize_profile(profile: Dictionary) -> Dictionary:
result[ETHICS.back()] = maxf(0.0, 1.0 - assigned)
return result
static func profile_from_counts(population: Dictionary) -> Dictionary:
var total: Variant = QuantityMath.sum(population)
if E.le(total, 0): return empty_profile()
var profile: Dictionary = {}
for ethic in ETHICS:
profile[ethic] = E.approx(E.divide(population.get(ethic, 0), total))
return normalize_profile(profile)
static func materialize_profile(target: Dictionary, id: String, profile: Dictionary, count: Variant) -> Dictionary:
var normalized: Dictionary = normalize_profile(profile)
if not target.has("civilization_remainders"): target.civilization_remainders = {}
var shares: Array = []
for ethic in ETHICS: shares.append(normalized[ethic])
var result: Dictionary = QuantityMath.split_profile(target.civilization_remainders, "profile:" + id, count, ETHICS, shares)
for ethic in ETHICS:
if not result.has(ethic): result[ethic] = 0
return result
static func merge_profile(base: Dictionary, base_count: Variant, incoming: Dictionary) -> Dictionary:
var incoming_count: Variant = QuantityMath.sum(incoming)
return merge_profiles(base, base_count, profile_from_counts(incoming), incoming_count)
static func merge_profiles(base: Dictionary, base_count: Variant, incoming_profile: Dictionary, incoming_count: Variant) -> Dictionary:
var total: Variant = E.add(base_count, incoming_count)
if E.le(total, 0): return empty_profile()
var base_ratio: float = E.approx(E.divide(base_count, total)) if E.gt(base_count, 0) else 0.0
var incoming_ratio: float = E.approx(E.divide(incoming_count, total)) if E.gt(incoming_count, 0) else 0.0
var base_ratio: float = E.ratio_approx(base_count, total) if E.gt(base_count, 0) else 0.0
var incoming_ratio: float = E.ratio_approx(incoming_count, total) if E.gt(incoming_count, 0) else 0.0
var normalized_base: Dictionary = normalize_profile(base)
var result: Dictionary = {}
for ethic in ETHICS:
@@ -66,7 +44,7 @@ static func is_space_civilization(id: String, rules: Dictionary) -> bool:
return (rules.has(id) and E.ne(str(rules[id].home), ""))
# Keep one six-value float profile per stage. Absolute population remains in
# the exact physical object ledger and is materialized only when a transition needs it.
# the exact physical object ledger; flow calculations use shares directly.
static func reconcile(world, target: Dictionary, rules: Dictionary, physical: Dictionary = {}, only_ids: Array = []) -> void:
if (not target.has("ethics")):
target.ethics = {}
@@ -277,7 +255,7 @@ static func effective_span(world, target: Dictionary, id: String, rules: Diction
if E.le(population, 0): return base
var homes: Dictionary = stage_homes(world, target, id, world.catalog.civilization_layout(rules))
target.civilization_homes[id] = homes
var weighted: Variant = population
var multiplier: float = 1.0
for home in homes:
weighted = E.add(weighted, E.multiply(homes[home], (E.subtract(E.maximum(0, world.catalog.entities[home].civ_bonus), 1))))
return (E.mul_div(base, population, weighted) if E.gt(weighted, 0) else base)
multiplier += E.ratio_approx(homes[home], population) * (maxf(0.0, E.approx(world.catalog.entities[home].civ_bonus)) - 1.0)
return E.approx(base) / multiplier if multiplier > 0.0 else base
+31 -32
View File
@@ -1,12 +1,14 @@
extends "res://world/owniverse/simulation_module.gd"
const E = preload("res://world/owniverse/math/exact.gd")
const CivilizationEvolution = preload("res://world/owniverse/civilization_evolution.gd")
const CivilizationState = preload("res://world/owniverse/civilization_state.gd")
const SimulationTime = preload("res://world/owniverse/simulation_time.gd")
const WorldParameters = preload("res://world/owniverse/world_parameters.gd")
const QuantityMath = preload("res://world/owniverse/quantity_math.gd")
func initialize_life_batches() -> void:
for id in Global.CivilizationRules:
world.state.batches.erase(id)
world.state.batch_origins.erase(id)
CivilizationState.detach_space_civilizations(world.state, Global.CivilizationRules)
CivilizationState.enforce_capacity(world, world.state, Global.CivilizationRules, world.state.time.datetime)
var physical: Dictionary = world.ledger.physical_amounts(world.state)
@@ -51,6 +53,12 @@ func evolute_to_datetime(datetime: Variant, clock: Dictionary = {}, budget: RefC
var profile_production: int = world.profile_start()
world.automatic.produce_for_period(previous, next, quantities, distribution, from_year, to_year)
world.profile_end("production", profile_production)
if budget != null:
budget.call("set_phase", "civilization_flows")
await world.civilization_flow.evolve(next, from_year, to_year, budget)
if budget.cancelled: return
else:
world.civilization_flow.evolve(next, from_year, to_year)
var profile_generations: int = world.profile_start()
if budget != null:
await evolve_batches(next, to_year, budget)
@@ -86,8 +94,8 @@ func evolve_batches(target: Dictionary, to_year: Variant, budget: RefCounted = n
func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Dictionary = { }, budget: RefCounted = null, context: Dictionary = {}) -> bool:
var _keep_alive: RefCounted = self
# Apply direct completions at the original next-generation boundary, so
# binding transfers, the blast environment and capacity checks keep order.
# Apply direct completions at their original generation boundaries, so
# binding transfers, blast environment and capacity checks keep order.
if budget != null and not await budget.pause(): return false
if budget != null: budget.call("set_phase", "generation_setup_and_withdrawal")
var completed_now: Dictionary = completed.duplicate()
@@ -97,7 +105,9 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
var quantities: Dictionary = world.ledger.physical_amounts(target)
var expired: Dictionary = {}
var withdrawn: Dictionary = {}
var batch_ids: Array = target.batches.keys()
var batch_ids: Array = []
for id in target.batches:
if not Global.CivilizationRules.has(id): batch_ids.append(id)
var planets_changed: bool = false
var profile_withdraw: int = world.profile_start()
for id in batch_ids:
@@ -125,8 +135,8 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
break
if (not has_expired):
return false
# This per-generation summary is valid until the next transition mutates
# the cohort set. Reuse it for civilization planning and transfer ratios.
# Aggregate quantities are used to calculate the type-level transfer ratio;
# transition products remain calculated for each expiring cohort.
var expired_totals: Dictionary = {}
for id in expired:
var total: Variant = 0
@@ -138,28 +148,25 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
if budget != null: budget.call("set_phase", "civilization_stellar_budget")
var star_budget: Dictionary = CivilizationState.stellar_budget(world, target, Global.CivilizationRules, current_quantities)
if budget != null: budget.call("set_phase", "civilization_prepare")
var civilization_transaction: Dictionary = await CivilizationEvolution.prepare(world, target, expired, to_year, star_budget, Global.CivilizationRules, current_quantities, budget, expired_totals)
if budget != null and budget.cancelled: return false
var profile_products: int = world.profile_start()
for id in expired:
if budget != null and not await budget.pause(): return false
var total: Variant = expired_totals[id]
fractions[id] = (E.clamp_value(E.divide(total, quantities[id]), 0, 1) if E.gt(quantities.get(id, 0), 0) else 0)
outputs[id] = {}
var combined: Dictionary = {}
for part : Dictionary in expired[id]:
if (not part.has("products")):
for part: Dictionary in expired[id]:
if not part.has("products"):
part.products = world.stellar.evolution_products(target, world.catalog.entities[id], part.amount, context)
for successor in part.products.keys():
if Global.CivilizationRules.has(id):
continue
if Global.CivilizationRules.has(id): continue
var requested: Variant = part.products[successor]
var admitted: Variant = CivilizationState.admit_transition(star_budget, Global.CivilizationRules, id, successor, requested)
part.products[successor] = admitted
if E.lt(admitted, requested):
part.products[id] = E.subtract(E.add(part.products.get(id, 0), requested), admitted)
part.products[id] = E.add(part.products.get(id, 0), E.subtract(requested, admitted))
for successor in part.products:
combined[successor] = E.add(combined.get(successor, 0), part.products[successor])
outputs[id] = {}
if E.gt(total, 0):
for successor in combined:
outputs[id][successor] = E.divide(combined[successor], total)
@@ -175,38 +182,30 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
var incoming: Dictionary = world.ledger.transfer_evolution(target, fractions, outputs)
var new_owners: Dictionary = {}
var born_civilizations: Dictionary = {}
var blast_power: Variant = 0
for parts: Array in expired.values():
for part: Dictionary in parts:
blast_power = E.add(blast_power, part.products.get("blast", 0))
# A zero blast does not need density/mass/volume recalculated. For an actual
# blast retain the original snapshot before crediting newborn populations.
var environment: Dictionary = world.environment.stellar_environment(target) if E.gt(blast_power, 0) else {}
var resolution: Variant = world.batches.temporal_resolution(target)
if budget != null: budget.call("set_phase", "transfer_and_newborns")
for id in expired:
for part : Dictionary in expired[id]:
for part: Dictionary in expired[id]:
for successor in part.products:
if budget != null and not await budget.pause(): return false
var amount: Variant = part.products[successor]
if E.le(amount, 0): continue
if E.eq(successor, "blast"):
continue
if (not world.catalog.entities.has(successor)):
continue
if (E.eq(successor, id) and (((not world.catalog.entities[id].life_rule.is_empty()) or Global.CivilizationRules.has(id)))):
if E.le(amount, 0) or E.eq(successor, "blast") or not world.catalog.entities.has(successor): continue
if E.eq(successor, id) and (not world.catalog.entities[id].life_rule.is_empty() or Global.CivilizationRules.has(id)):
target.amount[id] = E.add(target.amount.get(id, 0), amount)
else:
amount = world.ledger.credit(target, successor, amount)
var produces: bool = not world.automatic.yearly_products(world.catalog.entities[successor]).is_empty()
var newborn_adjustment: Dictionary = {}
var cutoff: Variant = (to_year if world.stellar.uses_short_lived_fast_path(successor) else E.neg(INF))
var cutoff: Variant = to_year if world.stellar.uses_short_lived_fast_path(successor) else E.neg(INF)
var born_from: Variant = part.from
var born_to: Variant = part.to
if E.gt(resolution, 0) and (Global.CivilizationRules.has(successor) or not world.catalog.entities[successor].life_rule.is_empty()):
# Biological stages (including C0.0/C0.1) and civilizations
# share the coarse clock. Historical retry intervals must
# not retrigger their early population within the same bucket.
born_to = E.multiply(E.ceil_value(E.divide(born_to, resolution)), resolution)
born_from = born_to
var profile_newborn: int = world.profile_start()
@@ -220,6 +219,7 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
world.automatic.produce_newborn_lifetime(target, successor, amount, born_from, born_to, to_year, newborn_adjustment.get("past_years", 0), integral)
world.profile_end("newborn_batches", profile_newborn)
new_owners[successor] = E.add(new_owners.get(successor, 0), amount)
if Global.CivilizationRules.has(successor): born_civilizations[successor] = E.add(born_civilizations.get(successor, 0), amount)
world.ledger.accept_transfers(target, incoming)
if budget != null: budget.call("set_phase", "finish_and_capacity")
world.ledger.bind_new_owners(target, new_owners)
@@ -227,11 +227,10 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
for resource in world.catalog.entities[owner].bind:
for member in world.catalog.members(resource): affected[member] = true
world.ledger.round_object_allocations(target, affected)
if budget != null:
await CivilizationEvolution.finish(world, target, civilization_transaction, to_year, Global.CivilizationRules, budget)
if budget.cancelled: return false
else:
CivilizationEvolution.finish(world, target, civilization_transaction, to_year, Global.CivilizationRules)
for id in born_civilizations:
var current: Variant = world.ledger.physical_amount(target, id)
var born: Variant = born_civilizations[id]
target.ethics[id] = CivilizationState.merge_profiles(target.ethics.get(id, {}), E.maximum(0, E.subtract(current, born)), CivilizationState.empty_profile(), born)
if E.gt(blast_power, 0): world.stellar.apply_blast(target, blast_power, environment)
CivilizationState.enforce_capacity(world, target, Global.CivilizationRules, to_year)
return true
+9 -14
View File
@@ -67,20 +67,15 @@ static func divmod(a: Array, b: Array) -> Array:
assert(b != [0], "Division by zero")
if b == [1]: return [a.duplicate(), [0]]
if compare(a, b) < 0: return [[0], a.duplicate()]
# Decimal ratios often share entire base-1e9 factors. Cancel them before
# long division; restore the remainder's original scale afterwards.
var shared: int = 0
while shared < mini(a.size(), b.size()) - 1 and a[shared] == 0 and b[shared] == 0:
shared += 1
if shared:
var reduced: Array = divmod(a.slice(shared), b.slice(shared))
if reduced[1] != [0]:
var restored: Array = []
restored.resize(shared)
restored.fill(0)
restored.append_array(reduced[1])
reduced[1] = restored
return reduced
# A divisor with trailing base limbs can divide the high part directly.
# The low numerator limbs belong entirely to the remainder.
var shift: int = 0
while shift < b.size() - 1 and b[shift] == 0: shift += 1
if shift > 0:
var divided: Array = divmod(a.slice(shift), b.slice(shift))
var restored: Array = a.slice(0, shift)
if divided[1] != [0]: restored.append_array(divided[1])
return [divided[0], trim(restored)]
if b.size() == 1:
var quotient: Array = []
quotient.resize(a.size())
+33
View File
@@ -132,6 +132,23 @@ static func compare(a, b) -> int:
if magnitude == 0 and x[1] != y[1]: magnitude = 1 if x[1] > y[1] else -1
return magnitude * x[2]
## Ratios used only for float profiles, never for integer quota allocation.
static func ratio_approx(a, b) -> float:
var numerator: float = approx(a)
var denominator: float = approx(b)
if is_finite(numerator) and is_finite(denominator): return numerator / denominator
var x: Array = _blocks(a)
var y: Array = _blocks(b)
var nx: int = maxi(0, x[0].size() - 3)
var ny: int = maxi(0, y[0].size() - 3)
var leading_x: float = 0.0
var leading_y: float = 0.0
for i in range(x[0].size() - 1, nx - 1, -1): leading_x = leading_x * SMALL + x[0][i]
for i in range(y[0].size() - 1, ny - 1, -1): leading_y = leading_y * SMALL + y[0][i]
var power: int = 9 * (nx - ny)
if power < -323: return 0.0
return leading_x / leading_y * pow(10.0, power) * x[2] * y[2]
static func eq(a, b) -> bool:
if a == null: return b == null
if b == null: return false
@@ -225,6 +242,22 @@ static func mul_div(a, b, divisor):
return make(Big.multiply(Big.multiply(x[0], y[0]), z[1]), Big.multiply(Big.multiply(x[1], y[1]), z[0]), x[2] * y[2] * z[2])
## Reuse the count and divisor decomposition across all quotas.
static func whole_quotas(count, weights: Array, divisor: int) -> Array:
assert(divisor > 0 and ge(count, 0) and eq(count, floor_value(count)))
var population: Array = _blocks(count)[0]
var denominator: Array = _int_limbs(divisor)
var result: Array = []
for weight: int in weights:
if weight == 0:
result.append([0, 0.0])
elif count is int and count <= MAX_INT / weight:
var product: int = count * weight
result.append([product / divisor, float(product % divisor) / divisor])
else:
var qr: Array = Big.divmod(Big.multiply(population, _int_limbs(weight)), denominator)
result.append([_compose(qr[0]), _fraction_ratio(qr[1], denominator)])
return result
static func mul_div_many(a, values: Array, divisor) -> Array:
var result: Array = []
if not finite(a) or not finite(divisor):
+133
View File
@@ -4,6 +4,70 @@ 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")
## Diagnostic switch for paired measurements of the old and new timing models.
var cycle_stars: bool = true
var _cycle_widths: Dictionary = {}
func star_cycle_width(target: Dictionary) -> Variant:
var speed: int = int(target.get("speed", 1))
if not _cycle_widths.has(speed):
_cycle_widths[speed] = E.multiply(E.power(10, speed - 1), world.frame_step_seconds)
return _cycle_widths[speed]
## The cycle grid may widen a short window, never subdivide a wide one.
func uses_cycle_star(target: Dictionary, id: String, lifespan: Variant) -> bool:
return cycle_stars and int(target.get("speed", 1)) >= 4 and world.catalog.entities[id].group_as == "Star" and E.le(window_duration(target, lifespan), star_cycle_width(target))
func coarsen_cycle_stars(target: Dictionary, id: String) -> void:
var width: Variant = star_cycle_width(target)
var anchor: Variant = target.time.datetime
var groups: Dictionary = {}
var result: Array = []
var changed: bool = false
for batch: Dictionary in target.batches[id]:
var start: Variant = E.maximum(batch.start_year, batch.get("processed_until", batch.start_year))
var end: Variant = batch.end_year
# A cohort spanning more than one cycle stays intact.
if E.gt(E.subtract(end, start), width):
result.append(batch)
continue
var first: Variant = E.ceil_value(E.divide(E.subtract(start, anchor), width)) if E.eq(start, end) else E.add(E.floor_value(E.divide(E.subtract(start, anchor), width)), 1)
var last: Variant = E.ceil_value(E.divide(E.subtract(end, anchor), width))
if not E.eq(first, last):
result.append(batch)
continue
var key: String = str(last)
if not groups.has(key):
groups[key] = {"batch": batch, "merged": false}
continue
var entry: Dictionary = groups[key]
if not entry.merged:
entry.batch = entry.batch.duplicate()
entry.merged = true
var combined: Dictionary = entry.batch
combined.amount = E.add(combined.amount, batch.amount)
combined.withdraw_remainder = E.add(combined.get("withdraw_remainder", 0), batch.get("withdraw_remainder", 0))
if combined.has("created_from") and batch.has("created_from"):
combined.created_from = E.minimum(combined.created_from, batch.created_from)
combined.created_to = E.maximum(combined.created_to, batch.created_to)
else:
var lifespan: Variant = effective_lifespan(target, id)
combined.created_from = E.minimum(combined.get("created_from", E.maximum(0, E.subtract(combined.start_year, lifespan))), batch.get("created_from", E.maximum(0, E.subtract(batch.start_year, lifespan))))
combined.created_to = E.maximum(combined.created_from, E.maximum(combined.get("created_to", E.maximum(0, E.subtract(combined.end_year, lifespan))), batch.get("created_to", E.maximum(0, E.subtract(batch.end_year, lifespan)))))
if E.eq(combined.created_to, combined.created_from): combined.created_to = E.add(combined.created_from, width)
combined.start_year = E.add(anchor, E.multiply(last, width))
combined.end_year = combined.start_year
combined.erase("processed_until")
changed = true
if not changed: return
for entry: Dictionary in groups.values(): result.append(entry.batch)
result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return E.lt(a.get("created_from", a.start_year), b.get("created_from", b.start_year)))
target.batches[id] = result
_batch_indices.erase(id)
_cycle_indices.erase(id)
_ready_indices.erase(id)
_instant_appends.erase(id)
func temporal_resolution(target: Dictionary) -> Variant:
var speed: int = int(target.get("speed", 1))
return E.divide(E.power(10, speed - 1), 10) if speed >= 4 else 0
@@ -20,6 +84,9 @@ func coarsen(target: Dictionary) -> void:
for id in target.batches:
if target.batches[id].size() <= 1: continue
if not world.catalog.entities.has(id): continue
if cycle_stars and world.catalog.entities[id].group_as == "Star":
coarsen_cycle_stars(target, id)
continue
var lifespan: Variant = effective_lifespan(target, id)
if E.le(lifespan, 10): continue
var width: Variant = window_duration(target, lifespan)
@@ -58,6 +125,69 @@ var _instant_target: Dictionary = {}
var _instant_appends: Dictionary = {}
var _static_spans: Dictionary = {}
var _star_spans: Dictionary = {}
var _cycle_target: Dictionary = {}
var _cycle_indices: Dictionary = {}
## High-speed stars share a whole transition at a simulation-cycle boundary.
## Used only when the original window is no wider than a simulation cycle.
func append_cycle_star(target: Dictionary, id: String, amount: Variant, created_from: Variant, created_to: Variant, lifespan: Variant, completed: Dictionary, completed_before: Variant, budget: RefCounted, newborn_adjustment: Variant, newborn_until: Variant) -> Variant:
var width: Variant = star_cycle_width(target)
var anchor: Variant = target.time.datetime
var batches: Array = target.batches[id]
if not is_same(_cycle_target, target):
_cycle_target = target
_cycle_indices.clear()
var cached: Dictionary = _cycle_indices.get(id, {})
if cached.is_empty() or not is_same(cached.batches, batches) or cached.count != batches.size():
var deaths: Dictionary = {}
for batch: Dictionary in batches:
if E.eq(batch.start_year, batch.end_year): deaths[str(batch.end_year)] = batch
cached = {"batches": batches, "count": batches.size(), "deaths": deaths}
_cycle_indices[id] = cached
var duration: Variant = E.subtract(created_to, created_from)
var first_death: Variant = E.add(created_from, lifespan)
var index: Variant = E.ceil_value(E.divide(E.subtract(first_death, anchor), width)) if E.eq(duration, 0) else E.add(E.floor_value(E.divide(E.subtract(first_death, anchor), width)), 1)
var remaining: Variant = amount
var integral: Variant = E.multiply(amount, E.maximum(0, E.subtract(completed_before, E.divide(E.add(created_from, created_to), 2)))) if E.finite(completed_before) else NAN
var order_changed: bool = false
while true:
if budget != null and not await budget.pause(): return NAN
# Jump over windows with zero whole allocation for sparse populations.
if E.gt(duration, 0):
var threshold: Variant = E.add(created_from, E.mul_div(duration, E.add(E.subtract(amount, remaining), 1), amount))
index = E.maximum(index, E.ceil_value(E.divide(E.subtract(E.add(threshold, lifespan), anchor), width)))
var death: Variant = E.add(anchor, E.multiply(index, width))
var part_end: Variant = E.minimum(created_to, E.subtract(death, lifespan))
var count: Variant = remaining
if E.gt(duration, 0) and E.lt(part_end, created_to):
count = E.subtract(E.floor_value(E.mul_div(amount, E.subtract(part_end, created_from), duration)), E.subtract(amount, remaining))
if E.gt(count, 0):
if E.le(death, completed_before):
if not completed.has(id): completed[id] = []
completed[id].append({"amount": count, "from": death, "to": death})
integral = E.subtract(integral, E.multiply(count, expired_years(completed_before, death, death)))
else:
var key: String = str(death)
var batch: Dictionary = cached.deaths.get(key, {})
if not batch.is_empty() and not E.eq(batch.end_year, death): batch = {}
if batch.is_empty():
var birth_start: Variant = E.maximum(0, E.subtract(E.subtract(death, lifespan), width))
batch = {"created_from": birth_start, "created_to": E.add(birth_start, width), "start_year": death, "end_year": death, "amount": 0}
order_changed = order_changed or (not batches.is_empty() and E.lt(batch.created_from, batches.back().get("created_from", -INF)))
batches.append(batch)
cached.deaths[key] = batch
batch.amount = E.add(batch.amount, count)
if newborn_adjustment != null and E.finite(newborn_until):
newborn_adjustment.past_years = E.add(newborn_adjustment.get("past_years", 0), E.multiply(count, expired_years(newborn_until, death, death)))
remaining = E.subtract(remaining, count)
if E.eq(duration, 0) or E.ge(part_end, created_to): break
index = E.add(index, 1)
if order_changed:
batches.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return E.lt(a.get("created_from", -INF), b.get("created_from", -INF)))
cached.count = batches.size()
_batch_indices.erase(id)
_instant_appends.erase(id)
return E.maximum(0, integral) if E.finite(integral) else integral
func effective_lifespan(target: Dictionary, id: String) -> Variant:
var entity: OwniverseEntity = world.catalog.entities[id]
@@ -128,6 +258,7 @@ func scale_batches(target: Dictionary, id: String, factor: Variant) -> void:
func append_batch(target: Dictionary, id: String, amount: Variant, created_from: Variant, created_to: Variant, completed: Dictionary = { }, completed_before: Variant = - INF, budget: RefCounted = null, newborn_adjustment: Variant = null, newborn_until: Variant = NAN) -> Variant:
var _keep_alive: RefCounted = self
if Global.CivilizationRules.has(id): return NAN
if ((E.le(amount, 0) or (not world.catalog.entities.has(id))) or E.le(world.catalog.entities[id].lifespan, 0)):
return NAN
if E.lt(created_to, created_from):
@@ -139,6 +270,8 @@ func append_batch(target: Dictionary, id: String, amount: Variant, created_from:
target.batch_origins = {}
var batches: Array = target.batches[id]
var lifespan: Variant = effective_lifespan(target, id)
if uses_cycle_star(target, id, lifespan):
return await append_cycle_star(target, id, amount, created_from, created_to, lifespan, completed, completed_before, budget, newborn_adjustment, newborn_until)
var batch_duration: Variant = window_duration(target, lifespan)
if E.le(batch_duration, 0):
return NAN
+7 -3
View File
@@ -101,9 +101,13 @@ func credit(target: Dictionary, id: String, amount: Variant) -> Variant:
if is_discrete(id):
if (not target.has("production_remainders")):
target.production_remainders = {}
var expected: Variant = E.add(amount, target.production_remainders.get(id, 0))
amount = E.floor_value(expected)
target.production_remainders[id] = E.subtract(expected, amount)
var carry: Variant = target.production_remainders.get(id, 0)
if E.eq(amount, E.floor_value(amount)) and E.ge(carry, 0) and E.lt(carry, 1):
target.production_remainders[id] = carry
else:
var expected: Variant = E.add(amount, carry)
amount = E.floor_value(expected)
target.production_remainders[id] = E.subtract(expected, amount)
target.amount[id] = E.add(target.amount.get(id, 0), amount)
target.stats.total_amount[id] = E.add(target.stats.total_amount.get(id, 0), amount)
if ((world.catalog.entities.has(id) and E.ne(world.catalog.entities[id].group_as, "")) and E.ne(world.catalog.entities[id].group_as, id)):
+8 -33
View File
@@ -14,6 +14,7 @@ var stellar = preload("res://world/owniverse/stellar_rules.gd").new(self)
var life = preload("res://world/owniverse/life_evolution.gd").new(self)
var environment = preload("res://world/owniverse/universe_environment.gd").new(self)
var evolution = preload("res://world/owniverse/evolution_coordinator.gd").new(self)
var civilization_flow = preload("res://world/owniverse/civilization_flow.gd").new(self)
var profile_enabled: bool = false
var profile: Dictionary = {}
@@ -65,27 +66,7 @@ var _requested_speed: int = 0
var _queued_productions: Array = []
var frame_active_clock: Dictionary = {}
var _resume_clock: Dictionary = {}
var frame_step_seconds: float = 0.1
var _frame_step_speed: int = 0
var _frame_started_usec: int = 0
var _frame_duration_seconds: float = 0.0
var _frame_cost_ratio: float = 0.0
## Hysteresis prevents alternating step sizes around one queue threshold.
## Only scheduling uses float; both clocks and the accumulated debt stay exact.
func update_frame_step() -> float:
if _frame_step_speed != speed:
_frame_step_speed = speed
frame_step_seconds = 0.1
_frame_cost_ratio = 0.0
if speed < 4: return 1.0 / 60.0
var debt: Variant = E.maximum(0, E.subtract(frame_clock.get("datetime", state.time.datetime), state.time.datetime))
var seconds: float = E.approx(E.divide(debt, state.speed_in_year))
if seconds > frame_step_seconds * 2.0 or (_frame_cost_ratio > 0.85 and seconds > frame_step_seconds):
frame_step_seconds = 0.2 if frame_step_seconds < 0.2 else 0.5
elif seconds < frame_step_seconds * 0.5:
frame_step_seconds = 0.2 if frame_step_seconds > 0.2 else 0.1
return frame_step_seconds
const frame_step_seconds: float = 0.2
## Keep the requested clock advancing while a transaction spans render frames.
func evolute_frame(delta: float, tree: SceneTree) -> void:
@@ -93,18 +74,15 @@ func evolute_frame(delta: float, tree: SceneTree) -> void:
if frame_clock.is_empty(): frame_clock = state.time.duplicate()
frame_clock = SimulationTime.advance(frame_clock, delta, speed)
if frame_busy: return
if E.le(frame_clock.datetime, state.time.datetime) and _resume_clock.is_empty(): return
var next_clock: Dictionary = _resume_clock
if next_clock.is_empty():
next_clock = SimulationTime.advance(state.time, frame_step_seconds, speed)
# Start only after a full 0.2 seconds of simulation time is requested.
if E.gt(next_clock.datetime, frame_clock.datetime): return
frame_busy = true
frame_budget = preload("res://world/owniverse/frame_budget.gd").new(tree)
frame_active_clock = _resume_clock if not _resume_clock.is_empty() else frame_clock.duplicate()
# Bound catch-up work; never turn the entire accumulated debt into one step.
if _resume_clock.is_empty():
var normal_clock: Dictionary = SimulationTime.advance(state.time, update_frame_step(), speed)
if E.lt(normal_clock.datetime, frame_clock.datetime):
frame_active_clock = normal_clock
frame_active_clock = next_clock
_resume_clock = {}
_frame_duration_seconds = E.approx(E.divide(E.subtract(frame_active_clock.datetime, state.time.datetime), state.speed_in_year))
_frame_started_usec = Time.get_ticks_usec()
evolution.run_frame(frame_active_clock, frame_budget)
func request_production(id: String, amount: Variant, resource: bool = false) -> void:
@@ -129,9 +107,6 @@ func restore_frame_checkpoint(checkpoint: Dictionary) -> void:
_requested_speed = checkpoint.get("speed", state.speed)
func finish_frame() -> void:
if _frame_started_usec > 0 and _frame_duration_seconds > 0:
var ratio: float = (Time.get_ticks_usec() - _frame_started_usec) / 1000000.0 / _frame_duration_seconds
_frame_cost_ratio = ratio if _frame_cost_ratio == 0.0 else lerpf(_frame_cost_ratio, ratio, 0.25)
frame_busy = false
frame_commits += 1
if _requested_speed != 0:
+67 -7
View File
@@ -45,6 +45,41 @@ static func allocate_whole(requests: Dictionary, limit: Variant) -> Dictionary:
total = E.add(total, result[id])
return _allocate_whole_with_total(requests, limit, result, total)
## Whole stocks apportioned using float shares. Both the total and every stock
## ceiling are enforced exactly after quota rounding; no quantity becomes float.
static func allocate_profiled(stocks: Dictionary, limit: Variant) -> Dictionary:
var total: Variant = sum(stocks)
var available: Variant = E.floor_value(E.minimum(E.maximum(0, limit), total))
if E.eq(available, total): return stocks.duplicate()
var ids: Array = stocks.keys()
var shares: Array = []
var result: Dictionary = {}
var only: Variant = null
var positives: int = 0
for id in ids:
result[id] = 0
if E.gt(stocks[id], 0):
only = id
positives += 1
if E.le(available, 0): return result
if positives == 1:
result[only] = available
return result
for id in ids: shares.append(E.ratio_approx(stocks[id], total))
result = split_profile({}, "stock", available, ids, shares)
var assigned: Variant = 0
for id in ids:
result[id] = E.minimum(stocks[id], result[id])
assigned = E.add(assigned, result[id])
var missing: Variant = E.subtract(available, assigned)
for id in ids:
if E.le(missing, 0): break
var extra: Variant = E.minimum(missing, E.subtract(stocks[id], result[id]))
result[id] = E.add(result[id], extra)
missing = E.subtract(missing, extra)
assert(E.eq(missing, 0), "Whole stock allocation must exhaust the available quota")
return result
## Variant for callers that already maintain the sum of floored request values.
## The supplied total must equal sum(floor(max(0, requests[id]))).
@@ -61,6 +96,15 @@ static func _allocate_whole_with_total(requests: Dictionary, limit: Variant, res
if E.le(available, 0):
for id in result: result[id] = 0
return result
var only: Variant = null
var positive_count: int = 0
for id in result:
if E.gt(result[id], 0):
only = id
positive_count += 1
if positive_count == 1:
result[only] = available
return result
var ids: Array = requests.keys()
var rests: Array = []
var order: Array = []
@@ -154,21 +198,23 @@ static func split_profile(remainders: Dictionary, key: String, count: Variant, i
if _split_keys.size() >= 512: _split_keys.clear()
cached = {"ids": ids.duplicate(), "keys": keys}
_split_keys[key] = cached
var expected: Array = E.mul_div_many(count, weights, SCALE)
var quotas: Array = E.whole_quotas(count, weights, SCALE)
var rests: Array = []
var order: Array = []
var total: Variant = 0
for i in ids.size():
expected[i] = E.add(expected[i], remainders.get(cached.keys[i], 0))
result[ids[i]] = E.floor_value(E.maximum(0, expected[i]))
var fraction: float = quotas[i][1] + E.approx(remainders.get(cached.keys[i], 0))
var adjustment: int = floori(fraction)
result[ids[i]] = E.maximum(0, E.add(quotas[i][0], adjustment))
total = E.add(total, result[ids[i]])
rests.append(E.subtract(expected[i], result[ids[i]]))
rests.append(fraction - adjustment if E.ge(quotas[i][0], -adjustment) else fraction)
order.append(i)
order.sort_custom(func(a: int, b: int) -> bool: return E.gt(rests[a], rests[b]) if E.ne(rests[a], rests[b]) else str(ids[a]) < str(ids[b]))
order.sort_custom(func(a: int, b: int) -> bool: return rests[a] > rests[b] if rests[a] != rests[b] else str(ids[a]) < str(ids[b]))
if E.lt(total, count):
for i in order:
if E.ge(total, count): break
result[ids[i]] = E.add(result[ids[i]], 1)
rests[i] -= 1.0
total = E.add(total, 1)
elif E.gt(total, count):
order.reverse()
@@ -176,9 +222,10 @@ static func split_profile(remainders: Dictionary, key: String, count: Variant, i
if E.le(total, count): break
if E.le(result[ids[i]], 0): continue
result[ids[i]] = E.subtract(result[ids[i]], 1)
rests[i] += 1.0
total = E.subtract(total, 1)
for i in ids.size():
remainders[cached.keys[i]] = E.clamp_value(E.subtract(expected[i], result[ids[i]]), E.neg(1), 1)
remainders[cached.keys[i]] = clampf(rests[i], -1.0, 1.0)
return result
static func split(remainders: Dictionary, key: String, count: Variant, weights: Dictionary) -> Dictionary:
@@ -256,7 +303,20 @@ static func split_arrays(remainders: Dictionary, key: String, count: Variant, id
static func accumulate_rate(remainders: Dictionary, key: String, count: Variant, probability: Variant) -> Variant:
var expected: Variant = E.add(E.multiply(count, E.maximum(0, probability)), remainders.get(key, 0))
var product: Variant = E.multiply(count, E.maximum(0, probability))
var carry: float = E.approx(remainders.get(key, 0))
if carry >= 0.0 and carry < 1.0 and E.ge(product, 0):
if product is int:
remainders[key] = carry
return product
if product is E.Value:
var fraction: float = product.fraction + carry
if product.fraction > 0.0 and carry > 0.0 and fraction > 0.5 and absf(fraction - roundf(fraction)) <= 1e-12:
fraction = roundf(fraction)
var extra: int = floori(fraction)
remainders[key] = fraction - extra
return E.add(E.floor_value(product), extra)
var expected: Variant = E.add(product, remainders.get(key, 0))
var actual: Variant = E.floor_value(expected)
remainders[key] = E.maximum(0, E.subtract(expected, actual))
return actual