26347fca30
- Introduced a new test script for civilization evolution (`test_civilization_evolution.gd`) to validate the evolution mechanics, including population dynamics, ethical transitions, and war impacts. - Updated `README.md` to include instructions for running the new civilization evolution tests. - Modified `test_civilization_state.gd` to accommodate changes in civilization rules and ensure proper validation of ethical stages. - Enhanced `test_descriptions.gd` to check for accurate population counters and state validation. - Adjusted `test_life.gd` to reflect updates in life version management. - Improved `civilization_state.gd` to include habitat budget calculations and transition management. - Added `civilization_evolution.gd` to handle the logic for civilization evolution, including population drift, warfare, and colonization mechanics. - Updated `owniverse.gd` and `owniverse_entity.gd` to integrate new evolution logic and ensure proper initialization of entities.
250 lines
14 KiB
GDScript
250 lines
14 KiB
GDScript
extends RefCounted
|
|
|
|
const State = preload("res://world/owniverse/civilization_state.gd")
|
|
const ETHICS = State.ETHICS
|
|
const OPPOSITE = {"m": "l", "l": "m", "w": "d", "d": "w", "c": "b", "b": "c"}
|
|
|
|
static func total(pop: Dictionary) -> float:
|
|
var result: float = 0.0
|
|
for n in pop.values(): result += n
|
|
return result
|
|
|
|
static func add(to: Dictionary, from: Dictionary) -> void:
|
|
for key in from: to[key] = to.get(key, 0.0) + from[key]
|
|
|
|
static func take(world, pop: Dictionary, count: float) -> Dictionary:
|
|
var taken: Dictionary = world._allocate_whole(pop, count)
|
|
for key in taken: pop[key] -= taken[key]
|
|
return taken
|
|
|
|
static func rate(target: Dictionary, key: String, count: float, probability: float) -> float:
|
|
if not target.has("civilization_remainders"): target.civilization_remainders = {}
|
|
var expected: float = count * maxf(0.0, probability) + target.civilization_remainders.get(key, 0.0)
|
|
var actual: float = floorf(snappedf(expected, 1e-10))
|
|
target.civilization_remainders[key] = maxf(0.0, expected - actual)
|
|
return actual
|
|
|
|
# Fixed-size largest-remainder apportionment with saved signed rounding errors.
|
|
# Runtime depends on the number of branches, never the number of civilizations.
|
|
static func split(target: Dictionary, key: String, count: float, weights: Dictionary) -> Dictionary:
|
|
var result: Dictionary = {}
|
|
var sum_weights: float = total(weights)
|
|
if count <= 0.0 or sum_weights <= 0.0: return result
|
|
if not target.has("civilization_remainders"): target.civilization_remainders = {}
|
|
var expected: Dictionary = {}
|
|
var positive_sum: float = 0.0
|
|
for id in weights:
|
|
expected[id] = count * weights[id] / sum_weights + target.civilization_remainders.get(key + ":" + id, 0.0)
|
|
positive_sum += maxf(0.0, expected[id])
|
|
var order: Array = []
|
|
var assigned: float = 0.0
|
|
for id in weights:
|
|
var normalized: float = maxf(0.0, expected[id]) * count / positive_sum if positive_sum > 0.0 else 0.0
|
|
result[id] = floorf(normalized)
|
|
assigned += result[id]
|
|
order.append({"id": id, "rest": normalized - result[id]})
|
|
order.sort_custom(func(a, b): return a.rest > b.rest if a.rest != b.rest else str(a.id) < str(b.id))
|
|
for entry in order:
|
|
if assigned >= count: break
|
|
result[entry.id] += 1.0
|
|
assigned += 1.0
|
|
for id in weights:
|
|
target.civilization_remainders[key + ":" + id] = clampf(expected[id] - result[id], -1.0, 1.0)
|
|
return result
|
|
|
|
static func drift(target: Dictionary, key: String, pop: Dictionary, rows: Dictionary,
|
|
diplomacy: bool = false, colony: bool = false) -> Dictionary:
|
|
var weights: Dictionary = {}
|
|
for ethic in pop:
|
|
var influence: float = float(rows[ethic].diplomacy_rate) if diplomacy else 1.0
|
|
for other in ETHICS:
|
|
var weight: float = 9.0 if colony else 1.0
|
|
if other == ethic: weight = 960.0 if colony else 196.0
|
|
elif other == OPPOSITE[ethic]: weight = 4.0 if colony else 0.0
|
|
weights[other] = weights.get(other, 0.0) + pop[ethic] * influence * weight
|
|
if total(weights) <= 0.0: return pop.duplicate()
|
|
return split(target, key, total(pop), weights)
|
|
|
|
static func emit(plan: Dictionary, destination: String, pop: Dictionary) -> void:
|
|
if not plan.outputs.has(destination): plan.outputs[destination] = {}
|
|
add(plan.outputs[destination], pop)
|
|
|
|
static func fall(target: Dictionary, plan: Dictionary, rules: Dictionary, pop: Dictionary) -> void:
|
|
for ethic in pop:
|
|
var weights: Dictionary = {}
|
|
for term in str(rules[plan.id].ethics[ethic].fall).split("^", false):
|
|
var pair: PackedStringArray = term.split(":")
|
|
weights[pair[0]] = float(pair[1])
|
|
if weights.is_empty(): weights[rules[plan.id].previous] = 1.0
|
|
var shares: Dictionary = split(target, "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:
|
|
var successor: String = rules[plan.id].next
|
|
var admitted: float = State.admit_transition(budget, rules, plan.id, successor, total(pop))
|
|
var accepted: Dictionary = take(world, pop, admitted)
|
|
emit(plan, successor, accepted)
|
|
return pop
|
|
|
|
static func new_plan(id: String, year: float) -> Dictionary:
|
|
return {"id": id, "from": year, "to": -INF, "amount": 0.0, "outputs": {}}
|
|
|
|
static func consume_untriggered(world, target: Dictionary, plan: Dictionary, pop: Dictionary, count: float, year: float) -> Dictionary:
|
|
var old_total: float = total(pop)
|
|
var removed: Dictionary = take(world, pop, count)
|
|
var actual: float = total(removed)
|
|
if actual > 0.0:
|
|
world._scale_batches(target, plan.id, (old_total - actual) / old_total)
|
|
plan.amount += actual
|
|
plan.to = maxf(plan.to, year)
|
|
return removed
|
|
|
|
static func prepare(world, target: Dictionary, expired: Dictionary, year: float, budget: Dictionary, rules: Dictionary) -> Dictionary:
|
|
State.reconcile(world, target, rules)
|
|
var remaining: Dictionary = target.ethics.duplicate(true)
|
|
var plans: Dictionary = {}
|
|
var colonies: Array = []
|
|
var collateral: Dictionary = {}
|
|
if not target.has("civilization_stats"): target.civilization_stats = {}
|
|
var ids: Array = expired.keys()
|
|
ids.sort()
|
|
for id in ids:
|
|
if not rules.has(id) or expired[id].is_empty(): continue
|
|
var plan: Dictionary = new_plan(id, year)
|
|
plans[id] = plan
|
|
for part: Dictionary in expired[id]:
|
|
plan.amount += part.amount
|
|
plan.from = minf(plan.from, part.from)
|
|
plan.to = maxf(plan.to, part.to)
|
|
var triggered: Dictionary = take(world, remaining[id], plan.amount)
|
|
var successes: Dictionary = {}
|
|
var failures: Dictionary = {}
|
|
var warriors: Dictionary = {}
|
|
var settlers: Dictionary = {}
|
|
for ethic in triggered:
|
|
var row: Dictionary = rules[id].ethics[ethic]
|
|
var p: float = maxf(0.0, float(row.progress))
|
|
var d: float = maxf(0.0, float(row.degradation))
|
|
var scale: float = maxf(1.0, p + d)
|
|
var pro: float = rate(target, id + ":" + ethic + ":pro", triggered[ethic], p / scale)
|
|
var deg: float = rate(target, id + ":" + ethic + ":deg", triggered[ethic], d / scale)
|
|
var bounded: Dictionary = world._allocate_whole({"pro": pro, "deg": deg}, triggered[ethic])
|
|
if rules[id].fights: warriors[ethic] = rate(target, id + ":" + ethic + ":war", triggered[ethic], float(row.war))
|
|
if rules[id].colonizes: settlers[ethic] = rate(target, id + ":" + ethic + ":col", triggered[ethic], float(row.colonization))
|
|
successes[ethic] = bounded.pro
|
|
failures[ethic] = bounded.deg
|
|
triggered[ethic] -= bounded.pro + bounded.deg
|
|
add(triggered, progress(world, target, rules, budget, plan, successes))
|
|
fall(target, plan, rules, failures)
|
|
if rules[id].fights:
|
|
for ethic in warriors: warriors[ethic] = minf(warriors[ethic], triggered[ethic])
|
|
var wars: float = minf(total(warriors), floorf((total(triggered) + total(remaining[id])) / 2.0))
|
|
warriors = world._allocate_whole(warriors, wars)
|
|
for ethic in warriors: triggered[ethic] -= warriors[ethic]
|
|
var outcomes: Dictionary = split(target, id + ":wars", wars, {"deg": 1.0, "pro25": 2.0, "pro50": 3.0, "pro75": 3.0})
|
|
var win_expected: float = outcomes.get("pro25", 0.0) * 0.25 + outcomes.get("pro50", 0.0) * 0.5 + outcomes.get("pro75", 0.0) * 0.75
|
|
var loss_expected: float = outcomes.get("deg", 0.0) * 2.0 + outcomes.get("pro25", 0.0) + outcomes.get("pro50", 0.0) * 1.5 + outcomes.get("pro75", 0.0) * 2.0
|
|
var winners: Dictionary = take(world, warriors, rate(target, id + ":wins", win_expected, 1.0))
|
|
var attackers_lost: Dictionary = take(world, warriors, outcomes.get("deg", 0.0))
|
|
add(triggered, progress(world, target, rules, budget, plan, winners))
|
|
fall(target, plan, rules, attackers_lost)
|
|
var losses: float = minf(rate(target, id + ":loss", loss_expected, 1.0), total(triggered) + total(remaining[id]))
|
|
var sides: Dictionary = world._allocate_whole({"due": total(triggered), "later": total(remaining[id])}, losses)
|
|
var victims: Dictionary = take(world, triggered, sides.due)
|
|
add(victims, consume_untriggered(world, target, plan, remaining[id], sides.later, year))
|
|
fall(target, plan, rules, victims)
|
|
add(triggered, warriors)
|
|
var damage: float = outcomes.get("pro50", 0.0) * 10.0 + outcomes.get("pro75", 0.0) * 30.0
|
|
collateral[rules[id].previous] = collateral.get(rules[id].previous, 0.0) + damage
|
|
target.civilization_stats.wars = target.civilization_stats.get("wars", 0.0) + wars
|
|
emit(plan, id, triggered)
|
|
if total(settlers) > 0.0: colonies.append({"stage": rules[id].colony, "source": id, "population": settlers})
|
|
# Collateral is applied after all stages planned their own attempts.
|
|
for id in collateral:
|
|
if not rules.has(id): continue
|
|
if not plans.has(id): plans[id] = new_plan(id, year)
|
|
var plan: Dictionary = plans[id]
|
|
var retry: Dictionary = plan.outputs.get(id, {})
|
|
var amount: float = minf(collateral[id], total(retry) + total(remaining[id]))
|
|
var sides: Dictionary = world._allocate_whole({"due": total(retry), "later": total(remaining[id])}, amount)
|
|
var victims: Dictionary = take(world, retry, sides.due)
|
|
add(victims, consume_untriggered(world, target, plan, remaining[id], sides.later, year))
|
|
fall(target, plan, rules, victims)
|
|
target.civilization_stats.collateral = target.civilization_stats.get("collateral", 0.0) + total(victims)
|
|
var incoming: Dictionary = {}
|
|
var planetary: Dictionary = {}
|
|
for id in plans:
|
|
var plan: Dictionary = plans[id]
|
|
var products: Dictionary = {}
|
|
for destination in plan.outputs:
|
|
var pop: Dictionary = plan.outputs[destination]
|
|
if rules.has(destination):
|
|
if destination != "Ct":
|
|
pop = drift(target, id + ":" + destination + ":drift", pop, rules[id].ethics, destination == id and rules[id].diplomacy)
|
|
if not incoming.has(destination): incoming[destination] = {}
|
|
add(incoming[destination], pop)
|
|
products[destination] = total(pop)
|
|
if State.is_space_civilization(id, rules) and world.entities[destination].group_as == "Planet":
|
|
planetary[destination] = planetary.get(destination, 0.0) + total(pop)
|
|
expired[id] = [{"amount": plan.amount, "from": plan.from, "to": maxf(plan.from, plan.to), "products": products}]
|
|
return {"remaining": remaining, "incoming": incoming, "planetary": planetary, "colonies": colonies}
|
|
|
|
static func finish(world, target: Dictionary, transaction: Dictionary, year: float, rules: Dictionary) -> void:
|
|
target.ethics = transaction.remaining
|
|
for id in transaction.incoming:
|
|
if not target.ethics.has(id): target.ethics[id] = {}
|
|
add(target.ethics[id], transaction.incoming[id])
|
|
# Space civilizations that fall back to planetary stages need a stellar host.
|
|
var physical: Dictionary = world._physical_amounts(target)
|
|
var hosts: Dictionary = {}
|
|
for id in physical:
|
|
if world.entities[id].event_group == "DwStar" and physical[id] > 0.0: hosts[id] = physical[id]
|
|
for id in transaction.planetary:
|
|
var shares: Dictionary = split(target, "return:" + id, minf(transaction.planetary[id], target.amount.get(id, 0.0)), hosts)
|
|
for owner in shares:
|
|
if not target.binds.has(owner): target.binds[owner] = {}
|
|
target.binds[owner][id] = target.binds[owner].get(id, 0.0) + shares[owner]
|
|
target.amount[id] -= shares[owner]
|
|
colonize(world, target, transaction.colonies, year, rules)
|
|
State.reconcile(world, target, rules)
|
|
var worship: float = 0.0
|
|
for id in rules:
|
|
if not rules[id].worships: continue
|
|
for ethic in target.ethics[id]: worship += target.ethics[id][ethic] * float(rules[id].ethics[ethic].worship)
|
|
target.civilization_stats.worship = worship
|
|
|
|
static func colonize(world, target: Dictionary, colonies: Array, year: float, rules: Dictionary) -> void:
|
|
var physical: Dictionary = world._physical_amounts(target)
|
|
var stock: Dictionary = {}
|
|
for id in ["GG", "AB", "RP", "HP"]: stock[id] = physical.get(id, 0.0)
|
|
var requests: Dictionary = {}
|
|
for i in colonies.size(): requests[str(i)] = total(colonies[i].population)
|
|
var granted: Dictionary = world._allocate_whole(requests, total(stock))
|
|
for i in colonies.size():
|
|
var colony: Dictionary = colonies[i]
|
|
var population: Dictionary = world._allocate_whole(colony.population, granted[str(i)])
|
|
population = drift(target, colony.source + ":colonies", population, rules[colony.source].ethics, false, true)
|
|
var count: float = total(population)
|
|
var targets: Dictionary = world._allocate_whole(stock, count)
|
|
for id in targets:
|
|
var amount: float = targets[id]
|
|
if amount <= 0.0: continue
|
|
var slots: Dictionary = {"": target.amount.get(id, 0.0)}
|
|
for owner in target.binds: slots[owner] = target.binds[owner].get(id, 0.0)
|
|
var taken: Dictionary = world._allocate_whole(slots, amount)
|
|
world._scale_batches(target, id, 1.0 - amount / stock[id])
|
|
stock[id] -= amount
|
|
for owner in taken:
|
|
if taken[owner] <= 0.0: continue
|
|
if owner == "": target.amount[id] -= taken[owner]
|
|
else: target.binds[owner][id] -= taken[owner]
|
|
var created: float = world._credit(target, colony.stage, taken[owner])
|
|
if owner != "":
|
|
target.amount[colony.stage] -= created
|
|
target.binds[owner][colony.stage] = target.binds[owner].get(colony.stage, 0.0) + created
|
|
world._append_batch(target, colony.stage, amount, year, year)
|
|
if not target.ethics.has(colony.stage): target.ethics[colony.stage] = {}
|
|
add(target.ethics[colony.stage], population)
|
|
target.civilization_stats.colonies = target.civilization_stats.get("colonies", 0.0) + count
|