Enable first operating day with staff assignments, opening guide, and ledger

This commit is contained in:
Codex
2026-09-09 08:26:20 +09:00
parent 423fd6dd2e
commit 29a660aed0
28 changed files with 2451 additions and 156 deletions
+205 -5
View File
@@ -18,7 +18,8 @@ const CommandResultType = preload("res://scripts/core/command_result.gd")
const LEGACY_SNAPSHOT_VERSION := 1
const PAYROLL_SNAPSHOT_VERSION := 2
const SNAPSHOT_VERSION := 3
const EMPLOYMENT_SNAPSHOT_VERSION := 3
const SNAPSHOT_VERSION := 4
const HIGH_PRIORITY_THRESHOLD := 90
const DEFAULT_LATE_REASON := &"PERSONAL"
const MAX_PAYROLL_DAY := 1_000_000
@@ -36,6 +37,7 @@ var _staff: Dictionary = {}
var _shifts: Dictionary = {}
var _tasks: Dictionary = {}
var _schedule_overrides: Dictionary = {}
var _work_assignments: Dictionary = {}
var _departed_payroll: Dictionary = {}
var _payroll_settlements: Dictionary = {}
var _payroll_days: Dictionary = {}
@@ -375,6 +377,164 @@ func get_shifts() -> Array[ShiftInstance]:
return result
## A work assignment is a repeating plan; concrete Shift records retain the
## facility and elapsed interval used by historical wage allocation.
func get_work_assignments() -> Array[Dictionary]:
var rows: Array[Dictionary] = []
for staff_id: StringName in _sorted_keys(_work_assignments):
rows.append((_work_assignments[staff_id] as Dictionary).duplicate(true))
return rows
func get_work_assignment(staff_id: StringName) -> Dictionary:
return (_work_assignments.get(staff_id, {}) as Dictionary).duplicate(true)
func set_work_assignment(staff_id: StringName, facility_id: StringName, template_id: StringName, absolute_minute := -1) -> CommandResult:
var minute := current_minute if absolute_minute < 0 else absolute_minute
if minute < current_minute:
return _invalid("Assignment time cannot move backwards.", {&"reason": &"TIME_REGRESSION"})
var staff := get_staff(staff_id)
if staff == null:
return _invalid("Staff was not found.", {&"reason": &"STAFF_NOT_FOUND"})
if template_id not in [&"DAY", &"NIGHT"]:
return _invalid("Choose DAY or NIGHT.", {&"reason": &"INVALID_TEMPLATE"})
if not staff.current_task_id.is_empty():
return _invalid("Finish the current task before changing assignment.", {&"reason": &"STAFF_BUSY"})
if not staff.can_take_shift() or staff.state not in [
StaffRuntimeType.State.OFF_DUTY, StaffRuntimeType.State.COMMUTING, StaffRuntimeType.State.AVAILABLE,
]:
return _invalid("Staff cannot change assignment in the current state.", {&"reason": &"STAFF_UNAVAILABLE"})
var previous := get_work_assignment(staff_id)
if not previous.is_empty() and StringName(previous[&"facility_id"]) == facility_id \
and StringName(previous[&"template_id"]) == template_id:
return _success(_work_assignment_result(staff_id, &"ASSIGNMENT_UNCHANGED"))
# Build every replacement before the first live write. Never repurpose the
# facility of elapsed work, including a NIGHT segment crossing midnight.
var replacements: Dictionary = {}
for shift: ShiftInstance in _shifts_for_staff(staff_id):
if shift.cancelled or shift.end_at <= minute:
continue
var replacement := ShiftInstanceType.from_snapshot(shift.create_snapshot())
if replacement.start_at >= minute:
replacement.cancelled = true
else:
replacement.end_at = minute
replacement.end_minute_of_day = posmod(minute, MINUTES_PER_DAY)
replacement.worked_minutes = mini(replacement.worked_minutes, minute - replacement.start_at)
replacement.close(minute)
if not replacement.is_valid():
return _invalid("Existing shift could not be split.", {&"reason": &"SHIFT_CONFLICT"})
replacements[replacement.shift_instance_id] = replacement
var interval := _work_interval(template_id, minute)
var start_at := maxi(interval.x, minute)
var new_shift: ShiftInstance = null
if not facility_id.is_empty():
new_shift = _make_work_shift(staff_id, facility_id, template_id, start_at, interval.y)
if _shifts.has(new_shift.shift_instance_id):
return _invalid("Generated shift ID conflicts.", {&"reason": &"SHIFT_CONFLICT"})
for shift_id: StringName in _sorted_keys(replacements):
_shifts[shift_id] = replacements[shift_id]
# An explicit repeating plan supersedes future one-day overrides. Keeping
# them would silently move an employee back to an old facility later.
for override_id: StringName in _sorted_keys(_schedule_overrides):
var entry: Dictionary = _schedule_overrides[override_id]
if StringName(entry[&"staff_id"]) == staff_id \
and int(entry[&"start_day"]) >= minute / MINUTES_PER_DAY:
_schedule_overrides.erase(override_id)
_work_assignments[staff_id] = {
&"staff_id": String(staff_id), &"facility_id": String(facility_id),
&"template_id": String(template_id), &"changed_at": minute,
&"effective_from": start_at if new_shift != null else minute,
&"next_start_at": interval.x + MINUTES_PER_DAY,
}
staff.shift_id = &""
staff.assigned_facility_id = &""
staff.is_overtime = false
staff.transition_to(StaffRuntimeType.State.OFF_DUTY, &"WORK_ASSIGNMENT_CHANGED", minute)
if new_shift != null:
_shifts[new_shift.shift_instance_id] = new_shift
_next_shift_sequence += 1
# APPLY_COMMANDS precedes the Staff AI phase in a running tick. The AI
# records that new minute later; paused edits record the boundary here.
_update_shift_states(minute, staff_id, minute == current_minute)
if new_shift != null:
shift_registered.emit(new_shift)
return _success(_work_assignment_result(staff_id, &"ASSIGNMENT_UPDATED"))
func _work_assignment_result(staff_id: StringName, reason: StringName) -> Dictionary:
var row := get_work_assignment(staff_id)
row[&"reason"] = reason
row[&"shift_id"] = get_staff(staff_id).shift_id
row[&"unassigned"] = String(row[&"facility_id"]).is_empty()
return row
static func _work_interval(template_id: StringName, absolute_minute: int) -> Vector2i:
var day := absolute_minute / MINUTES_PER_DAY
var minute_of_day := posmod(absolute_minute, MINUTES_PER_DAY)
if template_id == &"DAY":
if minute_of_day >= ShiftInstanceType.DAY_END:
day += 1
return Vector2i(day * MINUTES_PER_DAY + ShiftInstanceType.DAY_START, day * MINUTES_PER_DAY + ShiftInstanceType.DAY_END)
if minute_of_day < ShiftInstanceType.NIGHT_END:
day -= 1
return Vector2i(day * MINUTES_PER_DAY + ShiftInstanceType.NIGHT_START, (day + 1) * MINUTES_PER_DAY + ShiftInstanceType.NIGHT_END)
func _make_work_shift(staff_id: StringName, facility_id: StringName, template_id: StringName, start_at: int, end_at: int) -> ShiftInstance:
return ShiftInstanceType.new().configure(
StringName("shift_%08d_d%05d" % [_next_shift_sequence, start_at / MINUTES_PER_DAY]),
staff_id, template_id, start_at / MINUTES_PER_DAY,
posmod(start_at, MINUTES_PER_DAY), posmod(end_at, MINUTES_PER_DAY), facility_id
)
func _ensure_work_assignment_shifts(absolute_minute: int) -> void:
for staff_id: StringName in _sorted_keys(_work_assignments):
var row: Dictionary = _work_assignments[staff_id]
if String(row[&"facility_id"]).is_empty() \
or absolute_minute < int(row[&"next_start_at"]) - ShiftInstanceType.COMMUTE_LEAD_MINUTES:
continue
var interval := _work_interval(StringName(row[&"template_id"]), absolute_minute)
# During a running shift the next occurrence still lies in the future.
if interval.x < int(row[&"next_start_at"]):
continue
var shift := _make_work_shift(staff_id, StringName(row[&"facility_id"]), StringName(row[&"template_id"]), maxi(interval.x, 0), interval.y)
_apply_override_to_shift(shift)
var overlaps := false
for existing: ShiftInstance in _shifts_for_staff(staff_id):
if _shifts_overlap(existing, shift):
overlaps = true
break
row[&"next_start_at"] = interval.x + MINUTES_PER_DAY
# Explicit one-day shifts win over the recurring template on that day.
if overlaps or _shifts.has(shift.shift_instance_id) or not shift.is_valid():
continue
_shifts[shift.shift_instance_id] = shift
_next_shift_sequence += 1
shift_registered.emit(shift)
static func _work_assignment_shape_is_valid(row: Dictionary) -> bool:
if row.size() != 6:
return false
for key: StringName in [&"staff_id", &"facility_id", &"template_id"]:
if typeof(row.get(key, null)) not in [TYPE_STRING, TYPE_STRING_NAME]:
return false
for key: StringName in [&"changed_at", &"effective_from", &"next_start_at"]:
if typeof(row.get(key, null)) != TYPE_INT or int(row[key]) < 0:
return false
if String(row[&"staff_id"]).is_empty() or StringName(row[&"template_id"]) not in [&"DAY", &"NIGHT"]:
return false
var standard_start := ShiftInstanceType.DAY_START if StringName(row[&"template_id"]) == &"DAY" else ShiftInstanceType.NIGHT_START
return int(row[&"effective_from"]) >= int(row[&"changed_at"]) \
and int(row[&"effective_from"]) < int(row[&"next_start_at"]) \
and posmod(int(row[&"next_start_at"]), MINUTES_PER_DAY) == standard_start
func set_schedule_override(
staff_id: StringName,
start_day: int,
@@ -470,6 +630,7 @@ func advance_one_minute(
return {&"ok": false, &"reason": &"MINUTE_ALREADY_APPLIED"}
current_minute = absolute_minute
var repairs := audit_orphan_tasks(current_minute)
_ensure_work_assignment_shifts(current_minute)
_update_shift_states(current_minute)
var task_actions := _advance_assigned_tasks(current_minute)
var high_priority := _has_high_priority_open_task()
@@ -665,6 +826,7 @@ func remove_staff(
continue
removed_override_days.append(int(entry.get(&"start_day", -1)))
_schedule_overrides.erase(override_id)
_work_assignments.erase(staff_id)
_staff.erase(staff_id)
staff_removed.emit(staff_id, reason)
last_error = ""
@@ -843,6 +1005,7 @@ func create_snapshot() -> Dictionary:
&"shifts": shift_snapshots,
&"tasks": task_snapshots,
&"schedule_overrides": override_snapshots,
&"work_assignments": get_work_assignments(),
&"departed_payroll": departed_payroll,
&"payroll_settlements": payroll_settlements,
&"dispatcher": _dispatcher.create_snapshot(),
@@ -867,6 +1030,7 @@ func restore_snapshot_with_orphan_recovery(snapshot: Dictionary) -> bool:
_shifts = candidate._shifts
_tasks = candidate._tasks
_schedule_overrides = candidate._schedule_overrides
_work_assignments = candidate._work_assignments
_departed_payroll = candidate._departed_payroll
_payroll_settlements = candidate._payroll_settlements
_payroll_days = candidate._payroll_days
@@ -896,7 +1060,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recover_orphan_tasks: bool
if (
typeof(raw_version) != TYPE_INT
or int(raw_version) not in [
LEGACY_SNAPSHOT_VERSION, PAYROLL_SNAPSHOT_VERSION, SNAPSHOT_VERSION,
LEGACY_SNAPSHOT_VERSION, PAYROLL_SNAPSHOT_VERSION, EMPLOYMENT_SNAPSHOT_VERSION, SNAPSHOT_VERSION,
]
):
last_error = "Unsupported StaffService snapshot version."
@@ -939,7 +1103,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recover_orphan_tasks: bool
raw_staff.has(&"employment_effective_minute")
or raw_staff.has("employment_effective_minute")
)
if snapshot_version == SNAPSHOT_VERSION:
if snapshot_version >= EMPLOYMENT_SNAPSHOT_VERSION:
if not has_row_version or not has_effective_minute:
last_error = "Current Staff snapshot entry is missing employment-time schema fields."
return false
@@ -994,6 +1158,38 @@ func _restore_snapshot_internal(snapshot: Dictionary, recover_orphan_tasks: bool
&"patch": (raw_override[&"patch"] as Dictionary).duplicate(true),
}
var next_assignments: Dictionary = {}
if snapshot_version >= SNAPSHOT_VERSION:
if not snapshot.get(&"work_assignments", null) is Array:
last_error = "Staff work assignments must be an Array."
return false
for raw_assignment: Variant in snapshot[&"work_assignments"]:
if not raw_assignment is Dictionary or not _work_assignment_shape_is_valid(raw_assignment):
last_error = "Staff work assignment is malformed."
return false
var owner := StringName(raw_assignment[&"staff_id"])
if not next_staff.has(owner) or next_assignments.has(owner) \
or int(raw_assignment[&"changed_at"]) > int(snapshot.get(&"current_minute", -1)):
last_error = "Staff work assignment owner or time is invalid."
return false
var changed_at := int(raw_assignment[&"changed_at"])
var assigned_facility := StringName(raw_assignment[&"facility_id"])
var template := StringName(raw_assignment[&"template_id"])
var expected_effective := changed_at if assigned_facility.is_empty() else maxi(changed_at, _work_interval(template, changed_at).x)
var next_start := int(raw_assignment[&"next_start_at"])
var current_interval := _work_interval(template, int(snapshot.get(&"current_minute", 0)))
if int(raw_assignment[&"effective_from"]) != expected_effective \
or (not assigned_facility.is_empty() and (
next_start <= int(snapshot.get(&"current_minute", 0))
or next_start not in [current_interval.x, current_interval.x + MINUTES_PER_DAY]
)):
last_error = "Staff work assignment cadence is invalid."
return false
next_assignments[owner] = raw_assignment.duplicate(true)
elif snapshot.has(&"work_assignments"):
last_error = "Legacy Staff snapshot contains work assignments from a later schema."
return false
var next_payroll: Dictionary = {}
for raw_payroll: Variant in snapshot[&"departed_payroll"]:
if not raw_payroll is Dictionary:
@@ -1067,6 +1263,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recover_orphan_tasks: bool
_shifts = next_shifts
_tasks = next_tasks
_schedule_overrides = next_overrides
_work_assignments = next_assignments
_departed_payroll = next_payroll
_payroll_settlements = next_settlements
_payroll_days = next_payroll_days
@@ -1097,8 +1294,10 @@ func _restore_snapshot_internal(snapshot: Dictionary, recover_orphan_tasks: bool
return true
func _update_shift_states(absolute_minute: int) -> void:
func _update_shift_states(absolute_minute: int, only_staff: StringName = &"", record_duty := true) -> void:
for staff_id: StringName in _sorted_keys(_staff):
if not only_staff.is_empty() and only_staff != staff_id:
continue
var staff: StaffRuntime = _staff[staff_id]
var shift := get_shift(staff.shift_id)
if shift != null and shift.staff_id != staff.staff_id:
@@ -1161,7 +1360,8 @@ func _update_shift_states(absolute_minute: int) -> void:
]:
staff.transition_to(StaffRuntimeType.State.AVAILABLE, &"SHIFT_STARTED", absolute_minute)
shift_state_changed.emit(staff.staff_id, shift.shift_instance_id, staff.state)
shift.record_duty_minute(absolute_minute)
if record_duty:
shift.record_duty_minute(absolute_minute)
if absolute_minute >= shift.start_at + 15 and staff.state == StaffRuntimeType.State.COMMUTING:
shift.mark_late(absolute_minute, DEFAULT_LATE_REASON)