Files
owniverse/world/owniverse/owniverse.gd
T

1516 lines
66 KiB
GDScript
Raw Normal View History

2026-03-30 00:48:20 +03:00
extends Node
class_name Owniverse
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")
2026-03-30 00:48:20 +03:00
#var state: State = State.new()
var entities: Dictionary[String, OwniverseEntity] = {}
var entity_groups: Dictionary[String, Array] = {}
var entity_event_groups: Dictionary[String, Array] = {}
var _group_members: Dictionary = {}
var _indexed_entity_count: int = -1
var items_to_update: Dictionary[String, float] = {}
var defer_item_updates: bool = false
var item_amounts_dirty: bool = true
2026-07-05 12:23:33 +03:00
var auto_production: Array = []
var locked_entities: Array = []
# ------------------------------------
var state: Dictionary = {
'version': SimulationTime.STATE_VERSION,
'time': {
'datetime': 0.0,
'year': 0.0,
},
'speed': 1,
'speed_in_year': 1,
'energy': 1E14,
'batches': {},
'batch_origins': {},
'binds': {},
'amount': {},
'distribution': {},
'auto_resources': {},
'auto_star_deficits': {},
'production_remainders': {},
'resource_remainders': {'basic': {}, 'automatic': {}},
'evolution_remainders': {},
'life_remainders': {},
'planet_remainders': {},
'blast_remainders': {},
'destruction_remainders': {},
'civilization_remainders': {},
'civilization_stats': {},
'civilization_homes': {},
'ethics': {},
'stats': {
'total_amount': {},
}
}
var new_state = state.duplicate_deep()
var speed: int:
get:
return state.speed
set(value):
state.speed = clampi(value, 1, Global.SPEED_LIMIT)
state.speed_in_year = pow(10.0, state.speed - 1)
func evolute(delta: float) -> void:
if delta <= 0.0 or not is_finite(delta):
return
var datetime: float = state.time.datetime + delta * pow(10.0, speed - 1)
if not is_finite(datetime) or not is_finite(datetime * 10.0):
return
_evolute_to_datetime(datetime)
func initialize_life_batches() -> void:
CivilizationState.detach_space_civilizations(state, Global.CivilizationRules)
CivilizationState.enforce_capacity(self, state, Global.CivilizationRules, state.time.datetime)
var physical: Dictionary = _physical_amounts(state)
for id in entities:
if entities[id].life_rule.is_empty():
continue
var tracked: float = 0.0
for batch: Dictionary in state.batches.get(id, []):
tracked += batch.amount
_append_batch(state, id, maxf(0.0, physical.get(id, 0.0) - tracked), state.time.datetime, state.time.datetime)
new_state = state
func _evolute_to_datetime(datetime: float) -> void:
# Reconcile only when simulation advances.
# Fractional frame updates cannot change capacity or populations.
var processed_year: float = SimulationTime.processed_year(datetime)
if processed_year > state.time.year:
initialize_life_batches()
var previous: Dictionary = state
var next: Dictionary = previous.duplicate_deep()
next.version = SimulationTime.STATE_VERSION
next.speed = clampi(int(previous.speed), 1, Global.SPEED_LIMIT)
next.speed_in_year = pow(10.0, next.speed - 1)
var from_year: float = previous.time.year
var to_year: float = processed_year
if to_year > from_year:
var quantities: Dictionary = _physical_amounts(previous)
var distribution: Dictionary = _normalize_distribution(quantities)
next.distribution = distribution
_produce_for_period(previous, next, quantities, distribution, from_year, to_year)
_evolve_batches(next, to_year)
_refresh_groups(next)
_clamp_auto_resources(next)
next.time = {"datetime": datetime, "year": to_year}
# Publish only after every phase has finished; previous stays untouched.
new_state = next
state = next
if to_year > from_year:
refresh_item_amounts()
## Aggregate only for presentation; never write aliases back into simulation state.
func get_item_amounts(total_produced: bool = false) -> Dictionary[String, float]:
var source: Dictionary = state.stats.total_amount if total_produced else state.amount
if not total_produced:
source = source.duplicate()
var physical: Dictionary = _physical_amounts(state)
var planets: float = 0.0
for id in physical:
if entities[id].group_as == "Planet":
source[id] = physical[id]
planets += physical[id]
source["Planet"] = planets
var result: Dictionary[String, float] = {}
for id in source:
result[id] = 0.0
for id in source:
# group_as counters are already sums and must not be counted a second time.
if _is_group(id):
continue
var item_id: String = id
if entities.has(id) and entities[id].count_as != "":
item_id = entities[id].count_as
result[item_id] = result.get(item_id, 0.0) + float(source[id])
for id in source:
if _is_group(id):
result[id] = float(source[id])
return result
func refresh_item_amounts(force: bool = false) -> void:
item_amounts_dirty = true
if defer_item_updates and not force:
return
items_to_update = get_item_amounts()
item_amounts_dirty = false
func _add_time_delta(delta: float) -> Dictionary:
var new_datetime: float = state.time.datetime + delta * pow(10.0, speed - 1)
return {"datetime": new_datetime, "year": SimulationTime.processed_year(new_datetime)}
## Rebuild after changing group_as or replacing definitions without changing their count.
## Adding/removing definitions is detected automatically during initialization.
func rebuild_group_index() -> void:
_group_members.clear()
for id in entities:
var group: String = entities[id].group_as
if not _group_members.has(group):
_group_members[group] = []
_group_members[group].append(id)
_indexed_entity_count = entities.size()
func _ensure_group_index() -> void:
if _indexed_entity_count != entities.size():
rebuild_group_index()
func _is_group(id: String) -> bool:
_ensure_group_index()
return entity_groups.has(id) or _group_members.has(id)
func _physical_amounts(source: Dictionary) -> Dictionary:
var amounts: Dictionary = {}
for id in entities:
if not _is_group(id):
amounts[id] = maxf(0.0, source.amount.get(id, 0.0))
for bindings: Dictionary in source.binds.values():
for id in bindings:
if amounts.has(id):
amounts[id] += maxf(0.0, bindings[id])
return amounts
func _members(id: String) -> Array:
if not _is_group(id):
return [id]
# Callers may modify the returned array without changing the index.
return _group_members.get(id, []).duplicate()
func _available(source: Dictionary, id: String) -> float:
var amount: float = 0.0
for member in _members(id):
amount += maxf(0.0, source.amount.get(member, 0.0))
return amount
# Resolve group references to concrete types, proportional to free quantities.
func _expand_requirements(source: Dictionary, requirements: Dictionary) -> Dictionary:
var expanded: Dictionary = {}
for id in requirements:
var weights: Dictionary = {}
for member in _members(id):
weights[member] = source.amount.get(member, 0.0)
weights = _normalize_distribution(weights)
for member in weights:
expanded[member] = expanded.get(member, 0.0) + maxf(0.0, requirements[id]) * weights[member]
return expanded
func _take_available(target: Dictionary, id: String, amount: float) -> Dictionary:
var quantities: Dictionary = {}
for member in _members(id):
quantities[member] = target.amount.get(member, 0.0)
var distribution: Dictionary = _normalize_distribution(quantities)
var taken: Dictionary = {}
var total: float = minf(amount, _available(target, id))
var discrete: bool = not distribution.is_empty()
for member in distribution:
discrete = discrete and _is_discrete(member)
if discrete:
total = floorf(total)
var shares: Array = []
var assigned: float = 0.0
for member in distribution:
var expected: float = total * distribution[member]
taken[member] = floorf(expected) if discrete else expected
assigned += taken[member]
shares.append({"id": member, "rest": expected - taken[member]})
if discrete:
shares.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a.rest > b.rest if a.rest != b.rest else str(a.id) < str(b.id))
for share: Dictionary in shares:
if assigned >= total:
break
taken[share.id] += 1.0
assigned += 1.0
for member in taken:
target.amount[member] = maxf(0.0, target.amount.get(member, 0.0) - taken[member])
return taken
func _scale_batches(target: Dictionary, id: String, factor: float) -> void:
var batches: Array = target.batches.get(id, [])
var total: float = 0.0
var assigned: float = 0.0
var shares: Array = []
for batch: Dictionary in batches:
var expected: float = batch.amount * maxf(0.0, factor)
total += expected
batch.amount = floorf(expected)
assigned += batch.amount
shares.append({"batch": batch, "rest": expected - batch.amount})
shares.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a.rest > b.rest)
for share: Dictionary in shares:
if assigned < roundf(total):
share.batch.amount += 1.0
assigned += 1.0
share.batch.withdraw_remainder = minf(share.batch.get("withdraw_remainder", 0.0), share.batch.amount)
func _is_discrete(_id: String) -> bool:
return true
func _credit(target: Dictionary, id: String, amount: float) -> float:
if amount <= 0.0:
return 0.0
if _is_discrete(id):
if not target.has("production_remainders"):
target.production_remainders = {}
var expected: float = amount + target.production_remainders.get(id, 0.0)
amount = floorf(expected)
target.production_remainders[id] = expected - amount
target.amount[id] = target.amount.get(id, 0.0) + amount
target.stats.total_amount[id] = target.stats.total_amount.get(id, 0.0) + amount
if entities.has(id) and entities[id].group_as != "" and entities[id].group_as != id:
var group: String = entities[id].group_as
target.stats.total_amount[group] = target.stats.total_amount.get(group, 0.0) + amount
return amount
# Allocate whole objects between free stock and owners without changing totals.
func _round_object_allocations(target: Dictionary) -> void:
var physical: Dictionary = _physical_amounts(target)
for id in physical:
if not _is_discrete(id):
continue
var slots: Array = [ {"owner": "", "value": target.amount.get(id, 0.0)}]
for owner_id in target.binds:
if target.binds[owner_id].has(id):
slots.append({"owner": owner_id, "value": target.binds[owner_id][id]})
var assigned: float = 0.0
for slot: Dictionary in slots:
slot.whole = floorf(slot.value)
slot.rest = slot.value - slot.whole
assigned += slot.whole
slots.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a.rest > b.rest if a.rest != b.rest else str(a.owner) < str(b.owner))
var left: float = roundf(physical[id]) - assigned
for slot: Dictionary in slots:
if left > 0.0:
slot.whole += 1.0
left -= 1.0
if slot.owner == "":
target.amount[id] = slot.whole
else:
target.binds[slot.owner][id] = slot.whole
func _refresh_groups(target: Dictionary) -> void:
var groups: Dictionary = {}
for id in entities:
var group: String = entities[id].group_as
if group != "":
groups[group] = groups.get(group, 0.0) + target.amount.get(id, 0.0)
for group in groups:
target.amount[group] = groups[group]
func _clamp_auto_resources(target: Dictionary) -> void:
for id in target.get("auto_resources", {}):
target.auto_resources[id] = minf(target.auto_resources[id], _available(target, id))
# Each type collects production into consecutive lifespan_delta-wide creation windows.
# Its evolution window is the creation window shifted forward by one lifespan.
# Optional direct completions return the remaining object-year integral for
# newborn production. NAN means the caller should integrate ordinary batches.
func _append_batch(target: Dictionary, id: String, amount: float,
created_from: float, created_to: float, completed: Dictionary = {},
completed_before: float = -INF) -> float:
if amount <= 0.0 or not entities.has(id) or entities[id].lifespan <= 0.0:
return NAN
if created_to < created_from:
return NAN
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: float = CivilizationState.effective_span(self, target, id, Global.CivilizationRules)
var batch_duration: float = lifespan / Global.batch_count if lifespan > 10.0 else lifespan
if batch_duration <= 0.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: float = target.batch_origins[id]
var cursor: float = created_from
var duration: float = created_to - created_from
var remaining_amount: float = amount
var completed_amount: float = 0.0
var completed_from: float = INF
var completed_to: float = -INF
# Subtract virtual windows in their original order to preserve floating-point
# rounding of lifetime production, rather than subtracting one large sum.
var remaining_object_years: float = amount * maxf(0.0, completed_before - (created_from + created_to) * 0.5) if is_finite(completed_before) else NAN
# Compute the index once. Recomputing floor from a rounded boundary can
# select the preceding window forever (e.g. fractional origins at large ages).
var window_index: float = floorf((cursor - origin) / batch_duration)
if origin + (window_index + 1.0) * batch_duration <= cursor:
window_index += 1.0
var batches_by_start: Dictionary = {}
for existing: Dictionary in batches:
var start: float = existing.get("created_from", -INF)
if not batches_by_start.has(start):
batches_by_start[start] = []
batches_by_start[start].append(existing)
while true:
var window_start: float = origin + window_index * batch_duration
var window_end: float = origin + (window_index + 1.0) * batch_duration
if window_end <= cursor:
push_error("Batch window exceeds floating-point time precision: " + id)
return remaining_object_years
var part_end: float = minf(created_to, window_end)
var part_amount: float = remaining_amount
if duration > 0.0 and part_end < created_to:
part_amount = floorf(amount * (part_end - created_from) / duration) - (amount - remaining_amount)
if window_end + lifespan <= completed_before:
# Retain the original integer allocation and lifetime integral, but
# do not allocate thousands of already-dead batch dictionaries.
completed_amount += part_amount
completed_from = minf(completed_from, window_start + lifespan)
completed_to = maxf(completed_to, window_end + lifespan)
remaining_object_years -= part_amount * _expired_years(completed_before, window_start + lifespan, window_end + lifespan)
else:
var batch: Dictionary = {}
for existing: Dictionary in batches_by_start.get(window_start, []):
if existing.get("created_from", -INF) == window_start and existing.start_year == window_start + lifespan and existing.end_year == window_end + lifespan:
batch = existing
break
if batch.is_empty():
batch = {
"created_from": window_start,
"created_to": window_end,
"start_year": window_start + lifespan,
"end_year": window_end + lifespan,
"amount": 0.0,
}
batches.append(batch)
if not batches_by_start.has(window_start):
batches_by_start[window_start] = []
batches_by_start[window_start].append(batch)
batch.amount += part_amount
remaining_amount -= part_amount
if duration == 0.0 or part_end >= created_to:
break
cursor = window_end
window_index += 1.0
batches.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a.created_from < b.created_from)
if completed_amount > 0.0:
if not completed.has(id): completed[id] = []
completed[id].append({"amount": completed_amount, "from": completed_from, "to": completed_to})
return remaining_object_years
# Move the left boundary after each partial withdrawal, keeping a uniform density.
func _withdraw_batches(target: Dictionary, id: String, to_year: float) -> Array:
var expired: Array = []
var remaining: Array = []
for batch: Dictionary in target.batches.get(id, []):
var start: float = maxf(batch.start_year, batch.get("processed_until", batch.start_year))
var end: float = batch.end_year
var amount: float = batch.amount
if amount <= 0.0:
continue
if to_year < start or (to_year == start and end > start):
remaining.append(batch)
continue
var consumed: float = amount
var until: float = minf(to_year, end)
if to_year < end:
var carry: float = batch.get("withdraw_remainder", 0.0)
var expected: float = carry + (amount - carry) * (to_year - start) / (end - start)
consumed = floorf(expected)
batch.withdraw_remainder = expected - consumed
batch.amount = maxf(0.0, amount - consumed)
batch.processed_until = to_year
remaining.append(batch)
if consumed > 0.0:
expired.append({"amount": consumed, "from": start, "to": until})
target.batches[id] = remaining
return expired
func _add_output_terms(outputs: Dictionary, terms: String, count: float) -> void:
if count <= 0.0:
return
for term in terms.split("&"):
var parts: PackedStringArray = term.strip_edges().split(":")
var id: String = parts[0].strip_edges()
var amount: float = float(parts[1]) if parts.size() == 2 else 1.0
if id != "" and amount > 0.0:
outputs[id] = outputs.get(id, 0.0) + count * amount
# One life attempt: progress, degradation, or retry at the same stage.
# Retried worlds are not new planets and must not increment production statistics.
func _life_products(target: Dictionary, entity: OwniverseEntity, count: float) -> Dictionary:
var rule: Dictionary = entity.life_rule
var pending: Dictionary = {}
if not target.has("life_remainders"):
target.life_remainders = {}
for channel in ["progress", "degradation"]:
var key: String = entity.id + ":" + channel
var expected: float = count * float(rule[channel]) + target.life_remainders.get(key, 0.0)
target.life_remainders[key] = expected
pending[channel] = floorf(snappedf(expected, 1.0e-12))
var assigned: Dictionary = _allocate_whole(pending, count)
for channel in assigned:
var key: String = entity.id + ":" + channel
target.life_remainders[key] = maxf(0.0, target.life_remainders[key] - assigned[channel])
var result: Dictionary = {entity.id: count - assigned.progress - assigned.degradation}
result[rule.next] = assigned.progress
var weights: Dictionary = {}
var weight_sum: float = 0.0
for term in str(rule.fall).split("^"):
var pair: PackedStringArray = term.split(":")
weights[pair[0]] = float(pair[1])
weight_sum += float(pair[1])
var requests: Dictionary = {}
for id in weights:
var key: String = entity.id + ":fall:" + id
var expected: float = assigned.degradation * weights[id] / weight_sum + target.life_remainders.get(key, 0.0)
target.life_remainders[key] = expected
requests[id] = maxf(0.0, floorf(snappedf(expected, 1.0e-12)))
var falls: Dictionary = _allocate_whole(requests, assigned.degradation)
var distributed: float = 0.0
for id in falls:
distributed += falls[id]
result[id] = result.get(id, 0.0) + falls[id]
var key: String = entity.id + ":fall:" + id
target.life_remainders[key] -= falls[id]
# Fill sub-unit fall quotas deterministically, retaining signed allocation errors.
while distributed < assigned.degradation:
var selected: String = ""
var largest: float = -INF
for id in weights:
var rest: float = target.life_remainders[entity.id + ":fall:" + id]
if rest > largest:
largest = rest
selected = id
result[selected] = result.get(selected, 0.0) + 1.0
target.life_remainders[entity.id + ":fall:" + selected] -= 1.0
distributed += 1.0
return result
func _evolution_products(target: Dictionary, entity: OwniverseEntity, count: float) -> Dictionary:
if not entity.life_rule.is_empty():
return _life_products(target, entity, count)
var outputs: Dictionary = {}
for id in entity.output_and:
outputs[id] = count * maxf(0.0, entity.output_and[id])
if not entity.output_or.is_empty():
# The parser stores the percentage after '%', e.g. 0.1 means 0.1 percent.
var chance: float = clampf(entity.output_or.get("chance", 0.0) / 100.0, 0.0, 1.0)
if not target.has("evolution_remainders"):
target.evolution_remainders = {}
var expected: float = count * chance + target.evolution_remainders.get(entity.id, 0.0)
var positive: float = minf(count, floorf(snappedf(expected, 1.0e-12)))
target.evolution_remainders[entity.id] = maxf(0.0, expected - positive)
_add_output_terms(outputs, str(entity.output_or.get("positive", "")), positive)
_add_output_terms(outputs, str(entity.output_or.get("negative", "")), count - positive)
# Entity.blast is authoritative even if imported output still contains
# blast:N. Zero disables the event; never add both representations.
outputs.erase("blast")
if entity.blast > 0:
outputs["blast"] = count * entity.blast
return outputs
# Derived from current concrete stock, including bound objects, never UI aliases.
# GI has no Unity force parameters; stellar effects use their neutral multiplier 1.
func stellar_environment(source: Dictionary) -> Dictionary:
var quantities: Dictionary = _physical_amounts(source)
# SMBH is a self-group in GI; its quantity stores mass, not object count.
var mass: float = source.amount.get("SMBH", 0.0)
var volume: float = quantities.get("S", 0.0)
for id in quantities:
var entity: OwniverseEntity = entities[id]
if id in ["H", "He", "Met", "SMBH"]:
mass += quantities[id]
else:
mass += quantities[id] * maxf(0.0, entity.mass)
volume += quantities[id] * maxf(0.0, entity.space)
volume += quantities[id] * maxf(0.0, entity.voids) * 1.0e22
var metallicity: float = 0.0
if mass > 0.0:
metallicity = maxf(0.0, 0.4 * log(100.0 * source.amount.get("Met", 0.0) / mass + 0.11) + 0.88)
var base_mass: float = 65536.0 * (pow(mass, 0.1) if mass > 0.0 and mass < 1.0e10 else (10.0 if mass >= 1.0e10 else 1.0))
return {
"metallicity": metallicity,
"density": 1.23456e-7 * sqrt(base_mass + mass) / pow(maxf(1.0, volume), 1.0 / 6.0),
"blast": clampf(source.amount.get("S", 0.0) / volume, 0.0, 1.0) if volume > 0.0 else 1.0,
}
func _accumulate_whole(target: Dictionary, field: String, key: String, value: float) -> float:
if not target.has(field):
target[field] = {}
var expected: float = maxf(0.0, value) + target[field].get(key, 0.0)
var whole: float = floorf(snappedf(expected, 1.0e-12))
target[field][key] = maxf(0.0, expected - whole)
return whole
func _create_planets(target: Dictionary, star_id: String, count: float,
from_year: float, to_year: float, metallicity: float) -> void:
if count <= 0.0 or entities[star_id].planets.is_empty():
return
if not target.binds.has(star_id):
target.binds[star_id] = {}
for id in entities[star_id].planets:
if not entities.has(id):
continue
var rule: Dictionary = entities[star_id].planets[id]
var amount: float = _accumulate_whole(target, "planet_remainders", star_id + ":" + id,
count * rule.amount * rule.chance * maxf(0.0, metallicity))
amount = _credit(target, id, amount)
target.amount[id] -= amount
target.binds[star_id][id] = target.binds[star_id].get(id, 0.0) + amount
_append_batch(target, id, amount, from_year, to_year)
# Successors born and dying inside one long step must still emit their lifetime S.
# Integrate the birth interval and subtract the newly added death-window integral.
func _produce_newborn_lifetime(target: Dictionary, id: String, count: float,
born_from: float, born_to: float, to_year: float, before_batches: Array,
remaining_object_years: float = NAN) -> void:
var products: Dictionary = _yearly_products(entities[id])
if products.is_empty() or count <= 0.0:
return
var object_years: float = remaining_object_years if is_finite(remaining_object_years) else count * maxf(0.0, to_year - (born_from + born_to) * 0.5)
var previous_amounts: Dictionary = {}
for old: Dictionary in before_batches:
var start: float = old.get("created_from", -INF)
if not previous_amounts.has(start):
previous_amounts[start] = old.amount
for batch: Dictionary in target.batches.get(id, []):
var added: float = batch.amount - previous_amounts.get(batch.get("created_from", INF), 0.0)
object_years -= maxf(0.0, added) * _expired_years(to_year, batch.start_year, batch.end_year)
object_years = maxf(0.0, object_years)
for resource in products:
var output: float = object_years * products[resource]
if resource in ["H", "S"]:
output = minf(output, target.energy)
var actual: float = _credit(target, resource, output)
if resource in ["H", "S"]:
target.energy = maxf(0.0, target.energy - actual)
func _stellar_successors(products: Dictionary) -> Dictionary:
var weights: Dictionary = {}
for id in products:
if entities.has(id) and (entities[id].group_as in ["Star", "BH"] or entities[id].event_group == "SNGRBB" or entities[id].count_as == "BD"):
weights[id] = products[id]
return _normalize_distribution(weights)
# Level 1 sterilizes vulnerable worlds to RP; level 2 removes every planet.
func _destroy_planets(target: Dictionary, owner_id: String, ratio: float, level: int, cause: String = "local") -> void:
if ratio <= 0.0 or level not in [1, 2]:
return
var held: Dictionary = target.binds.get(owner_id, {})
var physical: Dictionary = _physical_amounts(target)
var rocky: float = 0.0
for id in held.keys():
if not entities.has(id) or entities[id].group_as != "Planet":
continue
if level == 1 and not entities[id].blastability:
continue
var key: String = cause + ":" + str(level) + ":" + owner_id + ":" + id
var destroyed: float = minf(held[id], _accumulate_whole(target, "destruction_remainders", key, held[id] * minf(1.0, ratio)))
if destroyed == held[id]:
target.destruction_remainders[key] = 0.0
if destroyed <= 0.0:
continue
held[id] -= destroyed
_scale_batches(target, id, 1.0 - destroyed / physical[id])
if level == 1:
rocky += destroyed
if rocky > 0.0:
rocky = _credit(target, "RP", rocky)
target.amount.RP -= rocky
held.RP = held.get("RP", 0.0) + rocky
func _apply_blast(target: Dictionary, power: float, environment: Dictionary) -> void:
var hits: float = _accumulate_whole(target, "blast_remainders", "hits", power * environment.blast)
var quantities: Dictionary = _physical_amounts(target)
var weights: Dictionary = {}
for id in entities:
if not entities[id].planets.is_empty() and quantities.get(id, 0.0) > 0.0:
weights[id] = quantities[id]
var distribution: Dictionary = _normalize_distribution(weights)
# Fractional exposure is deterministic; destruction itself remains whole.
for id in distribution:
_destroy_planets(target, id, hits * distribution[id] / quantities[id] * environment.density, 1, "blast")
func _evolve_batches(target: Dictionary, to_year: float) -> void:
# A long cycle may contain several generations; preserve actual birth intervals.
var completed: Dictionary = {}
while _evolve_batch_generation(target, to_year, completed):
pass
func _uses_short_lived_fast_path(id: String) -> bool:
# Only terminal stellar events with linear outputs and no living successors.
var entity: OwniverseEntity = entities[id]
if entity.event_group != "SNGRBB" or not entity.output_or.is_empty() or not entity.life_rule.is_empty():
return false
for successor in entity.output_and:
if entities.has(successor) and entities[successor].lifespan > 0.0:
return false
return true
func _evolve_batch_generation(target: Dictionary, to_year: float, completed: Dictionary = {}) -> bool:
# Apply direct completions at the original next-generation boundary, so
# binding transfers, the blast environment and capacity checks keep order.
var completed_now: Dictionary = completed.duplicate(true)
completed.clear()
var quantities: Dictionary = _physical_amounts(target)
var expired: Dictionary = {}
var preview: Dictionary = {"batches": {}}
for id in target.batches:
if entities.has(id) and entities[id].destroy_level > 0:
preview.batches[id] = target.batches[id].duplicate(true)
for id in target.batches.keys():
if not entities.has(id) or entities[id].destroy_level <= 0:
continue
var dying: float = 0.0
for part: Dictionary in completed_now.get(id, []):
dying += part.amount
for part: Dictionary in _withdraw_batches(preview, id, to_year):
dying += part.amount
if quantities.get(id, 0.0) > 0.0:
_destroy_planets(target, id, dying / quantities[id], entities[id].destroy_level)
quantities = _physical_amounts(target)
# Withdraw all types before creating successors: iteration order cannot affect them.
for id in target.batches.keys():
if entities.has(id):
expired[id] = completed_now.get(id, []) + _withdraw_batches(target, id, to_year)
var has_expired: bool = false
for parts: Array in expired.values():
if not parts.is_empty():
has_expired = true
break
if not has_expired:
return false
var fractions: Dictionary = {}
var outputs: Dictionary = {}
var star_budget: Dictionary = CivilizationState.stellar_budget(self, target, Global.CivilizationRules)
var civilization_transaction: Dictionary = CivilizationEvolution.prepare(self, target, expired, to_year, star_budget, Global.CivilizationRules)
for id in expired:
var total: float = 0.0
for part: Dictionary in expired[id]:
total += part.amount
fractions[id] = clampf(total / quantities[id], 0.0, 1.0) if quantities.get(id, 0.0) > 0.0 else 0.0
var combined: Dictionary = {}
for part: Dictionary in expired[id]:
if not part.has("products"):
part.products = _evolution_products(target, entities[id], part.amount)
for successor in part.products.keys():
if Global.CivilizationRules.has(id):
continue
var requested: float = part.products[successor]
var admitted: float = CivilizationState.admit_transition(star_budget, Global.CivilizationRules, id, successor, requested)
part.products[successor] = admitted
if admitted < requested:
part.products[id] = part.products.get(id, 0.0) + requested - admitted
for successor in part.products:
combined[successor] = combined.get(successor, 0.0) + part.products[successor]
outputs[id] = {}
if total > 0.0:
for successor in combined:
outputs[id][successor] = combined[successor] / total
# Use a frozen binding table so simultaneous transitions cannot cascade by type order.
var bindings: Dictionary = target.binds.duplicate(true)
var incoming: Dictionary = {}
for owner_id in bindings:
var owner_survival: float = 1.0 - fractions.get(owner_id, 0.0)
target.binds[owner_id] = {}
for id in bindings[owner_id]:
var held: float = bindings[owner_id][id]
var released: float = held * (1.0 - owner_survival)
target.amount[id] = target.amount.get(id, 0.0) + released
var heirs: Dictionary = _stellar_successors(outputs.get(owner_id, {})) if entities.has(id) and entities[id].group_as == "Planet" else {}
var fraction: float = fractions.get(id, 0.0)
for heir in heirs:
if not incoming.has(heir):
incoming[heir] = {}
incoming[heir][id] = incoming[heir].get(id, 0.0) + released * (1.0 - fraction) * heirs[heir]
for child in outputs.get(id, {}):
if entities.has(child) and not CivilizationState.is_space_civilization(child, Global.CivilizationRules):
incoming[heir][child] = incoming[heir].get(child, 0.0) + released * fraction * heirs[heir] * outputs[id][child]
var remaining: float = held * owner_survival * (1.0 - fraction)
target.binds[owner_id][id] = target.binds[owner_id].get(id, 0.0) + remaining
for successor in outputs.get(id, {}):
if not entities.has(successor) or CivilizationState.is_space_civilization(successor, Global.CivilizationRules):
continue
var transferred: float = held * owner_survival * fraction * maxf(0.0, outputs[id][successor])
if not incoming.has(owner_id):
incoming[owner_id] = {}
incoming[owner_id][successor] = incoming[owner_id].get(successor, 0.0) + transferred
for id in fractions:
target.amount[id] = maxf(0.0, target.amount.get(id, 0.0) * (1.0 - fractions[id]))
var new_owners: Dictionary = {}
var blast_power: float = 0.0
var environment: Dictionary = stellar_environment(target)
for id in expired:
for part: Dictionary in expired[id]:
for successor in part.products:
var amount: float = part.products[successor]
if successor == "blast":
blast_power += amount
continue
if not entities.has(successor):
continue
if successor == id and (not entities[id].life_rule.is_empty() or Global.CivilizationRules.has(id)):
target.amount[id] = target.amount.get(id, 0.0) + amount
else:
amount = _credit(target, successor, amount)
var before_batches: Array = target.batches.get(successor, []).duplicate(true)
var cutoff: float = to_year if _uses_short_lived_fast_path(successor) else -INF
var integral: float = _append_batch(target, successor, amount, part.from, part.to, completed, cutoff)
_produce_newborn_lifetime(target, successor, amount, part.from, part.to, to_year, before_batches, integral)
new_owners[successor] = new_owners.get(successor, 0.0) + amount
for owner_id in incoming:
if not target.binds.has(owner_id):
target.binds[owner_id] = {}
for id in incoming[owner_id]:
var transferred: float = minf(incoming[owner_id][id], target.amount.get(id, 0.0))
target.binds[owner_id][id] = target.binds[owner_id].get(id, 0.0) + transferred
target.amount[id] = maxf(0.0, target.amount.get(id, 0.0) - transferred)
_bind_new_owners(target, new_owners)
_round_object_allocations(target)
CivilizationEvolution.finish(self, target, civilization_transaction, to_year, Global.CivilizationRules)
_apply_blast(target, blast_power, environment)
CivilizationState.enforce_capacity(self, target, Global.CivilizationRules, to_year)
return true
# Plan all bindings on a copy, reserving specific types before broader groups.
func _bind_full(target: Dictionary, amounts: Dictionary) -> bool:
# Binding only mutates free amounts and owners' holdings.
var trial: Dictionary = {
"amount": target.amount.duplicate(),
"binds": target.binds.duplicate(true),
}
var requests: Array = []
for owner_id in amounts:
if not entities.has(owner_id):
return false
for id in entities[owner_id].bind:
var quantity: float = amounts[owner_id] * maxf(0.0, entities[owner_id].bind[id])
if not is_equal_approx(quantity, roundf(quantity)):
return false
if quantity > 0.0:
requests.append({"owner": owner_id, "id": id, "amount": roundf(quantity), "members": _members(id).size()})
requests.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
if a.members != b.members:
return a.members < b.members
return str(a.id) < str(b.id) if a.id != b.id else str(a.owner) < str(b.owner))
for request: Dictionary in requests:
var taken: Dictionary = _take_available(trial, request.id, request.amount)
var total: float = 0.0
for quantity in taken.values():
total += quantity
if total != request.amount:
return false
if not trial.binds.has(request.owner):
trial.binds[request.owner] = {}
for id in taken:
trial.binds[request.owner][id] = trial.binds[request.owner].get(id, 0.0) + taken[id]
target.amount = trial.amount
target.binds = trial.binds
return true
# Neither costs nor bindings are committed unless the whole batch is funded.
func _try_production(target: Dictionary, entity: OwniverseEntity, amount: float,
automatic: bool = false) -> bool:
if amount <= 0.0 or amount != floorf(amount):
return false
var trial: Dictionary = target.duplicate()
trial.amount = target.amount.duplicate()
trial.binds = target.binds.duplicate(true)
trial.auto_resources = target.auto_resources.duplicate()
trial.batches = target.batches.duplicate()
var copied_batches: Dictionary = {}
for resource in entity.cost:
var cost: float = roundf(maxf(0.0, entity.cost[resource]) * amount)
if automatic and trial.auto_resources.get(resource, 0.0) < cost:
return false
var before: Dictionary = _physical_amounts(trial)
var taken: Dictionary = _take_available(trial, resource, cost)
var total: float = 0.0
for quantity in taken.values():
total += quantity
if total != cost:
return false
if automatic:
trial.auto_resources[resource] = maxf(0.0, trial.auto_resources.get(resource, 0.0) - cost)
for id in taken:
# Isolate only batches actually touched by this transaction.
if trial.batches.has(id) and not copied_batches.has(id):
trial.batches[id] = trial.batches[id].duplicate(true)
copied_batches[id] = true
var factor: float = 1.0 - taken[id] / before[id] if before.get(id, 0.0) > 0.0 else 0.0
_scale_batches(trial, id, factor)
if not _bind_new_owners(trial, {entity.id: amount}, true):
return false
_refresh_groups(trial)
_clamp_auto_resources(trial)
for field in ["amount", "binds", "batches", "auto_resources"]:
if trial.has(field):
target[field] = trial[field]
return true
func _bind_new_owners(target: Dictionary, amounts: Dictionary, require_full: bool = false) -> bool:
if require_full:
return _bind_full(target, amounts)
var requests: Dictionary = {}
for owner_id in amounts:
if not entities.has(owner_id):
continue
for id in entities[owner_id].bind:
var quantity: float = amounts[owner_id] * maxf(0.0, entities[owner_id].bind[id])
var weights: Dictionary = {}
for member in _members(id):
weights[member] = target.amount.get(member, 0.0)
weights = _normalize_distribution(weights)
for member in weights:
if not requests.has(member):
requests[member] = {}
requests[member][owner_id] = requests[member].get(owner_id, 0.0) + quantity * weights[member]
for id in requests:
var requested: float = 0.0
for quantity in requests[id].values():
requested += quantity
var available: float = _available(target, id)
var factor: float = minf(1.0, available / requested) if requested > 0.0 else 0.0
for owner_id in requests[id]:
var quantity: float = requests[id][owner_id] * factor
if not target.binds.has(owner_id):
target.binds[owner_id] = {}
target.binds[owner_id][id] = target.binds[owner_id].get(id, 0.0) + quantity
target.amount[id] = maxf(0.0, target.amount.get(id, 0.0) - quantity)
return true
# Integrate survival over the period, including partial batch expiry.
func _object_years(source: Dictionary, id: String, quantity: float,
from_year: float, to_year: float) -> float:
var result: float = quantity * SimulationTime.elapsed_years(from_year, to_year)
for batch: Dictionary in source.batches.get(id, []):
var start: float = maxf(batch.start_year, batch.get("processed_until", batch.start_year))
var end: float = batch.end_year
result -= batch.amount * (_expired_years(to_year, start, end) - _expired_years(from_year, start, end))
return maxf(0.0, result)
func _expired_years(year: float, start: float, end: float) -> float:
if year <= start:
return 0.0
if end <= start:
return year - start
if year >= end:
return year - end + (end - start) * 0.5
return (year - start) * (year - start) / (2.0 * (end - start))
# Largest-remainder allocation: whole float values, bounded by each request.
func _allocate_whole(requests: Dictionary, limit: float) -> Dictionary:
var total: float = 0.0
for amount in requests.values():
total += floorf(maxf(0.0, amount))
var available: float = floorf(minf(maxf(0.0, limit), total))
var result: Dictionary = {}
var order: Array = []
var assigned: float = 0.0
for id in requests:
var expected: float = floorf(maxf(0.0, requests[id])) * available / total if total > 0.0 else 0.0
result[id] = floorf(expected)
assigned += result[id]
order.append({"id": id, "rest": expected - result[id]})
order.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a.rest > b.rest if a.rest != b.rest else str(a.id) < str(b.id))
for item: Dictionary in order:
if assigned >= available:
break
if result[item.id] < floorf(requests[item.id]):
result[item.id] += 1.0
assigned += 1.0
return result
# The entity currently exposes type/amount fields; life_product can contain
# several resources, each specified as total lifetime output.
func _yearly_products(entity: OwniverseEntity) -> Dictionary:
var products: Dictionary = {}
if not entity.life_product.is_empty() and entity.lifespan > 0.0:
for resource in entity.life_product:
products[resource] = maxf(0.0, entity.life_product[resource]) / entity.lifespan
elif entity.product_per_year_type != "":
products[entity.product_per_year_type] = maxf(0.0, entity.product_per_year_amount)
return products
func _produce_for_period(previous: Dictionary, target: Dictionary, quantities: Dictionary,
distribution: Dictionary, from_year: float, to_year: float) -> void:
var basic: Dictionary = {}
var automatic: Dictionary = {}
for id in quantities:
var entity: OwniverseEntity = entities[id]
var object_years: float = _object_years(previous, id, quantities[id], from_year, to_year)
var auto_share: float = clampf(entity.auto, 0.0, 1.0)
var products: Dictionary = _yearly_products(entity)
for product in products:
var amount: float = object_years * products[product]
basic[product] = basic.get(product, 0.0) + amount * (1.0 - auto_share)
automatic[product] = automatic.get(product, 0.0) + amount * auto_share
# Basic and automatic output each retain their unproduced remainder.
if not target.has("resource_remainders"):
target.resource_remainders = {"basic": {}, "automatic": {}}
var pending: Dictionary = target.resource_remainders
var requests: Dictionary = {}
for id in basic:
pending.basic[id] = pending.basic.get(id, 0.0) + basic[id]
pending.automatic[id] = pending.automatic.get(id, 0.0) + automatic[id]
var totals: Dictionary = {}
for channel in ["basic", "automatic"]:
for id in pending[channel]:
totals[id] = maxf(0.0, pending.basic.get(id, 0.0) + pending.automatic.get(id, 0.0))
for id in totals:
if id == "S" or id == "H":
requests[id] = floorf(totals[id])
var funded: Dictionary = _allocate_whole(requests, floorf(maxf(0.0, previous.energy)))
automatic = {}
target.energy = previous.energy
for id in totals:
var amount: float = floorf(totals[id])
if id == "S" or id == "H":
amount = funded.get(id, 0.0)
target.energy -= amount
# Split the actual whole output, retaining the allocation error for next time.
var auto_ratio: float = clampf(pending.automatic.get(id, 0.0) / totals[id], 0.0, 1.0) if totals[id] > 0.0 else 0.0
var auto_amount: float = floorf(amount * auto_ratio)
pending.automatic[id] = pending.automatic.get(id, 0.0) - auto_amount
pending.basic[id] = pending.basic.get(id, 0.0) - (amount - auto_amount)
automatic[id] = auto_amount
amount = _credit(target, id, amount)
_append_batch(target, id, amount, from_year, to_year)
# Keep unused automatic resources across cycles; they are part of free amounts.
var budget: Dictionary = previous.get("auto_resources", {}).duplicate(true)
for id in automatic:
budget[id] = budget.get(id, 0.0) + automatic[id]
for id in budget:
budget[id] = minf(maxf(0.0, budget[id]), _available(target, id))
target.auto_resources = budget
_produce_brown_dwarfs(target, automatic.get("H", 0.0), from_year, to_year)
budget = _ordinary_star_budget(target)
# One common multiplier preserves the initial count ratios, even under scarcity.
var candidates: Dictionary = {}
for id in distribution:
var entity: OwniverseEntity = entities[id]
if entity.group_as != "Star":
continue
var has_cost: bool = false
for resource in entity.cost:
if entity.cost[resource] > 0.0:
has_cost = true
if has_cost:
candidates[id] = distribution[id]
candidates = _normalize_distribution(candidates)
if candidates.is_empty():
return
var costs: Dictionary = {}
var needs: Dictionary = {}
for id in candidates:
if _auto_capacity(target, entities[id], budget) <= 0.0:
continue
for resource in entities[id].cost:
var cost: float = maxf(0.0, entities[id].cost[resource]) * candidates[id]
costs[resource] = costs.get(resource, 0.0) + cost
needs[resource] = needs.get(resource, 0.0) + cost
for resource in entities[id].bind:
needs[resource] = needs.get(resource, 0.0) + maxf(0.0, entities[id].bind[resource]) * candidates[id]
var scale: float = INF
for resource in costs:
if costs[resource] > 0.0:
scale = minf(scale, budget.get(resource, 0.0) / costs[resource])
for resource in needs:
if needs[resource] > 0.0:
scale = minf(scale, _available(target, resource) / needs[resource])
var concrete_needs: Dictionary = _expand_requirements(target, needs)
for resource in concrete_needs:
if concrete_needs[resource] > 0.0:
scale = minf(scale, _available(target, resource) / concrete_needs[resource])
if not is_finite(scale) or scale <= 0.0:
return
# Rare types are served first; signed count deficits compensate later cycles.
if not target.has("auto_star_deficits"):
target.auto_star_deficits = {}
var deficits: Dictionary = target.auto_star_deficits
var order: Array = candidates.keys()
order.sort_custom(func(a: String, b: String) -> bool:
return candidates[a] < candidates[b] if candidates[a] != candidates[b] else a < b)
var created: Dictionary = {}
var metallicity: float = stellar_environment(target).metallicity
var total_created: float = 0.0
for id in order:
var entity: OwniverseEntity = entities[id]
var step: float = _production_step(entity)
if not is_finite(step):
continue
var quota: float = maxf(0.0, candidates[id] * scale + deficits.get(id, 0.0))
var desired: float = floorf(quota / step) * step
var actual: float = minf(desired, _auto_capacity(target, entity, budget))
if actual <= 0.0:
continue
if not _try_production(target, entity, actual, true):
continue
budget = _ordinary_star_budget(target)
created[id] = actual
total_created += actual
for id in candidates:
# Credit only actual production, so retained resources aren't counted twice.
deficits[id] = deficits.get(id, 0.0) + candidates[id] * total_created - created.get(id, 0.0)
for id in created:
_credit(target, id, created[id])
_append_batch(target, id, created[id], from_year, to_year)
_create_planets(target, id, created[id], from_year, to_year, metallicity)
# HBD quota is in objects. HBD_fuel is prepaid hydrogen removed from free stock.
# Signed rounding debt and the PRNG state survive saving with this dictionary.
func _produce_brown_dwarfs(target: Dictionary, new_hydrogen: float, from_year: float, to_year: float) -> void:
if not entities.has("HBD"):
return
var entity: OwniverseEntity = entities.HBD
var cost: float = entity.cost.get("H", 0.0)
if cost <= 0.0:
return
var quota: float = target.auto_star_deficits.get("HBD", 0.0) + maxf(0.0, new_hydrogen) * 0.001 / cost
var fuel: float = maxf(0.0, target.auto_star_deficits.get("HBD_fuel", 0.0))
var available: float = minf(target.auto_resources.get("H", 0.0), _available(target, "H"))
quota = minf(quota, (available + fuel) / cost)
var expected: float = maxf(0.0, snappedf(quota, 1e-9))
var amount: float = floorf(expected)
# Do not retry a fractional chance on frames with no new automatic fuel.
if new_hydrogen > 0.0 and expected > amount:
var random_state: float = target.auto_star_deficits.get("HBD_random", 1234567.0)
random_state = fmod(random_state * 48271.0, 2147483647.0)
target.auto_star_deficits.HBD_random = random_state
if random_state / 2147483647.0 < expected - amount:
amount += 1.0
amount = minf(amount, floorf(snappedf((available + fuel) / cost, 1e-9)))
var charge: float = ceilf(maxf(0.0, snappedf(amount * cost - fuel, 1e-9)))
if amount > 0.0 and charge <= available:
_take_available(target, "H", charge)
target.auto_resources.H = maxf(0.0, target.auto_resources.get("H", 0.0) - charge)
target.auto_star_deficits.HBD_fuel = maxf(0.0, snappedf(fuel + charge - amount * cost, 1e-9))
quota -= amount
_credit(target, "HBD", amount)
_append_batch(target, "HBD", amount, from_year, to_year)
target.auto_star_deficits.HBD = quota
func _ordinary_star_budget(target: Dictionary) -> Dictionary:
var budget: Dictionary = target.auto_resources.duplicate()
if entities.has("HBD"):
var reserved: float = maxf(0.0, target.auto_star_deficits.get("HBD", 0.0) * entities.HBD.cost.get("H", 0.0) - target.auto_star_deficits.get("HBD_fuel", 0.0))
budget.H = maxf(0.0, budget.get("H", 0.0) - reserved)
return budget
func _auto_capacity(source: Dictionary, entity: OwniverseEntity, budget: Dictionary) -> float:
var capacity: float = INF
var needs: Dictionary = {}
for resource in entity.cost:
var cost: float = maxf(0.0, entity.cost[resource])
needs[resource] = cost
if cost > 0.0:
capacity = minf(capacity, floorf(budget.get(resource, 0.0) / cost))
for resource in entity.bind:
needs[resource] = needs.get(resource, 0.0) + maxf(0.0, entity.bind[resource])
for resource in needs:
if needs[resource] > 0.0:
capacity = minf(capacity, floorf(_available(source, resource) / needs[resource]))
var concrete: Dictionary = _expand_requirements(source, needs)
for resource in concrete:
if concrete[resource] > 0.0:
capacity = minf(capacity, floorf(_available(source, resource) / concrete[resource]))
var step: float = _production_step(entity)
return floorf(capacity / step) * step if is_finite(capacity) and is_finite(step) else 0.0
# ----------
#region Production
## Produces entities with cost and bind constraints
## @param item_id - entity id to produce
## @param desired_amount - desired quantity to produce
## @return actual amount produced based on available resources
func _smbh_sources() -> Dictionary:
var masses: Dictionary = {}
for id in entities:
var entity: OwniverseEntity = entities[id]
if entity.count_as == "BH" and entity.mass > 0.0:
masses[id] = floorf(maxf(0.0, state.amount.get(id, 0.0))) * entity.mass
return masses
func _smbh_available() -> float:
var mass: float = 0.0
for value in _smbh_sources().values():
mass += value
return floorf(mass)
func _plan_smbh(desired_amount: float) -> Dictionary:
var empty: Dictionary = {"amount": 0.0, "taken": {}}
if desired_amount <= 0.0 or not is_finite(desired_amount):
return empty
var total_mass: float = _smbh_available()
if total_mass <= 0.0:
return empty
var requested: float = minf(floorf(desired_amount), total_mass)
if requested <= 0.0:
return empty
var masses: Dictionary = _smbh_sources()
var distribution: Dictionary = _normalize_distribution(masses)
var taken: Dictionary = {}
var order: Array = []
var consumed_mass: float = 0.0
for id in distribution:
var expected: float = requested * distribution[id] / entities[id].mass
taken[id] = minf(floorf(expected), floorf(state.amount[id]))
consumed_mass += taken[id] * entities[id].mass
order.append({"id": id, "remainder": expected - taken[id]})
order.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a.remainder > b.remainder if a.remainder != b.remainder else str(a.id) < str(b.id))
# Consume whole BH only; all excess mass becomes SMBH, never disappears.
for candidate: Dictionary in order:
if consumed_mass >= requested:
break
var id: String = candidate.id
if taken[id] < floorf(state.amount[id]):
taken[id] += 1.0
consumed_mass += entities[id].mass
if consumed_mass <= 0.0 or consumed_mass != floorf(consumed_mass):
return empty
return {"amount": consumed_mass, "taken": taken}
func _produce_smbh(desired_amount: float) -> float:
var plan: Dictionary = _plan_smbh(desired_amount)
var consumed_mass: float = plan.amount
var taken: Dictionary = plan.taken
if consumed_mass <= 0.0:
return 0.0
var next: Dictionary = state.duplicate(true)
var physical: Dictionary = _physical_amounts(state)
for id in taken:
if taken[id] <= 0.0:
continue
next.amount[id] -= taken[id]
var fraction: float = taken[id] / physical[id]
_scale_batches(next, id, 1.0 - fraction)
# Release the consumed owners' share of any resources they held.
for resource in next.binds.get(id, {}):
var released: float = next.binds[id][resource] * fraction
next.binds[id][resource] -= released
next.amount[resource] = next.amount.get(resource, 0.0) + released
_credit(next, "SMBH", consumed_mass)
_append_batch(next, "SMBH", consumed_mass, state.time.datetime, state.time.datetime)
_round_object_allocations(next)
_refresh_groups(next)
_clamp_auto_resources(next)
state = next
new_state = next
refresh_item_amounts()
return consumed_mass
func produce(item_id: String, desired_amount: float) -> float:
if item_id == "SMBH":
return _produce_smbh(desired_amount)
# Use special production for S and H
if item_id == "S" or item_id == "H":
return produce_hs(item_id, desired_amount)
var entity: OwniverseEntity = entities[item_id]
if desired_amount <= 0:
return 0.0
var max_producible = floorf(calculate_max_producible(entity, desired_amount))
var budget: Dictionary = CivilizationState.stellar_budget(self, state, Global.CivilizationRules)
max_producible = CivilizationState.admit_transition(budget, Global.CivilizationRules, "", item_id, max_producible)
if max_producible <= 0:
return 0.0
var metallicity: float = stellar_environment(state).metallicity
if not _try_production(state, entity, max_producible):
return 0.0
# Add produced entities
_process_enitity(item_id, max_producible)
add_entity_to_batch(item_id, max_producible, state.time.datetime)
_create_planets(state, item_id, max_producible, state.time.datetime, state.time.datetime, metallicity)
_refresh_groups(state)
refresh_item_amounts()
return max_producible
2026-06-24 23:25:55 +03:00
## Produces S (space) or H (hydrogen) using energy from state.energy
## @param entity_id - entity id to produce ("S" or "H")
## @param value - desired amount to produce
## @return actual amount produced (limited by available energy)
func produce_hs(entity_id: String, value: float) -> float:
if value <= 0:
return 0.0
2026-06-24 23:25:55 +03:00
var to_produce: float = floorf(value)
to_produce = minf(to_produce, floorf(state.energy))
if to_produce <= 0:
return 0.0
state.energy -= to_produce
_process_enitity(entity_id, to_produce)
return to_produce
2026-06-24 23:25:55 +03:00
## Calculates maximum producible amount based on cost and bind constraints
## @param entity - entity to produce
## @param desired_amount - desired quantity
## @return maximum producible amount considering all constraints
# Find the smallest whole batch whose cost is whole for every resource.
# Decimal costs are represented as integer fractions; combine their denominators.
func _production_step(entity: OwniverseEntity) -> float:
var step: float = 1.0
for value in entity.cost.values() + entity.bind.values():
var cost: float = maxf(0.0, value)
if cost == 0.0:
continue
var decimal: String = String.num(cost, 12)
var point: int = decimal.find(".")
var digits: int = decimal.length() - point - 1 if point >= 0 else 0
var denominator: float = pow(10.0, digits)
var numerator: float = roundf(cost * denominator)
if numerator == 0.0:
return INF
denominator /= _whole_gcd(numerator, denominator)
step = step / _whole_gcd(step, denominator) * denominator
return step
func _whole_gcd(a: float, b: float) -> float:
while b > 0.0:
var remainder: float = fmod(a, b)
a = b
b = remainder
return a
func calculate_max_producible(entity: OwniverseEntity, desired_amount: float = 1.0E150) -> float:
if entity.id == "SMBH":
return _plan_smbh(desired_amount).amount
var requirements: Dictionary = {}
for resource in entity.cost:
requirements[resource] = maxf(0.0, entity.cost[resource])
for resource in entity.bind:
requirements[resource] = requirements.get(resource, 0.0) + maxf(0.0, entity.bind[resource])
var has_requirements: bool = false
for quantity in requirements.values():
if quantity > 0.0:
has_requirements = true
break
# No recipe means unavailable for production, not unlimited free objects.
if not has_requirements:
return 0.0
var available: float = maxf(0.0, desired_amount)
for resource in requirements:
if requirements[resource] > 0.0:
available = minf(available, floorf(_available(state, resource) / requirements[resource]))
var concrete: Dictionary = _expand_requirements(state, requirements)
for resource in concrete:
if concrete[resource] > 0.0:
available = minf(available, floorf(_available(state, resource) / concrete[resource]))
var step: float = _production_step(entity)
return floorf(available / step) * step if is_finite(step) else 0.0
## Consume resources without erasing cumulative production statistics.
func apply_costs(entity: OwniverseEntity, amount: float) -> void:
var costs: Dictionary = {}
for resource in entity.cost:
costs[resource] = roundf(maxf(0.0, entity.cost[resource]) * amount)
for resource in costs:
var quantities: Dictionary = _physical_amounts(state)
var taken: Dictionary = _take_available(state, resource, costs[resource])
for id in taken:
var factor: float = 1.0 - taken[id] / quantities[id] if quantities.get(id, 0.0) > 0.0 else 0.0
_scale_batches(state, id, factor)
_refresh_groups(state)
_clamp_auto_resources(state)
refresh_item_amounts()
## Keep concrete bound types, so their ages and transitions remain traceable.
func apply_binds(item_id: String, _entity: OwniverseEntity, amount: float) -> bool:
if not _bind_new_owners(state, {item_id: amount}, true):
return false
_refresh_groups(state)
_clamp_auto_resources(state)
refresh_item_amounts()
return true
## Computes a normalized distribution of dictionary values (sum equals 1.0)
## @param values - dictionary containing numeric values
## @return dictionary with normalized values, each ranging from 0.0 to 1.0
func _normalize_distribution(values: Dictionary) -> Dictionary:
var normalized_distribution: Dictionary = {}
var distribution_total: float = 0
for id in values:
if values[id] > 0:
distribution_total += values[id]
normalized_distribution[id] = values[id]
for id in normalized_distribution:
normalized_distribution[id] /= distribution_total
return normalized_distribution
#endregion
#region Batches
## Withdraws only the previously unprocessed part of each evolution interval.
func consume_from_batches(entity: OwniverseEntity, current_year: float) -> float:
var total: float = 0.0
var physical: Dictionary = _physical_amounts(state)
for part: Dictionary in _withdraw_batches(state, entity.id, current_year):
total += part.amount
var fraction: float = clampf(total / physical[entity.id], 0.0, 1.0) if physical.get(entity.id, 0.0) > 0.0 else 0.0
state.amount[entity.id] = state.amount.get(entity.id, 0.0) * (1.0 - fraction)
for owner_id in state.binds:
if state.binds[owner_id].has(entity.id):
state.binds[owner_id][entity.id] *= 1.0 - fraction
_round_object_allocations(state)
_refresh_groups(state)
refresh_item_amounts()
return total
## Add manually created objects to the lifespan_delta window containing current_year.
func add_entity_to_batch(entity_id: String, amount: float, current_year: float) -> void:
_append_batch(state, entity_id, amount, current_year, current_year)
#endregion
#region ProductionLegacy
#-----
#func _produce_automatically(year_delta: float) -> void:
#var cycle_production = {"S": 0.0, "H": 0.0}
#for entity: OwniverseEntity in auto_production:
#if current_amount[entity.id] == 0:
#continue
#cycle_production[entity.product_per_year_type] += \
#current_amount[entity.id] * entity.product_per_year_amount * year_delta
#for item in cycle_production:
#_count_amount(item, cycle_production[item])
#
#func automated_production(_delta: float) -> void:
#for entity_id in auto_production:
#var entity = entities[entity_id]
#var _amount_to_produce = int(entity.amount * entity.product_per_year_amount * _delta)
#func get_available_to_produce(item_id: String) -> float:
#var entity: OwniverseEntity = entities[item_id]
#if entity.cost == {}: return 0
#var amount: float = 1E50
#for elem in entity.cost:
#amount = min(floorf(current_amount[elem] / entity.cost[elem]), amount)
#return amount
# add to all lists
## Processes entity production/consumption
## @param entity_id - id of entity to process
## @param amount - amount to change (positive for production, negative for consumption)
func _process_enitity(entity_id: String, amount: float) -> void:
if amount == 0:
return
2026-06-24 23:25:55 +03:00
# Update state amount
state.amount[entity_id] = state.amount.get(entity_id, 0.0) + amount
state.stats.total_amount[entity_id] = state.stats.total_amount.get(entity_id, 0.0) + maxf(0.0, amount)
# Handle group updates if entity belongs to a group
var entity = entities[entity_id]
if entity.group_as != "":
state.amount[entity.group_as] = state.amount.get(entity.group_as, 0.0) + amount
state.stats.total_amount[entity.group_as] = state.stats.total_amount.get(entity.group_as, 0.0) + maxf(0.0, amount)
refresh_item_amounts()
#endregion
2026-07-05 12:23:33 +03:00
#region Time
2026-06-24 23:25:55 +03:00
2026-07-05 12:23:33 +03:00
#endregion