494 lines
17 KiB
GDScript
494 lines
17 KiB
GDScript
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)
|