class_name SupplyAutoPurchasePlans extends RefCounted ## Owned player intent and the last actual attempt. No plan is inferred on load. ## Current catalogue prices/storage are reused; target stock is player input. const Catalog := preload("res://scripts/supply/supply_catalog.gd") const SNAPSHOT_VERSION := 1 const RECEIPT_PREFIX := "supply_purchase.auto.v1:" const MAX_SIGNED_INT_TEXT := "9223372036854775807" const RESULT_PURCHASED := &"PURCHASED" const FAILED_RESULTS: Array[StringName] = [ &"INVENTORY_ERROR", &"PAYMENT_FAILED", &"INSUFFICIENT_FUNDS", &"STORAGE_FULL", &"FACILITY_CLOSED", &"INVALID_QUANTITY", &"ITEM_NOT_PURCHASABLE", &"NOT_WAREHOUSE", &"REPLAY_MISMATCH", ] var last_error := "" var _plans: Dictionary = {} func get_plan(warehouse_id: StringName, item_id: StringName) -> Dictionary: if _plans.has(warehouse_id) and (_plans[warehouse_id] as Dictionary).has(item_id): return (_plans[warehouse_id][item_id] as Dictionary).duplicate(true) return _empty_plan(warehouse_id, item_id) func get_plans() -> Array[Dictionary]: var rows: Array[Dictionary] = [] for warehouse_id: StringName in _sorted_names(_plans): for item_id: StringName in _sorted_names(_plans[warehouse_id]): rows.append(get_plan(warehouse_id, item_id)) return rows func set_plan(warehouse_id: StringName, item_id: StringName, enabled: bool, target_stock: int, facilities: FacilityService) -> Dictionary: var validation := validate_intent(warehouse_id, item_id, enabled, target_stock, facilities) if not bool(validation[&"ok"]): return validation var plan := get_plan(warehouse_id, item_id) plan[&"enabled"] = enabled plan[&"target_stock"] = target_stock if not _plans.has(warehouse_id): _plans[warehouse_id] = {} _plans[warehouse_id][item_id] = plan last_error = "" return {&"ok": true, &"plan": plan.duplicate(true)} func record_attempt(warehouse_id: StringName, item_id: StringName, minute: int, result: CommandResult, economy: EconomyService) -> bool: if not _plans.has(warehouse_id) or not (_plans[warehouse_id] as Dictionary).has(item_id): return false var plan: Dictionary = _plans[warehouse_id][item_id] if minute < 0 or minute <= int(plan[&"last_attempt_minute"]): return false var reason: StringName = RESULT_PURCHASED if result.is_success() \ else StringName(result.details.get(&"reason", &"PAYMENT_FAILED")) if reason != RESULT_PURCHASED and reason not in FAILED_RESULTS: return false var transaction_id := StringName(result.details.get(&"transaction_id", &"")) if result.is_success() else &"" var quantity: int = int(result.details.get(&"quantity", 0)) if result.is_success() else 0 var unit_price: int = int(result.details.get(&"unit_price", 0)) if result.is_success() else 0 var total_cost: int = int(result.details.get(&"total_cost", 0)) if result.is_success() else 0 if result.is_success() and (not valid_charge(quantity, unit_price, total_cost) or not valid_receipt(economy, warehouse_id, item_id, minute, transaction_id, total_cost, quantity, unit_price)): return false plan[&"last_attempt_minute"] = minute plan[&"last_result"] = reason plan[&"last_transaction_id"] = String(transaction_id) plan[&"last_quantity"] = quantity plan[&"last_unit_price"] = unit_price plan[&"last_total_cost"] = total_cost return true func remove_warehouse(warehouse_id: StringName) -> void: _plans.erase(warehouse_id) func create_snapshot() -> Dictionary: return {&"version": SNAPSHOT_VERSION, &"plans": get_plans()} func restore_snapshot(snapshot: Dictionary, facilities: FacilityService, economy: EconomyService, minute: int) -> bool: last_error = "" if not _keys_match(snapshot, [&"version", &"plans"]) \ or typeof(snapshot[&"version"]) != TYPE_INT or snapshot[&"version"] != SNAPSHOT_VERSION \ or not snapshot[&"plans"] is Array or facilities == null or economy == null or minute < 0: return _restore_failure("Invalid automatic purchase snapshot.") # At most one plan per real warehouse/catalog item; input cannot broaden it. var maximum_rows := facilities.get_instances().size() * Catalog.get_purchase_items().size() if (snapshot[&"plans"] as Array).size() > maximum_rows: return _restore_failure("Too many automatic purchase plans.") var candidate: Dictionary = {} for value: Variant in snapshot[&"plans"]: if not value is Dictionary: return _restore_failure("Automatic purchase row is not a Dictionary.") var row: Dictionary = value if not _keys_match(row, [&"warehouse_id", &"item_id", &"enabled", &"target_stock", &"last_attempt_minute", &"last_result", &"last_transaction_id", &"last_quantity", &"last_unit_price", &"last_total_cost"]) \ or typeof(row[&"warehouse_id"]) != TYPE_STRING or typeof(row[&"item_id"]) != TYPE_STRING \ or not _text(row[&"warehouse_id"]) or not _text(row[&"item_id"]) \ or typeof(row[&"enabled"]) != TYPE_BOOL or typeof(row[&"target_stock"]) != TYPE_INT \ or typeof(row[&"last_attempt_minute"]) != TYPE_INT \ or typeof(row[&"last_result"]) != TYPE_STRING_NAME \ or typeof(row[&"last_transaction_id"]) != TYPE_STRING \ or typeof(row[&"last_quantity"]) != TYPE_INT or typeof(row[&"last_unit_price"]) != TYPE_INT or typeof(row[&"last_total_cost"]) != TYPE_INT: return _restore_failure("Invalid automatic purchase row fields/types.") var warehouse_id := StringName(row[&"warehouse_id"]) var item_id := StringName(row[&"item_id"]) if not bool(validate_intent(warehouse_id, item_id, row[&"enabled"], row[&"target_stock"], facilities)[&"ok"]): return _restore_failure("Automatic purchase plan does not match its warehouse/catalog.") if candidate.has(warehouse_id) and (candidate[warehouse_id] as Dictionary).has(item_id): return _restore_failure("Duplicate automatic purchase plan.") var attempted := int(row[&"last_attempt_minute"]) var reason: StringName = row[&"last_result"] var transaction_id := StringName(row[&"last_transaction_id"]) var quantity: int = row[&"last_quantity"] var unit_price: int = row[&"last_unit_price"] var total_cost: int = row[&"last_total_cost"] if reason == RESULT_PURCHASED and not valid_charge(quantity, unit_price, total_cost): return _restore_failure("Saved automatic purchase quote is invalid.") if reason != RESULT_PURCHASED and (quantity != 0 or unit_price != 0 or total_cost != 0): return _restore_failure("Unpurchased automatic intent retained a charge.") if attempted < -1 or attempted > minute \ or (attempted == -1 and (not reason.is_empty() or not transaction_id.is_empty())) \ or (attempted >= 0 and reason != RESULT_PURCHASED and reason not in FAILED_RESULTS): return _restore_failure("Invalid automatic purchase attempt state.") if attempted >= 0: var paid_id := economy.get_transaction_id_for_idempotency_key(order_key(warehouse_id, item_id, attempted)) if reason == RESULT_PURCHASED: if not valid_receipt(economy, warehouse_id, item_id, attempted, transaction_id, total_cost, quantity, unit_price): return _restore_failure("Automatic purchase receipt differs from its unique ledger transaction.") elif not transaction_id.is_empty() or (not paid_id.is_empty() and (reason != &"REPLAY_MISMATCH" or valid_receipt(economy, warehouse_id, item_id, attempted, paid_id))): return _restore_failure("Failed automatic purchase retained its own payment.") if not candidate.has(warehouse_id): candidate[warehouse_id] = {} candidate[warehouse_id][item_id] = row.duplicate(true) _plans = candidate return true static func receipt_memo(item_id: StringName, quantity: int, unit_price: int, total_cost: int) -> StringName: if String(item_id).strip_edges().is_empty() or not valid_charge(quantity, unit_price, total_cost): return &"" # Character length, not delimiter escaping, keeps arbitrary item punctuation # unambiguous. Only automatic payments use this immutable historical quote. return StringName(RECEIPT_PREFIX + "%d:%s:%d:%d:%d" % [ String(item_id).length(), item_id, quantity, unit_price, total_cost]) static func decode_receipt_memo(memo: StringName) -> Dictionary: var text := String(memo) if not text.begins_with(RECEIPT_PREFIX): return {} var body := text.substr(RECEIPT_PREFIX.length()) var delimiter := body.find(":") if delimiter <= 0: return {} var item_length := _positive_decimal(body.substr(0, delimiter)) var remainder := body.substr(delimiter + 1) if item_length <= 0 or item_length >= remainder.length() or remainder.substr(item_length, 1) != ":": return {} var item_text := remainder.substr(0, item_length) if item_text.strip_edges().is_empty(): return {} var amounts := remainder.substr(item_length + 1).split(":", true) if amounts.size() != 3: return {} var quantity := _positive_decimal(amounts[0]) var unit_price := _positive_decimal(amounts[1]) var total_cost := _positive_decimal(amounts[2]) if not valid_charge(quantity, unit_price, total_cost) \ or receipt_memo(StringName(item_text), quantity, unit_price, total_cost) != memo: return {} return {&"item_id": item_text, &"quantity": quantity, &"unit_price": unit_price, &"total_cost": total_cost} static func receipt_from_ledger(economy: EconomyService, warehouse_id: StringName, item_id: StringName, minute: int, transaction_id: StringName) -> Dictionary: if economy == null or transaction_id.is_empty() \ or economy.get_transaction_id_for_idempotency_key(order_key(warehouse_id, item_id, minute)) != transaction_id: return {} var entries: Array = economy.get_transaction_entries(transaction_id) if entries.size() != 1: return {} var entry: LedgerEntry = entries[0] var receipt := decode_receipt_memo(entry.memo_key) if receipt.is_empty() or receipt[&"item_id"] != String(item_id) \ or entry.absolute_minute != minute or entry.source_entity_id != warehouse_id \ or entry.category != &"OPERATING_EXPENSE" or not entry.related_transaction_id.is_empty() \ or entry.credit_account != &"PLAYER_CASH" or entry.debit_account != &"OPERATING_EXPENSE" \ or entry.amount != receipt[&"total_cost"]: return {} return receipt static func valid_receipt(economy: EconomyService, warehouse_id: StringName, item_id: StringName, minute: int, transaction_id: StringName, total_cost: int = -1, quantity: int = -1, unit_price: int = -1) -> bool: var receipt := receipt_from_ledger(economy, warehouse_id, item_id, minute, transaction_id) return not receipt.is_empty() and (total_cost < 0 or receipt[&"total_cost"] == total_cost) \ and (quantity < 0 or receipt[&"quantity"] == quantity) \ and (unit_price < 0 or receipt[&"unit_price"] == unit_price) static func _positive_decimal(text: String) -> int: # Validate textual range before to_int: no float conversion or saturating # overflow can reinterpret a corrupt receipt as another positive amount. if text.is_empty() or text.length() > MAX_SIGNED_INT_TEXT.length() or text.substr(0, 1) == "0": return 0 for index: int in text.length(): var code := text.unicode_at(index) if code < 48 or code > 57: return 0 if text.length() == MAX_SIGNED_INT_TEXT.length() and text > MAX_SIGNED_INT_TEXT: return 0 return text.to_int() static func valid_charge(quantity: int, unit_price: int, total_cost: int) -> bool: # Existing purchase input ceiling; division avoids multiplication overflow. @warning_ignore("integer_division") return quantity > 0 and quantity <= 10000 and unit_price > 0 and total_cost > 0 and total_cost / quantity == unit_price and total_cost % quantity == 0 static func validate_intent(warehouse_id: StringName, item_id: StringName, enabled: bool, target_stock: int, facilities: FacilityService) -> Dictionary: var facility: FacilityInstance = facilities.get_instance(warehouse_id) if facilities != null else null if facility == null or facility.definition_id != &"warehouse": return {&"ok": false, &"reason": &"NOT_WAREHOUSE"} var item := Catalog.get_item(item_id) if item.is_empty() or not bool(item.get(&"purchasable", false)): return {&"ok": false, &"reason": &"ITEM_NOT_PURCHASABLE"} var maximum := maximum_target(warehouse_id, item_id, facilities) if target_stock < 0 or target_stock > maximum or (enabled and target_stock == 0): return {&"ok": false, &"reason": &"INVALID_TARGET", &"maximum_target": maximum} return {&"ok": true, &"maximum_target": maximum} static func maximum_target(warehouse_id: StringName, item_id: StringName, facilities: FacilityService) -> int: var facility: FacilityInstance = facilities.get_instance(warehouse_id) if facilities != null else null var item := Catalog.get_item(item_id) if facility == null or facility.definition_id != &"warehouse" or item.is_empty() \ or not bool(item.get(&"purchasable", false)) or int(item.get(&"unit_weight", 0)) <= 0: return 0 @warning_ignore("integer_division") return Catalog.storage_capacity(facility.definition_id) / int(item[&"unit_weight"]) static func order_key(warehouse_id: StringName, item_id: StringName, minute: int) -> StringName: # Length encoding avoids punctuation collisions. This is not a command ID. return StringName("supply.auto.%d:%s.%d:%s.%d" % [ String(warehouse_id).length(), warehouse_id, String(item_id).length(), item_id, minute]) static func _empty_plan(warehouse_id: StringName, item_id: StringName) -> Dictionary: return {&"warehouse_id": String(warehouse_id), &"item_id": String(item_id), &"enabled": false, &"target_stock": 0, &"last_attempt_minute": -1, &"last_result": &"", &"last_transaction_id": "", &"last_quantity": 0, &"last_unit_price": 0, &"last_total_cost": 0} static func _sorted_names(values: Dictionary) -> Array[StringName]: var ids: Array[StringName] = [] for value: Variant in values: ids.append(StringName(value)) ids.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) return ids static func _keys_match(value: Dictionary, expected: Array[StringName]) -> bool: if value.size() != expected.size(): return false for key: Variant in value: if typeof(key) != TYPE_STRING_NAME or key not in expected: return false return true static func _text(value: Variant) -> bool: return typeof(value) in [TYPE_STRING, TYPE_STRING_NAME] and not String(value).strip_edges().is_empty() func _restore_failure(message: String) -> bool: last_error = message return false