Files
TheTree_mock/scripts/economy/facility_upkeep_accrual.gd
T
Takeshi 5dde0c5ec2 Record original-alignment implementation and traceability checkpoint
Capture current source, content data, tests, asset licenses, and original-specification traceability. Original implementation remains incomplete. The accepted matching UI generation passed 154 suites and 12,253 checks, with eight rendered staff-contract screens reviewed. Latest native Windows and full-original acceptance remain outstanding.
2026-09-30 23:52:24 +09:00

209 lines
7.3 KiB
GDScript

class_name FacilityUpkeepAccrual
extends RefCounted
## Exact upkeep accumulation in gold-minute units. The caller supplies the
## applicable daily rates for [current_minute, target_minute); this component
## deliberately does not decide whether closed/suspended rooms are billable.
## No money is rounded, debited, or forgiven here. Settlement owns those rules.
const SNAPSHOT_VERSION := 1
const MINUTES_PER_DAY := 1440
const MAX_DAILY_RATE := 1000000000
const MAX_FACILITIES := 20000
const MAX_PERIODS := 10000
const MAX_MINUTE := 1000000000
var _started_at := -1
var _current_minute := -1
var _period_start := -1
var _pending_units: Dictionary = {}
var _closed_periods: Array[Dictionary] = []
func start_at(minute: int) -> Dictionary:
if _started_at >= 0:
return _failure(&"ALREADY_STARTED")
if minute < 0 or minute > MAX_MINUTE:
return _failure(&"INVALID_MINUTE")
_started_at = minute
_current_minute = minute
_period_start = minute
return {&"ok": true}
func get_current_minute() -> int:
return _current_minute
func advance_to(minute: int, daily_rates: Dictionary) -> Dictionary:
if _started_at < 0:
return _failure(&"NOT_STARTED")
if minute < _current_minute or minute > MAX_MINUTE:
return _failure(&"INVALID_MINUTE")
# A bounded batch also supports migration/testing without an unbounded loop.
if minute - _current_minute > MINUTES_PER_DAY:
return _failure(&"INTERVAL_TOO_LARGE")
var parsed := _parse_units(daily_rates, MAX_DAILY_RATE)
if not parsed[&"ok"]:
return _failure(&"INVALID_RATES")
var rates: Dictionary = parsed[&"value"]
var touched := _pending_units.duplicate()
for id: String in rates:
if int(rates[id]) > 0:
touched[id] = 0
if touched.size() > MAX_FACILITIES:
return _failure(&"TOO_MANY_FACILITIES")
var closures := minute / MINUTES_PER_DAY - _current_minute / MINUTES_PER_DAY
if _closed_periods.size() + closures > MAX_PERIODS:
return _failure(&"TOO_MANY_PERIODS")
if minute == _current_minute:
return {&"ok": true, &"advanced_minutes": 0, &"closed_periods": []}
var before := _current_minute
var newly_closed: Array[Dictionary] = []
while _current_minute < minute:
var midnight := (_current_minute / MINUTES_PER_DAY + 1) * MINUTES_PER_DAY
var until := mini(midnight, minute)
var elapsed := until - _current_minute
for id: String in rates:
var rate := int(rates[id])
if rate > 0:
_pending_units[id] = int(_pending_units.get(id, 0)) + rate * elapsed
_current_minute = until
if until == midnight:
var closed := {
&"period_start": _period_start,
&"period_end": until,
&"facility_units": _canonical_units(_pending_units),
}
_closed_periods.append(closed)
newly_closed.append(closed.duplicate(true))
_pending_units = {}
_period_start = until
return {&"ok": true, &"advanced_minutes": minute - before, &"closed_periods": newly_closed}
func get_unsettled_units(facility_id: StringName, settled_periods: int) -> int:
var id := String(facility_id)
var units := int(_pending_units.get(id, 0))
for index in range(clampi(settled_periods, 0, _closed_periods.size()), _closed_periods.size()):
units += int(_closed_periods[index][&"facility_units"].get(id, 0))
return units
func get_closed_periods_from(index: int) -> Array:
return _closed_periods.slice(clampi(index, 0, _closed_periods.size())).duplicate(true)
func get_report() -> Dictionary:
return {
&"current_minute": _current_minute,
&"period_start": _period_start,
&"gold_denominator": MINUTES_PER_DAY,
&"pending_facility_units": _canonical_units(_pending_units),
&"closed_periods": _closed_periods.duplicate(true),
}
func create_snapshot() -> Dictionary:
return {
&"version": SNAPSHOT_VERSION,
&"started_at": _started_at,
&"current_minute": _current_minute,
&"period_start": _period_start,
&"pending_units": _canonical_units(_pending_units),
&"closed_periods": _closed_periods.duplicate(true),
}
func restore_snapshot(source: Dictionary) -> bool:
# Parse and cross-check the complete candidate before mutating live state.
if not _exact_keys(source, ["version", "started_at", "current_minute", "period_start", "pending_units", "closed_periods"]):
return false
for key: String in ["version", "started_at", "current_minute", "period_start"]:
if typeof(source[key]) != TYPE_INT:
return false
if source["version"] != SNAPSHOT_VERSION or not source["closed_periods"] is Array:
return false
var started := int(source["started_at"])
var current := int(source["current_minute"])
var period_start := int(source["period_start"])
var raw_periods: Array = source["closed_periods"]
if raw_periods.size() > MAX_PERIODS:
return false
if started == -1:
if current != -1 or period_start != -1 or not raw_periods.is_empty() \
or not source["pending_units"] is Dictionary or not source["pending_units"].is_empty():
return false
elif started < 0 or current < started or current > MAX_MINUTE \
or period_start < started or period_start > current \
or period_start / MINUTES_PER_DAY != current / MINUTES_PER_DAY:
return false
var periods: Array[Dictionary] = []
var expected_start := started
for raw: Variant in raw_periods:
if not raw is Dictionary or not _exact_keys(raw, ["period_start", "period_end", "facility_units"]):
return false
if typeof(raw["period_start"]) != TYPE_INT or typeof(raw["period_end"]) != TYPE_INT:
return false
var end := int(raw["period_end"])
if raw["period_start"] != expected_start or end > current \
or end != (expected_start / MINUTES_PER_DAY + 1) * MINUTES_PER_DAY:
return false
var units := _parse_units(raw["facility_units"], MAX_DAILY_RATE * (end - expected_start), false)
if not units[&"ok"]:
return false
periods.append({&"period_start": expected_start, &"period_end": end, &"facility_units": units[&"value"]})
expected_start = end
if expected_start != period_start:
return false
var pending := _parse_units(source["pending_units"], MAX_DAILY_RATE * maxi(current - period_start, 0), false)
if not pending[&"ok"]:
return false
_started_at = started
_current_minute = current
_period_start = period_start
_pending_units = pending[&"value"]
_closed_periods = periods
return true
static func _parse_units(value: Variant, maximum: int, allow_zero := true) -> Dictionary:
if not value is Dictionary or value.size() > MAX_FACILITIES:
return {&"ok": false}
var result: Dictionary = {}
for raw_id: Variant in value:
if typeof(raw_id) not in [TYPE_STRING, TYPE_STRING_NAME]:
return {&"ok": false}
var id := String(raw_id)
if id.is_empty() or id.length() > 128 or result.has(id):
return {&"ok": false}
var amount: Variant = value[raw_id]
if typeof(amount) != TYPE_INT or amount < (0 if allow_zero else 1) or amount > maximum:
return {&"ok": false}
result[id] = amount
return {&"ok": true, &"value": _canonical_units(result)}
static func _canonical_units(source: Dictionary) -> Dictionary:
var keys := source.keys()
keys.sort()
var result: Dictionary = {}
for key: Variant in keys:
result[String(key)] = source[key]
return result
static func _exact_keys(value: Dictionary, expected: Array) -> bool:
if value.size() != expected.size():
return false
for key: Variant in value:
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] or String(key) not in expected:
return false
for key: String in expected:
if not value.has(key):
return false
return true
static func _failure(reason: StringName) -> Dictionary:
return {&"ok": false, &"reason": reason}