Preserve production code, regression fixtures, audio assets and provenance, test infrastructure, and the original-completion plan and progress ledger. Final game and Windows acceptance remain pending; excluded local artifacts and native experiments are not part of this snapshot.
1132 lines
44 KiB
GDScript
1132 lines
44 KiB
GDScript
class_name FacilityService
|
|
extends RefCounted
|
|
|
|
signal instance_registered(instance: FacilityInstance)
|
|
signal instance_unregistered(instance_id: StringName)
|
|
signal facility_state_changed(instance_id: StringName, old_state: int, new_state: int, reasons: Array[StringName])
|
|
signal request_enqueued(request: ServiceRequest)
|
|
signal request_left_queue(request_id: StringName, reason: StringName)
|
|
signal service_started(service: ServiceInstance)
|
|
signal service_interrupted(service: ServiceInstance, reason: StringName)
|
|
signal service_completed(service: ServiceInstance)
|
|
signal service_failed(service: ServiceInstance, reason: StringName)
|
|
|
|
const FacilityInstanceType = preload("res://scripts/facility/facility_instance.gd")
|
|
const ServiceRequestType = preload("res://scripts/facility/service_request.gd")
|
|
const ServiceInstanceType = preload("res://scripts/facility/service_instance.gd")
|
|
const CommandResultType = preload("res://scripts/core/command_result.gd")
|
|
const FacilityPricingType = preload("res://scripts/facility/facility_pricing.gd")
|
|
|
|
const SNAPSHOT_VERSION := 1
|
|
const STAFF_REPLACEMENT_GRACE_MINUTES := 5
|
|
|
|
const INTERRUPTION_PLAYER_CLOSED := &"PLAYER_CLOSED"
|
|
const INTERRUPTION_NO_POWER := &"NO_POWER"
|
|
const INTERRUPTION_MANA_STOP := &"MANA_STOP"
|
|
const INTERRUPTION_STAFF_UNAVAILABLE := &"STAFF_UNAVAILABLE"
|
|
const INTERRUPTION_EMERGENCY := &"EMERGENCY"
|
|
const INTERRUPTION_FIRE := &"FIRE"
|
|
const INTERRUPTION_RIOT := &"RIOT"
|
|
const INTERRUPTION_GUEST_LEFT := &"GUEST_LEFT"
|
|
const INTERRUPTION_BROKEN := &"BROKEN"
|
|
|
|
var current_minute := 0
|
|
var last_error := ""
|
|
|
|
var _instances: Dictionary = {}
|
|
var _requests: Dictionary = {}
|
|
var _known_instance_ids: Dictionary = {}
|
|
var _known_request_ids: Dictionary = {}
|
|
var _known_service_ids: Dictionary = {}
|
|
var _service_history: Dictionary = {}
|
|
var _next_service_sequence := 1
|
|
var _start_validator := Callable()
|
|
var _completion_callback := Callable()
|
|
|
|
|
|
func configure_callbacks(
|
|
start_validator: Callable = Callable(),
|
|
completion_callback: Callable = Callable()
|
|
) -> FacilityService:
|
|
_start_validator = start_validator
|
|
_completion_callback = completion_callback
|
|
return self
|
|
|
|
|
|
func set_start_validator(callback: Callable) -> void:
|
|
_start_validator = callback
|
|
|
|
|
|
func set_completion_callback(callback: Callable) -> void:
|
|
_completion_callback = callback
|
|
|
|
|
|
func register_instance(instance: Variant) -> CommandResult:
|
|
if instance == null or not instance is FacilityInstance:
|
|
return _invalid("Facility instance is missing or has an invalid type.")
|
|
var facility: FacilityInstance = instance
|
|
if not facility.is_valid():
|
|
return _invalid("Facility instance is invalid.", {&"instance_id": facility.instance_id})
|
|
if _known_instance_ids.has(facility.instance_id):
|
|
return _duplicate("Facility instance", facility.instance_id)
|
|
if not facility.queue_entries.is_empty() or not facility.active_services.is_empty():
|
|
return _invalid(
|
|
"A new facility must be registered before it owns requests or services.",
|
|
{&"instance_id": facility.instance_id}
|
|
)
|
|
_instances[facility.instance_id] = facility
|
|
_known_instance_ids[facility.instance_id] = true
|
|
facility.evaluate_state(current_minute)
|
|
instance_registered.emit(facility)
|
|
last_error = ""
|
|
return _success({&"instance_id": facility.instance_id})
|
|
|
|
|
|
func create_instance(configuration: Dictionary) -> CommandResult:
|
|
var instance := FacilityInstanceType.new().configure(
|
|
StringName(_value(configuration, &"instance_id", &"")),
|
|
StringName(_value(configuration, &"definition_id", &"")),
|
|
configuration
|
|
)
|
|
var result := register_instance(instance)
|
|
if result.is_success():
|
|
result.details[&"instance"] = instance
|
|
return result
|
|
|
|
|
|
func has_instance(instance_id: StringName) -> bool:
|
|
return _instances.has(instance_id)
|
|
|
|
|
|
func get_instance(instance_id: StringName) -> FacilityInstance:
|
|
return _instances.get(instance_id) as FacilityInstance
|
|
|
|
|
|
func get_facility(instance_id: StringName) -> FacilityInstance:
|
|
return get_instance(instance_id)
|
|
|
|
|
|
func get_instances() -> Array[FacilityInstance]:
|
|
var result: Array[FacilityInstance] = []
|
|
for instance_id: StringName in _sorted_keys(_instances):
|
|
result.append(_instances[instance_id])
|
|
return result
|
|
|
|
|
|
func unregister_instance(instance_id: StringName) -> CommandResult:
|
|
var instance := get_instance(instance_id)
|
|
if instance == null:
|
|
return _not_found("Facility instance", instance_id)
|
|
if not instance.queue_entries.is_empty() or not instance.active_services.is_empty():
|
|
return _invalid(
|
|
"A facility with queued or active services cannot be unregistered.",
|
|
{&"instance_id": instance_id}
|
|
)
|
|
_instances.erase(instance_id)
|
|
refresh_operating_dependencies(current_minute)
|
|
instance_unregistered.emit(instance_id)
|
|
last_error = ""
|
|
return _success({&"instance_id": instance_id})
|
|
|
|
|
|
func set_operational_status(
|
|
instance_id: StringName,
|
|
conditions: Dictionary,
|
|
absolute_minute: int = -1
|
|
) -> CommandResult:
|
|
var instance := get_instance(instance_id)
|
|
if instance == null:
|
|
return _not_found("Facility instance", instance_id)
|
|
var old_state := instance.state
|
|
if _has_value(conditions, &"related_facility_id"):
|
|
var raw_related: Variant = _value(conditions, &"related_facility_id", null)
|
|
if typeof(raw_related) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
|
return _invalid("Related facility must be one facility ID.")
|
|
var related := StringName(raw_related)
|
|
if related == instance_id or (not related.is_empty() and not has_instance(related)):
|
|
return _invalid("Related facility is missing or refers to itself.")
|
|
if instance.requires_operating_kitchen() and not related.is_empty():
|
|
var kitchen := get_instance(related)
|
|
if kitchen.definition_id != &"kitchen" or kitchen.origin.z != WorldService.LAYER_BACK:
|
|
return _invalid("Dining facilities require one existing BACK kitchen.", {&"reason": &"INVALID_RELATED_KITCHEN"})
|
|
if not _required_related_placement_matches(instance, kitchen):
|
|
return _invalid("The BACK kitchen must lie inside this FRONT dining facility's XY footprint.", {&"reason": &"INVALID_RELATED_KITCHEN_PLACEMENT"})
|
|
var seen := {instance_id: true}
|
|
var cursor := related
|
|
while not cursor.is_empty() and has_instance(cursor):
|
|
if seen.has(cursor):
|
|
return _invalid("Related facilities cannot form a cycle.")
|
|
seen[cursor] = true
|
|
cursor = get_instance(cursor).related_facility_id
|
|
if _has_value(conditions, &"related_facility_operating") or _has_value(conditions, &"operation_state"):
|
|
return _invalid("Operating flags are computed from actual facility conditions.")
|
|
if _has_value(conditions, &"suspension_reason") and (typeof(_value(conditions, &"suspension_reason", null)) not in [TYPE_STRING, TYPE_STRING_NAME] or StringName(_value(conditions, &"suspension_reason", &"")) not in [&"DESTROYED", &"RAIDED"]):
|
|
return _invalid("Suspension recovery is not specified.")
|
|
var minute := _resolve_minute(absolute_minute)
|
|
instance.set_operational_conditions(conditions, minute)
|
|
if old_state != instance.state or _has_value(conditions, &"related_facility_id"):
|
|
refresh_operating_dependencies(minute)
|
|
_emit_state_change(instance, old_state)
|
|
last_error = ""
|
|
return _success({
|
|
&"instance_id": instance_id,
|
|
&"state": FacilityInstanceType.State.keys()[instance.state],
|
|
&"state_reasons": instance.state_reasons.duplicate(),
|
|
})
|
|
|
|
|
|
func set_opening_hours(
|
|
instance_id: StringName,
|
|
open_minute_of_day: int,
|
|
close_minute_of_day: int,
|
|
absolute_minute: int = -1
|
|
) -> CommandResult:
|
|
var instance := get_instance(instance_id)
|
|
if instance == null:
|
|
return _not_found("Facility instance", instance_id)
|
|
if not instance.set_opening_hours(open_minute_of_day, close_minute_of_day):
|
|
return _invalid("Opening hours must be valid minute-of-day values.")
|
|
var old_state := instance.state
|
|
instance.evaluate_state(_resolve_minute(absolute_minute))
|
|
refresh_operating_dependencies(current_minute)
|
|
_emit_state_change(instance, old_state)
|
|
last_error = ""
|
|
return _success({&"instance_id": instance_id})
|
|
|
|
|
|
func set_policy(
|
|
instance_id: StringName,
|
|
patch: Dictionary,
|
|
effective_at: int = -1
|
|
) -> CommandResult:
|
|
var instance := get_instance(instance_id)
|
|
if instance == null:
|
|
return _not_found("Facility instance", instance_id)
|
|
if patch.is_empty():
|
|
return _invalid("Policy patch cannot be empty.")
|
|
var validation := FacilityPricingType.validate_patch(patch)
|
|
if not bool(validation[&"valid"]):
|
|
return _invalid(String(validation[&"message"]), {&"reason": validation[&"reason"]})
|
|
var normalized_patch: Dictionary = validation[&"patch"]
|
|
if effective_at >= 0 and effective_at > current_minute:
|
|
instance.schedule_policy_patch(normalized_patch, effective_at)
|
|
last_error = ""
|
|
return _success({
|
|
&"instance_id": instance_id,
|
|
&"pending_revision": instance.policy_pending_revision,
|
|
&"effective_at": effective_at,
|
|
})
|
|
var changed := instance.apply_policy_patch(normalized_patch)
|
|
var old_state := instance.state
|
|
instance.evaluate_state(current_minute)
|
|
refresh_operating_dependencies(current_minute)
|
|
_emit_state_change(instance, old_state)
|
|
last_error = ""
|
|
return _success({
|
|
&"instance_id": instance_id,
|
|
&"policy_revision": instance.policy_revision,
|
|
&"changed": changed,
|
|
})
|
|
|
|
|
|
func create_request(
|
|
request_id: StringName,
|
|
facility_id: StringName,
|
|
guest_ids: Array[StringName],
|
|
service_type: StringName,
|
|
created_at: int,
|
|
options: Dictionary = {}
|
|
) -> ServiceRequest:
|
|
var instance := get_instance(facility_id)
|
|
if instance == null:
|
|
return null
|
|
var quotation := options.duplicate(true)
|
|
if not _has_value(quotation, &"quoted_price"):
|
|
quotation[&"quoted_price"] = FacilityPricingType.quote(instance.base_price, instance.policy_current)
|
|
if not _has_value(quotation, &"expected_quality"):
|
|
quotation[&"expected_quality"] = instance.base_quality
|
|
if not _has_value(quotation, &"service_minutes"):
|
|
quotation[&"service_minutes"] = instance.base_service_minutes
|
|
quotation[&"policy_snapshot"] = instance.policy_current.duplicate(true)
|
|
quotation[&"policy_revision"] = instance.policy_revision
|
|
return ServiceRequestType.new().configure(
|
|
request_id,
|
|
facility_id,
|
|
guest_ids,
|
|
service_type,
|
|
created_at,
|
|
quotation
|
|
)
|
|
|
|
|
|
func enqueue_request(request: Variant, absolute_minute: int = -1, commit_clock: bool = true) -> CommandResult:
|
|
if request == null or not request is ServiceRequest:
|
|
return _invalid("Service request is missing or has an invalid type.")
|
|
var queued_request: ServiceRequest = request
|
|
if not queued_request.is_valid():
|
|
return _invalid("Service request is invalid.")
|
|
if _known_request_ids.has(queued_request.request_id):
|
|
return _duplicate("Service request", queued_request.request_id)
|
|
var instance := get_instance(queued_request.facility_id)
|
|
if instance == null:
|
|
return _not_found("Facility instance", queued_request.facility_id)
|
|
if not instance.has_queue_space():
|
|
return _invalid(
|
|
"Facility queue is full.",
|
|
{&"instance_id": instance.instance_id, &"queue_capacity": instance.queue_capacity}
|
|
)
|
|
# Queue registration can precede the Clock increment in APPLY_COMMANDS.
|
|
# Preserve the request effective time without claiming participant advancement.
|
|
if not commit_clock and absolute_minute != current_minute + 1:
|
|
return _invalid("Deferred queue registration must target the next uncommitted minute.")
|
|
var minute := _resolve_minute(absolute_minute) if commit_clock else absolute_minute
|
|
queued_request.enqueued_at = minute
|
|
_requests[queued_request.request_id] = queued_request
|
|
_known_request_ids[queued_request.request_id] = true
|
|
instance.queue_entries.append(queued_request.request_id)
|
|
_sort_queue(instance)
|
|
queued_request.expected_wait = _estimate_wait_before(instance, queued_request.request_id)
|
|
_increment_stat(instance.statistics_today, &"requests_enqueued")
|
|
_increment_stat(instance.lifetime_statistics, &"requests_enqueued")
|
|
request_enqueued.emit(queued_request)
|
|
last_error = ""
|
|
return _success({
|
|
&"request_id": queued_request.request_id,
|
|
&"expected_wait": queued_request.expected_wait,
|
|
&"queue_position": instance.queue_entries.find(queued_request.request_id),
|
|
})
|
|
|
|
|
|
func submit_request(request: Variant, absolute_minute: int = -1) -> CommandResult:
|
|
return enqueue_request(request, absolute_minute)
|
|
|
|
|
|
func get_request(request_id: StringName) -> ServiceRequest:
|
|
return _requests.get(request_id) as ServiceRequest
|
|
|
|
|
|
func get_queue(instance_id: StringName) -> Array[ServiceRequest]:
|
|
var queued: Array[ServiceRequest] = []
|
|
var instance := get_instance(instance_id)
|
|
if instance == null:
|
|
return queued
|
|
for request_id: StringName in instance.queue_entries:
|
|
var request := get_request(request_id)
|
|
if request != null:
|
|
queued.append(request)
|
|
return queued
|
|
|
|
|
|
func peek_next_request(instance_id: StringName) -> ServiceRequest:
|
|
var instance := get_instance(instance_id)
|
|
if instance == null or instance.queue_entries.is_empty():
|
|
return null
|
|
return get_request(instance.queue_entries[0])
|
|
|
|
|
|
func leave_queue(
|
|
instance_id: StringName,
|
|
request_id: StringName,
|
|
reason: StringName = &"GUEST_LEFT"
|
|
) -> CommandResult:
|
|
var instance := get_instance(instance_id)
|
|
if instance == null:
|
|
return _not_found("Facility instance", instance_id)
|
|
var queue_index := instance.queue_entries.find(request_id)
|
|
if queue_index < 0:
|
|
return _not_found("Queued service request", request_id)
|
|
instance.queue_entries.remove_at(queue_index)
|
|
_requests.erase(request_id)
|
|
_increment_stat(instance.statistics_today, &"queue_departures")
|
|
_increment_stat(instance.lifetime_statistics, &"queue_departures")
|
|
request_left_queue.emit(request_id, reason)
|
|
last_error = ""
|
|
return _success({&"request_id": request_id, &"reason": reason})
|
|
|
|
|
|
func cancel_request(request_id: StringName, reason: StringName = &"GUEST_LEFT") -> CommandResult:
|
|
for instance_id: StringName in _sorted_keys(_instances):
|
|
var instance: FacilityInstance = _instances[instance_id]
|
|
if instance.queue_entries.has(request_id):
|
|
return leave_queue(instance_id, request_id, reason)
|
|
return _not_found("Queued service request", request_id)
|
|
|
|
|
|
func _required_related_kind_matches(instance: FacilityInstance) -> bool:
|
|
if not instance.requires_operating_kitchen():
|
|
return true
|
|
var kitchen := get_instance(instance.related_facility_id)
|
|
return kitchen != null and kitchen.definition_id == &"kitchen" and kitchen.origin.z == WorldService.LAYER_BACK
|
|
|
|
|
|
# Original 2.2 and 8.3 put a BACK kitchen behind the FRONT dining venue.
|
|
# XY containment is Root's operational interpretation, not a width/origin-x
|
|
# equality rule. Use actual occupied cells; do not synthesize missing geometry,
|
|
# rewrite old IDs, or persist this derived predicate.
|
|
func _required_related_placement_matches(instance: FacilityInstance, proposed_kitchen: FacilityInstance = null) -> bool:
|
|
if not instance.requires_operating_kitchen():
|
|
return true
|
|
var kitchen: FacilityInstance = proposed_kitchen if proposed_kitchen != null else get_instance(instance.related_facility_id)
|
|
if kitchen == null or kitchen.definition_id != &"kitchen" \
|
|
or instance.origin.z != WorldService.LAYER_FRONT or kitchen.origin.z != WorldService.LAYER_BACK \
|
|
or instance.occupied_cells.is_empty() or kitchen.occupied_cells.is_empty():
|
|
return false
|
|
var front_xy: Dictionary = {}
|
|
for cell: Vector3i in instance.occupied_cells:
|
|
if cell.z != WorldService.LAYER_FRONT:
|
|
return false
|
|
front_xy[Vector2i(cell.x, cell.y)] = true
|
|
for cell: Vector3i in kitchen.occupied_cells:
|
|
if cell.z != WorldService.LAYER_BACK or not front_xy.has(Vector2i(cell.x, cell.y)):
|
|
return false
|
|
return true
|
|
|
|
|
|
# Read-only derived eligibility. Legacy cached state/IDs remain untouched until
|
|
# actual gameplay evaluates a facility or advances one minute.
|
|
func get_required_kitchen_report(instance_id: StringName) -> Dictionary:
|
|
var instance := get_instance(instance_id)
|
|
if instance == null:
|
|
return {}
|
|
var required := instance.requires_operating_kitchen()
|
|
var related := instance.related_facility_id
|
|
var kitchen := get_instance(related)
|
|
var valid_kind := _required_related_kind_matches(instance)
|
|
var valid_placement := _required_related_placement_matches(instance)
|
|
var operating := not required or (valid_placement and _restored_dependency_operates(related, {}))
|
|
var reason: StringName = &""
|
|
if required:
|
|
if related.is_empty(): reason = &"KITCHEN_NOT_LINKED"
|
|
elif kitchen == null: reason = &"KITCHEN_REMOVED"
|
|
elif not valid_kind: reason = &"INVALID_RELATED_KITCHEN"
|
|
elif not valid_placement: reason = &"INVALID_RELATED_KITCHEN_PLACEMENT"
|
|
elif not kitchen.suspension_reason.is_empty(): reason = &"KITCHEN_SUSPENDED"
|
|
elif not operating: reason = &"KITCHEN_INACTIVE"
|
|
return {&"required": required, &"related_facility_id": related, &"exists": kitchen != null, &"valid_kind": valid_kind, &"operating": operating, &"reason": reason}
|
|
|
|
|
|
# Read-only eligibility of a proposed link. Does not select a kitchen, alter an
|
|
# existing related ID, evaluate state, advance time, or clear persistent suspension.
|
|
func get_kitchen_placement_report(instance_id: StringName, kitchen_id: StringName) -> Dictionary:
|
|
var instance := get_instance(instance_id)
|
|
var kitchen := get_instance(kitchen_id)
|
|
var required := instance != null and instance.requires_operating_kitchen()
|
|
var valid_kind := kitchen != null and kitchen.definition_id == &"kitchen" and kitchen.origin.z == WorldService.LAYER_BACK
|
|
var valid_placement := required and valid_kind and _required_related_placement_matches(instance, kitchen)
|
|
var reason: StringName = &""
|
|
if instance == null: reason = &"FACILITY_NOT_FOUND"
|
|
elif not required: reason = &"KITCHEN_NOT_REQUIRED"
|
|
elif kitchen == null: reason = &"KITCHEN_REMOVED"
|
|
elif not valid_kind: reason = &"INVALID_RELATED_KITCHEN"
|
|
elif not valid_placement: reason = &"INVALID_RELATED_KITCHEN_PLACEMENT"
|
|
return {&"facility_id": instance_id, &"kitchen_id": kitchen_id, &"required": required, &"exists": kitchen != null, &"valid_kind": valid_kind, &"valid_placement": valid_placement, &"reason": reason}
|
|
|
|
|
|
func refresh_operating_dependencies(minute: int) -> void:
|
|
var resolved: Dictionary = {}
|
|
for instance_id: StringName in _sorted_keys(_instances):
|
|
_resolve_operating_dependency(instance_id, minute, resolved, {})
|
|
|
|
|
|
func _resolve_operating_dependency(id: StringName, minute: int, resolved: Dictionary, visiting: Dictionary) -> bool:
|
|
if resolved.has(id):
|
|
return bool(resolved[id])
|
|
if visiting.has(id) or not has_instance(id):
|
|
return false
|
|
visiting[id] = true
|
|
var instance := get_instance(id)
|
|
instance.apply_pending_policy(minute)
|
|
var related := instance.related_facility_id
|
|
instance.related_facility_operating = _required_related_placement_matches(instance) and (related.is_empty() or _resolve_operating_dependency(related, minute, resolved, visiting))
|
|
var old_state := instance.state
|
|
instance.evaluate_state(minute)
|
|
_emit_state_change(instance, old_state)
|
|
visiting.erase(id)
|
|
resolved[id] = instance.operation_state == FacilityInstanceType.OperationState.OPERATING
|
|
return bool(resolved[id])
|
|
|
|
|
|
func start_next_service(
|
|
instance_id: StringName,
|
|
absolute_minute: int = -1,
|
|
validator: Callable = Callable()
|
|
) -> CommandResult:
|
|
var instance := get_instance(instance_id)
|
|
if instance == null:
|
|
return _not_found("Facility instance", instance_id)
|
|
var minute := _resolve_minute(absolute_minute)
|
|
_resolve_operating_dependency(instance_id, minute, {}, {})
|
|
instance.apply_pending_policy(minute)
|
|
var old_state := instance.state
|
|
instance.evaluate_state(minute)
|
|
_emit_state_change(instance, old_state)
|
|
if not instance.can_start_service(minute):
|
|
return _invalid(
|
|
"Facility cannot start a service in its current state.",
|
|
{
|
|
&"instance_id": instance_id,
|
|
&"state": FacilityInstanceType.State.keys()[instance.state],
|
|
&"reasons": instance.state_reasons.duplicate(),
|
|
}
|
|
)
|
|
_cleanup_missing_queue_requests(instance)
|
|
if instance.queue_entries.is_empty():
|
|
return _not_found("Queued service request", &"")
|
|
var request := get_request(instance.queue_entries[0])
|
|
if request == null:
|
|
return _invalid("The next queue entry is corrupt.")
|
|
if not instance.can_accept_customers(request.guest_ids):
|
|
return _invalid("Simultaneous customer limit reached.", {&"reason": &"CUSTOMER_CAPACITY"})
|
|
var effective_validator := validator if validator.is_valid() else _start_validator
|
|
var callback_failure := _run_boundary_callback(
|
|
effective_validator,
|
|
[request, instance, minute],
|
|
"Service start validation failed.",
|
|
CommandResultType.Code.VALIDATION_FAILED
|
|
)
|
|
if callback_failure != null:
|
|
callback_failure.details[&"request_id"] = request.request_id
|
|
callback_failure.details[&"instance_id"] = instance_id
|
|
return callback_failure
|
|
|
|
var service_id := _next_service_id()
|
|
var service := ServiceInstanceType.new().configure(
|
|
service_id,
|
|
request.request_id,
|
|
instance_id,
|
|
minute,
|
|
request.service_minutes if request.service_minutes > 0 else instance.base_service_minutes,
|
|
request.guest_ids,
|
|
instance.active_staff_ids,
|
|
{
|
|
&"quality_snapshot": request.expected_quality,
|
|
&"price_snapshot": request.quoted_price,
|
|
&"policy_snapshot": request.policy_snapshot,
|
|
&"inventory_reservations": request.reserved_inventory_ids,
|
|
}
|
|
)
|
|
instance.queue_entries.remove_at(0)
|
|
# Report this start's service index, independent of customer headcount and callbacks.
|
|
var slot := instance.active_services.size()
|
|
instance.active_services[service_id] = service
|
|
_known_service_ids[service_id] = true
|
|
_increment_stat(instance.statistics_today, &"services_started")
|
|
_increment_stat(instance.lifetime_statistics, &"services_started")
|
|
service_started.emit(service)
|
|
last_error = ""
|
|
return _success({
|
|
&"instance_id": instance_id,
|
|
&"request_id": request.request_id,
|
|
&"service_id": service_id,
|
|
&"slot": slot,
|
|
})
|
|
|
|
|
|
func fill_available_slots(
|
|
instance_id: StringName,
|
|
absolute_minute: int = -1,
|
|
validator: Callable = Callable()
|
|
) -> Array[CommandResult]:
|
|
var results: Array[CommandResult] = []
|
|
var instance := get_instance(instance_id)
|
|
if instance == null:
|
|
results.append(_not_found("Facility instance", instance_id))
|
|
return results
|
|
var minute := _resolve_minute(absolute_minute)
|
|
while instance.available_slots() > 0 and not instance.queue_entries.is_empty():
|
|
var result := start_next_service(instance_id, minute, validator)
|
|
results.append(result)
|
|
if not result.is_success():
|
|
break
|
|
return results
|
|
|
|
|
|
func advance_one_minute(absolute_minute: int = -1) -> Array[CommandResult]:
|
|
if absolute_minute < 0:
|
|
current_minute += 1
|
|
elif absolute_minute < current_minute:
|
|
return [_invalid("FacilityService cannot advance backwards in time.")]
|
|
else:
|
|
current_minute = absolute_minute
|
|
refresh_operating_dependencies(current_minute)
|
|
var results: Array[CommandResult] = []
|
|
for instance_id: StringName in _sorted_keys(_instances):
|
|
var instance: FacilityInstance = _instances[instance_id]
|
|
instance.apply_pending_policy(current_minute)
|
|
var old_state := instance.state
|
|
instance.evaluate_state(current_minute)
|
|
_emit_state_change(instance, old_state)
|
|
var active_ids := _sorted_keys(instance.active_services)
|
|
for service_id: StringName in active_ids:
|
|
if not instance.active_services.has(service_id):
|
|
continue
|
|
var service: ServiceInstance = instance.active_services[service_id]
|
|
var preparation := _prepare_service_for_tick(instance, service)
|
|
if preparation != null:
|
|
results.append(preparation)
|
|
if not instance.active_services.has(service_id):
|
|
continue
|
|
if service.state != ServiceInstanceType.State.ACTIVE:
|
|
continue
|
|
var ready_to_complete := service.elapsed_minutes >= service.duration_minutes
|
|
if not ready_to_complete:
|
|
ready_to_complete = service.advance_one_minute()
|
|
if ready_to_complete:
|
|
results.append(_complete_service(instance, service))
|
|
var starts := fill_available_slots(instance_id, current_minute)
|
|
results.append_array(starts)
|
|
last_error = ""
|
|
return results
|
|
|
|
|
|
func interrupt_service(
|
|
service_id: StringName,
|
|
reason: StringName,
|
|
absolute_minute: int = -1
|
|
) -> CommandResult:
|
|
var located := _find_active_service(service_id)
|
|
if located.is_empty():
|
|
return _not_found("Active service", service_id)
|
|
var instance: FacilityInstance = located[&"instance"]
|
|
var service: ServiceInstance = located[&"service"]
|
|
var minute := _resolve_minute(absolute_minute)
|
|
match reason:
|
|
INTERRUPTION_PLAYER_CLOSED:
|
|
return _success({&"service_id": service_id, &"continues": true})
|
|
INTERRUPTION_NO_POWER, INTERRUPTION_MANA_STOP:
|
|
service.pause(INTERRUPTION_NO_POWER, minute)
|
|
INTERRUPTION_STAFF_UNAVAILABLE:
|
|
service.wait_for_staff(reason, minute, STAFF_REPLACEMENT_GRACE_MINUTES)
|
|
INTERRUPTION_BROKEN, INTERRUPTION_EMERGENCY, INTERRUPTION_FIRE, INTERRUPTION_RIOT, INTERRUPTION_GUEST_LEFT:
|
|
_fail_service(instance, service, reason, minute)
|
|
return _success({&"service_id": service_id, &"failed": true, &"reason": reason})
|
|
_:
|
|
return _invalid("Unknown service interruption reason.", {&"reason": reason})
|
|
service_interrupted.emit(service, reason)
|
|
last_error = ""
|
|
return _success({
|
|
&"service_id": service_id,
|
|
&"state": ServiceInstanceType.State.keys()[service.state],
|
|
&"reason": reason,
|
|
})
|
|
|
|
|
|
func resume_service(service_id: StringName) -> CommandResult:
|
|
var located := _find_active_service(service_id)
|
|
if located.is_empty():
|
|
return _not_found("Active service", service_id)
|
|
var service: ServiceInstance = located[&"service"]
|
|
if not service.resume():
|
|
return _invalid("Only an interrupted service can resume.")
|
|
last_error = ""
|
|
return _success({&"service_id": service_id})
|
|
|
|
|
|
func replace_service_staff(service_id: StringName, staff_ids: Array[StringName]) -> CommandResult:
|
|
var located := _find_active_service(service_id)
|
|
if located.is_empty():
|
|
return _not_found("Active service", service_id)
|
|
if staff_ids.is_empty():
|
|
return _invalid("Replacement staff list cannot be empty.")
|
|
var service: ServiceInstance = located[&"service"]
|
|
service.staff_ids.assign(staff_ids)
|
|
if service.state == ServiceInstanceType.State.WAITING_FOR_STAFF:
|
|
service.resume()
|
|
last_error = ""
|
|
return _success({&"service_id": service_id})
|
|
|
|
|
|
func get_service(service_id: StringName) -> ServiceInstance:
|
|
var located := _find_active_service(service_id)
|
|
if not located.is_empty():
|
|
return located[&"service"] as ServiceInstance
|
|
return _service_history.get(service_id) as ServiceInstance
|
|
|
|
|
|
func get_active_services(instance_id: StringName) -> Array[ServiceInstance]:
|
|
var services: Array[ServiceInstance] = []
|
|
var instance := get_instance(instance_id)
|
|
if instance == null:
|
|
return services
|
|
for service_id: StringName in _sorted_keys(instance.active_services):
|
|
services.append(instance.active_services[service_id])
|
|
return services
|
|
|
|
|
|
func create_snapshot() -> Dictionary:
|
|
var saved_instances: Array[Dictionary] = []
|
|
for instance_id: StringName in _sorted_keys(_instances):
|
|
var instance: FacilityInstance = _instances[instance_id]
|
|
saved_instances.append(instance.create_snapshot())
|
|
var saved_requests: Array[Dictionary] = []
|
|
for request_id: StringName in _sorted_keys(_requests):
|
|
var request: ServiceRequest = _requests[request_id]
|
|
saved_requests.append(request.create_snapshot())
|
|
var saved_history: Array[Dictionary] = []
|
|
for service_id: StringName in _sorted_keys(_service_history):
|
|
var service: ServiceInstance = _service_history[service_id]
|
|
saved_history.append(service.create_snapshot())
|
|
return {
|
|
&"schema_version": SNAPSHOT_VERSION,
|
|
&"current_minute": current_minute,
|
|
&"next_service_sequence": _next_service_sequence,
|
|
&"known_instance_ids": _keys_to_strings(_known_instance_ids),
|
|
&"known_request_ids": _keys_to_strings(_known_request_ids),
|
|
&"known_service_ids": _keys_to_strings(_known_service_ids),
|
|
&"instances": saved_instances,
|
|
&"requests": saved_requests,
|
|
&"service_history": saved_history,
|
|
}
|
|
|
|
|
|
func restore_snapshot(snapshot: Dictionary) -> bool:
|
|
if int(_value(snapshot, &"schema_version", 0)) != SNAPSHOT_VERSION:
|
|
last_error = "Unsupported FacilityService snapshot version."
|
|
return false
|
|
var array_fields := [
|
|
&"known_instance_ids", &"known_request_ids", &"known_service_ids",
|
|
&"instances", &"requests", &"service_history",
|
|
]
|
|
for field: StringName in array_fields:
|
|
if not _value(snapshot, field, null) is Array:
|
|
last_error = "FacilityService snapshot field '%s' must be an Array." % field
|
|
return false
|
|
|
|
var restored_instances: Dictionary = {}
|
|
for saved_instance: Variant in _value(snapshot, &"instances", []):
|
|
if not saved_instance is Dictionary:
|
|
last_error = "Facility snapshot entry must be a Dictionary."
|
|
return false
|
|
var instance := FacilityInstanceType.from_snapshot(saved_instance)
|
|
if instance == null or restored_instances.has(instance.instance_id):
|
|
last_error = "Facility snapshot contains an invalid or duplicate instance."
|
|
return false
|
|
if not _saved_policy_is_valid(instance.policy_current) or not _saved_policy_is_valid(instance.policy_pending):
|
|
return false
|
|
for active: ServiceInstance in instance.active_services.values():
|
|
if not _saved_policy_is_valid(active.policy_snapshot):
|
|
return false
|
|
restored_instances[instance.instance_id] = instance
|
|
|
|
var restored_requests: Dictionary = {}
|
|
for saved_request: Variant in _value(snapshot, &"requests", []):
|
|
if not saved_request is Dictionary:
|
|
last_error = "Request snapshot entry must be a Dictionary."
|
|
return false
|
|
var request := ServiceRequestType.from_snapshot(saved_request)
|
|
if request == null or restored_requests.has(request.request_id):
|
|
last_error = "Facility snapshot contains an invalid or duplicate request."
|
|
return false
|
|
if not _saved_policy_is_valid(request.policy_snapshot):
|
|
return false
|
|
restored_requests[request.request_id] = request
|
|
|
|
var restored_history: Dictionary = {}
|
|
for saved_service: Variant in _value(snapshot, &"service_history", []):
|
|
if not saved_service is Dictionary:
|
|
last_error = "Service history entry must be a Dictionary."
|
|
return false
|
|
var service := ServiceInstanceType.from_snapshot(saved_service)
|
|
if service == null or not service.is_terminal() or restored_history.has(service.service_id):
|
|
last_error = "Facility snapshot contains invalid service history."
|
|
return false
|
|
if not _saved_policy_is_valid(service.policy_snapshot):
|
|
return false
|
|
restored_history[service.service_id] = service
|
|
|
|
var restored_known_instances: Variant = _id_set(_value(snapshot, &"known_instance_ids", []))
|
|
var restored_known_requests: Variant = _id_set(_value(snapshot, &"known_request_ids", []))
|
|
var restored_known_services: Variant = _id_set(_value(snapshot, &"known_service_ids", []))
|
|
if restored_known_instances == null or restored_known_requests == null or restored_known_services == null:
|
|
last_error = "Known ID lists contain an empty or duplicate ID."
|
|
return false
|
|
if not _validate_restored_graph(
|
|
restored_instances,
|
|
restored_requests,
|
|
restored_history,
|
|
restored_known_instances,
|
|
restored_known_requests,
|
|
restored_known_services
|
|
):
|
|
return false
|
|
|
|
var restored_next_sequence := int(_value(snapshot, &"next_service_sequence", 0))
|
|
var restored_minute := int(_value(snapshot, &"current_minute", -1))
|
|
if restored_next_sequence < 1 or restored_minute < 0:
|
|
last_error = "FacilityService counters are invalid."
|
|
return false
|
|
|
|
# Commit only after every object and cross-reference has validated.
|
|
_instances = restored_instances
|
|
_requests = restored_requests
|
|
_service_history = restored_history
|
|
_known_instance_ids = restored_known_instances
|
|
_known_request_ids = restored_known_requests
|
|
_known_service_ids = restored_known_services
|
|
_next_service_sequence = restored_next_sequence
|
|
current_minute = restored_minute
|
|
for instance: FacilityInstance in _instances.values():
|
|
instance.related_facility_operating = _required_related_placement_matches(instance) and _restored_dependency_operates(instance.related_facility_id, {})
|
|
instance.operation_state = FacilityInstanceType.OperationState.SUSPENDED if not instance.suspension_reason.is_empty() else (FacilityInstanceType.OperationState.OPERATING if instance.state in [FacilityInstanceType.State.OPEN, FacilityInstanceType.State.DEGRADED] and instance.related_facility_operating else FacilityInstanceType.OperationState.INACTIVE)
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func _restored_dependency_operates(id: StringName, seen: Dictionary) -> bool:
|
|
if id.is_empty():
|
|
return true
|
|
if seen.has(id) or not has_instance(id):
|
|
return false
|
|
seen[id] = true
|
|
var instance := get_instance(id)
|
|
return _required_related_placement_matches(instance) and instance.suspension_reason.is_empty() and instance.state in [FacilityInstanceType.State.OPEN, FacilityInstanceType.State.DEGRADED] and _restored_dependency_operates(instance.related_facility_id, seen)
|
|
|
|
|
|
func _saved_policy_is_valid(policy: Dictionary) -> bool:
|
|
var validation := FacilityPricingType.validate_patch(policy)
|
|
if not bool(validation[&"valid"]):
|
|
last_error = "Facility snapshot contains an invalid policy: %s" % validation[&"reason"]
|
|
return false
|
|
return true
|
|
|
|
|
|
func _validate_restored_graph(
|
|
instances: Dictionary,
|
|
requests: Dictionary,
|
|
history: Dictionary,
|
|
known_instances: Dictionary,
|
|
known_requests: Dictionary,
|
|
known_services: Dictionary
|
|
) -> bool:
|
|
var referenced_requests: Dictionary = {}
|
|
var active_service_ids: Dictionary = {}
|
|
for instance_id: StringName in _sorted_keys(instances):
|
|
var instance: FacilityInstance = instances[instance_id]
|
|
var dependency_seen: Dictionary = {instance_id: true}
|
|
var dependency := instance.related_facility_id
|
|
while not dependency.is_empty() and instances.has(dependency):
|
|
if dependency_seen.has(dependency):
|
|
last_error = "Related facility cycle in snapshot."
|
|
return false
|
|
dependency_seen[dependency] = true
|
|
dependency = (instances[dependency] as FacilityInstance).related_facility_id
|
|
if not known_instances.has(instance_id):
|
|
last_error = "Active facility ID is absent from known IDs."
|
|
return false
|
|
for request_id: StringName in instance.queue_entries:
|
|
if referenced_requests.has(request_id) or not requests.has(request_id):
|
|
last_error = "Queue contains a duplicate or missing request."
|
|
return false
|
|
var request: ServiceRequest = requests[request_id]
|
|
if request.facility_id != instance_id:
|
|
last_error = "Queued request targets another facility."
|
|
return false
|
|
referenced_requests[request_id] = true
|
|
for service_id: StringName in _sorted_keys(instance.active_services):
|
|
var service: ServiceInstance = instance.active_services[service_id]
|
|
if active_service_ids.has(service_id) or history.has(service_id):
|
|
last_error = "Service ID occurs more than once."
|
|
return false
|
|
if not known_services.has(service_id) or not requests.has(service.request_id):
|
|
last_error = "Active service has a missing known ID or request."
|
|
return false
|
|
if referenced_requests.has(service.request_id):
|
|
last_error = "Request is referenced by multiple queue/service entries."
|
|
return false
|
|
var request: ServiceRequest = requests[service.request_id]
|
|
if request.facility_id != instance_id:
|
|
last_error = "Active service request targets another facility."
|
|
return false
|
|
referenced_requests[service.request_id] = true
|
|
active_service_ids[service_id] = true
|
|
for request_id: StringName in _sorted_keys(requests):
|
|
if not known_requests.has(request_id) or not referenced_requests.has(request_id):
|
|
last_error = "Live request is orphaned or absent from known IDs."
|
|
return false
|
|
for service_id: StringName in _sorted_keys(history):
|
|
if not known_services.has(service_id):
|
|
last_error = "Historical service is absent from known IDs."
|
|
return false
|
|
return true
|
|
|
|
|
|
func _prepare_service_for_tick(
|
|
instance: FacilityInstance,
|
|
service: ServiceInstance
|
|
) -> CommandResult:
|
|
if instance.state == FacilityInstanceType.State.BROKEN:
|
|
_fail_service(instance, service, INTERRUPTION_BROKEN, current_minute)
|
|
return _success({&"service_id": service.service_id, &"failed": true})
|
|
if not instance.related_facility_operating:
|
|
if service.state == ServiceInstanceType.State.ACTIVE:
|
|
service.pause(&"RELATED_FACILITY_INACTIVE", current_minute)
|
|
service_interrupted.emit(service, &"RELATED_FACILITY_INACTIVE")
|
|
return null
|
|
if service.state == ServiceInstanceType.State.PAUSED and service.interruption_reason == &"RELATED_FACILITY_INACTIVE":
|
|
service.resume()
|
|
if not instance.power_available:
|
|
if service.state == ServiceInstanceType.State.ACTIVE:
|
|
service.pause(INTERRUPTION_NO_POWER, current_minute)
|
|
service_interrupted.emit(service, INTERRUPTION_NO_POWER)
|
|
return null
|
|
if service.state == ServiceInstanceType.State.PAUSED \
|
|
and service.interruption_reason == INTERRUPTION_NO_POWER:
|
|
service.resume()
|
|
var has_required_staff := instance.active_staff_ids.size() >= instance.staff_min
|
|
if not has_required_staff:
|
|
if service.state in [ServiceInstanceType.State.ACTIVE, ServiceInstanceType.State.PAUSED]:
|
|
service.wait_for_staff(
|
|
INTERRUPTION_STAFF_UNAVAILABLE,
|
|
current_minute,
|
|
STAFF_REPLACEMENT_GRACE_MINUTES
|
|
)
|
|
service_interrupted.emit(service, INTERRUPTION_STAFF_UNAVAILABLE)
|
|
elif instance.staff_min > 0 \
|
|
and service.state == ServiceInstanceType.State.WAITING_FOR_STAFF \
|
|
and service.interruption_reason == INTERRUPTION_STAFF_UNAVAILABLE:
|
|
service.resume()
|
|
if service.state == ServiceInstanceType.State.WAITING_FOR_STAFF:
|
|
if service.staff_grace_deadline >= 0 and current_minute >= service.staff_grace_deadline:
|
|
_fail_service(instance, service, INTERRUPTION_STAFF_UNAVAILABLE, current_minute)
|
|
return _success({&"service_id": service.service_id, &"failed": true})
|
|
return null
|
|
|
|
|
|
func _complete_service(
|
|
instance: FacilityInstance,
|
|
service: ServiceInstance,
|
|
callback: Callable = Callable()
|
|
) -> CommandResult:
|
|
var request := get_request(service.request_id)
|
|
if request == null:
|
|
_fail_service(instance, service, &"MISSING_REQUEST", current_minute)
|
|
return _invalid(
|
|
"Active service lost its request and was failed safely.",
|
|
{&"service_id": service.service_id}
|
|
)
|
|
var effective_callback := callback if callback.is_valid() else _completion_callback
|
|
var callback_failure := _run_boundary_callback(
|
|
effective_callback,
|
|
[service, request, instance, current_minute],
|
|
"Service completion transaction failed.",
|
|
CommandResultType.Code.TRANSACTION_APPLY_FAILED
|
|
)
|
|
if callback_failure != null:
|
|
callback_failure.details[&"service_id"] = service.service_id
|
|
callback_failure.details[&"request_id"] = request.request_id
|
|
return callback_failure
|
|
service.mark_completed(current_minute)
|
|
instance.active_services.erase(service.service_id)
|
|
_requests.erase(request.request_id)
|
|
_service_history[service.service_id] = service
|
|
_increment_stat(instance.statistics_today, &"services_completed")
|
|
_increment_stat(instance.lifetime_statistics, &"services_completed")
|
|
service_completed.emit(service)
|
|
return _success({
|
|
&"instance_id": instance.instance_id,
|
|
&"request_id": request.request_id,
|
|
&"service_id": service.service_id,
|
|
&"completed": true,
|
|
})
|
|
|
|
|
|
func _fail_service(
|
|
instance: FacilityInstance,
|
|
service: ServiceInstance,
|
|
reason: StringName,
|
|
absolute_minute: int
|
|
) -> void:
|
|
service.mark_failed(reason, absolute_minute)
|
|
instance.active_services.erase(service.service_id)
|
|
_requests.erase(service.request_id)
|
|
_service_history[service.service_id] = service
|
|
_increment_stat(instance.statistics_today, &"services_failed")
|
|
_increment_stat(instance.lifetime_statistics, &"services_failed")
|
|
service_failed.emit(service, reason)
|
|
|
|
|
|
func _run_boundary_callback(
|
|
callback: Callable,
|
|
arguments: Array,
|
|
fallback_message: String,
|
|
failure_code: CommandResult.Code
|
|
) -> CommandResult:
|
|
if not callback.is_valid():
|
|
return null
|
|
var argument_count := callback.get_argument_count()
|
|
var effective_arguments := arguments
|
|
if argument_count >= 0 and argument_count < arguments.size():
|
|
effective_arguments = arguments.slice(0, argument_count)
|
|
var returned: Variant = callback.callv(effective_arguments)
|
|
if returned == null or (returned is bool and returned):
|
|
return null
|
|
if returned is CommandResult:
|
|
var command_result: CommandResult = returned
|
|
return null if command_result.is_success() else command_result.copy()
|
|
if returned is Dictionary:
|
|
if bool(returned.get(&"ok", returned.get("ok", false))):
|
|
return null
|
|
var dictionary_message := String(
|
|
returned.get(&"message", returned.get("message", fallback_message))
|
|
)
|
|
return CommandResultType.failed(failure_code, 0, dictionary_message, returned)
|
|
var message := String(returned) if returned is String and not returned.is_empty() else fallback_message
|
|
return CommandResultType.failed(failure_code, 0, message)
|
|
|
|
|
|
func _estimate_wait_before(instance: FacilityInstance, target_request_id: StringName) -> int:
|
|
var outstanding_minutes := 0
|
|
for service_id: StringName in _sorted_keys(instance.active_services):
|
|
var service: ServiceInstance = instance.active_services[service_id]
|
|
outstanding_minutes += service.remaining_minutes()
|
|
for request_id: StringName in instance.queue_entries:
|
|
if request_id == target_request_id:
|
|
break
|
|
var request := get_request(request_id)
|
|
if request != null:
|
|
outstanding_minutes += request.service_minutes
|
|
return ceili(float(outstanding_minutes) / float(maxi(instance.capacity, 1)))
|
|
|
|
|
|
func _cleanup_missing_queue_requests(instance: FacilityInstance) -> void:
|
|
var valid_ids: Array[StringName] = []
|
|
for request_id: StringName in instance.queue_entries:
|
|
if _requests.has(request_id):
|
|
valid_ids.append(request_id)
|
|
else:
|
|
push_warning(
|
|
"Removed corrupt FacilityService queue entry '%s' from '%s'." \
|
|
% [request_id, instance.instance_id]
|
|
)
|
|
instance.queue_entries = valid_ids
|
|
_sort_queue(instance)
|
|
|
|
|
|
func _sort_queue(instance: FacilityInstance) -> void:
|
|
instance.queue_entries.sort_custom(Callable(self, "_queue_entry_less"))
|
|
|
|
|
|
func _queue_entry_less(left_id: StringName, right_id: StringName) -> bool:
|
|
var left := get_request(left_id)
|
|
var right := get_request(right_id)
|
|
if left == null:
|
|
return false
|
|
if right == null:
|
|
return true
|
|
if left.priority != right.priority:
|
|
return left.priority > right.priority
|
|
if left.enqueued_at != right.enqueued_at:
|
|
return left.enqueued_at < right.enqueued_at
|
|
return String(left.request_id) < String(right.request_id)
|
|
|
|
|
|
func _find_active_service(service_id: StringName) -> Dictionary:
|
|
for instance_id: StringName in _sorted_keys(_instances):
|
|
var instance: FacilityInstance = _instances[instance_id]
|
|
if instance.active_services.has(service_id):
|
|
return {
|
|
&"instance": instance,
|
|
&"service": instance.active_services[service_id],
|
|
}
|
|
return {}
|
|
|
|
|
|
func _next_service_id() -> StringName:
|
|
while true:
|
|
var candidate := StringName("service_%08d" % _next_service_sequence)
|
|
_next_service_sequence += 1
|
|
if not _known_service_ids.has(candidate):
|
|
return candidate
|
|
return &""
|
|
|
|
|
|
func _resolve_minute(absolute_minute: int) -> int:
|
|
if absolute_minute >= 0:
|
|
current_minute = maxi(current_minute, absolute_minute)
|
|
return current_minute
|
|
|
|
|
|
func _emit_state_change(instance: FacilityInstance, old_state: int) -> void:
|
|
if old_state != instance.state:
|
|
facility_state_changed.emit(
|
|
instance.instance_id,
|
|
old_state,
|
|
instance.state,
|
|
instance.state_reasons.duplicate()
|
|
)
|
|
|
|
|
|
func _success(details: Dictionary = {}) -> CommandResult:
|
|
return CommandResultType.succeeded(0, details)
|
|
|
|
|
|
func _invalid(message: String, details: Dictionary = {}) -> CommandResult:
|
|
last_error = message
|
|
return CommandResultType.failed(
|
|
CommandResultType.Code.VALIDATION_FAILED,
|
|
0,
|
|
message,
|
|
details
|
|
)
|
|
|
|
|
|
func _not_found(entity_name: String, entity_id: StringName) -> CommandResult:
|
|
var message := "%s '%s' was not found." % [entity_name, entity_id]
|
|
last_error = message
|
|
return CommandResultType.failed(
|
|
CommandResultType.Code.NOT_FOUND,
|
|
0,
|
|
message,
|
|
{&"id": entity_id}
|
|
)
|
|
|
|
|
|
func _duplicate(entity_name: String, entity_id: StringName) -> CommandResult:
|
|
var message := "%s '%s' already exists." % [entity_name, entity_id]
|
|
last_error = message
|
|
return CommandResultType.failed(
|
|
CommandResultType.Code.DUPLICATE_COMMAND_ID,
|
|
0,
|
|
message,
|
|
{&"id": entity_id}
|
|
)
|
|
|
|
|
|
static func _increment_stat(statistics: Dictionary, key: StringName) -> void:
|
|
statistics[key] = int(statistics.get(key, 0)) + 1
|
|
|
|
|
|
static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant:
|
|
return source.get(key, source.get(String(key), fallback))
|
|
|
|
|
|
static func _has_value(source: Dictionary, key: StringName) -> bool:
|
|
return source.has(key) or source.has(String(key))
|
|
|
|
|
|
static func _sorted_keys(source: Dictionary) -> Array[StringName]:
|
|
var values: Array[StringName] = []
|
|
for value: Variant in source.keys():
|
|
values.append(StringName(value))
|
|
values.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
|
return values
|
|
|
|
|
|
static func _keys_to_strings(source: Dictionary) -> Array[String]:
|
|
var values: Array[String] = []
|
|
for key: StringName in _sorted_keys(source):
|
|
values.append(String(key))
|
|
return values
|
|
|
|
|
|
static func _id_set(source: Variant) -> Variant:
|
|
if not source is Array:
|
|
return null
|
|
var ids: Dictionary = {}
|
|
for raw_id: Variant in source:
|
|
var id := StringName(raw_id)
|
|
if id.is_empty() or ids.has(id):
|
|
return null
|
|
ids[id] = true
|
|
return ids
|