class_name KitchenProductionService extends RefCounted const Catalog = preload("res://scripts/supply/supply_catalog.gd") const SNAPSHOT_VERSION := 1 var current_minute := 0 var last_error := "" var last_restore_error := "" var _inventory: InventoryService var _facilities: FacilityService var _registry: DefinitionRegistry var _plans: Dictionary = {} var _active_batches: Dictionary = {} var _next_batch_sequence := 1 func configure( inventory: InventoryService, facilities: FacilityService, registry: DefinitionRegistry, minute: int ) -> void: _inventory = inventory _facilities = facilities _registry = registry current_minute = maxi(minute, 0) func set_plan(facility_id: StringName, enabled: bool, target_stock: int = Catalog.KITCHEN_TARGET_DEFAULT) -> Dictionary: if _kitchen(facility_id) == null: return _failure(&"INVALID_FACILITY", "Production plans require an existing kitchen.") if not _valid_target(target_stock): return _failure(&"INVALID_TARGET", "Target must be 4, 8, 12, or 16 meals.") _plans[facility_id] = { &"facility_id": String(facility_id), &"enabled": enabled, &"target_stock": target_stock, } last_error = "" return {&"ok": true, &"plan": get_plan(facility_id)} func get_plan(facility_id: StringName) -> Dictionary: if _plans.has(facility_id): return (_plans[facility_id] as Dictionary).duplicate(true) if _kitchen(facility_id) == null: return {} return {&"facility_id": String(facility_id), &"enabled": false, &"target_stock": Catalog.KITCHEN_TARGET_DEFAULT} func get_plans() -> Array[Dictionary]: var result: Array[Dictionary] = [] for facility_id: StringName in _sorted_ids(_plans): result.append(get_plan(facility_id)) return result ## Also works after a successful facility removal, when the instance no longer ## exists. Runtime checks enabled plans and batches before authorizing removal. func remove_plan(facility_id: StringName) -> Dictionary: if _active_batches.has(facility_id): return {&"ok": false, &"reason": &"ACTIVE_BATCH"} if _plans.has(facility_id) and bool(_plans[facility_id][&"enabled"]): return {&"ok": false, &"reason": &"PLAN_ENABLED"} var existed := _plans.erase(facility_id) return {&"ok": true, &"reason": &"REMOVED" if existed else &"NO_PLAN"} func get_report(facility_id: StringName) -> Dictionary: var facility := _kitchen(facility_id) if facility == null: return {} var plan := get_plan(facility_id) var batch: Dictionary = _active_batches.get(facility_id, {}) var state := _production_state(facility, plan, batch, current_minute) return { &"facility_id": String(facility_id), &"inventory_id": String(facility.local_inventory_id), &"enabled": plan[&"enabled"], &"target_stock": plan[&"target_stock"], &"state": state, &"status": state, &"raw_stock": _inventory.available_quantity(facility.local_inventory_id, Catalog.RAW_FOOD, current_minute), &"meal_stock": _usable_quantity(facility.local_inventory_id, Catalog.BASIC_MEAL, current_minute), &"progress_minutes": int(batch.get(&"progress_minutes", 0)), &"duration_minutes": Catalog.PRODUCTION_MINUTES, &"active_batch": batch.duplicate(true), } ## Runtime calls this after projecting current staffing/power/access. A batch ## started at t first earns work at t+1; interruptions retain earned work. func advance_one_minute(minute: int) -> Dictionary: if not _configured(): return _failure(&"NOT_CONFIGURED", "Inventory, facilities, and registry must be configured.") if minute == current_minute: return {&"ok": true, &"replay": true, &"events": []} if minute != current_minute + 1: return _failure(&"INVALID_MINUTE", "Production must advance one minute at a time.") current_minute = minute var events: Array[Dictionary] = [] for facility_id: StringName in _sorted_ids(_plans): var facility := _kitchen(facility_id) var batch: Dictionary = _active_batches.get(facility_id, {}) if facility == null: if not batch.is_empty(): _release_batch(batch) _active_batches.erase(facility_id) events.append(_event(&"BATCH_FAILED", facility_id, batch, &"FACILITY_REMOVED")) _plans.erase(facility_id) continue # Expiry also closes reservations while a plan or facility is paused. if not batch.is_empty() and not _batch_links_valid(batch, minute): _release_batch(batch) _active_batches.erase(facility_id) events.append(_event(&"BATCH_FAILED", facility_id, batch, &"INPUT_UNAVAILABLE")) continue var plan: Dictionary = _plans[facility_id] var state := _production_state(facility, plan, batch, minute) if batch.is_empty(): if state == &"READY": var result := _start_batch(facility, minute) if bool(result[&"ok"]): events.append(_event(&"BATCH_STARTED", facility_id, _active_batches[facility_id])) continue if state != &"PRODUCING": continue batch[&"progress_minutes"] = mini(int(batch[&"progress_minutes"]) + 1, Catalog.PRODUCTION_MINUTES) if int(batch[&"progress_minutes"]) == Catalog.PRODUCTION_MINUTES: var result := _complete_batch(batch, minute) if bool(result[&"ok"]): _active_batches.erase(facility_id) events.append(_event(&"BATCH_COMPLETED", facility_id, batch)) else: # Atomic inventory failure keeps the reservation and full progress. events.append(_event(&"BATCH_WAITING", facility_id, batch, result.get(&"code", &"INVENTORY_ERROR"))) last_error = "" return {&"ok": true, &"events": events} func create_snapshot() -> Dictionary: var batches: Array[Dictionary] = [] for facility_id: StringName in _sorted_ids(_active_batches): batches.append((_active_batches[facility_id] as Dictionary).duplicate(true)) return { &"version": SNAPSHOT_VERSION, &"current_minute": current_minute, &"next_batch_sequence": _next_batch_sequence, &"plans": get_plans(), &"active_batches": batches, } ## Restore is transactional and read-only with respect to inventory. Inventory ## and FacilityService must already contain their restored state. func restore_snapshot(snapshot: Dictionary) -> bool: last_restore_error = "" if not _configured() or not _keys_match(snapshot, [&"version", &"current_minute", &"next_batch_sequence", &"plans", &"active_batches"]): return _restore_failure("Missing dependencies or invalid snapshot fields.") if not _integer(snapshot[&"version"], SNAPSHOT_VERSION, SNAPSHOT_VERSION) \ or not _integer(snapshot[&"current_minute"], 0) \ or not _integer(snapshot[&"next_batch_sequence"], 1) \ or not snapshot[&"plans"] is Array or not snapshot[&"active_batches"] is Array: return _restore_failure("Invalid snapshot version, clock, sequence, or collections.") var candidate_plans: Dictionary = {} for value: Variant in snapshot[&"plans"]: if not value is Dictionary or not _keys_match(value, [&"facility_id", &"enabled", &"target_stock"]): return _restore_failure("Invalid production plan fields.") var plan: Dictionary = value if not _text(plan[&"facility_id"]) or not plan[&"enabled"] is bool \ or not _integer(plan[&"target_stock"], 4, Catalog.KITCHEN_TARGET_MAX) \ or not _valid_target(int(plan[&"target_stock"])): return _restore_failure("Invalid production plan values.") var facility_id := StringName(plan[&"facility_id"]) if candidate_plans.has(facility_id) or _kitchen(facility_id) == null: return _restore_failure("Duplicate plan or unknown kitchen.") candidate_plans[facility_id] = plan.duplicate(true) var candidate_batches: Dictionary = {} var seen_batches: Dictionary = {} for value: Variant in snapshot[&"active_batches"]: if not value is Dictionary or not _valid_batch_shape(value, int(snapshot[&"current_minute"]), int(snapshot[&"next_batch_sequence"])): return _restore_failure("Invalid active batch.") var batch: Dictionary = value var facility_id := StringName(batch[&"facility_id"]) if not candidate_plans.has(facility_id) or candidate_batches.has(facility_id) or seen_batches.has(batch[&"batch_id"]): return _restore_failure("Duplicate batch or missing production plan.") seen_batches[batch[&"batch_id"]] = true candidate_batches[facility_id] = batch.duplicate(true) if not _crosslinks_valid(candidate_batches, int(snapshot[&"current_minute"]), int(snapshot[&"next_batch_sequence"])): return _restore_failure("Kitchen input reservations do not match the restored inventory.") _plans = candidate_plans _active_batches = candidate_batches _next_batch_sequence = int(snapshot[&"next_batch_sequence"]) current_minute = int(snapshot[&"current_minute"]) last_error = "" return true func validate_crosslinks() -> bool: return _configured() and _crosslinks_valid(_active_batches, current_minute, _next_batch_sequence) func _start_batch(facility: FacilityInstance, minute: int) -> Dictionary: var batch_id := StringName("kitchen.batch:%08d" % _next_batch_sequence) var reserved := _inventory.reserve_item( facility.local_inventory_id, Catalog.RAW_FOOD, Catalog.BATCH_INPUT, batch_id, Catalog.PRODUCTION_PURPOSE, minute ) if not bool(reserved[&"ok"]): return reserved _next_batch_sequence += 1 var reservation_ids: Array[String] = [] for reservation_id: StringName in reserved[&"reservation_ids"]: reservation_ids.append(String(reservation_id)) var definition := _registry.get_facility(facility.definition_id) _active_batches[facility.instance_id] = { &"batch_id": String(batch_id), &"facility_id": String(facility.instance_id), &"inventory_id": String(facility.local_inventory_id), &"started_at": minute, &"progress_minutes": 0, &"quality": clampi(definition.base_quality, 0, 100), &"reservation_ids": reservation_ids, } return {&"ok": true} func _complete_batch(batch: Dictionary, minute: int) -> Dictionary: if not _has_output_space(batch): return {&"ok": false, &"code": &"OUTPUT_FULL"} var before := _inventory.create_snapshot() var consumed := _inventory.consume_reservations(_reservation_ids(batch), &"", minute) if not bool(consumed[&"ok"]): return consumed var item := Catalog.get_item(Catalog.BASIC_MEAL) var produced := _inventory.add_stack( StringName(batch[&"inventory_id"]), Catalog.BASIC_MEAL, Catalog.BATCH_OUTPUT, int(batch[&"quality"]), minute + int(item[&"shelf_life_minutes"]), minute, int(item[&"unit_weight"]) ) if not bool(produced[&"ok"]): _inventory.restore_snapshot(before) return produced func _production_state(facility: FacilityInstance, plan: Dictionary, batch: Dictionary, minute: int) -> StringName: if not bool(plan[&"enabled"]): return &"DISABLED" if facility.durability <= 0: return &"BROKEN" if not facility.suspension_reason.is_empty(): return &"SUSPENDED" if not facility.related_facility_operating: return &"RELATED_FACILITY_INACTIVE" if not facility.access_available: return &"NO_ACCESS" if not facility.power_available: return &"NO_POWER" if facility.active_staff_ids.size() < maxi(facility.staff_min, 1): return &"NO_STAFF" if not facility.operator_open or not facility.is_within_opening_hours(minute): return &"CLOSED" if not batch.is_empty(): if not _batch_links_valid(batch, minute): return &"INPUT_UNAVAILABLE" if int(batch[&"progress_minutes"]) >= Catalog.PRODUCTION_MINUTES and not _has_output_space(batch): return &"OUTPUT_FULL" return &"PRODUCING" if _usable_quantity(facility.local_inventory_id, Catalog.BASIC_MEAL, minute) + Catalog.BATCH_OUTPUT > int(plan[&"target_stock"]): return &"TARGET_REACHED" if _inventory.available_quantity(facility.local_inventory_id, Catalog.RAW_FOOD, minute) < Catalog.BATCH_INPUT: return &"NO_INPUT" return &"READY" func _has_output_space(batch: Dictionary) -> bool: var consumed_weight := 0 for reservation_id: StringName in _reservation_ids(batch): var reservation := _inventory.get_reservation(reservation_id) if reservation == null: return false var stack := _inventory.get_stack(reservation.stack_id) if stack.is_empty(): return false consumed_weight += reservation.quantity * int(stack[&"unit_weight"]) var after_weight := Catalog.inventory_weight(_inventory, StringName(batch[&"inventory_id"])) - consumed_weight + Catalog.BATCH_OUTPUT return after_weight <= Catalog.storage_capacity(&"kitchen") func _batch_links_valid(batch: Dictionary, minute: int) -> bool: var facility := _kitchen(StringName(batch[&"facility_id"])) if facility == null or facility.local_inventory_id != StringName(batch[&"inventory_id"]): return false var quantity := 0 var allocated_by_stack: Dictionary = {} for reservation_id: StringName in _reservation_ids(batch): var reservation := _inventory.get_reservation(reservation_id) if reservation == null or not reservation.is_active() or reservation.is_expired(minute) or reservation.quantity <= 0: return false if reservation.owner_inventory_id != facility.local_inventory_id \ or reservation.item_id != Catalog.RAW_FOOD or reservation.requester_id != StringName(batch[&"batch_id"]) \ or reservation.purpose != Catalog.PRODUCTION_PURPOSE or reservation.created_at != int(batch[&"started_at"]): return false var stack := _inventory.get_stack(reservation.stack_id) if stack.is_empty() or StringName(stack[&"owner_inventory_id"]) != facility.local_inventory_id \ or StringName(stack[&"item_id"]) != Catalog.RAW_FOOD \ or int(stack[&"reserved_quantity"]) < reservation.quantity or int(stack[&"quantity"]) < reservation.quantity: return false if int(stack[&"expires_at"]) >= 0 and int(stack[&"expires_at"]) <= minute: return false allocated_by_stack[reservation.stack_id] = int(allocated_by_stack.get(reservation.stack_id, 0)) + reservation.quantity if int(allocated_by_stack[reservation.stack_id]) > int(stack[&"reserved_quantity"]): return false quantity += reservation.quantity return quantity == Catalog.BATCH_INPUT func _crosslinks_valid(batches: Dictionary, minute: int, next_sequence: int) -> bool: var linked: Dictionary = {} for batch: Dictionary in batches.values(): if not _batch_links_valid(batch, minute): return false for reservation_id: StringName in _reservation_ids(batch): if linked.has(reservation_id): return false linked[reservation_id] = true # Inventory retains terminal reservations. Their requester IDs are durable # evidence that a batch number was already used, even after completion, # failure, or removal of the kitchen's plan. for reservation: InventoryReservation in _inventory.get_reservations(): if reservation.purpose != Catalog.PRODUCTION_PURPOSE: continue var sequence := _batch_sequence(reservation.requester_id) if sequence < 1 or sequence >= next_sequence: return false if reservation.is_active() and not linked.has(reservation.reservation_id): return false return true func _release_batch(batch: Dictionary) -> void: for reservation_id: StringName in _reservation_ids(batch): var reservation := _inventory.get_reservation(reservation_id) # Only release this batch's own holds if external state was corrupted. if reservation != null and reservation.requester_id == StringName(batch[&"batch_id"]) \ and reservation.purpose == Catalog.PRODUCTION_PURPOSE: _inventory.release_reservation(reservation_id, &"", current_minute) func _kitchen(facility_id: StringName) -> FacilityInstance: if not _configured(): return null var facility := _facilities.get_instance(facility_id) if facility == null or facility.definition_id != &"kitchen" or facility.local_inventory_id == &"": return null if _registry.get_facility(facility.definition_id) == null: return null return facility func _configured() -> bool: return _inventory != null and _facilities != null and _registry != null func _usable_quantity(inventory_id: StringName, item_id: StringName, minute: int) -> int: var quantity := 0 for stack: Dictionary in _inventory.get_stacks(inventory_id): if StringName(stack[&"item_id"]) == item_id and (int(stack[&"expires_at"]) < 0 or int(stack[&"expires_at"]) > minute): quantity += int(stack[&"quantity"]) return quantity func _valid_batch_shape(batch: Dictionary, minute: int, next_sequence: int) -> bool: if not _keys_match(batch, [&"batch_id", &"facility_id", &"inventory_id", &"started_at", &"progress_minutes", &"quality", &"reservation_ids"]): return false for key: StringName in [&"batch_id", &"facility_id", &"inventory_id"]: if not _text(batch[key]): return false if not _integer(batch[&"started_at"], 0, minute) \ or not _integer(batch[&"progress_minutes"], 0, Catalog.PRODUCTION_MINUTES) \ or int(batch[&"progress_minutes"]) > minute - int(batch[&"started_at"]) \ or not _integer(batch[&"quality"], 0, 100) or not batch[&"reservation_ids"] is Array: return false var sequence := _batch_sequence(batch[&"batch_id"]) if sequence < 1 or sequence >= next_sequence: return false var seen: Dictionary = {} for reservation_id: Variant in batch[&"reservation_ids"]: if not _text(reservation_id) or seen.has(StringName(reservation_id)): return false seen[StringName(reservation_id)] = true return not seen.is_empty() func _batch_sequence(value: Variant) -> int: if not _text(value): return -1 var batch_id := String(value) var sequence_text := batch_id.trim_prefix("kitchen.batch:") if not sequence_text.is_valid_int(): return -1 var sequence := sequence_text.to_int() if sequence < 1 or batch_id != "kitchen.batch:%08d" % sequence: return -1 return sequence func _reservation_ids(batch: Dictionary) -> Array[StringName]: var ids: Array[StringName] = [] for value: Variant in batch[&"reservation_ids"]: ids.append(StringName(value)) return ids func _valid_target(target: int) -> bool: return target >= Catalog.BATCH_OUTPUT and target <= Catalog.KITCHEN_TARGET_MAX and target % Catalog.BATCH_OUTPUT == 0 func _integer(value: Variant, minimum: int, maximum: int = 9223372036854775807) -> bool: return typeof(value) == TYPE_INT and value >= minimum and value <= maximum func _text(value: Variant) -> bool: return typeof(value) in [TYPE_STRING, TYPE_STRING_NAME] and not String(value).strip_edges().is_empty() func _keys_match(value: Dictionary, expected: Array) -> bool: if value.size() != expected.size(): return false for key: StringName in expected: if not value.has(key): return false return true func _sorted_ids(values: Dictionary) -> Array[StringName]: var ids: Array[StringName] = [] for value: Variant in values.keys(): ids.append(StringName(value)) ids.sort() return ids func _event(kind: StringName, facility_id: StringName, batch: Dictionary, reason: StringName = &"") -> Dictionary: return { &"type": kind, &"facility_id": String(facility_id), &"batch_id": batch[&"batch_id"], &"minute": current_minute, &"reason": reason, &"output_quantity": Catalog.BATCH_OUTPUT if kind == &"BATCH_COMPLETED" else 0, } func _failure(code: StringName, message: String) -> Dictionary: last_error = message return {&"ok": false, &"code": code, &"message": message, &"events": []} func _restore_failure(message: String) -> bool: last_restore_error = message return false