Files
gregCore/mods/greg.Plugin.LangCompatBridge/CustomEmployeeManager.cs
T
Marvin 84b6f56a50 chore: add DataCenter-RustBridge submodule
Initialize the DataCenter-RustBridge submodule to integrate the Rust bridge plugin for the DataCenter project. This enables using the external Rust-based components as a git submodule.
2026-04-20 14:48:52 +02:00

1042 lines
38 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using Il2Cpp;
using MelonLoader.Utils;
using Il2CppTMPro;
using MelonLoader;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
namespace DataCenterModLoader;
public class CustomEmployeeEntry
{
public string EmployeeId;
public string Name;
public string Description;
public float SalaryPerHour;
public float RequiredReputation;
public bool IsHired;
public bool RequiresConfirmation;
}
public static class CustomEmployeeManager
{
private static readonly List<CustomEmployeeEntry> _employees = new();
private static readonly Dictionary<string, int> _employeeIndex = new();
private static readonly List<UnityAction> _liveCallbacks = new();
private static readonly string _statePath =
Path.Combine(MelonEnvironment.UserDataDirectory, "custom_employees_hired.txt");
private static string _pendingEmployeeId;
private static bool _pendingIsHire;
private static bool _salariesNeedReregistration;
public static IReadOnlyList<CustomEmployeeEntry> Employees => _employees;
public static bool HasPendingAction => _pendingEmployeeId != null;
public static int Register(string id, string name, string description, float salary, float reputation, bool requiresConfirmation = false)
{
if (string.IsNullOrEmpty(id)) return 0;
if (_employeeIndex.ContainsKey(id))
{
CrashLog.Log($"CustomEmployee: duplicate registration rejected for id={id}");
return 0;
}
var entry = new CustomEmployeeEntry
{
EmployeeId = id,
Name = name ?? "Unknown",
Description = description ?? "",
SalaryPerHour = salary,
RequiredReputation = reputation,
IsHired = false,
RequiresConfirmation = requiresConfirmation,
};
_employeeIndex[id] = _employees.Count;
_employees.Add(entry);
CrashLog.Log($"CustomEmployee registered: id={id}, name={name}, salary={salary}/h, requiredRep={reputation}");
Core.Instance?.LoggerInstance.Msg($"[CustomEmployee] Registered: {name} (id={id}, salary={salary}/h, rep={reputation})");
LoadState();
return 1;
}
public static bool IsHired(string id)
{
if (string.IsNullOrEmpty(id)) return false;
if (_employeeIndex.TryGetValue(id, out int idx))
return _employees[idx].IsHired;
return false;
}
// Returns: 1 = hired, 0 = not found, -1 = insufficient reputation, -2 = already hired
public static int Hire(string id)
{
if (!_employeeIndex.TryGetValue(id, out int idx)) return 0;
var entry = _employees[idx];
if (entry.IsHired) return -2;
float playerRep = 0f;
try { playerRep = PlayerManager.instance?.playerClass?.reputation ?? 0f; } catch { }
if (playerRep < entry.RequiredReputation)
{
CrashLog.Log($"CustomEmployee hire rejected: {id} requires rep {entry.RequiredReputation}, player has {playerRep}");
Core.Instance?.LoggerInstance.Warning($"[CustomEmployee] Cannot hire {entry.Name}: need reputation {entry.RequiredReputation} (you have {playerRep:F0})");
return -1;
}
entry.IsHired = true;
CrashLog.Log($"CustomEmployee hired: {id} ({entry.Name})");
Core.Instance?.LoggerInstance.Msg($"[CustomEmployee] Hired: {entry.Name}");
try { BalanceSheet.instance?.RegisterSalary((int)entry.SalaryPerHour); } catch { }
EventDispatcher.FireCustomEmployeeHired(id);
SaveState();
return 1;
}
// Returns: 1 = fired, 0 = not found or not currently hired
public static int Fire(string id)
{
if (!_employeeIndex.TryGetValue(id, out int idx)) return 0;
var entry = _employees[idx];
if (!entry.IsHired) return 0;
entry.IsHired = false;
CrashLog.Log($"CustomEmployee.Fire: step 1 - set IsHired=false for '{id}' ({entry.Name})");
try
{
Core.Instance?.LoggerInstance.Msg($"[CustomEmployee] Fired: {entry.Name}");
}
catch (Exception ex) { CrashLog.LogException("Fire: LoggerInstance.Msg", ex); }
CrashLog.Log($"CustomEmployee.Fire: step 2 - about to unregister salary ({-(int)entry.SalaryPerHour})");
try
{
var bs = BalanceSheet.instance;
if (bs != null)
{
bs.RegisterSalary(-(int)entry.SalaryPerHour);
CrashLog.Log("CustomEmployee.Fire: step 2 - salary unregistered OK");
}
else
{
CrashLog.Log("CustomEmployee.Fire: step 2 - BalanceSheet.instance is null, skipping salary");
}
}
catch (Exception ex) { CrashLog.LogException("Fire: RegisterSalary", ex); }
CrashLog.Log($"CustomEmployee.Fire: step 3 - dispatching CustomEmployeeFired event for '{id}'");
try
{
EventDispatcher.FireCustomEmployeeFired(id);
CrashLog.Log("CustomEmployee.Fire: step 3 - event dispatched OK");
}
catch (Exception ex) { CrashLog.LogException("Fire: FireCustomEmployeeFired", ex); }
CrashLog.Log("CustomEmployee.Fire: step 4 - saving state");
SaveState();
CrashLog.Log("CustomEmployee.Fire: step 5 - complete");
return 1;
}
#pragma warning disable CS0414
private static bool _scrollViewInjected = false;
private static Transform _injectedContent = null;
#pragma warning restore CS0414
private static Transform EnsureScrollView(Transform hrTransform, Transform grid)
{
var existingScroll = hrTransform.Find("ModScrollView");
if (existingScroll != null)
{
var existingContent = existingScroll.Find("Viewport/Content");
if (existingContent != null)
{
CrashLog.Log("CustomEmployee: ScrollView already exists, reusing");
return existingContent;
}
}
CrashLog.Log("CustomEmployee: Creating ScrollView wrapper for Grid");
var gridRect = grid.GetComponent<RectTransform>();
var gridParent = grid.parent;
var anchorMin = gridRect.anchorMin;
var anchorMax = gridRect.anchorMax;
var offsetMin = gridRect.offsetMin;
var offsetMax = gridRect.offsetMax;
var pivot = gridRect.pivot;
var sizeDelta = gridRect.sizeDelta;
var anchoredPos = gridRect.anchoredPosition;
int siblingIndex = grid.GetSiblingIndex();
var scrollGO = new GameObject("ModScrollView");
scrollGO.AddComponent<RectTransform>();
scrollGO.transform.SetParent(gridParent, false);
scrollGO.transform.SetSiblingIndex(siblingIndex);
var scrollRect_rt = scrollGO.GetComponent<RectTransform>();
scrollRect_rt.anchorMin = anchorMin;
scrollRect_rt.anchorMax = anchorMax;
scrollRect_rt.offsetMin = offsetMin;
scrollRect_rt.offsetMax = offsetMax;
scrollRect_rt.pivot = pivot;
scrollRect_rt.sizeDelta = sizeDelta;
scrollRect_rt.anchoredPosition = anchoredPos;
var viewportGO = new GameObject("Viewport");
viewportGO.AddComponent<RectTransform>();
viewportGO.AddComponent<RectMask2D>();
viewportGO.AddComponent<Image>();
viewportGO.transform.SetParent(scrollGO.transform, false);
var viewportRect = viewportGO.GetComponent<RectTransform>();
viewportRect.anchorMin = Vector2.zero;
viewportRect.anchorMax = Vector2.one;
viewportRect.offsetMin = Vector2.zero;
viewportRect.offsetMax = Vector2.zero;
viewportRect.pivot = new Vector2(0.5f, 1f);
// Transparent image needed for RectMask2D / raycasting
var viewportImage = viewportGO.GetComponent<Image>();
viewportImage.color = new Color(0, 0, 0, 0);
viewportImage.raycastTarget = true;
var contentGO = new GameObject("Content");
contentGO.AddComponent<RectTransform>();
contentGO.AddComponent<ContentSizeFitter>();
contentGO.transform.SetParent(viewportGO.transform, false);
var contentRect = contentGO.GetComponent<RectTransform>();
contentRect.anchorMin = new Vector2(0, 1);
contentRect.anchorMax = new Vector2(1, 1);
contentRect.pivot = new Vector2(0.5f, 1f);
contentRect.offsetMin = new Vector2(0, 0);
contentRect.offsetMax = new Vector2(0, 0);
contentRect.sizeDelta = new Vector2(0, 0);
var fitter = contentGO.GetComponent<ContentSizeFitter>();
fitter.horizontalFit = ContentSizeFitter.FitMode.Unconstrained;
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
var srcLayout = grid.GetComponent<GridLayoutGroup>();
if (srcLayout != null)
{
var dstLayout = contentGO.AddComponent<GridLayoutGroup>();
dstLayout.cellSize = srcLayout.cellSize;
dstLayout.spacing = srcLayout.spacing;
dstLayout.startCorner = srcLayout.startCorner;
dstLayout.startAxis = srcLayout.startAxis;
dstLayout.childAlignment = srcLayout.childAlignment;
dstLayout.constraint = srcLayout.constraint;
dstLayout.constraintCount = srcLayout.constraintCount;
dstLayout.padding = srcLayout.padding;
CrashLog.Log($"CustomEmployee: Copied GridLayoutGroup (cellSize={dstLayout.cellSize}, spacing={dstLayout.spacing}, constraint={dstLayout.constraint}, count={dstLayout.constraintCount})");
}
else
{
CrashLog.Log("CustomEmployee: Grid has no GridLayoutGroup, checking for other layouts...");
var hLayout = grid.GetComponent<HorizontalLayoutGroup>();
if (hLayout != null)
{
var dst = contentGO.AddComponent<HorizontalLayoutGroup>();
dst.spacing = hLayout.spacing;
dst.childAlignment = hLayout.childAlignment;
dst.padding = hLayout.padding;
}
var vLayout = grid.GetComponent<VerticalLayoutGroup>();
if (vLayout != null)
{
var dst = contentGO.AddComponent<VerticalLayoutGroup>();
dst.spacing = vLayout.spacing;
dst.childAlignment = vLayout.childAlignment;
dst.padding = vLayout.padding;
}
}
var childrenToMove = new System.Collections.Generic.List<Transform>();
for (int i = 0; i < grid.childCount; i++)
childrenToMove.Add(grid.GetChild(i));
foreach (var child in childrenToMove)
child.SetParent(contentGO.transform, false);
CrashLog.Log($"CustomEmployee: Moved {childrenToMove.Count} children from Grid to Content");
var scrollComp = scrollGO.AddComponent<ScrollRect>();
scrollComp.content = contentRect;
scrollComp.viewport = viewportRect;
scrollComp.horizontal = false;
scrollComp.vertical = true;
scrollComp.movementType = ScrollRect.MovementType.Clamped;
scrollComp.scrollSensitivity = 30f;
scrollComp.inertia = true;
scrollComp.decelerationRate = 0.1f;
grid.gameObject.SetActive(false);
_injectedContent = contentGO.transform;
_scrollViewInjected = true;
CrashLog.Log("CustomEmployee: ScrollView injection complete");
return contentGO.transform;
}
public static void InjectIntoHRSystem(HRSystem hrSystem)
{
if (_employees.Count == 0) return;
try
{
var hrTransform = hrSystem.gameObject.transform;
LogHierarchy(hrTransform, 0);
Transform contentGrid;
if (_injectedContent != null)
{
contentGrid = _injectedContent;
CrashLog.Log($"CustomEmployee: Reusing cached content '{contentGrid.name}' ({contentGrid.childCount} children)");
}
else
{
Transform grid = hrTransform.Find("Grid");
if (grid != null)
CrashLog.Log("CustomEmployee: Found container via legacy 'Grid' child");
if (grid == null)
{
var hireButtons = hrSystem.buttonsHireEmployees;
var fireButtons = hrSystem.buttonsFireEmployees;
Transform btn0 = hireButtons?.Length > 0 ? hireButtons[0]?.transform : null;
Transform btn1 = hireButtons?.Length > 1 ? hireButtons[1]?.transform
: fireButtons?.Length > 0 ? fireButtons[0]?.transform : null;
if (btn0 != null && btn1 != null)
{
grid = FindLowestCommonAncestor(btn0, btn1);
if (grid != null)
CrashLog.Log($"CustomEmployee: Found container via button LCA: '{grid.name}'");
}
else if (btn0 != null)
{
grid = btn0.parent?.parent?.parent ?? btn0.parent?.parent;
if (grid != null)
CrashLog.Log($"CustomEmployee: Found container via button walk-up: '{grid.name}'");
}
}
if (grid == null)
{
int maxChildren = 0;
for (int i = 0; i < hrTransform.childCount; i++)
{
var c = hrTransform.GetChild(i);
if (c.childCount > maxChildren) { maxChildren = c.childCount; grid = c; }
}
if (grid != null && maxChildren > 0)
CrashLog.Log($"CustomEmployee: Found container by scan: '{grid.name}' ({maxChildren} children)");
}
if (grid == null)
{
CrashLog.Log("CustomEmployee: Could not find employee card container in HRSystem");
return;
}
CrashLog.Log($"CustomEmployee: Found container '{grid.name}' with {grid.childCount} children");
contentGrid = EnsureScrollView(hrTransform, grid);
}
CrashLog.Log($"CustomEmployee: Using content grid '{contentGrid.name}' with {contentGrid.childCount} children");
// Prefer cards that start with "EmployeeCard" but fall back to any active
// non-custom child, since the new game version may use different card names
Transform templateCard = null;
for (int i = contentGrid.childCount - 1; i >= 0; i--)
{
var child = contentGrid.GetChild(i);
if (child.gameObject.activeSelf && !child.name.StartsWith("CustomEmployee_"))
{
if (child.name.StartsWith("EmployeeCard"))
{
templateCard = child;
break; // best match — stop immediately
}
if (templateCard == null)
templateCard = child; // fallback: first active non-custom card
}
}
if (templateCard == null)
{
CrashLog.Log("CustomEmployee: No EmployeeCard template found in content grid");
return;
}
CrashLog.Log($"CustomEmployee: Using template '{templateCard.name}'");
foreach (var entry in _employees)
{
string cardName = "CustomEmployee_" + entry.EmployeeId;
var existing = contentGrid.Find(cardName);
if (existing != null)
{
UpdateCard(existing, entry);
continue;
}
try
{
CreateCard(contentGrid, templateCard, entry, cardName);
}
catch (Exception ex)
{
CrashLog.LogException($"CreateCard({entry.EmployeeId})", ex);
}
}
}
catch (Exception ex)
{
CrashLog.LogException("InjectIntoHRSystem", ex);
}
}
public static void ResetInjectionState()
{
_injectedContent = null;
_scrollViewInjected = false;
_hierarchyLogged = false;
CrashLog.Log("CustomEmployee: injection state reset");
}
private static Transform FindLowestCommonAncestor(Transform a, Transform b)
{
if (a == null || b == null) return null;
var ancestors = new HashSet<Transform>();
for (var t = a.parent; t != null; t = t.parent)
ancestors.Add(t);
for (var t = b.parent; t != null; t = t.parent)
if (ancestors.Contains(t)) return t;
return null;
}
private static void CreateCard(Transform grid, Transform template, CustomEmployeeEntry entry, string cardName)
{
var newCardObj = UnityEngine.Object.Instantiate(template.gameObject, grid);
newCardObj.name = cardName;
newCardObj.SetActive(true);
var card = newCardObj.transform;
AssignCardTexts(card, entry);
SetupButtons(card, entry);
SetPortrait(card, entry.EmployeeId);
CrashLog.Log($"CustomEmployee: Card created for '{entry.Name}' (id={entry.EmployeeId}, hired={entry.IsHired})");
}
private static void UpdateCard(Transform card, CustomEmployeeEntry entry)
{
AssignCardTexts(card, entry);
SetupButtons(card, entry);
}
/// <summary>
/// Sets the three visible text fields on an employee card.
///
/// Strategy (most-to-least specific):
/// 1. Try the legacy hard-coded paths from the old UI ("VL/text_employeeName", etc.)
/// 2. If a path is missing, scan ALL TextMeshProUGUI / Text children of the card and
/// match by name-hint (case-insensitive contains: "name", "salary"/"pay"/"cost", "rep").
/// 3. If name-hints fail, fall back to positional assignment: first visible text = name,
/// second = salary, third = reputation.
/// This makes the card injection survive UI restructuring in game updates.
/// </summary>
private static void AssignCardTexts(Transform card, CustomEmployeeEntry entry)
{
string wantName = entry.Name;
string wantSalary = $"Salary: {entry.SalaryPerHour:F0} / h";
string wantRep = $"Required Reputation: {entry.RequiredReputation:F0}";
// --- attempt 1: legacy exact paths ---
bool nameSet = TrySetByPath(card, "VL/text_employeeName", wantName);
bool salarySet = TrySetByPath(card, "VL/text_employeeSalary", wantSalary);
bool repSet = TrySetByPath(card, "VL/text_requiredReputation", wantRep);
if (nameSet && salarySet && repSet) return;
// --- attempt 2: scan children by name hint ---
try
{
// Collect all text transforms in depth-first order, skipping button sub-trees
var textTransforms = new System.Collections.Generic.List<Transform>();
CollectTextTransforms(card, textTransforms);
CrashLog.Log($"AssignCardTexts: found {textTransforms.Count} text component(s) in card '{card.name}'");
foreach (var t in textTransforms)
{
string n = t.name.ToLowerInvariant();
if (!nameSet && (n.Contains("name") || n.Contains("employee")))
{
nameSet = TrySetTextOnTransform(t, wantName);
continue;
}
if (!salarySet && (n.Contains("salary") || n.Contains("pay") || n.Contains("cost") || n.Contains("wage")))
{
salarySet = TrySetTextOnTransform(t, wantSalary);
continue;
}
if (!repSet && (n.Contains("rep") || n.Contains("reputation") || n.Contains("prestige")))
{
repSet = TrySetTextOnTransform(t, wantRep);
continue;
}
}
// --- attempt 3: positional fallback for anything still unset ---
if (!nameSet || !salarySet || !repSet)
{
int pos = 0;
foreach (var t in textTransforms)
{
if (pos == 0 && !nameSet) { TrySetTextOnTransform(t, wantName); nameSet = true; }
else if (pos == 1 && !salarySet) { TrySetTextOnTransform(t, wantSalary); salarySet = true; }
else if (pos == 2 && !repSet) { TrySetTextOnTransform(t, wantRep); repSet = true; }
pos++;
if (nameSet && salarySet && repSet) break;
}
}
}
catch (Exception ex)
{
CrashLog.LogException("AssignCardTexts fallback scan", ex);
}
CrashLog.Log($"AssignCardTexts: name={nameSet}, salary={salarySet}, rep={repSet} for '{entry.EmployeeId}'");
}
private static bool TrySetByPath(Transform card, string path, string text)
{
var t = card.Find(path);
if (t == null) return false;
return TrySetTextOnTransform(t, text);
}
/// Collect all transforms that have a TextMeshProUGUI or legacy Text component,
/// skipping sub-trees that are buttons (to avoid overwriting button labels).
private static void CollectTextTransforms(Transform parent, System.Collections.Generic.List<Transform> result)
{
for (int i = 0; i < parent.childCount; i++)
{
var child = parent.GetChild(i);
// Skip button sub-trees
if (child.GetComponent<UnityEngine.UI.Button>() != null) continue;
try { if (child.GetComponent<ButtonExtended>() != null) continue; } catch { }
bool hasText = false;
try { if (child.GetComponent<TextMeshProUGUI>() != null) hasText = true; } catch { }
if (!hasText)
{
try { if (child.GetComponent<UnityEngine.UI.Text>() != null) hasText = true; } catch { }
}
if (hasText) result.Add(child);
CollectTextTransforms(child, result);
}
}
private static void SetTextAtPath(Transform root, string path, string text)
{
var target = root.Find(path);
if (target == null)
{
CrashLog.Log($"CustomEmployee: Path '{path}' not found under '{root.name}'");
return;
}
if (!TrySetTextOnTransform(target, text))
CrashLog.Log($"CustomEmployee: No text component at '{path}'");
}
private static bool TrySetTextOnTransform(Transform t, string text)
{
if (t == null) return false;
try
{
var tmp = t.GetComponent<TextMeshProUGUI>();
if (tmp != null)
{
tmp.text = text;
return true;
}
}
catch (Exception ex)
{
CrashLog.LogException("TrySetText TMP", ex);
}
try
{
var legacyText = t.GetComponent<Text>();
if (legacyText != null)
{
legacyText.text = text;
return true;
}
}
catch (Exception ex)
{
CrashLog.LogException("TrySetText legacy", ex);
}
return false;
}
/// <summary>
/// Finds a button transform inside <paramref name="card"/> using a prioritised search:
/// 1. Exact path (e.g. "VL/ButtonHire")
/// 2. Any direct or deep child whose name contains <paramref name="nameHint"/>
/// 3. Returns null if nothing matched.
/// </summary>
private static Transform FindButton(Transform card, string exactPath, string nameHint)
{
// 1. exact path
var t = card.Find(exactPath);
if (t != null) return t;
// 2. deep-child name search (case-insensitive contains)
return FindChildContaining(card, nameHint);
}
private static Transform FindChildContaining(Transform parent, string nameHint)
{
for (int i = 0; i < parent.childCount; i++)
{
var child = parent.GetChild(i);
if (child.name.IndexOf(nameHint, System.StringComparison.OrdinalIgnoreCase) >= 0)
return child;
var deep = FindChildContaining(child, nameHint);
if (deep != null) return deep;
}
return null;
}
private static void SetupButtons(Transform card, CustomEmployeeEntry entry)
{
// Try legacy exact paths first, then fall back to name-hint search,
// then fall back to ButtonExtended components in card order.
Transform buttonHireT = FindButton(card, "VL/ButtonHire", "Hire");
Transform buttonFireT = FindButton(card, "VL/ButtonFire", "Fire");
if (buttonHireT == null || buttonFireT == null)
{
// Last resort: grab the first two ButtonExtended components in the card.
// Index 0 → Hire, Index 1 → Fire (matches the game's own ordering).
try
{
var allBtns = card.GetComponentsInChildren<ButtonExtended>(includeInactive: true);
if (allBtns != null)
{
if (buttonHireT == null && allBtns.Length >= 1)
buttonHireT = allBtns[0].transform;
if (buttonFireT == null && allBtns.Length >= 2)
buttonFireT = allBtns[1].transform;
}
}
catch (Exception ex)
{
CrashLog.LogException("SetupButtons: ButtonExtended fallback", ex);
}
}
if (buttonHireT == null || buttonFireT == null)
{
CrashLog.Log($"CustomEmployee: SetupButtons — could not find hire/fire buttons for '{entry.EmployeeId}' (hire={buttonHireT != null}, fire={buttonFireT != null})");
return;
}
CrashLog.Log($"CustomEmployee: SetupButtons — using hire='{buttonHireT.name}', fire='{buttonFireT.name}' for '{entry.EmployeeId}'");
string employeeId = entry.EmployeeId;
buttonHireT.gameObject.SetActive(!entry.IsHired);
buttonFireT.gameObject.SetActive(entry.IsHired);
// Try the known text child name first; if absent scan for any text component.
bool hireTextSet = TrySetTextOnTransform(buttonHireT.Find("TextHire"), "Hire");
if (!hireTextSet) TrySetTextOnTransform(buttonHireT, "Hire");
bool fireTextSet = TrySetTextOnTransform(buttonFireT.Find("TextHire"), "Fire");
if (!fireTextSet) TrySetTextOnTransform(buttonFireT, "Fire");
WireButtonExtendedClick(buttonHireT, () =>
{
CrashLog.Log($"CustomEmployee: Hire clicked for '{employeeId}'");
if (entry.RequiresConfirmation)
{
_pendingEmployeeId = employeeId;
_pendingIsHire = true;
ShowOverlay(hire: true);
}
else
{
if (Hire(employeeId) == 1) RefreshAllCards();
}
});
WireButtonExtendedClick(buttonFireT, () =>
{
CrashLog.Log($"CustomEmployee: Fire clicked for '{employeeId}'");
if (entry.RequiresConfirmation)
{
_pendingEmployeeId = employeeId;
_pendingIsHire = false;
ShowOverlay(hire: false);
}
else
{
if (Fire(employeeId) == 1) RefreshAllCards();
}
});
CrashLog.Log($"CustomEmployee: Buttons configured for '{entry.EmployeeId}' (hired={entry.IsHired})");
}
private static void ShowOverlay(bool hire)
{
var hrSystems = UnityEngine.Object.FindObjectsOfType<HRSystem>();
if (hrSystems == null) return;
for (int i = 0; i < hrSystems.Count; i++)
{
var hr = hrSystems[i];
if (hr == null || !hr.gameObject.activeInHierarchy) continue;
var overlay = hire ? hr.confirmHireOverlay : hr.confirmFireOverlay;
overlay?.SetActive(true);
return;
}
}
// Called from Harmony prefix on ButtonConfirmHire. Returns true if handled (skip vanilla).
public static bool HandleConfirmHire(HRSystem hr)
{
if (_pendingEmployeeId == null || !_pendingIsHire) return false;
string id = _pendingEmployeeId;
_pendingEmployeeId = null;
CrashLog.Log($"HandleConfirmHire: confirming hire for '{id}'");
Hire(id);
hr.confirmHireOverlay?.SetActive(false);
RefreshAllCards();
return true;
}
// Called from Harmony prefix on ButtonConfirmFireEmployee. Returns true if handled.
public static bool HandleConfirmFire(HRSystem hr)
{
if (_pendingEmployeeId == null || _pendingIsHire) return false;
string id = _pendingEmployeeId;
_pendingEmployeeId = null;
CrashLog.Log($"HandleConfirmFire: confirming fire for '{id}'");
Fire(id);
hr.confirmFireOverlay?.SetActive(false);
RefreshAllCards();
return true;
}
public static void ClearPending() => _pendingEmployeeId = null;
public static void SaveState()
{
try
{
var lines = new List<string>();
foreach (var e in _employees)
if (e.IsHired)
lines.Add(e.EmployeeId);
File.WriteAllLines(_statePath, lines);
}
catch (Exception ex) { CrashLog.LogException("SaveState", ex); }
}
public static void LoadState()
{
try
{
if (!File.Exists(_statePath)) return;
var hired = new HashSet<string>(File.ReadAllLines(_statePath));
bool anyRestored = false;
foreach (var e in _employees)
{
if (hired.Contains(e.EmployeeId))
{
e.IsHired = true;
anyRestored = true;
CrashLog.Log($"LoadState: restored IsHired for '{e.EmployeeId}' ({e.Name}), salary={e.SalaryPerHour}");
}
}
if (anyRestored)
{
_salariesNeedReregistration = true;
CrashLog.Log("LoadState: salaries need re-registration (deferred until BalanceSheet is ready)");
}
}
catch (Exception ex) { CrashLog.LogException("LoadState", ex); }
}
// Deferred until BalanceSheet is available. Only acts once after LoadState sets the flag.
public static void ReregisterSalariesIfNeeded()
{
if (!_salariesNeedReregistration) return;
try
{
if (BalanceSheet.instance == null) return;
int count = 0;
foreach (var e in _employees)
{
if (e.IsHired)
{
BalanceSheet.instance.RegisterSalary((int)e.SalaryPerHour);
count++;
CrashLog.Log($"ReregisterSalariesIfNeeded: registered salary {e.SalaryPerHour} for '{e.EmployeeId}' ({e.Name})");
}
}
_salariesNeedReregistration = false;
CrashLog.Log($"ReregisterSalariesIfNeeded: done, re-registered {count} salary entries");
}
catch (Exception ex) { CrashLog.LogException("ReregisterSalariesIfNeeded", ex); }
}
// ButtonExtended is a Selectable subclass with its own onClick; falls back to standard Button.
private static void WireButtonExtendedClick(Transform buttonTransform, System.Action callback)
{
if (buttonTransform == null) return;
try
{
var btnExt = buttonTransform.GetComponent<ButtonExtended>();
if (btnExt != null)
{
// Replace entire event to nuke persistent listeners from cloned template
var freshEvent = new ButtonExtended.ButtonClickedEvent();
btnExt.m_OnClick = freshEvent;
UnityAction action = callback;
_liveCallbacks.Add(action);
freshEvent.AddListener(action);
CrashLog.Log($"CustomEmployee: Wired ButtonExtended.onClick on '{buttonTransform.name}'");
return;
}
}
catch (Exception ex)
{
CrashLog.LogException($"WireButtonExtendedClick on '{buttonTransform.name}'", ex);
}
// Fallback to standard Button
try
{
var button = buttonTransform.GetComponent<Button>();
if (button != null)
{
button.onClick = new Button.ButtonClickedEvent();
UnityAction action = callback;
_liveCallbacks.Add(action);
button.onClick.AddListener(action);
CrashLog.Log($"CustomEmployee: Wired Button.onClick fallback on '{buttonTransform.name}'");
return;
}
}
catch (Exception ex)
{
CrashLog.LogException($"WireButtonClick fallback on '{buttonTransform.name}'", ex);
}
CrashLog.Log($"CustomEmployee: No ButtonExtended or Button on '{buttonTransform.name}'");
}
private static void SetPortrait(Transform card, string employeeId)
{
try
{
var portraitTransform = card.Find("Image");
if (portraitTransform == null) return;
string assetsDir = Path.Combine(MelonEnvironment.UserDataDirectory, "ModAssets");
string imagePath = null;
foreach (var ext in new[] { ".jpg", ".png" })
{
string candidate = Path.Combine(assetsDir, employeeId + ext);
if (File.Exists(candidate)) { imagePath = candidate; break; }
}
if (imagePath != null)
{
try
{
byte[] imageData = File.ReadAllBytes(imagePath);
var tex = new Texture2D(2, 2, TextureFormat.RGBA32, false);
if (ImageConversion.LoadImage(tex, imageData))
{
var sprite = Sprite.Create(
tex,
new Rect(0, 0, tex.width, tex.height),
new Vector2(0.5f, 0.5f),
100f
);
var image = portraitTransform.GetComponent<Image>();
if (image != null)
{
image.sprite = sprite;
image.color = Color.white;
image.preserveAspect = true;
}
var rawImage = portraitTransform.GetComponent<RawImage>();
if (rawImage != null)
{
rawImage.texture = tex;
rawImage.color = Color.white;
}
CrashLog.Log($"CustomEmployee: Loaded portrait from '{imagePath}' for '{employeeId}'");
return;
}
else
{
CrashLog.Log($"CustomEmployee: Failed to decode image at '{imagePath}' for '{employeeId}'");
}
}
catch (Exception ex)
{
CrashLog.LogException($"SetPortrait: load image for '{employeeId}'", ex);
}
}
// Fallback: solid teal color
var fallbackImage = portraitTransform.GetComponent<Image>();
if (fallbackImage != null)
{
fallbackImage.sprite = null;
fallbackImage.color = new Color(0.0f, 0.6f, 0.7f, 1f);
}
var fallbackRaw = portraitTransform.GetComponent<RawImage>();
if (fallbackRaw != null)
{
fallbackRaw.texture = null;
fallbackRaw.color = new Color(0.0f, 0.6f, 0.7f, 1f);
}
}
catch (Exception ex)
{
CrashLog.LogException("SetPortrait", ex);
}
}
private static void RefreshAllCards()
{
try
{
var hrSystems = UnityEngine.Object.FindObjectsOfType<HRSystem>();
if (hrSystems == null) return;
for (int h = 0; h < hrSystems.Count; h++)
{
var hr = hrSystems[h];
if (hr == null) continue;
// Try scroll view content first, fall back to Grid
Transform contentGrid = null;
var scrollView = hr.transform.Find("ModScrollView");
if (scrollView != null)
contentGrid = scrollView.Find("Viewport/Content");
if (contentGrid == null)
contentGrid = hr.transform.Find("Grid");
if (contentGrid == null) continue;
foreach (var entry in _employees)
{
var cardTransform = contentGrid.Find("CustomEmployee_" + entry.EmployeeId);
if (cardTransform != null)
UpdateCard(cardTransform, entry);
}
}
}
catch (Exception ex)
{
CrashLog.LogException("RefreshAllCards", ex);
}
}
private static bool _hierarchyLogged = false;
private static void LogHierarchy(Transform t, int depth)
{
if (_hierarchyLogged) return;
if (depth == 0)
{
CrashLog.Log("=== HRSystem hierarchy dump ===");
_hierarchyLogged = true;
}
try
{
string indent = new string(' ', depth * 2);
string activeFlag = t.gameObject.activeSelf ? "" : " [INACTIVE]";
var components = t.gameObject.GetComponents<Component>();
var compNames = new List<string>();
if (components != null)
{
for (int i = 0; i < components.Count; i++)
{
try
{
var comp = components[i];
if (comp != null)
compNames.Add(comp.GetIl2CppType().Name);
}
catch { }
}
}
string compsStr = compNames.Count > 0 ? " [" + string.Join(", ", compNames) + "]" : "";
CrashLog.Log($"{indent}{t.name}{activeFlag}{compsStr}");
for (int i = 0; i < t.childCount; i++)
{
try { LogHierarchy(t.GetChild(i), depth + 1); }
catch { }
}
}
catch { }
if (depth == 0)
CrashLog.Log("=== end hierarchy dump ===");
}
}