344 lines
15 KiB
GDScript
344 lines
15 KiB
GDScript
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)):
|
|
target[field] = {}
|
|
var expected: Variant = E.add(E.maximum(0, value), target[field].get(key, 0))
|
|
var whole: Variant = E.floor_value(expected)
|
|
target[field][key] = E.maximum(0, E.subtract(expected, whole))
|
|
return whole
|
|
|
|
|
|
static func allocate_whole(requests: Dictionary, limit: Variant) -> Dictionary:
|
|
var result: Dictionary = {}
|
|
var total: Variant = 0
|
|
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)
|
|
|
|
## Whole stocks apportioned using float shares. Both the total and every stock
|
|
## ceiling are enforced exactly after quota rounding; no quantity becomes float.
|
|
static func allocate_profiled(stocks: Dictionary, limit: Variant) -> Dictionary:
|
|
var total: Variant = sum(stocks)
|
|
var available: Variant = E.floor_value(E.minimum(E.maximum(0, limit), total))
|
|
if E.eq(available, total): return stocks.duplicate()
|
|
var ids: Array = stocks.keys()
|
|
var shares: Array = []
|
|
var result: Dictionary = {}
|
|
var only: Variant = null
|
|
var positives: int = 0
|
|
for id in ids:
|
|
result[id] = 0
|
|
if E.gt(stocks[id], 0):
|
|
only = id
|
|
positives += 1
|
|
if E.le(available, 0): return result
|
|
if positives == 1:
|
|
result[only] = available
|
|
return result
|
|
for id in ids: shares.append(E.ratio_approx(stocks[id], total))
|
|
result = split_profile({}, "stock", available, ids, shares)
|
|
var assigned: Variant = 0
|
|
for id in ids:
|
|
result[id] = E.minimum(stocks[id], result[id])
|
|
assigned = E.add(assigned, result[id])
|
|
var missing: Variant = E.subtract(available, assigned)
|
|
for id in ids:
|
|
if E.le(missing, 0): break
|
|
var extra: Variant = E.minimum(missing, E.subtract(stocks[id], result[id]))
|
|
result[id] = E.add(result[id], extra)
|
|
missing = E.subtract(missing, extra)
|
|
assert(E.eq(missing, 0), "Whole stock allocation must exhaust the available quota")
|
|
return result
|
|
|
|
|
|
## Variant for callers that already maintain the sum of floored request values.
|
|
## The supplied total must equal sum(floor(max(0, requests[id]))).
|
|
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
|
|
if E.le(available, 0):
|
|
for id in result: result[id] = 0
|
|
return result
|
|
var only: Variant = null
|
|
var positive_count: int = 0
|
|
for id in result:
|
|
if E.gt(result[id], 0):
|
|
only = id
|
|
positive_count += 1
|
|
if positive_count == 1:
|
|
result[only] = available
|
|
return result
|
|
var ids: Array = requests.keys()
|
|
var rests: Array = []
|
|
var order: Array = []
|
|
var assigned: Variant = 0
|
|
for i in ids.size():
|
|
var expected: Variant = E.mul_div(result[ids[i]], available, total)
|
|
result[ids[i]] = E.floor_value(expected)
|
|
assigned = E.add(assigned, result[ids[i]])
|
|
rests.append(E.subtract(expected, 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:
|
|
if E.ge(assigned, available): break
|
|
if E.lt(result[ids[i]], E.floor_value(requests[ids[i]])):
|
|
result[ids[i]] = E.add(result[ids[i]], 1)
|
|
assigned = E.add(assigned, 1)
|
|
return result
|
|
|
|
|
|
static func production_step(costs: Array):
|
|
var step = 1
|
|
for cost in costs:
|
|
if E.le(cost, 0): continue
|
|
var terms: Array = E.parts(cost)
|
|
var denominator = E.make(E.Big.divmod(terms[1], E.Big.gcd(terms[0], terms[1]))[0])
|
|
step = E.multiply(E.divide(step, whole_gcd(step, denominator)), denominator)
|
|
return step
|
|
|
|
|
|
static func whole_gcd(a: Variant, b: Variant) -> Variant:
|
|
while E.gt(b, 0):
|
|
var remainder: Variant = E.remainder(a, b)
|
|
a = b
|
|
b = remainder
|
|
return a
|
|
|
|
|
|
static func normalize_distribution(values: Dictionary) -> Dictionary:
|
|
var normalized_distribution: Dictionary = {}
|
|
var distribution_total: Variant = 0
|
|
for id in values:
|
|
if E.gt(values[id], 0):
|
|
distribution_total = E.add(distribution_total, values[id])
|
|
normalized_distribution[id] = values[id]
|
|
for id in normalized_distribution:
|
|
normalized_distribution[id] = E.divide(normalized_distribution[id], distribution_total)
|
|
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 = {}
|
|
|
|
## Split a whole population using normalized float shares. Quantizing the six
|
|
## shares to 15 decimal places matches their stored float precision, while the
|
|
## integer denominator avoids a second expensive exact normalization pass.
|
|
static func split_profile(remainders: Dictionary, key: String, count: Variant, ids: Array, shares: Array) -> Dictionary:
|
|
var result: Dictionary = {}
|
|
if E.le(count, 0): return result
|
|
assert(E.eq(count, E.floor_value(count)) and ids.size() == shares.size() and not ids.is_empty())
|
|
const SCALE: int = 1000000000000000
|
|
var weights: Array = []
|
|
var assigned_weight: int = 0
|
|
for i in ids.size() - 1:
|
|
var weight: int = clampi(roundi(clampf(float(shares[i]), 0.0, 1.0) * SCALE), 0, SCALE)
|
|
weights.append(weight)
|
|
assigned_weight += weight
|
|
var last_weight: int = SCALE - assigned_weight
|
|
if last_weight < 0:
|
|
var excess: int = -last_weight
|
|
for i in range(weights.size() - 1, -1, -1):
|
|
var reduction: int = mini(excess, weights[i])
|
|
weights[i] -= reduction
|
|
excess -= reduction
|
|
if excess == 0: break
|
|
last_weight = 0
|
|
weights.append(last_weight)
|
|
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 quotas: Array = E.whole_quotas(count, weights, SCALE)
|
|
var rests: Array = []
|
|
var order: Array = []
|
|
var total: Variant = 0
|
|
for i in ids.size():
|
|
var fraction: float = quotas[i][1] + E.approx(remainders.get(cached.keys[i], 0))
|
|
var adjustment: int = floori(fraction)
|
|
result[ids[i]] = E.maximum(0, E.add(quotas[i][0], adjustment))
|
|
total = E.add(total, result[ids[i]])
|
|
rests.append(fraction - adjustment if E.ge(quotas[i][0], -adjustment) else fraction)
|
|
order.append(i)
|
|
order.sort_custom(func(a: int, b: int) -> bool: return rests[a] > rests[b] if rests[a] != rests[b] else str(ids[a]) < str(ids[b]))
|
|
if E.lt(total, count):
|
|
for i in order:
|
|
if E.ge(total, count): break
|
|
result[ids[i]] = E.add(result[ids[i]], 1)
|
|
rests[i] -= 1.0
|
|
total = E.add(total, 1)
|
|
elif E.gt(total, count):
|
|
order.reverse()
|
|
for i in order:
|
|
if E.le(total, count): break
|
|
if E.le(result[ids[i]], 0): continue
|
|
result[ids[i]] = E.subtract(result[ids[i]], 1)
|
|
rests[i] += 1.0
|
|
total = E.subtract(total, 1)
|
|
for i in ids.size():
|
|
remainders[cached.keys[i]] = clampf(rests[i], -1.0, 1.0)
|
|
return result
|
|
|
|
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, skip_conserved_identity: bool = false) -> Dictionary:
|
|
var result: Dictionary = {}
|
|
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 = []
|
|
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
|
|
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 and not (skip_conserved_identity and E.eq(positive_sum, count)))):
|
|
normalized = E.mul_div_many(count, positives, positive_sum)
|
|
var order: Array = []
|
|
var rests: Array = []
|
|
var assigned: Variant = 0
|
|
for i in ids.size():
|
|
result[ids[i]] = E.floor_value(normalized[i])
|
|
assigned = E.add(assigned, 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:
|
|
if E.ge(assigned, count): break
|
|
result[ids[i]] = E.add(result[ids[i]], 1)
|
|
assigned = E.add(assigned, 1)
|
|
for i in ids.size():
|
|
remainders[remainder_keys[i]] = E.clamp_value(E.subtract(expected[i], result[ids[i]]), E.neg(1), 1)
|
|
return result
|
|
|
|
|
|
static func accumulate_rate(remainders: Dictionary, key: String, count: Variant, probability: Variant) -> Variant:
|
|
# Reward rates are floats while populations are exact whole values. Keep
|
|
# the product as quotient plus fractional carry instead of constructing an
|
|
# intermediate Exact Value and flooring it again.
|
|
if probability is float and is_finite(probability) and probability >= 0.0 and (count is int or (count is E.Value and count.fraction == 0.0)) and E.ge(count, 0):
|
|
var terms: Array = E.parts(probability)
|
|
if terms[2] > 0:
|
|
var population: Array = E._blocks(count)[0]
|
|
var whole_product: Array = E.Big.multiply(population, terms[0])
|
|
var qr: Array = E.Big.divmod(whole_product, terms[1])
|
|
var fractional: float = E._fraction_ratio(qr[1], terms[1])
|
|
var carry: float = E.approx(remainders.get(key, 0))
|
|
if carry >= 0.0 and carry < 1.0:
|
|
if qr[1] == [0]:
|
|
remainders[key] = carry
|
|
return E._compose(qr[0])
|
|
fractional += carry
|
|
if fractional > 0.5 and absf(fractional - roundf(fractional)) <= 1e-12:
|
|
fractional = roundf(fractional)
|
|
var extra: int = floori(fractional)
|
|
remainders[key] = fractional - extra
|
|
return E.add(E._compose(qr[0]), extra)
|
|
var product: Variant = E.multiply(count, E.maximum(0, probability))
|
|
var carry: float = E.approx(remainders.get(key, 0))
|
|
if carry >= 0.0 and carry < 1.0 and E.ge(product, 0):
|
|
if product is int:
|
|
remainders[key] = carry
|
|
return product
|
|
if product is E.Value:
|
|
var fraction: float = product.fraction + carry
|
|
if product.fraction > 0.0 and carry > 0.0 and fraction > 0.5 and absf(fraction - roundf(fraction)) <= 1e-12:
|
|
fraction = roundf(fraction)
|
|
var extra: int = floori(fraction)
|
|
remainders[key] = fraction - extra
|
|
return E.add(E.floor_value(product), extra)
|
|
var expected: Variant = E.add(product, remainders.get(key, 0))
|
|
var actual: Variant = E.floor_value(expected)
|
|
remainders[key] = E.maximum(0, E.subtract(expected, actual))
|
|
return actual
|