Preserve approved pixel tower prototype as development baseline

This commit is contained in:
Codex
2026-09-09 07:51:16 +09:00
commit 423fd6dd2e
915 changed files with 95193 additions and 0 deletions
+240
View File
@@ -0,0 +1,240 @@
class_name ShiftInstance
extends RefCounted
## One concrete shift, identified by its start day even when it ends on the
## following day. Absolute integer minutes avoid every midnight ambiguity.
enum Kind {
DAY,
NIGHT,
FLEX,
}
const MINUTES_PER_DAY := 1_440
const COMMUTE_LEAD_MINUTES := 30
const DAY_START := 360
const DAY_END := 1_080
const NIGHT_START := 1_080
const NIGHT_END := 360
var shift_instance_id: StringName = &""
var staff_id: StringName = &""
var template_id: StringName = &"FLEX"
var kind: Kind = Kind.FLEX
var start_day := 0
var start_minute_of_day := 0
var end_minute_of_day := 1
var start_at := 0
var end_at := 1
var assigned_facility_id: StringName = &""
var cancelled := false
var actual_started_at := -1
var actual_ended_at := -1
var worked_minutes := 0
var overtime_minutes := 0
var late_minutes := 0
var late_reason: StringName = &""
func configure(
p_shift_instance_id: StringName,
p_staff_id: StringName,
p_template_id: StringName,
p_start_day: int,
p_start_minute_of_day: int,
p_end_minute_of_day: int,
p_assigned_facility_id: StringName = &"",
options: Dictionary = {}
) -> ShiftInstance:
shift_instance_id = p_shift_instance_id
staff_id = p_staff_id
template_id = p_template_id if not p_template_id.is_empty() else &"FLEX"
kind = _kind_from_template(template_id)
start_day = maxi(p_start_day, 0)
start_minute_of_day = p_start_minute_of_day
end_minute_of_day = p_end_minute_of_day
start_at = start_day * MINUTES_PER_DAY + start_minute_of_day
end_at = start_day * MINUTES_PER_DAY + end_minute_of_day
if end_minute_of_day <= start_minute_of_day:
end_at += MINUTES_PER_DAY
assigned_facility_id = p_assigned_facility_id
cancelled = bool(_value(options, &"cancelled", false))
actual_started_at = int(_value(options, &"actual_started_at", -1))
actual_ended_at = int(_value(options, &"actual_ended_at", -1))
worked_minutes = maxi(int(_value(options, &"worked_minutes", 0)), 0)
overtime_minutes = maxi(int(_value(options, &"overtime_minutes", 0)), 0)
late_minutes = maxi(int(_value(options, &"late_minutes", 0)), 0)
late_reason = StringName(_value(options, &"late_reason", &""))
return self
static func create_standard(
p_shift_instance_id: StringName,
p_staff_id: StringName,
p_template_id: StringName,
p_start_day: int,
p_assigned_facility_id: StringName = &""
) -> ShiftInstance:
var normalized := String(p_template_id).to_upper()
if normalized == "DAY":
return ShiftInstance.new().configure(
p_shift_instance_id,
p_staff_id,
&"DAY",
p_start_day,
DAY_START,
DAY_END,
p_assigned_facility_id
)
if normalized == "NIGHT":
return ShiftInstance.new().configure(
p_shift_instance_id,
p_staff_id,
&"NIGHT",
p_start_day,
NIGHT_START,
NIGHT_END,
p_assigned_facility_id
)
return null
func is_valid() -> bool:
return (
not shift_instance_id.is_empty()
and not staff_id.is_empty()
and start_day >= 0
and start_minute_of_day >= 0
and start_minute_of_day < MINUTES_PER_DAY
and end_minute_of_day >= 0
and end_minute_of_day < MINUTES_PER_DAY
and end_at > start_at
and end_at - start_at <= MINUTES_PER_DAY
and worked_minutes >= 0
and overtime_minutes >= 0
and late_minutes >= 0
)
func crosses_midnight() -> bool:
return end_at / MINUTES_PER_DAY > start_at / MINUTES_PER_DAY
func contains_minute(absolute_minute: int) -> bool:
return not cancelled and absolute_minute >= start_at and absolute_minute < end_at
func is_commute_window(absolute_minute: int) -> bool:
return (
not cancelled
and absolute_minute >= start_at - COMMUTE_LEAD_MINUTES
and absolute_minute < start_at
)
func has_ended(absolute_minute: int) -> bool:
return cancelled or absolute_minute >= end_at
func record_duty_minute(absolute_minute: int, overtime := false) -> bool:
if actual_started_at < 0:
actual_started_at = absolute_minute
if overtime:
if absolute_minute < end_at:
return false
overtime_minutes += 1
return true
if not contains_minute(absolute_minute):
return false
worked_minutes += 1
return true
func close(absolute_minute: int) -> void:
if actual_ended_at < 0:
actual_ended_at = maxi(absolute_minute, start_at)
func mark_late(absolute_minute: int, reason: StringName) -> bool:
if absolute_minute < start_at + 15:
return false
late_minutes = maxi(absolute_minute - start_at, 15)
late_reason = reason
return true
func create_snapshot() -> Dictionary:
return {
&"shift_instance_id": String(shift_instance_id),
&"staff_id": String(staff_id),
&"template_id": String(template_id),
&"kind": Kind.keys()[kind],
&"start_day": start_day,
&"start_minute_of_day": start_minute_of_day,
&"end_minute_of_day": end_minute_of_day,
&"start_at": start_at,
&"end_at": end_at,
&"assigned_facility_id": String(assigned_facility_id),
&"cancelled": cancelled,
&"actual_started_at": actual_started_at,
&"actual_ended_at": actual_ended_at,
&"worked_minutes": worked_minutes,
&"overtime_minutes": overtime_minutes,
&"late_minutes": late_minutes,
&"late_reason": String(late_reason),
}
static func from_snapshot(snapshot: Dictionary) -> ShiftInstance:
if not _snapshot_shape_is_valid(snapshot):
return null
var shift := ShiftInstance.new().configure(
StringName(snapshot.get(&"shift_instance_id", "")),
StringName(snapshot.get(&"staff_id", "")),
StringName(snapshot.get(&"template_id", "FLEX")),
int(snapshot.get(&"start_day", 0)),
int(snapshot.get(&"start_minute_of_day", 0)),
int(snapshot.get(&"end_minute_of_day", 1)),
StringName(snapshot.get(&"assigned_facility_id", "")),
{
&"cancelled": snapshot.get(&"cancelled", false),
&"actual_started_at": snapshot.get(&"actual_started_at", -1),
&"actual_ended_at": snapshot.get(&"actual_ended_at", -1),
&"worked_minutes": snapshot.get(&"worked_minutes", 0),
&"overtime_minutes": snapshot.get(&"overtime_minutes", 0),
&"late_minutes": snapshot.get(&"late_minutes", 0),
&"late_reason": snapshot.get(&"late_reason", ""),
}
)
if shift.start_at != int(snapshot.get(&"start_at", -1)):
return null
if shift.end_at != int(snapshot.get(&"end_at", -1)):
return null
var kind_index := Kind.keys().find(String(snapshot.get(&"kind", "")))
if kind_index < 0 or shift.kind != kind_index:
return null
return shift if shift.is_valid() else null
static func _snapshot_shape_is_valid(snapshot: Dictionary) -> bool:
return (
not String(snapshot.get(&"shift_instance_id", "")).is_empty()
and not String(snapshot.get(&"staff_id", "")).is_empty()
and int(snapshot.get(&"start_day", -1)) >= 0
and int(snapshot.get(&"worked_minutes", -1)) >= 0
and int(snapshot.get(&"overtime_minutes", -1)) >= 0
)
static func _kind_from_template(value: StringName) -> Kind:
match String(value).to_upper():
"DAY":
return Kind.DAY
"NIGHT":
return Kind.NIGHT
_:
return Kind.FLEX
static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant:
return source.get(key, source.get(String(key), fallback))
+1
View File
@@ -0,0 +1 @@
uid://0blu3y4pvj30
+493
View File
@@ -0,0 +1,493 @@
class_name StaffRuntime
extends RefCounted
## Mutable state for one employee. Static role definitions remain outside this
## object; the copied tags make dispatch deterministic even if content is hot
## reloaded later.
enum State {
OFF_DUTY,
COMMUTING,
AVAILABLE,
MOVING_TO_TASK,
WORKING,
BREAKING,
EATING,
SLEEPING,
SEEKING_MEDICAL,
FLEEING,
INCAPACITATED,
STRIKE,
DETAINED,
CAPTIVE,
UNAVAILABLE,
}
## EmploymentKind describes the commercial relationship only. Adult-content
## eligibility is deliberately validated by MvpContentCatalog before a hire is
## constructed; ordinary Staff fixtures therefore remain valid FREE_HIRE rows.
enum EmploymentKind {
FREE_HIRE,
ADULT_CONTRACT,
UNIQUE,
}
const MAX_PSYCHOLOGY_VALUE := 100
const MAX_SKILL_VALUE := 999
const DANGEROUS_TASK_FATIGUE_LIMIT := 90
const SNAPSHOT_VERSION := 1
var staff_id: StringName = &""
var role_id: StringName = &""
var current_cell := Vector3i.ZERO
var assigned_facility_id: StringName = &""
var shift_id: StringName = &""
var state: State = State.OFF_DUTY
var current_task_id: StringName = &""
var skills: Dictionary = {}
var fatigue := 0
var stress := 0
var morale := 100
var loyalty := 50
var health := 100
var carried_inventory_id: StringName = &""
var path_handle: StringName = &""
var absence_reason: StringName = &""
var permitted_task_tags: Array[StringName] = []
var role_tags: Array[StringName] = []
var legal_permissions: Array[StringName] = []
var equipment_tags: Array[StringName] = []
var required_equipment_tags: Array[StringName] = []
var daily_wage := 0
var employment_kind: EmploymentKind = EmploymentKind.FREE_HIRE
var employment_effective_minute := 0
var unpaid_wages := 0
var is_retiring := false
var is_overtime := false
var state_changed_at := 0
var state_reason: StringName = &""
var _employment_configuration_valid := true
func configure(
p_staff_id: StringName,
p_role_id: StringName,
options: Dictionary = {}
) -> StaffRuntime:
staff_id = p_staff_id
role_id = p_role_id
current_cell = _vector3i(_value(options, &"current_cell", Vector3i.ZERO))
assigned_facility_id = StringName(_value(options, &"assigned_facility_id", &""))
shift_id = StringName(_value(options, &"shift_id", &""))
state = _state_from_value(_value(options, &"state", State.OFF_DUTY), State.OFF_DUTY)
current_task_id = StringName(_value(options, &"current_task_id", &""))
skills = _normalize_skills(_value(options, &"skills", {}))
fatigue = clampi(int(_value(options, &"fatigue", 0)), 0, MAX_PSYCHOLOGY_VALUE)
stress = clampi(int(_value(options, &"stress", 0)), 0, MAX_PSYCHOLOGY_VALUE)
morale = clampi(int(_value(options, &"morale", 100)), 0, MAX_PSYCHOLOGY_VALUE)
loyalty = clampi(int(_value(options, &"loyalty", 50)), 0, MAX_PSYCHOLOGY_VALUE)
health = clampi(int(_value(options, &"health", 100)), 0, MAX_PSYCHOLOGY_VALUE)
carried_inventory_id = StringName(_value(options, &"carried_inventory_id", &""))
path_handle = StringName(_value(options, &"path_handle", &""))
absence_reason = StringName(_value(options, &"absence_reason", &""))
permitted_task_tags = _sorted_unique_names(_value(options, &"permitted_task_tags", []))
role_tags = _sorted_unique_names(_value(options, &"role_tags", []))
if not role_id.is_empty() and not role_tags.has(role_id):
role_tags.append(role_id)
role_tags.sort_custom(_name_less)
legal_permissions = _sorted_unique_names(_value(options, &"legal_permissions", []))
equipment_tags = _sorted_unique_names(_value(options, &"equipment_tags", []))
required_equipment_tags = _sorted_unique_names(_value(options, &"required_equipment_tags", []))
_employment_configuration_valid = true
var raw_daily_wage: Variant = _value(options, &"daily_wage", 0)
if typeof(raw_daily_wage) != TYPE_INT or int(raw_daily_wage) < 0:
_employment_configuration_valid = false
daily_wage = 0
else:
daily_wage = int(raw_daily_wage)
var parsed_employment := _parse_employment_kind(
_value(options, &"employment_kind", EmploymentKind.FREE_HIRE)
)
if not bool(parsed_employment[&"ok"]):
_employment_configuration_valid = false
employment_kind = EmploymentKind.FREE_HIRE
else:
employment_kind = int(parsed_employment[&"value"]) as EmploymentKind
var raw_employment_effective_minute: Variant = _value(
options, &"employment_effective_minute", 0
)
if (
typeof(raw_employment_effective_minute) != TYPE_INT
or int(raw_employment_effective_minute) < 0
):
_employment_configuration_valid = false
employment_effective_minute = 0
else:
employment_effective_minute = int(raw_employment_effective_minute)
unpaid_wages = maxi(int(_value(options, &"unpaid_wages", 0)), 0)
is_retiring = bool(_value(options, &"is_retiring", false))
is_overtime = bool(_value(options, &"is_overtime", false))
state_changed_at = maxi(int(_value(options, &"state_changed_at", 0)), 0)
state_reason = StringName(_value(options, &"state_reason", &""))
if health <= 0:
state = State.INCAPACITATED
return self
func is_valid() -> bool:
if staff_id.is_empty() or role_id.is_empty():
return false
if fatigue < 0 or fatigue > MAX_PSYCHOLOGY_VALUE:
return false
if stress < 0 or stress > MAX_PSYCHOLOGY_VALUE:
return false
if morale < 0 or morale > MAX_PSYCHOLOGY_VALUE:
return false
if loyalty < 0 or loyalty > MAX_PSYCHOLOGY_VALUE:
return false
if health < 0 or health > MAX_PSYCHOLOGY_VALUE:
return false
if unpaid_wages < 0:
return false
if not _employment_configuration_valid or daily_wage < 0:
return false
if employment_effective_minute < 0:
return false
if int(employment_kind) < 0 or int(employment_kind) >= EmploymentKind.size():
return false
for raw_skill_id: Variant in skills:
var skill_id := StringName(raw_skill_id)
var skill_value := int(skills[raw_skill_id])
if skill_id.is_empty() or skill_value < 0 or skill_value > MAX_SKILL_VALUE:
return false
return true
func can_take_shift() -> bool:
return health > 0 and not is_retiring and state not in [
State.INCAPACITATED,
State.STRIKE,
State.DETAINED,
State.CAPTIVE,
State.UNAVAILABLE,
]
func has_required_equipment() -> bool:
for equipment_id: StringName in required_equipment_tags:
if not equipment_tags.has(equipment_id):
return false
return true
func is_available_for_dispatch() -> bool:
return can_take_shift() and has_required_equipment() and state == State.AVAILABLE and current_task_id.is_empty()
func meets_task_requirements(task: Variant) -> bool:
if task == null or not can_take_shift() or not has_required_equipment():
return false
if bool(task.dangerous) and fatigue >= DANGEROUS_TASK_FATIGUE_LIMIT:
return false
for required_tag: StringName in task.required_role_tags:
if not role_tags.has(required_tag):
return false
for task_tag: StringName in task.task_tags:
if not permitted_task_tags.is_empty() and not permitted_task_tags.has(task_tag):
return false
for raw_skill_id: Variant in task.required_skill_min:
var skill_id := StringName(raw_skill_id)
if int(skills.get(skill_id, 0)) < int(task.required_skill_min[raw_skill_id]):
return false
return true
func role_fit_for(task: Variant) -> int:
if task == null:
return 0
if task.required_role_tags.is_empty():
return 0
for required_tag: StringName in task.required_role_tags:
if not role_tags.has(required_tag):
return 0
return 1
func skill_fit_for(task: Variant) -> int:
if task == null or task.required_skill_min.is_empty():
return 0
var total := 0
for raw_skill_id: Variant in task.required_skill_min:
var skill_id := StringName(raw_skill_id)
total += int(skills.get(skill_id, 0)) - int(task.required_skill_min[raw_skill_id])
return maxi(total, 0)
func reserve_task(task_id: StringName, absolute_minute: int) -> bool:
if task_id.is_empty() or not current_task_id.is_empty():
return false
if state != State.AVAILABLE:
return false
current_task_id = task_id
transition_to(State.MOVING_TO_TASK, &"TASK_RESERVED", absolute_minute)
return true
func begin_task(absolute_minute: int) -> bool:
if current_task_id.is_empty() or state not in [State.MOVING_TO_TASK, State.AVAILABLE]:
return false
transition_to(State.WORKING, &"TASK_STARTED", absolute_minute)
return true
func release_task(absolute_minute: int, reason: StringName = &"TASK_RELEASED") -> bool:
if current_task_id.is_empty():
return false
current_task_id = &""
if can_take_shift():
transition_to(State.AVAILABLE, reason, absolute_minute)
else:
transition_to(State.OFF_DUTY, reason, absolute_minute)
return true
func transition_to(next_state: State, reason: StringName, absolute_minute: int) -> void:
state = next_state
state_reason = reason
state_changed_at = maxi(absolute_minute, 0)
func add_fatigue(amount: int) -> int:
fatigue = clampi(fatigue + amount, 0, MAX_PSYCHOLOGY_VALUE)
return fatigue
func add_stress(amount: int) -> int:
stress = clampi(stress + amount, 0, MAX_PSYCHOLOGY_VALUE)
return stress
func get_employment_kind_name() -> StringName:
return StringName(EmploymentKind.keys()[employment_kind])
func create_snapshot() -> Dictionary:
return {
&"snapshot_version": SNAPSHOT_VERSION,
&"staff_id": String(staff_id),
&"role_id": String(role_id),
&"current_cell": [current_cell.x, current_cell.y, current_cell.z],
&"assigned_facility_id": String(assigned_facility_id),
&"shift_id": String(shift_id),
&"state": State.keys()[state],
&"current_task_id": String(current_task_id),
&"skills": _serialize_skills(skills),
&"fatigue": fatigue,
&"stress": stress,
&"morale": morale,
&"loyalty": loyalty,
&"health": health,
&"carried_inventory_id": String(carried_inventory_id),
&"path_handle": String(path_handle),
&"absence_reason": String(absence_reason),
&"permitted_task_tags": _names_to_strings(permitted_task_tags),
&"role_tags": _names_to_strings(role_tags),
&"legal_permissions": _names_to_strings(legal_permissions),
&"equipment_tags": _names_to_strings(equipment_tags),
&"required_equipment_tags": _names_to_strings(required_equipment_tags),
&"daily_wage": daily_wage,
&"employment_kind": String(get_employment_kind_name()),
&"employment_effective_minute": employment_effective_minute,
&"unpaid_wages": unpaid_wages,
&"is_retiring": is_retiring,
&"is_overtime": is_overtime,
&"state_changed_at": state_changed_at,
&"state_reason": String(state_reason),
}
static func from_snapshot(snapshot: Dictionary) -> StaffRuntime:
if not _snapshot_shape_is_valid(snapshot):
return null
var state_index := State.keys().find(String(snapshot.get(&"state", "")))
if state_index < 0:
return null
var staff := StaffRuntime.new().configure(
StringName(snapshot.get(&"staff_id", "")),
StringName(snapshot.get(&"role_id", "")),
{
&"current_cell": snapshot.get(&"current_cell", []),
&"assigned_facility_id": snapshot.get(&"assigned_facility_id", ""),
&"shift_id": snapshot.get(&"shift_id", ""),
&"state": state_index,
&"current_task_id": snapshot.get(&"current_task_id", ""),
&"skills": snapshot.get(&"skills", {}),
&"fatigue": snapshot.get(&"fatigue", 0),
&"stress": snapshot.get(&"stress", 0),
&"morale": snapshot.get(&"morale", 100),
&"loyalty": snapshot.get(&"loyalty", 50),
&"health": snapshot.get(&"health", 100),
&"carried_inventory_id": snapshot.get(&"carried_inventory_id", ""),
&"path_handle": snapshot.get(&"path_handle", ""),
&"absence_reason": snapshot.get(&"absence_reason", ""),
&"permitted_task_tags": snapshot.get(&"permitted_task_tags", []),
&"role_tags": snapshot.get(&"role_tags", []),
&"legal_permissions": snapshot.get(&"legal_permissions", []),
&"equipment_tags": snapshot.get(&"equipment_tags", []),
&"required_equipment_tags": snapshot.get(&"required_equipment_tags", []),
# Snapshots written before payroll support omitted both fields. Their
# deterministic migration is a zero-wage ordinary hire.
&"daily_wage": snapshot.get(&"daily_wage", 0),
&"employment_kind": snapshot.get(&"employment_kind", "FREE_HIRE"),
# Rows written before the employment-time payroll gate represent Staff
# who already existed at simulation minute zero.
&"employment_effective_minute": snapshot.get(&"employment_effective_minute", 0),
&"unpaid_wages": snapshot.get(&"unpaid_wages", 0),
&"is_retiring": snapshot.get(&"is_retiring", false),
&"is_overtime": snapshot.get(&"is_overtime", false),
&"state_changed_at": snapshot.get(&"state_changed_at", 0),
&"state_reason": snapshot.get(&"state_reason", ""),
}
)
return staff if staff.is_valid() else null
static func _snapshot_shape_is_valid(snapshot: Dictionary) -> bool:
var has_snapshot_version := snapshot.has(&"snapshot_version") or snapshot.has("snapshot_version")
var has_effective_minute := (
snapshot.has(&"employment_effective_minute")
or snapshot.has("employment_effective_minute")
)
if has_snapshot_version != has_effective_minute:
return false
if has_snapshot_version:
var raw_snapshot_version: Variant = snapshot.get(
&"snapshot_version", snapshot.get("snapshot_version", null)
)
if typeof(raw_snapshot_version) != TYPE_INT or int(raw_snapshot_version) != SNAPSHOT_VERSION:
return false
# Versioned rows are current-schema data; omission is corruption rather
# than permission to silently reinterpret the employee as a legacy hire.
if not has_effective_minute:
return false
if has_effective_minute:
var effective_minute: Variant = snapshot.get(
&"employment_effective_minute",
snapshot.get("employment_effective_minute", null)
)
if typeof(effective_minute) != TYPE_INT or int(effective_minute) < 0:
return false
var unpaid: Variant = snapshot.get(&"unpaid_wages", snapshot.get("unpaid_wages", null))
if typeof(unpaid) != TYPE_INT or int(unpaid) < 0:
return false
if snapshot.has(&"daily_wage") or snapshot.has("daily_wage"):
var wage: Variant = snapshot.get(&"daily_wage", snapshot.get("daily_wage"))
if typeof(wage) != TYPE_INT or int(wage) < 0:
return false
if snapshot.has(&"employment_kind") or snapshot.has("employment_kind"):
var kind: Variant = snapshot.get(
&"employment_kind", snapshot.get("employment_kind")
)
# The serialized contract is deliberately narrower than configure(): a
# current Save always writes one canonical enum name.
if typeof(kind) not in [TYPE_STRING, TYPE_STRING_NAME]:
return false
if not bool(_parse_employment_kind(kind)[&"ok"]):
return false
return (
not String(snapshot.get(&"staff_id", "")).is_empty()
and not String(snapshot.get(&"role_id", "")).is_empty()
and snapshot.get(&"current_cell", null) is Array
and snapshot.get(&"skills", null) is Dictionary
and snapshot.get(&"permitted_task_tags", null) is Array
and snapshot.get(&"role_tags", null) is Array
and snapshot.get(&"legal_permissions", null) is Array
and snapshot.get(&"equipment_tags", null) is Array
and snapshot.get(&"required_equipment_tags", null) is Array
and int(snapshot.get(&"fatigue", -1)) in range(0, MAX_PSYCHOLOGY_VALUE + 1)
and int(snapshot.get(&"health", -1)) in range(0, MAX_PSYCHOLOGY_VALUE + 1)
)
static func _parse_employment_kind(value: Variant) -> Dictionary:
if typeof(value) == TYPE_INT:
var numeric := int(value)
return {
&"ok": numeric >= 0 and numeric < EmploymentKind.size(),
&"value": numeric,
}
if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]:
return {&"ok": false, &"value": EmploymentKind.FREE_HIRE}
var index := EmploymentKind.keys().find(String(value))
return {
&"ok": index >= 0,
&"value": index if index >= 0 else EmploymentKind.FREE_HIRE,
}
static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant:
return source.get(key, source.get(String(key), fallback))
static func _state_from_value(value: Variant, fallback: State) -> State:
if value is int:
var numeric := int(value)
if numeric >= 0 and numeric < State.size():
return numeric as State
var index := State.keys().find(String(value))
return index as State if index >= 0 else fallback
static func _vector3i(value: Variant) -> Vector3i:
if value is Vector3i:
return value
if value is Array and value.size() == 3:
return Vector3i(int(value[0]), int(value[1]), int(value[2]))
return Vector3i.ZERO
static func _normalize_skills(source: Variant) -> Dictionary:
var result: Dictionary = {}
if not source is Dictionary:
return result
var keys: Array = source.keys()
keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b))
for raw_skill_id: Variant in keys:
var skill_id := StringName(raw_skill_id)
if not skill_id.is_empty():
result[skill_id] = clampi(int(source[raw_skill_id]), 0, MAX_SKILL_VALUE)
return result
static func _serialize_skills(source: Dictionary) -> Dictionary:
var result: Dictionary = {}
var keys: Array = source.keys()
keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b))
for raw_skill_id: Variant in keys:
result[String(raw_skill_id)] = int(source[raw_skill_id])
return result
static func _sorted_unique_names(source: Variant) -> Array[StringName]:
var result: Array[StringName] = []
if not source is Array:
return result
for value: Variant in source:
var name := StringName(value)
if not name.is_empty() and not result.has(name):
result.append(name)
result.sort_custom(_name_less)
return result
static func _names_to_strings(source: Array[StringName]) -> Array[String]:
var result: Array[String] = []
for value: StringName in source:
result.append(String(value))
return result
static func _name_less(a: StringName, b: StringName) -> bool:
return String(a) < String(b)
+1
View File
@@ -0,0 +1 @@
uid://d4d740ytn1ngs
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://sox5srfohstf
+361
View File
@@ -0,0 +1,361 @@
class_name StaffTask
extends RefCounted
enum State {
OPEN,
RESERVED,
MOVING,
WORKING,
COMPLETED,
BLOCKED,
CANCELLED,
FAILED,
}
const MAX_PRIORITY := 100
const MAX_INTERRUPT_LEVEL := 3
var task_id: StringName = &""
var task_type: StringName = &""
var priority := 0
var created_at := 0
var deadline_at := -1
var target_entity_id: StringName = &""
var target_cell := Vector3i.ZERO
var required_role_tags: Array[StringName] = []
var required_skill_min: Dictionary = {}
var required_items: Dictionary = {}
var estimated_minutes := 1
var interrupt_level := 3
var state: State = State.OPEN
var assigned_staff_id: StringName = &""
var retry_count := 0
var task_tags: Array[StringName] = []
var reservation_ids: Array[StringName] = []
var progress_minutes := 0
var dangerous := false
var emergency := false
var preserve_progress_on_interrupt := true
var state_reason: StringName = &""
var state_changed_at := 0
func configure(
p_task_id: StringName,
p_task_type: StringName,
p_priority: int,
p_created_at: int,
options: Dictionary = {}
) -> StaffTask:
task_id = p_task_id
task_type = p_task_type
priority = clampi(p_priority, 0, MAX_PRIORITY)
created_at = maxi(p_created_at, 0)
deadline_at = int(_value(options, &"deadline_at", -1))
target_entity_id = StringName(_value(options, &"target_entity_id", &""))
target_cell = _vector3i(_value(options, &"target_cell", Vector3i.ZERO))
required_role_tags = _sorted_unique_names(_value(options, &"required_role_tags", []))
required_skill_min = _normalize_positive_dictionary(_value(options, &"required_skill_min", {}), 999)
required_items = _normalize_positive_dictionary(_value(options, &"required_items", {}))
estimated_minutes = maxi(int(_value(options, &"estimated_minutes", 1)), 1)
interrupt_level = clampi(int(_value(options, &"interrupt_level", 3)), 0, MAX_INTERRUPT_LEVEL)
state = _state_from_value(_value(options, &"state", State.OPEN), State.OPEN)
assigned_staff_id = StringName(_value(options, &"assigned_staff_id", &""))
retry_count = maxi(int(_value(options, &"retry_count", 0)), 0)
task_tags = _sorted_unique_names(_value(options, &"task_tags", []))
reservation_ids = _sorted_unique_names(_value(options, &"reservation_ids", []))
progress_minutes = clampi(
int(_value(options, &"progress_minutes", 0)),
0,
estimated_minutes
)
dangerous = bool(_value(options, &"dangerous", false))
emergency = bool(_value(options, &"emergency", priority >= 90))
preserve_progress_on_interrupt = bool(_value(options, &"preserve_progress_on_interrupt", true))
state_reason = StringName(_value(options, &"state_reason", &""))
state_changed_at = maxi(int(_value(options, &"state_changed_at", created_at)), 0)
return self
func is_valid() -> bool:
if task_id.is_empty() or task_type.is_empty():
return false
if priority < 0 or priority > MAX_PRIORITY:
return false
if estimated_minutes <= 0 or progress_minutes < 0 or progress_minutes > estimated_minutes:
return false
if interrupt_level < 0 or interrupt_level > MAX_INTERRUPT_LEVEL:
return false
if state in [State.RESERVED, State.MOVING, State.WORKING] and assigned_staff_id.is_empty():
return false
if state in [State.OPEN, State.BLOCKED, State.COMPLETED, State.CANCELLED, State.FAILED] \
and not assigned_staff_id.is_empty():
return false
if state == State.OPEN and not reservation_ids.is_empty():
return false
return true
func is_terminal() -> bool:
return state in [State.COMPLETED, State.CANCELLED, State.FAILED]
func is_dispatchable() -> bool:
return state == State.OPEN and assigned_staff_id.is_empty()
func reserve(
staff_id: StringName,
p_reservation_ids: Array[StringName],
absolute_minute: int
) -> bool:
if not is_dispatchable() or staff_id.is_empty():
return false
if not required_items.is_empty() and p_reservation_ids.is_empty():
return false
assigned_staff_id = staff_id
reservation_ids.assign(p_reservation_ids)
state = State.RESERVED
state_reason = &""
state_changed_at = maxi(absolute_minute, 0)
return true
func begin_moving(absolute_minute: int) -> bool:
if state != State.RESERVED:
return false
state = State.MOVING
state_changed_at = maxi(absolute_minute, 0)
return true
func begin_working(absolute_minute: int) -> bool:
if state not in [State.RESERVED, State.MOVING]:
return false
state = State.WORKING
state_changed_at = maxi(absolute_minute, 0)
return true
func advance_work(minutes: int, absolute_minute: int) -> bool:
if state != State.WORKING or minutes <= 0:
return false
progress_minutes = mini(progress_minutes + minutes, estimated_minutes)
state_changed_at = maxi(absolute_minute, 0)
return progress_minutes >= estimated_minutes
func complete(absolute_minute: int) -> bool:
if state != State.WORKING or progress_minutes < estimated_minutes:
return false
assigned_staff_id = &""
reservation_ids.clear()
state = State.COMPLETED
state_changed_at = maxi(absolute_minute, 0)
return true
func block(reason: StringName, absolute_minute: int) -> bool:
if is_terminal():
return false
retry_count += 1
assigned_staff_id = &""
reservation_ids.clear()
state = State.BLOCKED
state_reason = reason
state_changed_at = maxi(absolute_minute, 0)
return true
func reopen(reason: StringName, absolute_minute: int, preserve_progress := true) -> bool:
if is_terminal():
return false
assigned_staff_id = &""
reservation_ids.clear()
if not preserve_progress:
progress_minutes = 0
state = State.OPEN
state_reason = reason
state_changed_at = maxi(absolute_minute, 0)
return true
func cancel(reason: StringName, absolute_minute: int) -> bool:
if is_terminal():
return false
assigned_staff_id = &""
reservation_ids.clear()
state = State.CANCELLED
state_reason = reason
state_changed_at = maxi(absolute_minute, 0)
return true
func fail(reason: StringName, absolute_minute: int) -> bool:
if is_terminal():
return false
assigned_staff_id = &""
reservation_ids.clear()
state = State.FAILED
state_reason = reason
state_changed_at = maxi(absolute_minute, 0)
return true
func can_be_interrupted_by(candidate: StaffTask) -> bool:
if candidate == null or candidate.task_id == task_id:
return false
match interrupt_level:
0:
return false
1:
return candidate.emergency
2:
return candidate.priority >= priority
_:
return true
func create_snapshot() -> Dictionary:
return {
&"task_id": String(task_id),
&"task_type": String(task_type),
&"priority": priority,
&"created_at": created_at,
&"deadline_at": deadline_at,
&"target_entity_id": String(target_entity_id),
&"target_cell": [target_cell.x, target_cell.y, target_cell.z],
&"required_role_tags": _names_to_strings(required_role_tags),
&"required_skill_min": _serialize_dictionary(required_skill_min),
&"required_items": _serialize_dictionary(required_items),
&"estimated_minutes": estimated_minutes,
&"interrupt_level": interrupt_level,
&"state": State.keys()[state],
&"assigned_staff_id": String(assigned_staff_id),
&"retry_count": retry_count,
&"task_tags": _names_to_strings(task_tags),
&"reservation_ids": _names_to_strings(reservation_ids),
&"progress_minutes": progress_minutes,
&"dangerous": dangerous,
&"emergency": emergency,
&"preserve_progress_on_interrupt": preserve_progress_on_interrupt,
&"state_reason": String(state_reason),
&"state_changed_at": state_changed_at,
}
static func from_snapshot(snapshot: Dictionary) -> StaffTask:
if not _snapshot_shape_is_valid(snapshot):
return null
var state_index := State.keys().find(String(snapshot.get(&"state", "")))
if state_index < 0:
return null
var task := StaffTask.new().configure(
StringName(snapshot.get(&"task_id", "")),
StringName(snapshot.get(&"task_type", "")),
int(snapshot.get(&"priority", 0)),
int(snapshot.get(&"created_at", 0)),
{
&"deadline_at": snapshot.get(&"deadline_at", -1),
&"target_entity_id": snapshot.get(&"target_entity_id", ""),
&"target_cell": snapshot.get(&"target_cell", []),
&"required_role_tags": snapshot.get(&"required_role_tags", []),
&"required_skill_min": snapshot.get(&"required_skill_min", {}),
&"required_items": snapshot.get(&"required_items", {}),
&"estimated_minutes": snapshot.get(&"estimated_minutes", 1),
&"interrupt_level": snapshot.get(&"interrupt_level", 3),
&"state": state_index,
&"assigned_staff_id": snapshot.get(&"assigned_staff_id", ""),
&"retry_count": snapshot.get(&"retry_count", 0),
&"task_tags": snapshot.get(&"task_tags", []),
&"reservation_ids": snapshot.get(&"reservation_ids", []),
&"progress_minutes": snapshot.get(&"progress_minutes", 0),
&"dangerous": snapshot.get(&"dangerous", false),
&"emergency": snapshot.get(&"emergency", false),
&"preserve_progress_on_interrupt": snapshot.get(&"preserve_progress_on_interrupt", true),
&"state_reason": snapshot.get(&"state_reason", ""),
&"state_changed_at": snapshot.get(&"state_changed_at", 0),
}
)
return task if task.is_valid() else null
static func _snapshot_shape_is_valid(snapshot: Dictionary) -> bool:
return (
not String(snapshot.get(&"task_id", "")).is_empty()
and not String(snapshot.get(&"task_type", "")).is_empty()
and snapshot.get(&"target_cell", null) is Array
and snapshot.get(&"required_role_tags", null) is Array
and snapshot.get(&"required_skill_min", null) is Dictionary
and snapshot.get(&"required_items", null) is Dictionary
and snapshot.get(&"task_tags", null) is Array
and snapshot.get(&"reservation_ids", null) is Array
and int(snapshot.get(&"priority", -1)) >= 0
and int(snapshot.get(&"priority", -1)) <= MAX_PRIORITY
and int(snapshot.get(&"estimated_minutes", 0)) > 0
and int(snapshot.get(&"interrupt_level", -1)) in range(0, MAX_INTERRUPT_LEVEL + 1)
)
static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant:
return source.get(key, source.get(String(key), fallback))
static func _state_from_value(value: Variant, fallback: State) -> State:
if value is int:
var numeric := int(value)
if numeric >= 0 and numeric < State.size():
return numeric as State
var index := State.keys().find(String(value))
return index as State if index >= 0 else fallback
static func _vector3i(value: Variant) -> Vector3i:
if value is Vector3i:
return value
if value is Array and value.size() == 3:
return Vector3i(int(value[0]), int(value[1]), int(value[2]))
return Vector3i.ZERO
static func _normalize_positive_dictionary(source: Variant, maximum := 2_147_483_647) -> Dictionary:
var result: Dictionary = {}
if not source is Dictionary:
return result
var keys: Array = source.keys()
keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b))
for raw_id: Variant in keys:
var item_id := StringName(raw_id)
var value := int(source[raw_id])
if not item_id.is_empty() and value > 0:
result[item_id] = mini(value, maximum)
return result
static func _serialize_dictionary(source: Dictionary) -> Dictionary:
var result: Dictionary = {}
var keys: Array = source.keys()
keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b))
for raw_id: Variant in keys:
result[String(raw_id)] = int(source[raw_id])
return result
static func _sorted_unique_names(source: Variant) -> Array[StringName]:
var result: Array[StringName] = []
if not source is Array:
return result
for value: Variant in source:
var name := StringName(value)
if not name.is_empty() and not result.has(name):
result.append(name)
result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
return result
static func _names_to_strings(source: Array[StringName]) -> Array[String]:
var result: Array[String] = []
for value: StringName in source:
result.append(String(value))
return result
+1
View File
@@ -0,0 +1 @@
uid://bi15b1x11sq6v
+419
View File
@@ -0,0 +1,419 @@
class_name TaskDispatcher
extends RefCounted
## Deterministic employee/task matcher. Inventory reservation is injected so
## this layer can keep the Task/Staff pair atomic without owning stock.
const StaffRuntimeType = preload("res://scripts/staff/staff_runtime.gd")
const StaffTaskType = preload("res://scripts/staff/staff_task.gd")
const SNAPSHOT_VERSION := 1
const DISPATCH_INTERVAL_MINUTES := 5
const HIGH_PRIORITY_THRESHOLD := 90
const PRIORITY_MULTIPLIER := 10
const SKILL_FIT_MULTIPLIER := 4
const ROLE_FIT_MULTIPLIER := 100
const TRAVEL_MINUTE_PENALTY := 3
const FATIGUE_PENALTY := 2
const OVERTIME_PENALTY := 200
const INTERRUPT_COST_LEVEL_1 := 300
const INTERRUPT_COST_LEVEL_2 := 150
const INTERRUPT_COST_LEVEL_3 := 25
const MIN_SCORE := -2_147_483_648
var last_dispatch_minute := -1
var last_report: Dictionary = {}
var last_restore_error := ""
var _item_reserver := Callable()
var _item_releaser := Callable()
var _travel_estimator := Callable()
var _assignment_history: Array[Dictionary] = []
func configure_callbacks(
item_reserver: Callable = Callable(),
item_releaser: Callable = Callable(),
travel_estimator: Callable = Callable()
) -> TaskDispatcher:
_item_reserver = item_reserver
_item_releaser = item_releaser
_travel_estimator = travel_estimator
return self
func should_dispatch(absolute_minute: int, has_high_priority_task := false) -> bool:
if has_high_priority_task:
return true
if last_dispatch_minute < 0:
return true
return absolute_minute - last_dispatch_minute >= DISPATCH_INTERVAL_MINUTES
func dispatch(
tasks: Array,
staff_by_id: Dictionary,
absolute_minute: int
) -> Dictionary:
var task_by_id := _task_index(tasks)
var pending := _sorted_open_tasks(tasks)
var assignments: Array[Dictionary] = []
var blocked: Array[Dictionary] = []
var unassigned: Array[Dictionary] = []
var interruptions: Array[Dictionary] = []
var assigned_this_pass: Dictionary = {}
for task: StaffTask in pending:
var selected := _select_candidate(
task,
staff_by_id,
task_by_id,
assigned_this_pass,
absolute_minute
)
if selected.is_empty():
unassigned.append({
&"task_id": task.task_id,
&"reason": &"NO_ELIGIBLE_STAFF",
})
continue
var staff_id: StringName = selected[&"staff_id"]
var staff: StaffRuntime = staff_by_id[staff_id]
var previous_task: StaffTask = selected.get(&"current_task") as StaffTask
var result := _reserve_pair(task, staff, previous_task, absolute_minute)
if not bool(result.get(&"ok", false)):
var reason := StringName(result.get(&"reason", &"RESERVATION_FAILED"))
if bool(result.get(&"blocked", false)):
blocked.append({&"task_id": task.task_id, &"reason": reason})
else:
unassigned.append({&"task_id": task.task_id, &"reason": reason})
continue
if previous_task != null:
interruptions.append({
&"staff_id": staff_id,
&"interrupted_task_id": previous_task.task_id,
&"replacement_task_id": task.task_id,
})
var assignment := {
&"task_id": task.task_id,
&"staff_id": staff_id,
&"score": int(selected[&"score"]),
&"travel_minutes": int(selected[&"travel_minutes"]),
&"assigned_at": absolute_minute,
}
assignments.append(assignment)
_assignment_history.append(assignment.duplicate(true))
assigned_this_pass[staff_id] = true
last_dispatch_minute = absolute_minute
last_report = {
&"assigned": assignments,
&"blocked": blocked,
&"unassigned": unassigned,
&"interruptions": interruptions,
}
return last_report.duplicate(true)
func score_candidate(
task: StaffTask,
staff: StaffRuntime,
travel_minutes: int,
current_task: StaffTask = null
) -> int:
if task == null or staff == null or travel_minutes < 0:
return MIN_SCORE
var interrupt_cost := 0
if current_task != null:
match current_task.interrupt_level:
1:
interrupt_cost = INTERRUPT_COST_LEVEL_1
2:
interrupt_cost = INTERRUPT_COST_LEVEL_2
_:
interrupt_cost = INTERRUPT_COST_LEVEL_3
return (
task.priority * PRIORITY_MULTIPLIER
+ staff.skill_fit_for(task) * SKILL_FIT_MULTIPLIER
+ staff.role_fit_for(task) * ROLE_FIT_MULTIPLIER
- travel_minutes * TRAVEL_MINUTE_PENALTY
- staff.fatigue * FATIGUE_PENALTY
- interrupt_cost
- (OVERTIME_PENALTY if staff.is_overtime else 0)
)
func create_snapshot() -> Dictionary:
var history: Array[Dictionary] = []
for assignment: Dictionary in _assignment_history:
var serialized := assignment.duplicate(true)
serialized[&"task_id"] = String(assignment[&"task_id"])
serialized[&"staff_id"] = String(assignment[&"staff_id"])
history.append(serialized)
return {
&"version": SNAPSHOT_VERSION,
&"last_dispatch_minute": last_dispatch_minute,
&"assignment_history": history,
}
func restore_snapshot(snapshot: Dictionary) -> bool:
last_restore_error = ""
if int(snapshot.get(&"version", -1)) != SNAPSHOT_VERSION:
last_restore_error = "Unsupported task dispatcher snapshot version."
return false
var raw_history: Variant = snapshot.get(&"assignment_history", null)
if not raw_history is Array:
last_restore_error = "Task dispatcher history is malformed."
return false
var next_history: Array[Dictionary] = []
for raw_assignment: Variant in raw_history:
if not raw_assignment is Dictionary:
last_restore_error = "Task dispatcher history entry must be a Dictionary."
return false
var task_id := StringName(raw_assignment.get(&"task_id", ""))
var staff_id := StringName(raw_assignment.get(&"staff_id", ""))
if task_id.is_empty() or staff_id.is_empty():
last_restore_error = "Task dispatcher history has an empty identifier."
return false
var assignment: Dictionary = raw_assignment.duplicate(true)
assignment[&"task_id"] = task_id
assignment[&"staff_id"] = staff_id
next_history.append(assignment)
var next_last_minute := int(snapshot.get(&"last_dispatch_minute", -1))
if next_last_minute < -1:
last_restore_error = "Task dispatcher minute is invalid."
return false
_assignment_history = next_history
last_dispatch_minute = next_last_minute
last_report = {}
return true
func assignment_history() -> Array[Dictionary]:
return _assignment_history.duplicate(true)
func _select_candidate(
task: StaffTask,
staff_by_id: Dictionary,
task_by_id: Dictionary,
excluded_staff_ids: Dictionary,
absolute_minute: int
) -> Dictionary:
var best: Dictionary = {}
for staff_id: StringName in _sorted_names(staff_by_id.keys()):
if excluded_staff_ids.has(staff_id):
continue
var staff: StaffRuntime = staff_by_id.get(staff_id) as StaffRuntime
if staff == null or not staff.meets_task_requirements(task):
continue
var current_task: StaffTask = null
if staff.current_task_id.is_empty():
if not staff.is_available_for_dispatch():
continue
else:
current_task = task_by_id.get(staff.current_task_id) as StaffTask
if current_task == null or not current_task.can_be_interrupted_by(task):
continue
var travel_minutes := _travel_minutes(staff, task, absolute_minute)
if travel_minutes < 0:
continue
var score := score_candidate(task, staff, travel_minutes, current_task)
if (
best.is_empty()
or score > int(best[&"score"])
or (score == int(best[&"score"]) and String(staff_id) < String(best[&"staff_id"]))
):
best = {
&"staff_id": staff_id,
&"staff": staff,
&"current_task": current_task,
&"travel_minutes": travel_minutes,
&"score": score,
}
return best
func _reserve_pair(
task: StaffTask,
staff: StaffRuntime,
previous_task: StaffTask,
absolute_minute: int
) -> Dictionary:
if not task.is_dispatchable() or staff == null:
return {&"ok": false, &"reason": &"STATE_CHANGED", &"blocked": false}
if previous_task == null and not staff.is_available_for_dispatch():
return {&"ok": false, &"reason": &"STAFF_STATE_CHANGED", &"blocked": false}
if previous_task != null:
if staff.current_task_id != previous_task.task_id:
return {&"ok": false, &"reason": &"STAFF_TASK_MISMATCH", &"blocked": false}
if previous_task.assigned_staff_id != staff.staff_id:
return {&"ok": false, &"reason": &"TASK_STAFF_MISMATCH", &"blocked": false}
if not previous_task.can_be_interrupted_by(task):
return {&"ok": false, &"reason": &"INTERRUPT_DENIED", &"blocked": false}
var item_result := _reserve_items(task, absolute_minute)
if not bool(item_result.get(&"ok", false)):
var item_reason := StringName(item_result.get(&"reason", &"ITEM_RESERVATION_FAILED"))
task.block(item_reason, absolute_minute)
return {&"ok": false, &"reason": item_reason, &"blocked": true}
var reservation_ids: Array[StringName] = _name_array(item_result.get(&"reservation_ids", []))
var staff_before := {
&"current_task_id": staff.current_task_id,
&"state": staff.state,
&"state_reason": staff.state_reason,
&"state_changed_at": staff.state_changed_at,
}
var task_before := {
&"state": task.state,
&"assigned_staff_id": task.assigned_staff_id,
&"reservation_ids": task.reservation_ids.duplicate(),
&"state_reason": task.state_reason,
&"state_changed_at": task.state_changed_at,
}
var previous_before: Dictionary = {}
if previous_task != null:
previous_before = {
&"state": previous_task.state,
&"assigned_staff_id": previous_task.assigned_staff_id,
&"reservation_ids": previous_task.reservation_ids.duplicate(),
&"progress_minutes": previous_task.progress_minutes,
&"state_reason": previous_task.state_reason,
&"state_changed_at": previous_task.state_changed_at,
}
previous_task.reopen(
&"INTERRUPTED",
absolute_minute,
previous_task.preserve_progress_on_interrupt
)
staff.release_task(absolute_minute, &"TASK_INTERRUPTED")
var task_reserved := task.reserve(staff.staff_id, reservation_ids, absolute_minute)
var staff_reserved := task_reserved and staff.reserve_task(task.task_id, absolute_minute)
if not task_reserved or not staff_reserved:
_restore_pair_state(task, staff, previous_task, task_before, staff_before, previous_before)
_release_items(reservation_ids, task, absolute_minute)
return {&"ok": false, &"reason": &"ATOMIC_PAIR_RESERVATION_FAILED", &"blocked": false}
if previous_task != null:
var old_ids: Array[StringName] = _name_array(previous_before[&"reservation_ids"])
if not _release_items(old_ids, previous_task, absolute_minute):
_restore_pair_state(task, staff, previous_task, task_before, staff_before, previous_before)
_release_items(reservation_ids, task, absolute_minute)
return {&"ok": false, &"reason": &"INTERRUPT_RELEASE_FAILED", &"blocked": false}
return {&"ok": true, &"reservation_ids": reservation_ids}
func _reserve_items(task: StaffTask, absolute_minute: int) -> Dictionary:
if task.required_items.is_empty():
return {&"ok": true, &"reservation_ids": []}
if not _item_reserver.is_valid():
return {&"ok": false, &"reason": &"ITEM_RESERVATION_UNAVAILABLE"}
var response: Variant = _item_reserver.call(task, absolute_minute)
if not response is Dictionary:
return {&"ok": false, &"reason": &"ITEM_RESERVATION_INVALID_RESPONSE"}
var result: Dictionary = response
if not bool(result.get(&"ok", false)):
return {
&"ok": false,
&"reason": StringName(result.get(&"reason", result.get(&"code", &"ITEM_RESERVATION_FAILED"))),
}
var reservation_ids := _name_array(result.get(&"reservation_ids", []))
if reservation_ids.is_empty():
return {&"ok": false, &"reason": &"ITEM_RESERVATION_IDS_MISSING"}
return {&"ok": true, &"reservation_ids": reservation_ids}
func _release_items(
reservation_ids: Array[StringName],
task: StaffTask,
absolute_minute: int
) -> bool:
if reservation_ids.is_empty():
return true
if not _item_releaser.is_valid():
return false
var response: Variant = _item_releaser.call(reservation_ids, task, absolute_minute)
if response is Dictionary:
return bool(response.get(&"ok", false))
return bool(response)
func _travel_minutes(staff: StaffRuntime, task: StaffTask, absolute_minute: int) -> int:
if _travel_estimator.is_valid():
return int(_travel_estimator.call(staff, task, absolute_minute))
var delta := staff.current_cell - task.target_cell
return absi(delta.x) + absi(delta.y) + absi(delta.z)
static func _restore_pair_state(
task: StaffTask,
staff: StaffRuntime,
previous_task: StaffTask,
task_before: Dictionary,
staff_before: Dictionary,
previous_before: Dictionary
) -> void:
task.state = int(task_before[&"state"]) as StaffTask.State
task.assigned_staff_id = task_before[&"assigned_staff_id"]
task.reservation_ids.assign(task_before[&"reservation_ids"])
task.state_reason = task_before[&"state_reason"]
task.state_changed_at = int(task_before[&"state_changed_at"])
staff.current_task_id = staff_before[&"current_task_id"]
staff.state = int(staff_before[&"state"]) as StaffRuntime.State
staff.state_reason = staff_before[&"state_reason"]
staff.state_changed_at = int(staff_before[&"state_changed_at"])
if previous_task != null:
previous_task.state = int(previous_before[&"state"]) as StaffTask.State
previous_task.assigned_staff_id = previous_before[&"assigned_staff_id"]
previous_task.reservation_ids.assign(previous_before[&"reservation_ids"])
previous_task.progress_minutes = int(previous_before[&"progress_minutes"])
previous_task.state_reason = previous_before[&"state_reason"]
previous_task.state_changed_at = int(previous_before[&"state_changed_at"])
static func _task_index(tasks: Array) -> Dictionary:
var result: Dictionary = {}
for value: Variant in tasks:
if value is StaffTask:
result[value.task_id] = value
return result
static func _sorted_open_tasks(tasks: Array) -> Array[StaffTask]:
var result: Array[StaffTask] = []
for value: Variant in tasks:
if value is StaffTask and value.is_dispatchable():
result.append(value)
result.sort_custom(func(a: StaffTask, b: StaffTask) -> bool:
if a.priority != b.priority:
return a.priority > b.priority
var a_deadline := 9_223_372_036_854_775_807 if a.deadline_at < 0 else a.deadline_at
var b_deadline := 9_223_372_036_854_775_807 if b.deadline_at < 0 else b.deadline_at
if a_deadline != b_deadline:
return a_deadline < b_deadline
return String(a.task_id) < String(b.task_id)
)
return result
static func _sorted_names(values: Variant) -> Array[StringName]:
var result: Array[StringName] = []
for value: Variant in values:
result.append(StringName(value))
result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
return result
static func _name_array(source: Variant) -> Array[StringName]:
var result: Array[StringName] = []
if not source is Array:
return result
for value: Variant in source:
var name := StringName(value)
if not name.is_empty() and not result.has(name):
result.append(name)
result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
return result
+1
View File
@@ -0,0 +1 @@
uid://vjdti463kwwt