Add comprehensive tests and optimizations for simulation and UI performance

- Introduced a new benchmark save script to create a stress test scenario with extreme values for simulation performance.
- Added a full UI benchmark script to measure frame rates and performance metrics during UI interactions.
- Implemented tests for coarse generations to ensure population transitions are handled correctly.
- Created tests for extra parameters to validate the loading and application of configuration settings.
- Developed simulation optimization tests to verify the efficiency of population drift and rounding mechanisms.
- Added split optimization tests to ensure accurate allocation and rounding behavior in population management.
- Included startup parameter tests to confirm the correct initialization of global parameters before scene readiness.
- Enhanced the audit rounding ledger to compare selective and full rounding results for consistency.
This commit is contained in:
Kirill
2026-10-05 22:16:59 +03:00
parent 6f6a1be3bd
commit e48d4bbe88
57 changed files with 37279 additions and 784 deletions
+2 -10
View File
@@ -203,20 +203,12 @@ func auto_capacity(source: Dictionary, entity: OwniverseEntity, budget: Dictiona
return (E.multiply(E.floor_value(E.divide(capacity, step)), step) if (E.finite(capacity) and E.finite(step)) else 0)
func produce_newborn_lifetime(target: Dictionary, id: String, count: Variant, born_from: Variant, born_to: Variant, to_year: Variant, before_batches: Array, remaining_object_years: Variant = NAN) -> void:
func produce_newborn_lifetime(target: Dictionary, id: String, count: Variant, born_from: Variant, born_to: Variant, to_year: Variant, newborn_past_years: Variant = 0, remaining_object_years: Variant = NAN) -> void:
var products: Dictionary = yearly_products(world.catalog.entities[id])
if (products.is_empty() or E.le(count, 0)):
return
var object_years: Variant = (remaining_object_years if E.finite(remaining_object_years) else E.multiply(count, E.maximum(0, E.subtract(to_year, E.multiply((E.add(born_from, born_to)), E.of("0.5"))))))
var previous_amounts: Dictionary = {}
for old : Dictionary in before_batches:
var start: Variant = old.get("created_from", E.neg(INF))
if (not previous_amounts.has(str(start))):
previous_amounts[str(start)] = old.amount
for batch : Dictionary in target.batches.get(id, []):
var added: Variant = E.subtract(batch.amount, previous_amounts.get(str(batch.get("created_from", INF)), 0))
object_years = E.subtract(object_years, E.multiply(E.maximum(0, added), world.batches.expired_years(to_year, batch.start_year, batch.end_year)))
object_years = E.maximum(0, object_years)
object_years = E.maximum(0, E.subtract(object_years, newborn_past_years))
for resource in products:
var coefficient: Variant = E.multiply(products[resource], WorldParameters.production(target, world.catalog.entities[id]))
var output: Variant = E.multiply(object_years, coefficient)
+234 -60
View File
@@ -7,6 +7,8 @@ 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)
@@ -14,8 +16,8 @@ static func total(pop: Dictionary) -> Variant:
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) -> Dictionary:
var taken: Dictionary = QuantityMath.allocate_whole(pop, count)
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
@@ -30,18 +32,124 @@ static func split(target: Dictionary, key: String, count: Variant, weights: Dict
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) -> Dictionary:
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)
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 pop:
var influence: Variant = (E.of(rows[ethic].diplomacy_rate) if diplomacy else 1)
var contribution: Variant = E.multiply(pop[ethic], influence)
for other in ETHICS:
var weight: Variant = (9 if colony else 1)
if E.eq(other, ethic): weight = (960 if colony else 196)
elif E.eq(other, OPPOSITE[ethic]): weight = (4 if colony else 0)
weights[other] = E.add(weights.get(other, 0), E.multiply(contribution, weight))
if E.le(total(weights), 0): return pop.duplicate()
return split(target, key, total(pop), weights)
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] = {}
@@ -50,37 +158,85 @@ static func emit(plan: Dictionary, destination: String, pop: Dictionary) -> void
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 = {}
for term in str(rules[plan.id].ethics[ethic].fall).split("^", false):
var pair: PackedStringArray = term.split(":")
weights[pair[0]] = E.of(pair[1])
if weights.is_empty(): weights[rules[plan.id].previous] = 1
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) -> Dictionary:
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
var admitted: Variant = State.admit_transition(budget, rules, plan.id, successor, total(pop))
var accepted: Dictionary = take(world, pop, admitted)
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) -> Dictionary:
var old_total: Variant = total(pop)
var removed: Dictionary = take(world, pop, count)
var actual: Variant = total(removed)
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
static func prepare(world, target: Dictionary, expired: Dictionary, year: Variant, budget: Dictionary, rules: Dictionary, physical: Dictionary = {}, budget_timer: RefCounted = null) -> Dictionary:
# Cache products per ethic, then sum them in the original stage/ethic order.
# Keeping the summation order preserves float fractional rounding exactly.
static func worship(world, target: Dictionary, rules: Dictionary) -> Variant:
var result: Variant = 0
for id in rules:
if not rules[id].worships: continue
var pop: Dictionary = target.ethics[id]
var coefficients: Dictionary = {}
for ethic in pop: coefficients[ethic] = rules[id].ethics[ethic].worship
var cached: Dictionary = world.ledger.worship_products.get(id, {})
if cached.is_empty() or not E.eq(cached.population, pop) or not E.eq(cached.coefficients, coefficients):
var products: Dictionary = {}
for ethic in pop: products[ethic] = E.multiply(pop[ethic], E.of(coefficients[ethic]))
cached = {"population": pop.duplicate(), "coefficients": coefficients, "products": products}
world.ledger.worship_products[id] = cached
for ethic in pop: result = E.add(result, cached.products[ethic])
return result
static func coefficients(world, rules: Dictionary, id: String, modifiers: Dictionary) -> Dictionary:
if not E.eq(world.ledger.civilization_modifiers, modifiers):
world.ledger.civilization_modifiers = modifiers.duplicate()
world.ledger.civilization_coefficients.clear()
var cached: Dictionary = world.ledger.civilization_coefficients.get(id, {})
var signature: String = ""
for ethic in ETHICS:
var row: Dictionary = rules[id].ethics[ethic]
signature += ethic + ":" + str(row.progress) + "," + str(row.degradation) + "," + str(row.war) + "," + str(row.colonization) + ";"
if not cached.is_empty() and cached.signature == signature: return cached.rates
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))}
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()
State.reconcile(world, target, rules, physical)
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)
State.reconcile(world, target, rules, physical, reconcile_ids)
var remaining: Dictionary = target.ethics.duplicate()
var copied: Dictionary = {}
var plans: Dictionary = {}
@@ -89,15 +245,15 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: Varian
var modifiers: Dictionary = WorldParameters.life_modifiers(target)
var order: Variant = WorldParameters.value(target, "Order")
if (not target.has("civilization_stats")): target.civilization_stats = {}
var ids: Array = expired.keys()
ids.sort()
for id in ids:
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]:
plan.amount = E.add(plan.amount, part.amount)
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()
@@ -107,16 +263,15 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: Varian
var failures: Dictionary = {}
var warriors: Dictionary = {}
var settlers: Dictionary = {}
var stage_rates: Dictionary = coefficients(world, rules, id, modifiers)
for ethic in triggered:
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))
var pro: Variant = rate(target, E.add(E.add(E.add(id, ":"), ethic), ":pro"), triggered[ethic], E.divide(p, scale))
var deg: Variant = rate(target, E.add(E.add(E.add(id, ":"), ethic), ":deg"), triggered[ethic], E.divide(d, scale))
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, E.add(E.add(E.add(id, ":"), ethic), ":war"), triggered[ethic], E.minimum(1, E.multiply(E.of(row.war), modifiers.war)))
if rules[id].colonizes: settlers[ethic] = rate(target, E.add(E.add(E.add(id, ":"), ethic), ":col"), triggered[ethic], E.minimum(1, E.multiply(E.of(row.colonization), modifiers.colonization)))
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))
@@ -127,22 +282,36 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: Varian
if rules[id].fights:
var profile_wars: int = world.profile_start()
for ethic in warriors: warriors[ethic] = E.minimum(warriors[ethic], triggered[ethic])
var wars: Variant = E.minimum(total(warriors), E.floor_value(E.divide((E.add(total(triggered), total(remaining[id]))), 2)))
warriors = QuantityMath.allocate_whole(warriors, wars)
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)
var outcomes: Dictionary = split(target, E.add(id, ":wars"), wars, {"deg": 1, "pro25": 2, "pro50": 3, "pro75": 3})
var win_expected: Variant = E.add(E.add(E.multiply(outcomes.get("pro25", 0), E.of("0.25")), E.multiply(outcomes.get("pro50", 0), E.of("0.5"))), E.multiply(outcomes.get("pro75", 0), E.of("0.75")))
var loss_expected: Variant = E.add(E.add(E.add(E.multiply(outcomes.get("deg", 0), 2), outcomes.get("pro25", 0)), E.multiply(outcomes.get("pro50", 0), E.of("1.5"))), E.multiply(outcomes.get("pro75", 0), 2))
var winners: Dictionary = take(world, warriors, rate(target, E.add(id, ":wins"), win_expected, 1))
var attackers_lost: Dictionary = take(world, warriors, rate(target, E.add(id, ":attackers_lost"), E.divide(outcomes.get("deg", 0), order), 1))
add(triggered, progress(world, target, rules, budget, plan, winners))
var wins_requested: Variant = rate(target, E.add(id, ":wins"), win_expected, 1)
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 losses_requested: Variant = rate(target, E.add(id, ":attackers_lost"), E.divide(outcomes.get("deg", 0), 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)
fall(target, plan, rules, attackers_lost)
var losses: Variant = E.minimum(rate(target, E.add(id, ":loss"), E.divide(loss_expected, order), 1), E.add(total(triggered), total(remaining[id])))
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))
remaining_total = total(remaining[id])
var losses: Variant = E.minimum(rate(target, E.add(id, ":loss"), E.divide(loss_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))
fall(target, plan, rules, victims)
add(triggered, warriors)
triggered_total = E.add(triggered_total, warriors_total)
var damage: Variant = rate(target, E.add(id, ":collateral"), E.divide((E.add(E.multiply(outcomes.get("pro50", 0), 10), E.multiply(outcomes.get("pro75", 0), 30))), 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)
@@ -158,10 +327,12 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: Varian
if (not plans.has(id)): plans[id] = new_plan(id, year)
var plan: Dictionary = plans[id]
var retry: Dictionary = plan.outputs.get(id, {})
var amount: Variant = E.minimum(collateral[id], E.add(total(retry), total(remaining[id])))
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))
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))
fall(target, plan, rules, victims)
target.civilization_stats.collateral = E.add(target.civilization_stats.get("collateral", 0), total(victims))
var incoming: Dictionary = {}
@@ -172,22 +343,29 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: Varian
var products: Dictionary = {}
for destination in plan.outputs:
var pop: Dictionary = plan.outputs[destination]
var count: Variant = total(pop)
if rules.has(destination):
if E.ne(destination, "Ct"):
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))
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] = total(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), total(pop))
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 {"remaining": remaining, "incoming": incoming, "planetary": planetary, "colonies": colonies}
return {"active": true, "remaining": remaining, "incoming": incoming, "planetary": planetary, "colonies": colonies}
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
target.ethics = transaction.remaining
for id in transaction.incoming:
if (not target.ethics.has(id)): target.ethics[id] = {}
@@ -209,11 +387,7 @@ static func finish(world, target: Dictionary, transaction: Dictionary, year: Var
else:
colonize(world, target, transaction.colonies, year, rules, physical)
State.reconcile(world, target, rules, physical if transaction.colonies.is_empty() else {})
var worship: Variant = 0
for id in rules:
if (not rules[id].worships): continue
for ethic in target.ethics[id]: worship = E.add(worship, E.multiply(target.ethics[id][ethic], E.of(rules[id].ethics[ethic].worship)))
target.civilization_stats.worship = worship
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:
+8 -2
View File
@@ -10,14 +10,19 @@ static func is_space_civilization(id: String, rules: Dictionary) -> bool:
# Preserve any existing ethics. New populations start equally distributed;
# reductions (including blasts) are proportional and never leave negative counts.
static func reconcile(world, target: Dictionary, rules: Dictionary, physical: Dictionary = {}) -> void:
static func reconcile(world, target: Dictionary, rules: Dictionary, physical: Dictionary = {}, only_ids: Array = []) -> void:
if (not target.has("ethics")):
target.ethics = {}
if physical.is_empty():
physical = world.ledger.physical_amounts(target)
for id in rules:
var ids: Variant = rules if only_ids.is_empty() else only_ids
for id in ids:
if not rules.has(id): continue
var count: Variant = physical.get(id, 0)
var current: Dictionary = target.ethics.get(id, {})
var cached: Dictionary = world.ledger.reconciled_ethics.get(id, {})
if not cached.is_empty() and E.eq(cached.count, count) and E.eq(cached.population, current):
continue
var total: Variant = 0
for ethic in ETHICS:
current[ethic] = E.maximum(0, current.get(ethic, 0))
@@ -31,6 +36,7 @@ static func reconcile(world, target: Dictionary, rules: Dictionary, physical: Di
for i in ETHICS.size():
current[ETHICS[i]] = E.add(current[ETHICS[i]], E.add(base, ((1 if E.lt(i, rest) else 0))))
target.ethics[id] = current
world.ledger.reconciled_ethics[id] = {"count": count, "population": current.duplicate()}
static func detach_space_civilizations(target: Dictionary, rules: Dictionary) -> void:
for held : Dictionary in target.binds.values():
+67 -26
View File
@@ -38,6 +38,7 @@ func evolute_to_datetime(datetime: Variant, clock: Dictionary = {}, budget: RefC
next.version = SimulationTime.STATE_VERSION
next.speed = clampi(E.integer(previous.speed), 1, Global.SPEED_LIMIT)
next.speed_in_year = E.power(10, E.subtract(next.speed, 1))
world.batches.coarsen(next)
var from_year: Variant = previous.time.year
var to_year: Variant = processed_year
if E.gt(to_year, from_year):
@@ -72,38 +73,48 @@ func evolve_batches(target: Dictionary, to_year: Variant, budget: RefCounted = n
var _keep_alive: RefCounted = self
# A long cycle may contain several generations; preserve actual birth intervals.
var completed: Dictionary = {}
while await evolve_batch_generation(target, to_year, completed, budget):
var context: Dictionary = {
"life_modifiers": WorldParameters.life_modifiers(target),
"enlightenment": WorldParameters.value(target, "Enlightenment"),
"explosiveness": WorldParameters.effective(target, "Exp")
}
while await evolve_batch_generation(target, to_year, completed, budget, context):
if budget != null and not await budget.pause(): return
func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Dictionary = { }, budget: RefCounted = null) -> bool:
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.
if budget != null and not await budget.pause(): return false
var completed_now: Dictionary = completed.duplicate(true)
var completed_now: Dictionary = completed.duplicate()
completed.clear()
if context.is_empty():
context = {"life_modifiers": WorldParameters.life_modifiers(target), "enlightenment": WorldParameters.value(target, "Enlightenment"), "explosiveness": WorldParameters.effective(target, "Exp")}
var quantities: Dictionary = world.ledger.physical_amounts(target)
var expired: Dictionary = {}
var preview: Dictionary = {"batches": {}}
for id in target.batches:
if (world.catalog.entities.has(id) and E.gt(world.catalog.entities[id].destroy_level, 0)):
preview.batches[id] = target.batches[id].duplicate(true)
for id in target.batches.keys():
var withdrawn: Dictionary = {}
var batch_ids: Array = target.batches.keys()
var planets_changed: bool = false
var profile_withdraw: int = world.profile_start()
for id in batch_ids:
if ((not world.catalog.entities.has(id)) or E.le(world.catalog.entities[id].destroy_level, 0)):
continue
var dying: Variant = 0
for part : Dictionary in completed_now.get(id, []):
dying = E.add(dying, part.amount)
for part : Dictionary in world.batches.withdraw_batches(preview, id, to_year):
withdrawn[id] = world.batches.withdraw_batches(target, id, to_year)
for part : Dictionary in withdrawn[id]:
dying = E.add(dying, part.amount)
if E.gt(quantities.get(id, 0), 0):
world.stellar.destroy_planets(target, id, E.divide(dying, quantities[id]), world.catalog.entities[id].destroy_level)
quantities = world.ledger.physical_amounts(target)
planets_changed = world.stellar.destroy_planets(target, id, E.divide(dying, quantities[id]), world.catalog.entities[id].destroy_level) or planets_changed
world.profile_end("generation_withdrawal", profile_withdraw)
if planets_changed: quantities = world.ledger.physical_amounts(target)
# Withdraw all types before creating successors: iteration order cannot affect them.
for id in target.batches.keys():
for id in batch_ids:
if world.catalog.entities.has(id):
expired[id] = E.add(completed_now.get(id, []), world.batches.withdraw_batches(target, id, to_year))
var parts: Array = withdrawn[id] if withdrawn.has(id) else world.batches.withdraw_batches(target, id, to_year)
expired[id] = E.add(completed_now.get(id, []), parts)
var has_expired: bool = false
for parts : Array in expired.values():
if (not parts.is_empty()):
@@ -111,22 +122,28 @@ 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.
var expired_totals: Dictionary = {}
for id in expired:
var total: Variant = 0
for part: Dictionary in expired[id]: total = E.add(total, part.amount)
expired_totals[id] = total
var fractions: Dictionary = {}
var outputs: Dictionary = {}
var current_quantities: Dictionary = quantities
var star_budget: Dictionary = CivilizationState.stellar_budget(world, target, Global.CivilizationRules, current_quantities)
var civilization_transaction: Dictionary = await CivilizationEvolution.prepare(world, target, expired, to_year, star_budget, Global.CivilizationRules, current_quantities, budget)
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 = 0
for part : Dictionary in expired[id]:
total = E.add(total, part.amount)
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)
var combined: Dictionary = {}
for part : Dictionary in expired[id]:
if (not part.has("products")):
part.products = world.stellar.evolution_products(target, world.catalog.entities[id], part.amount)
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
@@ -141,19 +158,32 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
if E.gt(total, 0):
for successor in combined:
outputs[id][successor] = E.divide(combined[successor], total)
world.profile_end("generation_products", profile_products)
var affected: Dictionary = {}
for id in fractions:
if E.le(fractions[id], 0): continue
affected[id] = true
for held in target.binds.get(id, {}): affected[held] = true
for successor in outputs[id]: affected[successor] = true
var incoming: Dictionary = world.ledger.transfer_evolution(target, fractions, outputs)
var new_owners: Dictionary = {}
var blast_power: Variant = 0
var environment: Dictionary = world.environment.stellar_environment(target)
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)
for id in expired:
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"):
blast_power = E.add(blast_power, amount)
continue
if (not world.catalog.entities.has(successor)):
continue
@@ -162,27 +192,38 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
else:
amount = world.ledger.credit(target, successor, amount)
var produces: bool = not world.automatic.yearly_products(world.catalog.entities[successor]).is_empty()
var before_batches: Array = target.batches.get(successor, []).duplicate(true) if produces else []
var newborn_adjustment: Dictionary = {}
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()
var integral: Variant
if budget != null:
integral = await world.batches.append_batch(target, successor, amount, part.from, part.to, completed, cutoff, budget)
integral = await world.batches.append_batch(target, successor, amount, born_from, born_to, completed, cutoff, budget, newborn_adjustment if produces else null, to_year)
if budget.cancelled: return false
else:
integral = world.batches.append_batch(target, successor, amount, part.from, part.to, completed, cutoff)
integral = world.batches.append_batch(target, successor, amount, born_from, born_to, completed, cutoff, null, newborn_adjustment if produces else null, to_year)
if produces:
world.automatic.produce_newborn_lifetime(target, successor, amount, part.from, part.to, to_year, before_batches, integral)
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)
world.ledger.accept_transfers(target, incoming)
world.ledger.bind_new_owners(target, new_owners)
world.ledger.round_object_allocations(target)
for owner in new_owners:
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)
world.stellar.apply_blast(target, blast_power, environment)
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
+2
View File
@@ -5,11 +5,13 @@ const E = preload("res://world/owniverse/math/exact.gd")
var pending_amounts: Dictionary [ String , Variant ] = {}
var deferred_updates: bool = false
var dirty: bool = true
var revision: int = 0
func refresh(state: Dictionary, catalog, force: bool = false) -> void:
dirty = true
if (deferred_updates and (not force)):
return
revision += 1
pending_amounts = amounts(state, catalog)
dirty = false
+8 -10
View File
@@ -3,11 +3,12 @@ const E = preload("res://world/owniverse/math/exact.gd")
const WorldParameters = preload("res://world/owniverse/world_parameters.gd")
const QuantityMath = preload("res://world/owniverse/quantity_math.gd")
func life_products(target: Dictionary, entity: OwniverseEntity, count: Variant) -> Dictionary:
var rule: Dictionary = entity.life_rule
func life_products(target: Dictionary, entity: OwniverseEntity, count: Variant, context: Dictionary = {}) -> Dictionary:
var plan: Dictionary = entity.generation_plan
var pending: Dictionary = {}
var modifiers: Dictionary = WorldParameters.life_modifiers(target)
var probabilities: Dictionary = {"progress": E.multiply(E.multiply(E.of(rule.progress), modifiers.progress), WorldParameters.value(target, "Enlightenment")), "degradation": E.multiply(E.of(rule.degradation), modifiers.degradation)}
var modifiers: Dictionary = context.life_modifiers if context.has("life_modifiers") else WorldParameters.life_modifiers(target)
var enlightenment: Variant = context.enlightenment if context.has("enlightenment") else WorldParameters.value(target, "Enlightenment")
var probabilities: Dictionary = {"progress": E.multiply(E.multiply(plan.life_progress, modifiers.progress), enlightenment), "degradation": E.multiply(plan.life_degradation, modifiers.degradation)}
var probability_sum: Variant = E.maximum(1, E.add(probabilities.progress, probabilities.degradation))
if (not target.has("life_remainders")):
target.life_remainders = {}
@@ -21,13 +22,10 @@ func life_products(target: Dictionary, entity: OwniverseEntity, count: Variant)
var key: String = E.add(E.add(entity.id, ":"), channel)
target.life_remainders[key] = E.maximum(0, E.subtract(target.life_remainders[key], assigned[channel]))
var result: Dictionary = {entity.id: E.subtract(E.subtract(count, assigned.progress), assigned.degradation)}
result[rule.next] = assigned.progress
var weights: Dictionary = {}
result[plan.life_next] = assigned.progress
var weights: Dictionary = plan.life_fall
var weight_sum: Variant = 0
for term in str(rule.fall).split("^"):
var pair: PackedStringArray = term.split(":")
weights[pair[0]] = E.of(pair[1])
weight_sum = E.add(weight_sum, E.of(pair[1]))
for weight in weights.values(): weight_sum = E.add(weight_sum, weight)
var requests: Dictionary = {}
for id in weights:
var key: String = E.add(E.add(entity.id, ":fall:"), id)
+17
View File
@@ -47,6 +47,9 @@ static func subtract(a: Array, b: Array) -> Array:
return trim(result)
static func multiply(a: Array, b: Array) -> Array:
if a == [0] or b == [0]: return [0]
if a == [1]: return b.duplicate()
if b == [1]: return a.duplicate()
var result: Array = []
result.resize(a.size() + b.size())
result.fill(0)
@@ -64,6 +67,20 @@ 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
if b.size() == 1:
var quotient: Array = []
quotient.resize(a.size())
+39
View File
@@ -170,6 +170,8 @@ static func absolute(a): return neg(a) if lt(a, 0) else a
static func add(a, b):
if not numeric(a) or not numeric(b): return a + b
if b is int and b == 0: return of(a)
if a is int and a == 0: return of(b)
if a is int and b is int:
if (b >= 0 and a <= MAX_INT - b) or (b < 0 and a >= MIN_INT - b): return a + b
if not finite(a) or not finite(b): return approx(a) + approx(b)
@@ -190,6 +192,7 @@ static func add(a, b):
static func subtract(a, b):
if not numeric(a) or not numeric(b): return a - b
if b is int and b == 0: return of(a)
if a is int and b is int:
if (b >= 0 and a >= MIN_INT + b) or (b < 0 and a <= MAX_INT + b): return a - b
return add(a, neg(b))
@@ -202,6 +205,10 @@ static func multiply(a, b):
if eq(b, 1): return of(a)
if a is int and b is int and a != MIN_INT and b != MIN_INT:
if absi(a) <= MAX_INT / absi(b): return a * b
if (a is int or (a is Value and a.fraction == 0.0)) and (b is int or (b is Value and b.fraction == 0.0)):
var whole_a: Array = _blocks(a)
var whole_b: Array = _blocks(b)
return _compose(Big.multiply(whole_a[0], whole_b[0]), 0.0, whole_a[2] * whole_b[2])
var x: Array = parts(a)
var y: Array = parts(b)
return make(Big.multiply(x[0], y[0]), Big.multiply(x[1], y[1]), x[2] * y[2])
@@ -217,6 +224,38 @@ static func mul_div(a, b, divisor):
assert(z[0] != [0], "Division by zero")
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 mul_div_many(a, values: Array, divisor) -> Array:
var result: Array = []
if not finite(a) or not finite(divisor):
for value in values: result.append(mul_div(a, value, divisor))
return result
var x: Array = parts(a)
var z: Array = parts(divisor)
assert(z[0] != [0], "Division by zero")
for value in values:
if not finite(value) or (a is int and value is int and a != MIN_INT and value != MIN_INT and (value == 0 or absi(a) <= MAX_INT / absi(value))):
result.append(mul_div(a, value, divisor))
continue
var y: Array = parts(value)
result.append(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]))
return result
## Cancel an integer population from quotas, retaining the original float ratio.
static func population_quotas(count, weights: Array, factor: int) -> Array:
var denominator = multiply(count, factor)
if ne(count, floor_value(count)) or le(count, 0): return mul_div_many(count, weights, denominator)
var population: Array = _blocks(count)[0]
var denominator_blocks: Array = Big.multiply(population, [factor])
var result: Array = []
for weight in weights:
if ne(weight, floor_value(weight)) or lt(weight, 0) or (count is int and weight is int and count != MIN_INT and weight != MIN_INT and (weight == 0 or absi(count) <= MAX_INT / absi(weight))):
result.append(mul_div(count, weight, denominator))
else:
var qr: Array = Big.divmod(_blocks(weight)[0], [factor])
result.append(_compose(qr[0], _fraction_ratio(Big.multiply(qr[1], population), denominator_blocks)))
return result
## Sum of nonnegative linear quotas rounded down separately at each boundary.
static func floor_sum(count, step, offset, divisor):
var n: Array = parts(count)
+130 -13
View File
@@ -4,7 +4,99 @@ 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")
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
func window_duration(target: Dictionary, lifespan: Variant) -> Variant:
if E.le(lifespan, 10): return lifespan
var base: Variant = E.divide(lifespan, Global.batch_count)
# At high speed one tenth of a real second is the temporal resolution.
# Short stellar explosions retain their original event windows.
return E.maximum(base, temporal_resolution(target))
func coarsen(target: Dictionary) -> void:
if int(target.get("speed", 1)) < 4: return
for id in target.batches:
if target.batches[id].size() <= 1: continue
if not world.catalog.entities.has(id): continue
var lifespan: Variant = effective_lifespan(target, id)
if E.le(lifespan, 10): continue
var width: Variant = window_duration(target, lifespan)
if E.le(width, E.divide(lifespan, Global.batch_count)): continue
var groups: Dictionary = {}
var result: Array = []
for batch: Dictionary in target.batches[id]:
# Never move an already processed boundary backwards.
var start: Variant = E.maximum(batch.start_year, batch.get("processed_until", batch.start_year))
var key: String = str(E.floor_value(E.divide(start, width)))
if not groups.has(key):
var combined: Dictionary = batch.duplicate()
groups[key] = combined
result.append(combined)
else:
var combined: Dictionary = groups[key]
combined.amount = E.add(combined.amount, batch.amount)
combined.start_year = E.minimum(combined.start_year, batch.start_year)
combined.end_year = E.maximum(combined.end_year, batch.end_year)
combined.created_from = E.minimum(combined.get("created_from", start), batch.get("created_from", start))
combined.created_to = E.maximum(combined.get("created_to", batch.end_year), batch.get("created_to", batch.end_year))
combined.withdraw_remainder = E.add(combined.get("withdraw_remainder", 0), batch.get("withdraw_remainder", 0))
if combined.has("processed_until") or batch.has("processed_until"):
combined.processed_until = E.maximum(combined.get("processed_until", combined.start_year), batch.get("processed_until", batch.start_year))
if result.size() < target.batches[id].size():
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
# Indices belong to one transaction. Withdrawal replaces the cohort array,
# invalidating that entity's index; amount changes leave its keys intact.
var _indexed_target: Dictionary = {}
var _batch_indices: Dictionary = {}
var _ready_target: Dictionary = {}
var _ready_indices: Dictionary = {}
var _instant_target: Dictionary = {}
var _instant_appends: Dictionary = {}
var _static_spans: Dictionary = {}
var _star_spans: Dictionary = {}
func effective_lifespan(target: Dictionary, id: String) -> Variant:
var entity: OwniverseEntity = world.catalog.entities[id]
if Global.CivilizationRules.has(id):
if CivilizationState.is_space_civilization(id, Global.CivilizationRules):
return CivilizationState.effective_span(world, target, id, Global.CivilizationRules)
return Global.CivilizationRules[id].span
var lifespan: Variant = _static_spans.get(id, entity.lifespan)
if not _static_spans.has(id): _static_spans[id] = lifespan
if entity.group_as != "Star": return lifespan
var burnability: Variant = WorldParameters.effective(target, "Bur")
var signature: String = id + ":" + str(burnability)
if _star_spans.has(signature): return _star_spans[signature]
var result: Variant = E.divide(lifespan, burnability)
if _star_spans.size() >= 256: _star_spans.clear()
_star_spans[signature] = result
return result
func batch_index(target: Dictionary, id: String, batches: Array) -> Dictionary:
if not is_same(_indexed_target, target):
_indexed_target = target
_batch_indices.clear()
var cached: Dictionary = _batch_indices.get(id, {})
if not cached.is_empty() and is_same(cached.batches, batches) and cached.count == batches.size():
return cached.starts
for i in range(1, batches.size()):
if E.lt(batches[i].get("created_from", -INF), batches[i - 1].get("created_from", -INF)):
batches.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return E.lt(a.get("created_from", -INF), b.get("created_from", -INF)))
break
var starts: Dictionary = {}
for existing: Dictionary in batches:
var key: String = str(existing.get("created_from", -INF))
if not starts.has(key): starts[key] = []
starts[key].append(existing)
_batch_indices[id] = {"batches": batches, "count": batches.size(), "starts": starts}
return starts
func scale_batches(target: Dictionary, id: String, factor: Variant) -> void:
_ready_indices.erase(id)
var batches: Array = target.batches.get(id, [])
if batches.is_empty():
return
@@ -34,26 +126,29 @@ func scale_batches(target: Dictionary, id: String, factor: Variant) -> void:
share.batch.withdraw_remainder = E.minimum(share.batch.get("withdraw_remainder", 0), share.batch.amount)
func append_batch(target: Dictionary, id: String, amount: Variant, created_from: Variant, created_to: Variant, completed: Dictionary = { }, completed_before: Variant = - INF, budget: RefCounted = null) -> Variant:
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 ((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):
return NAN
_ready_indices.erase(id)
if (not target.batches.has(id)):
target.batches[id] = []
if (not target.has("batch_origins")):
target.batch_origins = {}
var batches: Array = target.batches[id]
var lifespan: Variant = CivilizationState.effective_span(world, target, id, Global.CivilizationRules)
if E.eq(world.catalog.entities[id].group_as, "Star"):
lifespan = E.divide(lifespan, WorldParameters.effective(target, "Bur"))
var batch_duration: Variant = (E.divide(lifespan, Global.batch_count) if E.gt(lifespan, 10) else lifespan)
var lifespan: Variant = effective_lifespan(target, id)
var batch_duration: Variant = window_duration(target, lifespan)
if E.le(batch_duration, 0):
return NAN
if (not target.batch_origins.has(id)):
target.batch_origins[id] = (batches[0].get("created_from", created_from) if (not batches.is_empty()) else created_from)
var origin: Variant = target.batch_origins[id]
if E.gt(temporal_resolution(target), 0) and (Global.CivilizationRules.has(id) or not world.catalog.entities[id].life_rule.is_empty()):
# Match successor birth buckets, regardless of the old saved origin.
# Otherwise a rounded birth can be placed back into an earlier window.
origin = 0
var cursor: Variant = created_from
var duration: Variant = E.subtract(created_to, created_from)
var remaining_amount: Variant = amount
@@ -62,18 +157,23 @@ func append_batch(target: Dictionary, id: String, amount: Variant, created_from:
var completed_to: Variant = E.neg(INF)
# Subtract each completed window's lifetime production exactly.
var remaining_object_years: Variant = (E.multiply(amount, E.maximum(0, E.subtract(completed_before, E.multiply((E.add(created_from, created_to)), E.of("0.5"))))) if E.finite(completed_before) else NAN)
if not is_same(_instant_target, target):
_instant_target = target
_instant_appends.clear()
var instant: Dictionary = _instant_appends.get(id, {})
if E.eq(duration, 0) and not instant.is_empty() and is_same(instant.batches, batches) and instant.count == batches.size() and E.eq(instant.birth, created_from) and E.eq(instant.lifespan, lifespan) and E.eq(instant.width, batch_duration) and E.eq(instant.origin, origin) and E.gt(instant.batch.end_year, completed_before):
instant.batch.amount = E.add(instant.batch.amount, amount)
if newborn_adjustment != null and E.finite(newborn_until):
newborn_adjustment.past_years = E.add(newborn_adjustment.get("past_years", 0), E.multiply(amount, expired_years(newborn_until, instant.batch.start_year, instant.batch.end_year)))
return remaining_object_years
# Advance an exact window index rather than repeatedly finding a boundary.
var window_index: Variant = E.floor_value(E.divide((E.subtract(cursor, origin)), batch_duration))
if E.le(E.add(origin, E.multiply((E.add(window_index, 1)), batch_duration)), cursor):
window_index = E.add(window_index, 1)
var batches_by_start: Dictionary = {}
var batches_by_start: Dictionary = batch_index(target, id, batches)
var order_changed: bool = false
var skip_empty_expired: bool = E.gt(duration, 0) and E.finite(completed_before) and E.eq(amount, E.floor_value(amount))
var sparse_windows: bool = E.gt(duration, 0) and E.gt(amount, 0) and E.gt(duration, E.multiply(batch_duration, amount)) and not skip_empty_expired
for existing : Dictionary in batches:
var start: Variant = existing.get("created_from", E.neg(INF))
if (not batches_by_start.has(str(start))):
batches_by_start[str(start)] = []
batches_by_start[str(start)].append(existing)
while true:
if budget != null and not await budget.pause(): return NAN
# A zero cohort is discarded by withdraw_batches. Jump straight to the
@@ -145,17 +245,24 @@ func append_batch(target: Dictionary, id: String, amount: Variant, created_from:
break
if batch.is_empty():
batch = {"created_from": window_start, "created_to": window_end, "start_year": E.add(window_start, lifespan), "end_year": E.add(window_end, lifespan), "amount": 0}
order_changed = order_changed or (not batches.is_empty() and E.lt(window_start, batches.back().created_from))
batches.append(batch)
if (not batches_by_start.has(str(window_start))):
batches_by_start[str(window_start)] = []
batches_by_start[str(window_start)].append(batch)
batch.amount = E.add(batch.amount, part_amount)
if newborn_adjustment != null and E.finite(newborn_until):
newborn_adjustment.past_years = E.add(newborn_adjustment.get("past_years", 0), E.multiply(part_amount, expired_years(newborn_until, batch.start_year, batch.end_year)))
if E.eq(duration, 0):
_instant_appends[id] = {"batches": batches, "count": batches.size(), "birth": created_from, "lifespan": lifespan, "width": batch_duration, "origin": origin, "batch": batch}
remaining_amount = E.subtract(remaining_amount, part_amount)
if (E.eq(duration, 0) or E.ge(part_end, created_to)):
break
cursor = window_end
window_index = E.add(window_index, 1)
batches.sort_custom(func ( a : Dictionary , b : Dictionary ) -> bool : return E.lt(a.created_from, b.created_from))
if order_changed:
batches.sort_custom(func ( a : Dictionary , b : Dictionary ) -> bool : return E.lt(a.created_from, b.created_from))
_batch_indices[id].count = batches.size()
if E.gt(completed_amount, 0):
if (not completed.has(id)): completed[id] = []
completed[id].append({"amount": completed_amount, "from": completed_from, "to": completed_to})
@@ -163,9 +270,16 @@ func append_batch(target: Dictionary, id: String, amount: Variant, created_from:
func withdraw_batches(target: Dictionary, id: String, to_year: Variant) -> Array:
if not is_same(_ready_target, target):
_ready_target = target
_ready_indices.clear()
var batches: Array = target.batches.get(id, [])
var ready: Dictionary = _ready_indices.get(id, {})
if not ready.is_empty() and is_same(ready.batches, batches) and ready.count == batches.size() and E.lt(to_year, ready.year): return []
var expired: Array = []
var remaining: Array = []
for batch : Dictionary in target.batches.get(id, []):
var next_year: Variant = INF
for batch : Dictionary in batches:
var start: Variant = E.maximum(batch.start_year, batch.get("processed_until", batch.start_year))
var end: Variant = batch.end_year
var amount: Variant = batch.amount
@@ -173,6 +287,7 @@ func withdraw_batches(target: Dictionary, id: String, to_year: Variant) -> Array
continue
if (E.lt(to_year, start) or ((E.eq(to_year, start) and E.gt(end, start)))):
remaining.append(batch)
next_year = E.minimum(next_year, start)
continue
var consumed: Variant = amount
var until: Variant = E.minimum(to_year, end)
@@ -184,9 +299,11 @@ func withdraw_batches(target: Dictionary, id: String, to_year: Variant) -> Array
batch.amount = E.maximum(0, E.subtract(amount, consumed))
batch.processed_until = to_year
remaining.append(batch)
next_year = E.minimum(next_year, to_year)
if E.gt(consumed, 0):
expired.append({"amount": consumed, "from": start, "to": until})
target.batches[id] = remaining
_ready_indices[id] = {"batches": remaining, "count": remaining.size(), "year": next_year}
return expired
+34 -11
View File
@@ -7,6 +7,11 @@ var _amount_snapshot: Dictionary = {}
var _binds_snapshot: Dictionary = {}
var _physical_cache: Dictionary = {}
var _physical_revision: int = -1
# Per-world derived caches: never serialized and never shared between worlds.
var reconciled_ethics: Dictionary = {}
var worship_products: Dictionary = {}
var civilization_modifiers: Dictionary = {}
var civilization_coefficients: Dictionary = {}
func physical_amounts(source: Dictionary) -> Dictionary:
world.catalog._ensure_group_index()
@@ -107,9 +112,10 @@ func credit(target: Dictionary, id: String, amount: Variant) -> Variant:
return amount
func round_object_allocations(target: Dictionary) -> void:
var physical: Dictionary = physical_amounts(target)
for id in physical:
func round_object_allocations(target: Dictionary, affected: Variant = null) -> void:
var ids: Variant = world.catalog.entities if affected == null else affected
for id in ids:
if not world.catalog.entities.has(id) or world.catalog.is_group(id): continue
if (not is_discrete(id)):
continue
var slots: Array = [{"owner": "", "value": target.amount.get(id, 0)}]
@@ -117,12 +123,17 @@ func round_object_allocations(target: Dictionary) -> void:
if target.binds[owner_id].has(id):
slots.append({"owner": owner_id, "value": target.binds[owner_id][id]})
var assigned: Variant = 0
var physical: Variant = 0
var fractional: bool = false
for slot : Dictionary in slots:
slot.whole = E.floor_value(slot.value)
slot.rest = E.subtract(slot.value, slot.whole)
assigned = E.add(assigned, slot.whole)
physical = E.add(physical, E.maximum(0, slot.value))
fractional = fractional or E.ne(slot.rest, 0)
if not fractional: continue
slots.sort_custom(func ( a : Dictionary , b : Dictionary ) -> bool : return (E.gt(a.rest, b.rest) if E.ne(a.rest, b.rest) else E.lt(str(a.owner), str(b.owner))))
var left: Variant = E.subtract(E.round_value(physical[id]), assigned)
var left: Variant = E.subtract(E.round_value(physical), assigned)
for slot : Dictionary in slots:
if E.gt(left, 0):
slot.whole = E.add(slot.whole, 1)
@@ -182,15 +193,21 @@ func bind_new_owners(target: Dictionary, amounts: Dictionary, require_full: bool
if require_full:
return bind_full(target, amounts)
var requests: Dictionary = {}
var distributions: Dictionary = {}
for owner_id in amounts:
if (not world.catalog.entities.has(owner_id)):
continue
if E.le(amounts[owner_id], 0): continue
for id in world.catalog.entities[owner_id].bind:
var quantity: Variant = E.multiply(amounts[owner_id], E.maximum(0, world.catalog.entities[owner_id].bind[id]))
var weights: Dictionary = {}
for member in world.catalog.members(id):
weights[member] = target.amount.get(member, 0)
weights = QuantityMath.normalize_distribution(weights)
# Requests are collected before any stock is changed. Every owner
# asking for this resource therefore uses the same distribution.
if not distributions.has(id):
var members: Dictionary = {}
for member in world.catalog.members(id):
members[member] = target.amount.get(member, 0)
distributions[id] = QuantityMath.normalize_distribution(members)
var weights: Dictionary = distributions[id]
for member in weights:
if (not requests.has(member)):
requests[member] = {}
@@ -256,17 +273,23 @@ func process_entity(entity_id: String, amount: Variant) -> void:
## Freeze holdings before simultaneous transitions and reserve successors.
func transfer_evolution(target: Dictionary, fractions: Dictionary, outputs: Dictionary) -> Dictionary:
# Use a frozen binding table so simultaneous transitions cannot cascade by type order.
var bindings: Dictionary = target.binds.duplicate(true)
var bindings: Dictionary = target.binds.duplicate()
var incoming: Dictionary = {}
for owner_id in bindings:
var owner_survival: Variant = E.subtract(1, fractions.get(owner_id, 0))
var stellar_heirs: Dictionary = world.stellar.stellar_successors(outputs.get(owner_id, {}))
target.binds[owner_id] = {}
for id in bindings[owner_id]:
var held: Variant = bindings[owner_id][id]
var fraction: Variant = fractions.get(id, 0)
if E.eq(held, 0) or (E.eq(owner_survival, 1) and E.eq(fraction, 0)):
# Preserve the holding without running release/inheritance math
# for populations that did not participate in this generation.
target.binds[owner_id][id] = held
continue
var released: Variant = E.multiply(held, (E.subtract(1, owner_survival)))
target.amount[id] = E.add(target.amount.get(id, 0), released)
var heirs: Dictionary = (world.stellar.stellar_successors(outputs.get(owner_id, {})) if (world.catalog.entities.has(id) and E.eq(world.catalog.entities[id].group_as, "Planet")) else {})
var fraction: Variant = fractions.get(id, 0)
var heirs: Dictionary = (stellar_heirs if (world.catalog.entities.has(id) and E.eq(world.catalog.entities[id].group_as, "Planet")) else {})
for heir in heirs:
if (not incoming.has(heir)):
incoming[heir] = {}
+28 -4
View File
@@ -65,6 +65,27 @@ 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
## Keep the requested clock advancing while a transaction spans render frames.
func evolute_frame(delta: float, tree: SceneTree) -> void:
@@ -76,14 +97,14 @@ func evolute_frame(delta: float, tree: SceneTree) -> void:
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()
# Keep each transaction at the normal 60 Hz simulation interval. The
# requested clock holds the backlog, rather than feeding an ever-growing
# multi-generation interval into one transaction and freezing publication.
# 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, 1.0 / 60.0, speed)
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
_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:
@@ -108,6 +129,9 @@ 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:
+39
View File
@@ -1,6 +1,7 @@
extends Node
class_name OwniverseEntity
const E = preload("res://world/owniverse/math/exact.gd")
const QuantityMath = preload("res://world/owniverse/quantity_math.gd")
var id: String
var is_visible: bool = false
@@ -10,6 +11,8 @@ var bind: Dictionary = {}
var mass: Variant
var space: int
var life_rule: Dictionary = {}
var life_fall_weights: Dictionary = {}
var generation_plan: Dictionary = {}
var lifespan: Variant
var lifespan_delta: Variant
var auto: Variant
@@ -47,6 +50,8 @@ func _init(values: Dictionary) -> void:
life_rule = Global.LifeRules.get(id, {}).duplicate(true)
if Global.CivilizationRules.has(id):
life_rule = {"ethical": true, "span": Global.CivilizationRules[id].span}
elif not life_rule.is_empty():
life_fall_weights = QuantityMath.rule_weights(str(life_rule.get("fall", "")))
lifespan = (E.of(life_rule.span) if (not life_rule.is_empty()) else E.of(values.lifespan))
lifespan_delta = (E.divide(lifespan, Global.batch_count) if E.gt(lifespan, 10) else lifespan)
auto = E.of(values.auto)
@@ -72,6 +77,40 @@ func _init(values: Dictionary) -> void:
space_bonus = E.of(values.space_bonus)
civ_bonus = E.of(values.civ_bonus)
claim = values.claim
refresh_generation_plan()
func refresh_generation_plan() -> void:
var fall_text: String = str(life_rule.get("fall", ""))
if not generation_plan.is_empty() \
and E.eq(generation_plan.outputs, output_and) \
and E.eq(generation_plan.chance, output_or.get("chance", 0)) \
and E.eq(generation_plan.positive, output_or.get("positive", "")) \
and E.eq(generation_plan.negative, output_or.get("negative", "")) \
and E.eq(generation_plan.blast, blast) \
and E.eq(generation_plan.life_progress, life_rule.get("progress", 0)) \
and E.eq(generation_plan.life_degradation, life_rule.get("degradation", 0)) \
and E.eq(generation_plan.life_next, str(life_rule.get("next", id))) \
and E.eq(generation_plan.life_fall_text, fall_text):
return
life_fall_weights = QuantityMath.rule_weights(fall_text) if not fall_text.is_empty() else {}
generation_plan = {
"life": not life_rule.is_empty(),
"life_progress": E.of(life_rule.get("progress", 0)),
"life_degradation": E.of(life_rule.get("degradation", 0)),
"life_next": str(life_rule.get("next", id)),
"life_fall": life_fall_weights,
"outputs": output_and,
"chance": output_or.get("chance", 0),
"positive": output_or.get("positive", ""),
"negative": output_or.get("negative", ""),
"life_fall_text": fall_text,
"positive_terms": QuantityMath.output_terms(str(output_or.get("positive", ""))),
"negative_terms": QuantityMath.output_terms(str(output_or.get("negative", ""))),
"blast": blast,
"destroy_level": destroy_level
}
generation_plan.make_read_only()
func _parse_or_string(source_line: String) -> Dictionary:
+99 -13
View File
@@ -1,6 +1,32 @@
extends RefCounted
const E = preload("res://world/owniverse/math/exact.gd")
## Quantity arithmetic without scene tree or global settings.
static var _weight_rules: Dictionary = {}
static var _output_rules: Dictionary = {}
## Parsed rules are shared read-only; the string itself is the cache key.
static func rule_weights(text: String) -> Dictionary:
if _weight_rules.has(text): return _weight_rules[text]
var result: Dictionary = {}
for term in text.split("^", false):
var pair: PackedStringArray = term.split(":")
result[pair[0]] = E.of(pair[1])
if _weight_rules.size() >= 128: _weight_rules.clear()
result.make_read_only()
_weight_rules[text] = result
return result
static func output_terms(text: String) -> Array:
if _output_rules.has(text): return _output_rules[text]
var result: Array = []
for term in text.split("&"):
var pair: PackedStringArray = term.strip_edges().split(":")
if pair[0].strip_edges().is_empty(): continue
result.append({"id": pair[0].strip_edges(), "amount": E.of(pair[1]) if E.eq(pair.size(), 2) else 1})
if _output_rules.size() >= 128: _output_rules.clear()
result.make_read_only()
_output_rules[text] = result
return result
static func accumulate_whole(target: Dictionary, field: String, key: String, value: Variant) -> Variant:
if (not target.has(field)):
@@ -17,6 +43,18 @@ static func allocate_whole(requests: Dictionary, limit: Variant) -> Dictionary:
for id in requests:
result[id] = E.floor_value(E.maximum(0, requests[id]))
total = E.add(total, result[id])
return _allocate_whole_with_total(requests, limit, result, total)
## Variant for callers that already maintain the sum of floored request values.
## The supplied total must equal sum(floor(max(0, requests[id]))).
static func allocate_whole_with_total(requests: Dictionary, limit: Variant, request_total: Variant) -> Dictionary:
var result: Dictionary = {}
for id in requests: result[id] = E.floor_value(E.maximum(0, requests[id]))
return _allocate_whole_with_total(requests, limit, result, request_total)
static func _allocate_whole_with_total(requests: Dictionary, limit: Variant, result: Dictionary, total: Variant) -> Dictionary:
var available: Variant = E.floor_value(E.minimum(E.maximum(0, limit), total))
# No apportionment or rounding is necessary when every request fits.
if E.eq(available, total): return result
@@ -72,34 +110,82 @@ static func normalize_distribution(values: Dictionary) -> Dictionary:
return normalized_distribution
static func sum_array(values: Array) -> Variant:
var result: Variant = 0
for value in values: result = E.add(result, value)
return result
static func sum(pop: Dictionary) -> Variant:
var result: Variant = 0
for n in pop.values(): result = E.add(result, n)
return result
static var _split_keys: Dictionary = {}
static func split(remainders: Dictionary, key: String, count: Variant, weights: Dictionary) -> Dictionary:
if E.le(count, 0): return {}
return split_arrays(remainders, key, count, weights.keys(), weights.values())
static func split_arrays(remainders: Dictionary, key: String, count: Variant, ids: Array, weights: Array, population_factor: int = 0) -> Dictionary:
var result: Dictionary = {}
var sum_weights: Variant = sum(weights)
if (E.le(count, 0) or E.le(sum_weights, 0)): return result
var ids: Array = weights.keys()
if E.le(count, 0): return result
var sum_weights: Variant = 0
var positive_index: int = -1
var positive_count: int = 0
for i in weights.size():
sum_weights = E.add(sum_weights, weights[i])
if E.gt(weights[i], 0):
positive_index = i
positive_count += 1
if E.le(sum_weights, 0): return result
var cached: Dictionary = _split_keys.get(key, {})
if cached.is_empty() or cached.ids != ids:
var keys: Array = []
for id in ids: keys.append(key + ":" + str(id))
if _split_keys.size() >= 512: _split_keys.clear()
cached = {"ids": ids.duplicate(), "keys": keys}
_split_keys[key] = cached
var remainder_keys: Array = cached.keys
# A single branch is exact only when no saved rounding error redistributes it.
var single: bool = positive_count == 1 and E.eq(count, E.floor_value(count))
if single:
for i in ids.size():
if E.lt(weights[i], 0) or E.ne(remainders.get(remainder_keys[i], 0), 0):
single = false
break
if single:
for i in ids.size():
result[ids[i]] = count if i == positive_index else 0
remainders[remainder_keys[i]] = 0
return result
var expected: Array = []
var remainder_keys: Array = []
if population_factor > 0 and E.eq(sum_weights, E.multiply(count, population_factor)):
# Ordinary drift: count cancels exactly from count*weight/(factor*count).
expected = E.population_quotas(count, weights, population_factor)
else:
expected = E.mul_div_many(count, weights, sum_weights)
var positives: Array = []
var positive_sum: Variant = 0
for id in ids:
var remainder_key: String = key + ":" + str(id)
remainder_keys.append(remainder_key)
var value: Variant = E.add(E.mul_div(count, weights[id], sum_weights), remainders.get(remainder_key, 0))
expected.append(value)
positive_sum = E.add(positive_sum, E.maximum(0, value))
var whole_quotas: bool = true
for i in ids.size():
expected[i] = E.add(expected[i], remainders.get(remainder_keys[i], 0))
var positive: Variant = E.maximum(0, expected[i])
whole_quotas = whole_quotas and E.eq(positive, E.floor_value(positive))
positives.append(positive)
positive_sum = E.add(positive_sum, positive)
# Fractional identity ratios still round-trip through parts/make in the
# original algorithm. Retain them to preserve ties and saved errors exactly.
var normalized: Array = positives
if E.gt(positive_sum, 0) and (E.ne(positive_sum, count) or not whole_quotas):
normalized = E.mul_div_many(count, positives, positive_sum)
var order: Array = []
var rests: Array = []
var assigned: Variant = 0
for i in ids.size():
var normalized: Variant = E.mul_div(E.maximum(0, expected[i]), count, positive_sum) if E.gt(positive_sum, 0) else 0
result[ids[i]] = E.floor_value(normalized)
result[ids[i]] = E.floor_value(normalized[i])
assigned = E.add(assigned, result[ids[i]])
rests.append(E.subtract(normalized, result[ids[i]]))
rests.append(E.subtract(normalized[i], result[ids[i]]))
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]))
for i in order:
+24 -18
View File
@@ -9,35 +9,38 @@ var direct_completions: bool = true
func add_output_terms(outputs: Dictionary, terms: String, count: Variant) -> void:
if E.le(count, 0):
return
for term in terms.split("&"):
var parts: PackedStringArray = term.strip_edges().split(":")
var id: String = parts[0].strip_edges()
var amount: Variant = (E.of(parts[1]) if E.eq(parts.size(), 2) else 1)
if (E.ne(id, "") and E.gt(amount, 0)):
outputs[id] = E.add(outputs.get(id, 0), E.multiply(count, amount))
for term: Dictionary in QuantityMath.output_terms(terms):
if E.gt(term.amount, 0):
outputs[term.id] = E.add(outputs.get(term.id, 0), E.multiply(count, term.amount))
func evolution_products(target: Dictionary, entity: OwniverseEntity, count: Variant) -> Dictionary:
if (not entity.life_rule.is_empty()):
return world.life.life_products(target, entity, count)
func evolution_products(target: Dictionary, entity: OwniverseEntity, count: Variant, context: Dictionary = {}) -> Dictionary:
entity.refresh_generation_plan()
var plan: Dictionary = entity.generation_plan
if plan.life:
return world.life.life_products(target, entity, count, context)
var outputs: Dictionary = {}
for id in entity.output_and:
outputs[id] = E.multiply(count, E.maximum(0, entity.output_and[id]))
for id in plan.outputs:
outputs[id] = E.multiply(count, E.maximum(0, plan.outputs[id]))
if (not entity.output_or.is_empty()):
# The parser stores the percentage after '%', e.g. 0.1 means 0.1 percent.
var chance: Variant = E.clamp_value(E.multiply(E.divide(entity.output_or.get("chance", 0), 100), WorldParameters.effective(target, "Exp")), 0, 1)
var explosiveness: Variant = context.explosiveness if context.has("explosiveness") else WorldParameters.effective(target, "Exp")
var chance: Variant = E.clamp_value(E.multiply(E.divide(plan.chance, 100), explosiveness), 0, 1)
if (not target.has("evolution_remainders")):
target.evolution_remainders = {}
var expected: Variant = E.add(E.multiply(count, chance), target.evolution_remainders.get(entity.id, 0))
var positive: Variant = E.minimum(count, E.floor_value(expected))
target.evolution_remainders[entity.id] = E.maximum(0, E.subtract(expected, positive))
add_output_terms(outputs, str(entity.output_or.get("positive", "")), positive)
add_output_terms(outputs, str(entity.output_or.get("negative", "")), E.subtract(count, positive))
for term: Dictionary in plan.positive_terms:
if E.gt(term.amount, 0): outputs[term.id] = E.add(outputs.get(term.id, 0), E.multiply(positive, term.amount))
for term: Dictionary in plan.negative_terms:
if E.gt(term.amount, 0): outputs[term.id] = E.add(outputs.get(term.id, 0), E.multiply(E.subtract(count, positive), term.amount))
# Entity.blast is authoritative even if imported output still contains
# blast:N. Zero disables the event; never add both representations.
outputs.erase("blast")
if E.gt(entity.blast, 0):
outputs["blast"] = E.multiply(E.multiply(count, entity.blast), WorldParameters.effective(target, "Exp"))
if E.gt(plan.blast, 0):
var explosiveness: Variant = context.explosiveness if context.has("explosiveness") else WorldParameters.effective(target, "Exp")
outputs["blast"] = E.multiply(E.multiply(count, plan.blast), explosiveness)
return outputs
@@ -65,11 +68,12 @@ func stellar_successors(products: Dictionary) -> Dictionary:
return QuantityMath.normalize_distribution(weights)
func destroy_planets(target: Dictionary, owner_id: String, ratio: Variant, level: int, cause: String = "local") -> void:
func destroy_planets(target: Dictionary, owner_id: String, ratio: Variant, level: int, cause: String = "local") -> bool:
if (E.le(ratio, 0) or not E.contains([1, 2], level)):
return
return false
var held: Dictionary = target.binds.get(owner_id, {})
var rocky: Variant = 0
var changed: bool = false
for id in held.keys():
if ((not world.catalog.entities.has(id)) or E.ne(world.catalog.entities[id].group_as, "Planet")):
continue
@@ -81,6 +85,7 @@ func destroy_planets(target: Dictionary, owner_id: String, ratio: Variant, level
target.destruction_remainders[key] = 0
if E.le(destroyed, 0):
continue
changed = true
var physical: Variant = world.ledger.physical_amount(target, id)
held[id] = E.subtract(held[id], destroyed)
if E.gt(physical, 0):
@@ -91,6 +96,7 @@ func destroy_planets(target: Dictionary, owner_id: String, ratio: Variant, level
rocky = world.ledger.credit(target, "RP", rocky)
target.amount.RP = E.subtract(target.amount.RP, rocky)
held.RP = E.add(held.get("RP", 0), rocky)
return changed
func apply_blast(target: Dictionary, power: Variant, environment: Dictionary) -> void:
+6 -5
View File
@@ -16,24 +16,24 @@ func stellar_environment(source: Dictionary) -> Dictionary:
else:
mass = E.add(mass, E.multiply(quantities[id], E.maximum(0, entity.mass)))
volume += E.approx(quantities[id]) * maxf(0.0, E.approx(entity.space))
volume += E.approx(quantities[id]) * maxf(0.0, E.approx(entity.voids)) * 1e22
volume += E.approx(quantities[id]) * maxf(0.0, E.approx(entity.voids)) * E.approx(Global.ExtraParams.VoidSize)
var metallicity: Variant = 0
if E.gt(mass, 0):
metallicity = E.maximum(0, E.add(E.multiply(E.of("0.4"), E.logarithm(E.add(E.mul_div(100, source.amount.get("Met", 0), mass), E.of("0.11")))), E.of("0.88")))
var base_mass: Variant = E.multiply(65536, ((E.power(mass, E.of("0.1")) if (E.gt(mass, 0) and E.lt(mass, E.of("1.0e10"))) else ((10 if E.ge(mass, E.of("1.0e10")) else 1)))))
var base_mass: Variant = E.multiply(Global.ExtraParams.DA, ((E.power(mass, E.of("0.1")) if (E.gt(mass, 0) and E.lt(mass, E.of("1.0e10"))) else ((10 if E.ge(mass, E.of("1.0e10")) else 1)))))
if not is_finite(volume) or not is_finite(E.approx(E.add(base_mass, mass))):
# Dynamic counts may exceed the float exponent range. Evaluate the
# same geometry using logarithms, avoiding infinity / infinity.
var exact_volume: Variant = quantities.get("S", 0)
for id in quantities:
var entity: OwniverseEntity = world.catalog.entities[id]
var size: Variant = E.add(E.maximum(0, entity.space), E.multiply(E.maximum(0, entity.voids), E.of("1e22")))
var size: Variant = E.add(E.maximum(0, entity.space), E.multiply(E.maximum(0, entity.voids), Global.ExtraParams.VoidSize))
exact_volume = E.add(exact_volume, E.multiply(quantities[id], size))
var density_log: float = log(1.23456e-7) + E.approx(E.logarithm(E.add(base_mass, mass))) / 2.0 - E.approx(E.logarithm(E.maximum(1, exact_volume))) / 6.0
var density_log: float = log(E.approx(Global.ExtraParams.NPC)) + E.approx(E.logarithm(E.add(base_mass, mass))) / 2.0 - E.approx(E.logarithm(E.maximum(1, exact_volume))) / 6.0
var density: Variant = E.of("1e%d" % int(floor(density_log / log(10.0))))
density = E.multiply(density, E.coefficient(exp(density_log - floor(density_log / log(10.0)) * log(10.0))))
return {"metallicity": metallicity, "density": density, "blast": E.clamp_value(E.divide(source.amount.get("S", 0), exact_volume), 0, 1) if E.gt(exact_volume, 0) else 1}
return {"metallicity": metallicity, "density": E.coefficient(1.23456e-7 * sqrt(E.approx(E.add(base_mass, mass))) / pow(maxf(1.0, volume), 1.0 / 6.0)), "blast": E.coefficient(clampf(E.approx(source.amount.get("S", 0)) / volume, 0.0, 1.0)) if volume > 0.0 else 1}
return {"metallicity": metallicity, "density": E.coefficient(E.approx(Global.ExtraParams.NPC) * sqrt(E.approx(E.add(base_mass, mass))) / pow(maxf(1.0, volume), 1.0 / 6.0)), "blast": E.coefficient(clampf(E.approx(source.amount.get("S", 0)) / volume, 0.0, 1.0)) if volume > 0.0 else 1}
func modify_world_parameter(key: String, change: Variant) -> void:
@@ -51,3 +51,4 @@ func modify_world_parameter(key: String, change: Variant) -> void:
# withdrawal carry remain intact; already elapsed time is not replayed.
for field in ["start_year", "end_year", "processed_until"]:
if batch.has(field): batch[field] = E.add(now, E.multiply(E.maximum(0, E.subtract(batch[field], now)), ratio))
world.presentation.refresh(world.state, world.catalog)
+17 -13
View File
@@ -1,7 +1,11 @@
extends RefCounted
const E = preload("res://world/owniverse/math/exact.gd")
static var INITIAL_ENERGY = E.of("1.0e27")
static func extra_params() -> Dictionary:
return Engine.get_main_loop().root.get_node("Global").ExtraParams
static var INITIAL_ENERGY: Variant:
get: return extra_params().E
const TRENDS = ["Antitheism", "Integration", "Xenocide", "Reconstruction", "Spirituality"]
const WEIGHTS = {"m": [0, 2, 2, 0, 6], "l": [6, 1, 1, 2, 0], "w": [2, 0, 6, 0, 2], "d": [0, 6, 0, 1, 3], "c": [1, 3, 3, 3, 0], "b": [3, 0, 0, 6, 1]}
@@ -26,25 +30,25 @@ static func production(source: Dictionary, entity) -> Variant:
return effective(source, "Exp")
if E.eq(entity.group_as, "Star"):
var burn: Variant = effective(source, "Bur")
return (E.square_root(burn) if E.gt(burn, 1) else burn)
return (E.power(burn, extra_params().combustibility_bonus) if E.gt(burn, 1) else burn)
return 1
static func density_display(density: Variant) -> Dictionary:
if E.le(density, E.of("1.0e-5")):
if E.le(density, extra_params().bigrip1):
# Unity had log10(density) - 7 here; +7 maps 1e-7..1e-5 to 0..1.
return {"mode": "DensityRip", "position": E.clamp_value(E.divide((E.add(E.divide(E.logarithm(E.maximum(density, E.of("1e-7"))), E.logarithm(10)), 7)), 2), 0, 1)}
if E.ge(density, E.of("0.9")):
return {"mode": "DensityCrunch", "position": E.clamp_value(E.multiply((E.subtract(density, E.of("0.9"))), 10), 0, 1)}
return {"mode": "Density", "position": E.clamp_value(E.divide(E.logarithm(E.multiply(density, 100000)), E.logarithm(90000)), 0, 1)}
return {"mode": "DensityRip", "position": E.clamp_value(E.divide(E.logarithm(E.divide(E.maximum(density, extra_params().bigrip3), extra_params().bigrip3)), E.logarithm(E.divide(extra_params().bigrip1, extra_params().bigrip3))), 0, 1)}
if E.ge(density, extra_params().bigcrunch1):
return {"mode": "DensityCrunch", "position": E.clamp_value(E.divide(E.subtract(density, extra_params().bigcrunch1), E.subtract(extra_params().bigcrunch3, extra_params().bigcrunch1)), 0, 1)}
return {"mode": "Density", "position": E.clamp_value(E.divide(E.logarithm(E.divide(density, extra_params().bigrip1)), E.logarithm(E.divide(extra_params().bigcrunch1, extra_params().bigrip1))), 0, 1)}
## Unity ApocalypseCheck thresholds and WarningUI colors; visual warning only.
static func density_warning_color(density: Variant) -> Color:
if E.ge(density, 1): return Color.html("00c0ffff")
if E.ge(density, E.of("0.97")): return Color.html("20b0dfbf")
if E.ge(density, E.of("0.9")): return Color.html("40a0c07f")
if E.le(density, E.of("1.0e-7")): return Color.html("ff0000ff")
if E.le(density, E.of("1.0e-6")): return Color.html("df2020bf")
if E.le(density, E.of("1.0e-5")): return Color.html("c040407f")
if E.ge(density, extra_params().bigcrunch3): return Color.html("00c0ffff")
if E.ge(density, extra_params().bigcrunch2): return Color.html("20b0dfbf")
if E.ge(density, extra_params().bigcrunch1): return Color.html("40a0c07f")
if E.le(density, extra_params().bigrip3): return Color.html("ff0000ff")
if E.le(density, extra_params().bigrip2): return Color.html("df2020bf")
if E.le(density, extra_params().bigrip1): return Color.html("c040407f")
return Color(0, 0, 0, 0)
## Integrate the population present at the start of the simulation interval.