Implement short-lived stellar event handling and optimize batch processing; add tests for new functionality

This commit is contained in:
Kirill
2026-09-22 22:50:54 +03:00
parent a3d24114a1
commit d6fd40969f
11 changed files with 238 additions and 41 deletions
+76 -30
View File
@@ -340,12 +340,15 @@ func _clamp_auto_resources(target: Dictionary) -> void:
# 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) -> void:
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
return NAN
if created_to < created_from:
return
return NAN
if not target.batches.has(id):
target.batches[id] = []
if not target.has("batch_origins"):
@@ -354,13 +357,19 @@ func _append_batch(target: Dictionary, id: String, amount: float,
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
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)
@@ -377,29 +386,37 @@ func _append_batch(target: Dictionary, id: String, amount: float,
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
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)
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
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
@@ -407,6 +424,10 @@ func _append_batch(target: Dictionary, id: String, amount: float,
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.
@@ -517,6 +538,11 @@ func _evolution_products(target: Dictionary, entity: OwniverseEntity, count: flo
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
@@ -576,11 +602,12 @@ func _create_planets(target: Dictionary, star_id: String, count: float,
# 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) -> void:
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 = count * maxf(0.0, to_year - (born_from + born_to) * 0.5)
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)
@@ -650,11 +677,27 @@ func _apply_blast(target: Dictionary, power: float, environment: Dictionary) ->
func _evolve_batches(target: Dictionary, to_year: float) -> void:
# A long cycle may contain several generations; preserve actual birth intervals.
while _evolve_batch_generation(target, to_year):
var completed: Dictionary = {}
while _evolve_batch_generation(target, to_year, completed):
pass
func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool:
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": {}}
@@ -665,6 +708,8 @@ func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool:
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:
@@ -673,7 +718,7 @@ func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool:
# Withdraw all types before creating successors: iteration order cannot affect them.
for id in target.batches.keys():
if entities.has(id):
expired[id] = _withdraw_batches(target, id, to_year)
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():
@@ -758,8 +803,9 @@ func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool:
else:
amount = _credit(target, successor, amount)
var before_batches: Array = target.batches.get(successor, []).duplicate(true)
_append_batch(target, successor, amount, part.from, part.to)
_produce_newborn_lifetime(target, successor, amount, part.from, part.to, to_year, before_batches)
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):