Files
Takeshi 5dde0c5ec2 Record original-alignment implementation and traceability checkpoint
Capture current source, content data, tests, asset licenses, and original-specification traceability. Original implementation remains incomplete. The accepted matching UI generation passed 154 suites and 12,253 checks, with eight rendered staff-contract screens reviewed. Latest native Windows and full-original acceptance remain outstanding.
2026-09-30 23:52:24 +09:00

500 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_effective_daily_wage() -> int:
# Preserve historical stored wages for exact save round trips; UNIQUE
# characters accrue no new upkeep under original section 6.1.
return 0 if employment_kind == EmploymentKind.UNIQUE else daily_wage
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)