Skip to content

Issues backlog: PhoneApp onDestroyed, custom commands enhancement, employee appearances, more checks in CombatBehaviour#84

Open
k073l wants to merge 4 commits into
ifBars:stablefrom
k073l:backlog
Open

Issues backlog: PhoneApp onDestroyed, custom commands enhancement, employee appearances, more checks in CombatBehaviour#84
k073l wants to merge 4 commits into
ifBars:stablefrom
k073l:backlog

Conversation

@k073l
Copy link
Copy Markdown
Collaborator

@k073l k073l commented Jun 5, 2026

Closes a bunch of issues: #74, #76, #83.

Also closes one issue from Trello - custom commands now appear in the command list screen, just like native commands do.

Release Notes

  • PhoneApp lifecycle management: Implemented OnDestroy() wiring for PhoneAppButtonHandler to properly tear down the associated PhoneApp when the button handler component is destroyed
  • Custom commands UI integration: Custom commands now appear in the command list screen alongside native commands via updated console patches
  • Employee appearance API: Exposed employee appearance data through new EmployeeManager and EmployeeAppearance wrapper classes with access to appearance settings and mugshots
  • Combat behavior improvements: Refactored CombatBehaviour.DefaultWeaponAssetPath setter with safer type casting using CrossType.Is checks, switched to melonlogger for error reporting, and updated XML documentation
  • Internal registry accessibility: Made CustomConsoleRegistry.registry internal to support assembly-level access for custom command integration

Contributions by Author

Author Lines Added Lines Removed Net Change
k0 93 8 +85
ifBars 89 1 +88
Total 182 9 +173

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 5, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR enhances console command registration, adds a type-safe employee appearance API, and improves error handling with centralized logging and component lifecycle management. The custom console registry is exposed to allow UI injection patches to register commands in the in-game command list, the new EmployeeManager provides safe access to base-game employee data, and CombatBehaviour refactors error reporting through Logger while PhoneAppButtonHandler ensures proper cleanup.

Changes

Custom Console Commands UI Integration

Layer / File(s) Summary
Registry accessibility for patch integration
S1API/Console/CustomConsoleRegistry.cs
CustomConsoleRegistry's registry field changes from private to internal, enabling ConsolePatches to access and iterate registered custom commands.
Console command list UI patch and injection
S1API/Internal/Patches/ConsolePatches.cs
ConsolePatches adds Harmony postfix for S1CommandListScreen.Start to inject custom command entries into the in-game command list UI, handling deduplication and both IL2CPP and Mono builds. Conditional usings route to correct IL2CPP types.

Employee Appearance Wrapper API

Layer / File(s) Summary
Employee appearance wrapper types and API
S1API/Entities/Employees/EmployeeManager.cs
New EmployeeManager with GetAppearance and GetRandomAppearance static methods safely query the base-game employee system with null checks and error logging. EmployeeAppearance wraps appearance data (Settings, Mugshot) with conditional compilation for IL2CPP vs Mono.

Error Handling and Component Lifecycle Management

Layer / File(s) Summary
CombatBehaviour weapon-loading error handling
S1API/Entities/Behaviour/CombatBehaviour.cs
Adds Logger instance and refactors DefaultWeaponAssetPath setter to use CrossType.Is validation checks and centralized Logger.Error instead of Debug.LogError, with early return on validation failure. XML documentation updated with preferred setter guidance.
PhoneApp component cleanup on destroy
S1API/PhoneApp/PhoneApp.cs
PhoneAppButtonHandler implements OnDestroy lifecycle hook to call DestroyInternal on its associated PhoneApp, ensuring proper teardown when the handler component is destroyed.

Sequence Diagram(s)

sequenceDiagram
  participant CommandListScreen
  participant ConsolePatches
  participant CustomConsoleRegistry
  participant UIContainer
  CommandListScreen->>ConsolePatches: S1CommandListScreen.Start (Harmony postfix)
  ConsolePatches->>CustomConsoleRegistry: iterate registry.Keys
  loop For each custom command
    ConsolePatches->>UIContainer: instantiate command entry prefab
    ConsolePatches->>UIContainer: populate Command/Description/Example text
    ConsolePatches->>UIContainer: append entry to commandEntries
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested labels

bug

Suggested reviewers

  • ifBars

Poem

🐰 A rabbit hops through console halls,
With custom commands now on call,
Employee data wrapped up tight,
PhoneApp cleanup, lifecycle right!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The title addresses multiple unrelated changes across different features (PhoneApp, custom commands, employee appearances, CombatBehaviour), making it overly broad and generic without clearly conveying the main purpose of the changeset. Consider using a more focused title that either highlights the primary feature or uses a structured format like 'Backlog improvements' with detailed changelog in the description.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed Docstring coverage is 60.00% which is sufficient. The required threshold is 50.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot added the bug Something isn't working label Jun 5, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
S1API/Console/CustomConsoleRegistry.cs (1)

14-14: ⚡ Quick win

Keep the registry encapsulated.

ConsolePatches only needs to enumerate commands. Making the field itself internal lets any internal caller bypass Register()'s trim/empty-key checks and mutate the dictionary directly. Expose a read-only view or iterator instead.

Suggested change
-        internal static readonly Dictionary<string, BaseConsoleCommand> registry = new Dictionary<string, BaseConsoleCommand>(StringComparer.OrdinalIgnoreCase);
+        private static readonly Dictionary<string, BaseConsoleCommand> registry = new Dictionary<string, BaseConsoleCommand>(StringComparer.OrdinalIgnoreCase);
+        internal static IReadOnlyDictionary<string, BaseConsoleCommand> Registry => registry;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@S1API/Console/CustomConsoleRegistry.cs` at line 14, The registry field is
currently internal which allows bypassing Register() validations; make the
dictionary private (private static readonly Dictionary<string,
BaseConsoleCommand> registry) and expose a read-only view or enumerator instead
(for example a public/static IReadOnlyDictionary<string, BaseConsoleCommand> or
a method IEnumerable<KeyValuePair<string,BaseConsoleCommand>>
GetRegisteredCommands()) so ConsolePatches can enumerate without mutating; keep
Register() (and its trim/empty-key checks) as the single mutating entry point
and return or expose only the read-only wrapper from the new accessor.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@S1API/Entities/Behaviour/CombatBehaviour.cs`:
- Around line 92-93: The DefaultWeaponAssetPath setter currently calls
Object.Instantiate(gameObject) and then GetComponent<AvatarEquippable>, which
creates an undeleted runtime clone; instead, remove the Instantiate call and
validate the assigned prefab directly by calling
GetComponent<AvatarEquippable>() or GetComponentInChildren<AvatarEquippable>()
on the prefab reference (or this.gameObject/prefab variable used in the setter)
to check for AvatarEquippable, and keep the existing null-handling (log/throw)
path if the component is missing.

In `@S1API/Entities/Employees/EmployeeManager.cs`:
- Line 33: The wrapper must guard against nulls from the underlying API: change
the EmployeeManager appearance wrapper so that when
S1Employees.EmployeeManager.Instance.GetAppearance(male, index) returns null you
return null (or propagate appropriately) instead of always calling new
EmployeeAppearance(...); similarly update GetRandomAppearance to use the out
AvatarSettings? avatarSettings only if the base call returns true and
avatarSettings is non-null — do not always construct new
AvatarSettings(avatarSettings) or always return true; instead call
S1Employees.EmployeeManager.Instance.GetRandomAppearance(...) and if it returns
false or the out avatarSettings is null, set the wrapper out to null and return
false, otherwise construct new AvatarSettings(avatarSettings) and return true.
Ensure you reference EmployeeAppearance, EmployeeManager.Instance.GetAppearance,
GetRandomAppearance, AvatarSettings and the avatarSettings out parameter when
applying the checks.

In `@S1API/Internal/Patches/ConsolePatches.cs`:
- Line 43: The static HashSet _addedCommandsToList is being cleared only in
AddCommands() via S1Console.Awake which prevents newly created
S1CommandListScreen instances from receiving custom entries; move or add the
reset so duplicate tracking is cleared at the start of each command-list screen
creation by clearing _addedCommandsToList at the beginning of
AddCustomCommandEntries() (or change dedupe to an instance-scoped set inside
S1CommandListScreen) so each S1CommandListScreen gets its own fresh tracking and
custom keys are added correctly.
- Around line 174-189: The postfix currently adds custom command entries even
when their CommandWord collides with a built-in command; before instantiating or
adding an entry inside the foreach over CustomConsoleRegistry.registry.Keys,
check for collisions with the game's native registry (e.g., S1Console.commands
or the equivalent built-in command map) and skip entries whose key already
exists there so only executable custom commands are shown; update the logic
around CustomConsoleRegistry.registry, _addedCommandsToList, CommandEntryPrefab
and CommandEntryContainer to continue when a built-in command owns the key.

In `@S1API/PhoneApp/PhoneApp.cs`:
- Around line 651-655: OnDestroy currently unconditionally calls DestroyInternal
and can re-enter destruction when PhoneApp.OnDestroyed destroys _appPanel (which
tears down the handler); add a re-entrancy guard: introduce a private bool
(e.g., _isDestroying) on PhoneApp, set it true at the start of DestroyInternal
(or OnDestroyed) and skip any further destruction if it's already true, and
update OnDestroy to check that flag (or phoneApp != null &&
!phoneApp._isDestroying) before calling DestroyInternal to prevent double runs.

---

Nitpick comments:
In `@S1API/Console/CustomConsoleRegistry.cs`:
- Line 14: The registry field is currently internal which allows bypassing
Register() validations; make the dictionary private (private static readonly
Dictionary<string, BaseConsoleCommand> registry) and expose a read-only view or
enumerator instead (for example a public/static IReadOnlyDictionary<string,
BaseConsoleCommand> or a method
IEnumerable<KeyValuePair<string,BaseConsoleCommand>> GetRegisteredCommands()) so
ConsolePatches can enumerate without mutating; keep Register() (and its
trim/empty-key checks) as the single mutating entry point and return or expose
only the read-only wrapper from the new accessor.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ddf3d5e3-e041-4fb5-ab8d-cc937bd56ae3

📥 Commits

Reviewing files that changed from the base of the PR and between f6dfcfc and b6197c2.

📒 Files selected for processing (5)
  • S1API/Console/CustomConsoleRegistry.cs
  • S1API/Entities/Behaviour/CombatBehaviour.cs
  • S1API/Entities/Employees/EmployeeManager.cs
  • S1API/Internal/Patches/ConsolePatches.cs
  • S1API/PhoneApp/PhoneApp.cs

Comment on lines +92 to 93
var equippable = Object.Instantiate(gameObject).GetComponent<AvatarEquippable>();
if (equippable == null)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In Unity, does Object.Instantiate(GameObject) create a runtime scene clone that should be destroyed if used only for metadata/type checks?

💡 Result:

Yes, calling Object.Instantiate(GameObject) creates a complete, functional clone of the object hierarchy in the runtime scene [1][2]. It is an expensive operation that performs serialization and deserialization, allocates memory, registers the new objects with the engine's internal systems (physics, rendering, scripting), and triggers lifecycle methods like Awake and OnEnable [1][3]. Using Instantiate solely for metadata or type checks is strongly discouraged because: 1. Performance Impact: It incurs significant CPU and memory overhead, including Garbage Collection (GC) allocations, which can cause performance hitches [4][3]. 2. Lifecycle Side Effects: Because it creates a full clone, components' Awake and OnEnable methods will execute on the new instance, which may cause unintended side effects or errors if your code is not designed for it [1]. 3. Resource Management: If you instantiate an object for a temporary check, you must manually call Object.Destroy(clone) to remove it from the scene; otherwise, you will create a memory leak and clutter your scene with useless objects [3]. For metadata or type checks, you should access the original GameObject or prefab asset directly without instantiating it. You can access components, check types (e.g., using GetComponent or Type comparisons), and read serializable data directly from the reference without creating a runtime clone [1][2]. If you need to inspect an asset's properties that are only available at runtime, consider using Editor-time tools or custom serialization approaches that do not require full instantiation [2][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the file and show relevant surrounding code (small slice)
FILE="S1API/Entities/Behaviour/CombatBehaviour.cs"
if [ ! -f "$FILE" ]; then
  echo "Missing file: $FILE" >&2
  exit 1
fi

echo "---- Lines around 80-120 ----"
nl -ba "$FILE" | sed -n '70,130p'

echo "---- Search for DefaultWeaponAssetPath ----"
rg -n "DefaultWeaponAssetPath" "$FILE" || true

echo "---- Search for Instantiate(gameObject) / Instantiate( ----"
rg -n "Instantiate\s*\(" "$FILE" || true

echo "---- Search for Destroy( / DestroyImmediate( ----"
rg -n "Destroy(Immediate)?\s*\(" "$FILE" || true

echo "---- Show enclosing method for lines 92-93 ----"
# Try to find nearest method signature above the target lines
# Using awk to print from nearest earlier line containing typical method keywords.
TARGET_START=92
TARGET_END=93
nl -ba "$FILE" | awk -v s="$TARGET_START" -v e="$TARGET_END" '
  NR==s {found=1}
  found==1 && NR>e {exit}
  {
    if (found==0) {last_sig=$0}
    if (NR<=e) {
      # heuristics: store last line that looks like method signature
      if ($0 ~ /(public|private|protected|internal)\s+.*\)\s*\{/) last_sig=$0
      if ($0 ~ /void\s+\w+\s*\(.*\)\s*\{/) last_sig=$0
      if ($0 ~ /\bTask\b.*\)\s*\{/) last_sig=$0
    }
  }
  END{
    print "Last likely method signature before target:\n" last_sig
  }' 2>/dev/null || true

Repository: ifBars/S1API

Length of output: 148


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="S1API/Entities/Behaviour/CombatBehaviour.cs"
if [ ! -f "$FILE" ]; then
  echo "Missing file: $FILE" >&2
  exit 1
fi

echo "---- Lines 70-130 (with numbers via awk) ----"
awk 'NR>=70 && NR<=130 {printf "%d\t%s\n", NR, $0}' "$FILE" | sed -n '1,80p'

echo "---- Search for DefaultWeaponAssetPath ----"
rg -n "DefaultWeaponAssetPath" "$FILE" || true

echo "---- Search for Instantiate(gameObject) ----"
rg -n "Instantiate\s*\(" "$FILE" || true

echo "---- Search for Destroy( / DestroyImmediate( ----"
rg -n "Destroy(Immediate)?\s*\(" "$FILE" || true

Repository: ifBars/S1API

Length of output: 3033


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Search for any Destroy usage within the file
FILE="S1API/Entities/Behaviour/CombatBehaviour.cs"
echo "---- Destroy usage in file ----"
rg -n "Destroy(Immediate)?\s*\(" "$FILE" || true

# 2) Show the full property setter area (broader context)
echo "---- Lines 60-120 ----"
awk 'NR>=55 && NR<=120 {printf "%d\t%s\n", NR, $0}' "$FILE" | sed -n '1,120p'

# 3) Find all writes/usages of DefaultWeaponAssetPath across the repo
echo "---- Repo-wide usage of DefaultWeaponAssetPath ----"
rg -n "DefaultWeaponAssetPath\s*=" -S . || true

echo "---- Repo-wide reads/usages of DefaultWeaponAssetPath ----"
rg -n "DefaultWeaponAssetPath" -S . || true

Repository: ifBars/S1API

Length of output: 3271


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="S1API/Entities/Behaviour/CombatBehaviour.cs"

echo "---- Lines 150-220 ----"
awk 'NR>=150 && NR<=220 {printf "%d\t%s\n", NR, $0}' "$FILE"

echo "---- All occurrences of DefaultWeaponAssetPath (repo-wide) ----"
rg -n "DefaultWeaponAssetPath" -S . || true

Repository: ifBars/S1API

Length of output: 3627


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find AvatarEquippable definition
echo "---- Finding AvatarEquippable files ----"
fd -a "AvatarEquippable" . || true

# Also search for class name
rg -n "class\s+AvatarEquippable\b" -S . || true

# Show AvatarEquippable Awake/OnEnable/Start/OnDestroy if present
# (best-effort; we only need whether it self-destructs)
rg -n "AvatarEquippable.*\{|\bvoid\s+(Awake|OnEnable|Start|OnDestroy)\b|Destroy" -S . || true

Repository: ifBars/S1API

Length of output: 11307


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="S1API/Entities/Behaviour/CombatBehaviour.cs"

echo "---- Search for GetAssetPathFromEquippable ----"
rg -n "GetAssetPathFromEquippable" "$FILE" || true

echo "---- Show method(s) around GetAssetPathFromEquippable ----"
# Print a window around each match (awk line numbers)
python3 - <<'PY'
import re, pathlib
p=pathlib.Path("S1API/Entities/Behaviour/CombatBehaviour.cs")
txt=p.read_text()
lines=txt.splitlines()
pat=re.compile(r'GetAssetPathFromEquippable')
for i,l in enumerate(lines, start=1):
    if pat.search(l):
        start=max(1,i-20); end=min(len(lines), i+80)
        print(f"\n--- window around line {i} ({start}-{end}) ---")
        for j in range(start, end+1):
            print(f"{j}\t{lines[j-1]}")
PY

echo "---- Search for Instantiate(gameObject) in this file ----"
rg -n "Instantiate\s*\(" "$FILE" || true

Repository: ifBars/S1API

Length of output: 11934


Avoid Instantiate in DefaultWeaponAssetPath setter (lines 92-93)

Object.Instantiate(gameObject) creates a full runtime clone every time DefaultWeaponAssetPath is set, and this code path never destroys that instance. Instead, validate the prefab asset directly via GetComponent/GetComponentInChildren to avoid extra allocations and lifecycle side effects.

Proposed fix
-            var equippable = Object.Instantiate(gameObject).GetComponent<AvatarEquippable>();
+            var equippable = gameObject.GetComponent<AvatarEquippable>()
+                             ?? gameObject.GetComponentInChildren<AvatarEquippable>(true);
             if (equippable == null)
             {
                 Logger.Error("Could not get AvatarEquippable from weapon at path: " + value);
                 return;
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var equippable = Object.Instantiate(gameObject).GetComponent<AvatarEquippable>();
if (equippable == null)
var equippable = gameObject.GetComponent<AvatarEquippable>()
?? gameObject.GetComponentInChildren<AvatarEquippable>(true);
if (equippable == null)
{
Logger.Error("Could not get AvatarEquippable from weapon at path: " + value);
return;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@S1API/Entities/Behaviour/CombatBehaviour.cs` around lines 92 - 93, The
DefaultWeaponAssetPath setter currently calls Object.Instantiate(gameObject) and
then GetComponent<AvatarEquippable>, which creates an undeleted runtime clone;
instead, remove the Instantiate call and validate the assigned prefab directly
by calling GetComponent<AvatarEquippable>() or
GetComponentInChildren<AvatarEquippable>() on the prefab reference (or
this.gameObject/prefab variable used in the setter) to check for
AvatarEquippable, and keep the existing null-handling (log/throw) path if the
component is missing.

return null;
}

return new EmployeeAppearance(S1Employees.EmployeeManager.Instance.GetAppearance(male, index));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find all declarations/usages to confirm return types and failure conventions.
rg -nP --type=cs -C3 '\b(GetAppearance|GetRandomAppearance)\s*\('
rg -nP --type=cs -C3 '\bclass\s+EmployeeAppearance\b|\bclass\s+AvatarSettings\b'

Repository: ifBars/S1API

Length of output: 3944


Fix null-safety at wrapper boundary in EmployeeManager appearance APIs

GetAppearance(...) is nullable but wraps the base-game return value directly (new EmployeeAppearance(...GetAppearance...)) without guarding against a null base result. Likewise, GetRandomAppearance(...) always returns true and always constructs new AvatarSettings(avatarSettings) even though the base out parameter (avatarSettings) may be null per the wrapper’s nullable AvatarSettings? contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@S1API/Entities/Employees/EmployeeManager.cs` at line 33, The wrapper must
guard against nulls from the underlying API: change the EmployeeManager
appearance wrapper so that when
S1Employees.EmployeeManager.Instance.GetAppearance(male, index) returns null you
return null (or propagate appropriately) instead of always calling new
EmployeeAppearance(...); similarly update GetRandomAppearance to use the out
AvatarSettings? avatarSettings only if the base call returns true and
avatarSettings is non-null — do not always construct new
AvatarSettings(avatarSettings) or always return true; instead call
S1Employees.EmployeeManager.Instance.GetRandomAppearance(...) and if it returns
false or the out avatarSettings is null, set the wrapper out to null and return
false, otherwise construct new AvatarSettings(avatarSettings) and return true.
Ensure you reference EmployeeAppearance, EmployeeManager.Instance.GetAppearance,
GetRandomAppearance, AvatarSettings and the avatarSettings out parameter when
applying the checks.

if (__instance == null)
return;

_addedCommandsToList.Clear();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset duplicate tracking when the command list screen starts.

_addedCommandsToList is static, but it is cleared in AddCommands() on S1Console.Awake. If the game creates a fresh S1CommandListScreen later in the same console lifetime, every custom key is still marked as already added and the new screen gets no custom entries. Clear this set in AddCustomCommandEntries() or scope dedupe to the current screen instance.

Suggested change
-            _addedCommandsToList.Clear();
             var commandTypes = ReflectionUtils.GetDerivedClasses<BaseConsoleCommand>();
             foreach (var type in commandTypes)
             {
                 Logger.Msg($"Found console command: {type.FullName}");
@@
         private static void AddCustomCommandEntries(S1CommandListScreen __instance)
         {
             try
             {
+                _addedCommandsToList.Clear();
                 if (__instance == null || __instance.CommandEntryPrefab == null ||
                     __instance.CommandEntryContainer == null)
                     return;

Also applies to: 151-164

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@S1API/Internal/Patches/ConsolePatches.cs` at line 43, The static HashSet
_addedCommandsToList is being cleared only in AddCommands() via S1Console.Awake
which prevents newly created S1CommandListScreen instances from receiving custom
entries; move or add the reset so duplicate tracking is cleared at the start of
each command-list screen creation by clearing _addedCommandsToList at the
beginning of AddCustomCommandEntries() (or change dedupe to an instance-scoped
set inside S1CommandListScreen) so each S1CommandListScreen gets its own fresh
tracking and custom keys are added correctly.

Comment on lines +174 to +189
foreach (var commandKey in CustomConsoleRegistry.registry.Keys)
{
try
{
if (_addedCommandsToList.Contains(commandKey))
continue;
var rt = Object.Instantiate(__instance.CommandEntryPrefab, __instance.CommandEntryContainer);
rt.Find("Command").GetComponent<TextMeshProUGUI>().text =
CustomConsoleRegistry.registry[commandKey].CommandWord;
rt.Find("Description").GetComponent<TextMeshProUGUI>().text =
CustomConsoleRegistry.registry[commandKey].CommandDescription;
rt.Find("Example").GetComponent<TextMeshProUGUI>().text =
CustomConsoleRegistry.registry[commandKey].ExampleUsage;

commandEntries?.Add(rt);
_addedCommandsToList.Add(commandKey);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't inject rows for commands the base console will shadow.

Both routing prefixes hand execution back to S1Console.commands when a native command already owns the key, but this postfix still injects the custom entry unconditionally. A custom CommandWord that collides with a built-in command will be visible in the list even though the player can never execute it. Filter those collisions here, or reject them during registration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@S1API/Internal/Patches/ConsolePatches.cs` around lines 174 - 189, The postfix
currently adds custom command entries even when their CommandWord collides with
a built-in command; before instantiating or adding an entry inside the foreach
over CustomConsoleRegistry.registry.Keys, check for collisions with the game's
native registry (e.g., S1Console.commands or the equivalent built-in command
map) and skip entries whose key already exists there so only executable custom
commands are shown; update the logic around CustomConsoleRegistry.registry,
_addedCommandsToList, CommandEntryPrefab and CommandEntryContainer to continue
when a built-in command owns the key.

Comment on lines +651 to +655
private void OnDestroy()
{
// Destroy phone app when button handler is destroyed
phoneApp?.DestroyInternal();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent re-entrant destruction from handler teardown.

OnDestroy() at Line 654 unconditionally calls DestroyInternal(). When PhoneApp.OnDestroyed() (Line 188+) destroys _appPanel (Line 192), this handler is destroyed too and re-enters DestroyInternal(), causing OnDestroyed() to run twice.

Suggested guard-based fix
 public abstract class PhoneApp : Registerable
 {
+    private bool _isDestroying;
+    internal bool IsDestroying => _isDestroying;
 ...
     protected override void OnDestroyed()
     {
+        if (_isDestroying) return;
+        _isDestroying = true;
+
         if (_appPanel != null)
         {
             Object.Destroy(_appPanel);
             _appPanel = null;
         }
         ...
     }
 }
 ...
 internal class PhoneAppButtonHandler : MonoBehaviour
 {
     internal PhoneApp phoneApp;
 ...
     private void OnDestroy()
     {
-        // Destroy phone app when button handler is destroyed
-        phoneApp?.DestroyInternal();
+        if (phoneApp != null && !phoneApp.IsDestroying)
+            phoneApp.DestroyInternal();
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@S1API/PhoneApp/PhoneApp.cs` around lines 651 - 655, OnDestroy currently
unconditionally calls DestroyInternal and can re-enter destruction when
PhoneApp.OnDestroyed destroys _appPanel (which tears down the handler); add a
re-entrancy guard: introduce a private bool (e.g., _isDestroying) on PhoneApp,
set it true at the start of DestroyInternal (or OnDestroyed) and skip any
further destruction if it's already true, and update OnDestroy to check that
flag (or phoneApp != null && !phoneApp._isDestroying) before calling
DestroyInternal to prevent double runs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

1 participant