mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
feat: Implement adjustable room capacity and quest team size
- Added MaxCapacity property to RoomEntity and updated related controllers to utilize it. - Modified RoomsController to set MaxPlayers based on room's MaxCapacity. - Introduced QuestTeamSize class to dynamically adjust quest party sizes beyond the default limit. - Updated DevMenu to include options for debugging and managing room settings. - Created UI elements for adjusting MaxCapacity in the admin interface. - Added configuration file for DevMenu with default settings.
This commit is contained in:
@@ -1,124 +0,0 @@
|
||||
// Native watch "Dev Console" injection — typed against the per-install Il2Cpp
|
||||
// interop assemblies (referenced in the csproj, dev-tools only, never shipped).
|
||||
// Pure reflection couldn't drive this build's watch UI (FindObjectOfType /
|
||||
// property-get / GetComponentsInChildren all failed under Il2CppInterop), so
|
||||
// we use real types here: clone an existing watch Button3D, relabel it, and
|
||||
// wire its OnClick to run console commands via DebugConsole.Execute (which
|
||||
// works headless even though the console UI isn't in the scene). It's the
|
||||
// game's own Button3D, so it's interactable with the VR laser AND the
|
||||
// Screen-Mode mouse. Gated behind Cfg.EnableDevWatchButton.
|
||||
using System;
|
||||
using Il2CppInterop.Runtime;
|
||||
using Il2CppAGUI.StackedUI;
|
||||
using Il2CppRecRoom.Debugging;
|
||||
using Il2CppTMPro;
|
||||
using UnityEngine.Events;
|
||||
using UObject = UnityEngine.Object;
|
||||
|
||||
namespace DorkNet.ClientMod;
|
||||
|
||||
internal static class DevWatchButton
|
||||
{
|
||||
private static int _frame;
|
||||
private static bool _done;
|
||||
|
||||
// Polled from Mod.OnUpdate while Cfg.EnableDevWatchButton is set. Waits for
|
||||
// the watch's HomeScreenFlow to exist, then injects once.
|
||||
public static void Tick()
|
||||
{
|
||||
if (_done) return;
|
||||
_frame++;
|
||||
if (_frame < 120 || (_frame % 30) != 0) return; // settle, then ~2/s
|
||||
var home = UObject.FindObjectOfType<HomeScreenFlow>();
|
||||
if (home == null && _frame < 3600) return; // wait for the watch
|
||||
_done = true;
|
||||
if (home == null) { Mod.Log.Warning("[devbtn] HomeScreenFlow never appeared"); return; }
|
||||
try { Inject(home); }
|
||||
catch (Exception ex) { Mod.Log.Warning($"[devbtn] inject failed: {ex}"); }
|
||||
}
|
||||
|
||||
private static void Inject(HomeScreenFlow home)
|
||||
{
|
||||
var buttons = home.GetComponentsInChildren<Button3D>(true);
|
||||
if (buttons == null || buttons.Length == 0)
|
||||
{
|
||||
Mod.Log.Warning("[devbtn] no Button3D found under HomeScreenFlow");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer a source button that HAS a TMP label, so our clones can be
|
||||
// relabeled (HomeButton etc. are icon-only — no text child).
|
||||
Button3D? src = null;
|
||||
for (int i = 0; i < buttons.Length; i++)
|
||||
if (buttons[i].GetComponentInChildren<TextMeshProUGUI>(true) != null) { src = buttons[i]; break; }
|
||||
bool labeled = src != null;
|
||||
if (src == null) src = buttons[0]; // fall back: inject anyway, just no text
|
||||
Mod.Log.Msg($"[devbtn] {buttons.Length} watch buttons; clone source='{src.name}' labeled={labeled}");
|
||||
|
||||
int n = 0;
|
||||
foreach (var (label, cmd) in ParsePresets())
|
||||
{
|
||||
var clone = UObject.Instantiate(src, src.transform.parent);
|
||||
clone.name = $"DorkNetDev_{label}";
|
||||
clone.gameObject.SetActive(true);
|
||||
clone.transform.localScale = src.transform.localScale;
|
||||
|
||||
var tmp = clone.GetComponentInChildren<TextMeshProUGUI>(true);
|
||||
if (tmp != null) tmp.text = label;
|
||||
|
||||
var command = cmd; // capture per-button
|
||||
clone.OnClick.RemoveAllListeners();
|
||||
clone.OnClick.AddListener(DelegateSupport.ConvertDelegate<UnityAction>((Action)(() => Run(label, command))));
|
||||
_clones.Add(clone);
|
||||
n++;
|
||||
}
|
||||
Mod.Log.Msg($"[devbtn] injected {n} dev command button(s) into the watch bar — open the watch to use them");
|
||||
DumpCommands();
|
||||
}
|
||||
|
||||
// Log the real console command names from DebugConsoleCommandConfig.Metas
|
||||
// so we can set DevCommands correctly (the asset loads even though the
|
||||
// console component isn't in the scene).
|
||||
private static void DumpCommands()
|
||||
{
|
||||
try
|
||||
{
|
||||
var cfgs = UObject.FindObjectsOfTypeAll(Il2CppType.Of<DebugConsoleCommandConfig>());
|
||||
Mod.Log.Msg($"[devcmds] {cfgs.Length} DebugConsoleCommandConfig asset(s) loaded");
|
||||
for (int c = 0; c < cfgs.Length; c++)
|
||||
{
|
||||
var cfg = cfgs[c].TryCast<DebugConsoleCommandConfig>();
|
||||
var metas = cfg?.Metas;
|
||||
if (metas == null) continue;
|
||||
Mod.Log.Msg($"[devcmds] {metas.Count} command(s):");
|
||||
for (int i = 0; i < metas.Count; i++)
|
||||
{
|
||||
var m = metas[i];
|
||||
Mod.Log.Msg($" {m.MethodName} ({m.DeclaringType})");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { Mod.Log.Warning($"[devcmds] failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static void Run(string label, string command)
|
||||
{
|
||||
Mod.Log.Msg($"[devbtn] '{label}' → DebugConsole.Execute(\"{command}\")");
|
||||
try { DebugConsole.Execute(command); }
|
||||
catch (Exception ex) { Mod.Log.Warning($"[devbtn] Execute('{command}') failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Presets come from Cfg.DevCommands as "Label=command" strings.
|
||||
private static System.Collections.Generic.IEnumerable<(string label, string cmd)> ParsePresets()
|
||||
{
|
||||
foreach (var entry in Mod.Cfg.DevCommands)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entry)) continue;
|
||||
var eq = entry.IndexOf('=');
|
||||
if (eq > 0) yield return (entry.Substring(0, eq).Trim(), entry.Substring(eq + 1).Trim());
|
||||
else yield return (entry.Trim(), entry.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly System.Collections.Generic.List<Button3D> _clones = new();
|
||||
}
|
||||
@@ -64,29 +64,4 @@
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Dev-tools only: typed references to the per-install Il2Cpp interop
|
||||
assemblies MelonLoader generates (Assembly-CSharp / UnityEngine.* /
|
||||
TextMeshPro). Used by the native watch "Dev Console" injection
|
||||
(DevWatchButton.cs), which needs real types (Button3D.OnClick,
|
||||
Object.Instantiate, TextMeshProUGUI, UnityAction) that pure reflection
|
||||
couldn't drive in this build. These DLLs are game-derived and are NOT
|
||||
redistributed — they're only referenced at build time and resolved at
|
||||
runtime from the player's own install. Private=false so they're never
|
||||
copied into the mod output. Override the dir with -p:Il2CppAsmDir=… .
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<Il2CppAsmDir Condition="'$(Il2CppAsmDir)' == ''">C:\Users\Alexa\Downloads\DorkRoom-2020.12\MelonLoader\Il2CppAssemblies</Il2CppAsmDir>
|
||||
</PropertyGroup>
|
||||
<ItemGroup Condition="Exists('$(Il2CppAsmDir)')">
|
||||
<Reference Include="Assembly-CSharp"><HintPath>$(Il2CppAsmDir)\Assembly-CSharp.dll</HintPath><Private>false</Private></Reference>
|
||||
<Reference Include="Il2Cppmscorlib"><HintPath>$(Il2CppAsmDir)\Il2Cppmscorlib.dll</HintPath><Private>false</Private></Reference>
|
||||
<Reference Include="UnityEngine.CoreModule"><HintPath>$(Il2CppAsmDir)\UnityEngine.CoreModule.dll</HintPath><Private>false</Private></Reference>
|
||||
<Reference Include="UnityEngine.UI"><HintPath>$(Il2CppAsmDir)\UnityEngine.UI.dll</HintPath><Private>false</Private></Reference>
|
||||
<Reference Include="UnityEngine.InputLegacyModule"><HintPath>$(Il2CppAsmDir)\UnityEngine.InputLegacyModule.dll</HintPath><Private>false</Private></Reference>
|
||||
<Reference Include="Unity.TextMeshPro"><HintPath>$(Il2CppAsmDir)\Unity.TextMeshPro.dll</HintPath><Private>false</Private></Reference>
|
||||
<Reference Include="Il2CppRecRoom.Promises.Runtime"><HintPath>$(Il2CppAsmDir)\Il2CppRecRoom.Promises.Runtime.dll</HintPath><Private>false</Private></Reference>
|
||||
<Reference Include="Il2CppRecRoom.Datastructures.Runtime"><HintPath>$(Il2CppAsmDir)\Il2CppRecRoom.Datastructures.Runtime.dll</HintPath><Private>false</Private></Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+16
-14
@@ -133,6 +133,15 @@ public class Mod : MelonMod
|
||||
// DebugConsole.Execute(command). Edit to match this build's actual
|
||||
// DebugConsoleCommandConfig command names/args.
|
||||
public static string[] DevCommands = { "Help=help", "Fly=fly", "NoClip=noclip" };
|
||||
|
||||
// Raise RRO quest party size past the baked 4. The cap lives in each
|
||||
// quest GameConfigurationAsset.TeamConfigurations[].MaxTeamSize
|
||||
// ScriptableObject (baked in the client bundle; no server lever). The
|
||||
// quest scoreboard + party HUD build rows dynamically, so they
|
||||
// auto-scale once the team cap is raised. 0 = off (leave at 4).
|
||||
// Only single-team, quest-named configs are touched, so PvP team
|
||||
// sizes (paintball/laser tag) are never changed. See QuestTeamSize.
|
||||
public static int QuestMaxTeamSize = 0;
|
||||
}
|
||||
|
||||
public override void OnInitializeMelon()
|
||||
@@ -331,17 +340,6 @@ public class Mod : MelonMod
|
||||
|
||||
RegisterJoinTracePatches();
|
||||
|
||||
// Debug console: skip CheatManager's tamper detectors so the
|
||||
// console's movement/time commands don't trip a dorm-drop. The
|
||||
// console UI toggle itself is driven from OnUpdate (hotkey) and
|
||||
// resolved lazily — only the anti-cheat skip needs a patch.
|
||||
if (Cfg.EnableDebugConsole)
|
||||
{
|
||||
foreach (var m in new[] { "OnTimeCheatDetected", "OnObscuredTypeCheatDetected", "OnHeightCheatDetected", "OnAdvancedMovementCheatDetected" })
|
||||
TryPatchByName("CheatManager", m, prefix: nameof(DebugConsolePatches.SuppressCheatDetected_Prefix));
|
||||
Log.Msg($"[debugconsole] enabled — CheatManager detectors silenced; press '{Cfg.DebugConsoleToggleKey}' to toggle the console.");
|
||||
}
|
||||
|
||||
// Desktop Screen Sharing FPS override. Only register when a target
|
||||
// is set; the postfix on the gadget's Awake rewrites the baked
|
||||
// refresh-frequency field (and, if enabled, raises the Photon
|
||||
@@ -556,9 +554,12 @@ public class Mod : MelonMod
|
||||
_firstUpdateLogged = true;
|
||||
Log.Msg("[lifecycle] first OnUpdate tick (Unity frame loop is live)");
|
||||
}
|
||||
if (Cfg.EnableDebugConsole) DebugConsolePatches.PollToggleKey();
|
||||
if (Cfg.DiagnoseDevMenu) DevMenuProbe.Tick();
|
||||
if (Cfg.EnableDevWatchButton) DevWatchButton.Tick();
|
||||
|
||||
// Quest party-size bump runs independent of the diagnostic flow below
|
||||
// (quest GameConfigurationAssets load when the quest room loads, so we
|
||||
// re-apply periodically). Gated behind QuestMaxTeamSize > 0.
|
||||
if (Cfg.QuestMaxTeamSize > 0) QuestTeamSize.Tick();
|
||||
|
||||
if (_diagnosticGameComplete) return;
|
||||
if (_diagnosticRetryFrame++ > 3600) return;
|
||||
if ((_diagnosticRetryFrame % 60) != 0) return;
|
||||
@@ -613,6 +614,7 @@ public class Mod : MelonMod
|
||||
if (r.TryGetProperty("DesktopScreenShareResolution", out v)) Cfg.DesktopScreenShareResolution = v.GetInt32();
|
||||
if (r.TryGetProperty("DesktopScreenShareQuality", out v)) Cfg.DesktopScreenShareQuality = v.GetInt32();
|
||||
if (r.TryGetProperty("DiagnoseDevMenu", out v)) Cfg.DiagnoseDevMenu = v.GetBoolean();
|
||||
if (r.TryGetProperty("QuestMaxTeamSize", out v)) Cfg.QuestMaxTeamSize = v.GetInt32();
|
||||
if (r.TryGetProperty("EnableDevWatchButton", out v)) Cfg.EnableDevWatchButton = v.GetBoolean();
|
||||
if (r.TryGetProperty("DevCommands", out v) && v.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
// Raise RRO quest party size past the baked 4.
|
||||
//
|
||||
// The cap is NOT in quest-specific code and NOT server-driven — it lives in
|
||||
// each quest's GameConfigurationAsset.TeamConfigurations[].MaxTeamSize, a
|
||||
// ScriptableObject baked into the client asset bundle. The quest scoreboard
|
||||
// (GameScoreboardBodyView instantiates rows into a List) and the party HUD are
|
||||
// fully dynamic, so they auto-scale once the team cap is raised.
|
||||
//
|
||||
// Implemented with reflection (this project references only MelonLoader /
|
||||
// Harmony / Il2CppInterop.Runtime — NOT the game's interop assemblies), the
|
||||
// same way DevMenuProbe drives the game. Quest configs load when the quest
|
||||
// room loads, so we poll from Mod.OnUpdate rather than running once, and only
|
||||
// touch single-team, quest-named configs so PvP team sizes (paintball/laser
|
||||
// tag, 2+ teams) are never changed. Every config is logged once so we can
|
||||
// confirm targeting (these MaxTeamSize values aren't in the static dump).
|
||||
// Gated behind Cfg.QuestMaxTeamSize.
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using Il2CppInterop.Runtime;
|
||||
|
||||
namespace DorkNet.ClientMod;
|
||||
|
||||
internal static class QuestTeamSize
|
||||
{
|
||||
private const BindingFlags BF = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
private static int _frame;
|
||||
private static readonly HashSet<string> _logged = new(StringComparer.Ordinal);
|
||||
|
||||
public static void Tick()
|
||||
{
|
||||
_frame++;
|
||||
if (_frame < 120 || (_frame % 120) != 0) return; // settle, then ~every 2s
|
||||
try { Apply(Mod.Cfg.QuestMaxTeamSize); }
|
||||
catch (Exception ex) { Mod.Log.Warning($"[questsize] failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static void Apply(int target)
|
||||
{
|
||||
if (target <= 0) return;
|
||||
var assetType = Mod.ResolveType("RecRoom.Core.GameManagement.GameConfigurationAsset");
|
||||
if (assetType is null) return;
|
||||
|
||||
// Invoke Il2CppType.From via reflection so the Il2CppSystem.Type
|
||||
// return value stays boxed as object — calling it directly would
|
||||
// need a compile reference to Il2Cppmscorlib, which we don't carry.
|
||||
var il2 = ToIl2CppType(assetType);
|
||||
if (il2 is null) return;
|
||||
|
||||
var resources = Mod.ResolveType("UnityEngine.Resources");
|
||||
var findAll = OneArgStatic(resources, "FindObjectsOfTypeAll", il2);
|
||||
var all = findAll?.Invoke(null, new[] { il2 });
|
||||
int n = ArrLen(all);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var asset = ArrItem(all, i);
|
||||
if (asset is null) continue;
|
||||
asset = EnsureConcrete(asset, assetType);
|
||||
|
||||
var name = ReadName(asset);
|
||||
var teams = GetMember(asset, "TeamConfigurations");
|
||||
if (teams is null) continue;
|
||||
int tn = ArrLen(teams);
|
||||
|
||||
if (_logged.Add(name))
|
||||
{
|
||||
var sizes = new List<string>(tn);
|
||||
for (int t = 0; t < tn; t++) sizes.Add(ReadInt(ArrItem(teams, t), "MaxTeamSize").ToString());
|
||||
Mod.Log.Msg($"[questsize] config '{name}' teams=[{string.Join(",", sizes)}] (n={tn})");
|
||||
}
|
||||
|
||||
// Quests are SINGLE-TEAM co-op. That's the reliable signal: PvP
|
||||
// modes have 2+ teams (Paintball [4,4], StuntRunner [1,1,1,1])
|
||||
// and Sandbox has many — only quests (the generic "Quest" config
|
||||
// AND each per-quest config like "The Rise Of JumboTron") are
|
||||
// n==1. Name-matching missed the per-quest configs, so we key off
|
||||
// team count instead and leave every 2+-team mode untouched.
|
||||
if (tn != 1) continue;
|
||||
|
||||
var changed = false;
|
||||
for (int t = 0; t < tn; t++)
|
||||
{
|
||||
var box = ArrItem(teams, t);
|
||||
if (box is null || ReadInt(box, "MaxTeamSize") >= target) continue;
|
||||
if (WriteInt(box, "MaxTeamSize", target) && ArrSet(teams, t, box)) changed = true;
|
||||
}
|
||||
if (changed) Mod.Log.Msg($"[questsize] bumped '{name}' → MaxTeamSize {target}");
|
||||
}
|
||||
}
|
||||
|
||||
// FindObjectsOfTypeAll returns base-typed elements; downcast to the
|
||||
// concrete interop type so reflection sees TeamConfigurations/Name.
|
||||
private static object EnsureConcrete(object obj, Type target)
|
||||
{
|
||||
if (target.IsInstanceOfType(obj)) return obj;
|
||||
var bt = obj.GetType();
|
||||
while (bt != null && bt.Name != "Il2CppObjectBase") bt = bt.BaseType;
|
||||
var cast = bt?.GetMethod("Cast", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (cast != null)
|
||||
{
|
||||
try { return cast.MakeGenericMethod(target).Invoke(obj, null) ?? obj; }
|
||||
catch { }
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
private static string ReadName(object asset)
|
||||
{
|
||||
if (GetMember(asset, "Name") is string n && n.Length > 0) return n;
|
||||
if (GetMember(asset, "name") is string un && un.Length > 0) return un;
|
||||
return "?";
|
||||
}
|
||||
|
||||
private static object? GetMember(object o, string name)
|
||||
{
|
||||
var t = o.GetType();
|
||||
var p = t.GetProperty(name, BF);
|
||||
if (p != null && p.CanRead && p.GetIndexParameters().Length == 0)
|
||||
{ try { return p.GetValue(o); } catch { } }
|
||||
var f = t.GetField(name, BF);
|
||||
if (f != null) { try { return f.GetValue(o); } catch { } }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ReadInt(object? o, string name)
|
||||
{
|
||||
if (o is null) return 0;
|
||||
var v = GetMember(o, name);
|
||||
if (v is int n) return n;
|
||||
return v != null && int.TryParse(v.ToString(), out var p) ? p : 0;
|
||||
}
|
||||
|
||||
private static bool WriteInt(object o, string name, int val)
|
||||
{
|
||||
var t = o.GetType();
|
||||
var p = t.GetProperty(name, BF);
|
||||
if (p != null && p.CanWrite) { try { p.SetValue(o, val); return true; } catch { } }
|
||||
var f = t.GetField(name, BF);
|
||||
if (f != null) { try { f.SetValue(o, val); return true; } catch { } }
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int ArrLen(object? arr)
|
||||
{
|
||||
if (arr is null) return 0;
|
||||
if (arr is Array a) return a.Length;
|
||||
var lp = arr.GetType().GetProperty("Length") ?? arr.GetType().GetProperty("Count");
|
||||
return (lp?.GetValue(arr) as int?) ?? 0;
|
||||
}
|
||||
|
||||
private static object? ArrItem(object? arr, int i)
|
||||
{
|
||||
if (arr is null) return null;
|
||||
if (arr is Array a) return a.GetValue(i);
|
||||
var gi = arr.GetType().GetMethod("get_Item", new[] { typeof(int) });
|
||||
return gi?.Invoke(arr, new object[] { i });
|
||||
}
|
||||
|
||||
private static bool ArrSet(object arr, int i, object val)
|
||||
{
|
||||
var si = arr.GetType().GetMethod("set_Item", new[] { typeof(int), val.GetType() })
|
||||
?? arr.GetType().GetMethod("set_Item");
|
||||
if (si is null) return false;
|
||||
try { si.Invoke(arr, new object[] { i, val }); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
private static object? ToIl2CppType(Type managed)
|
||||
{
|
||||
try
|
||||
{
|
||||
var t = typeof(Il2CppType);
|
||||
var from = t.GetMethod("From", new[] { typeof(Type), typeof(bool) })
|
||||
?? t.GetMethod("From", new[] { typeof(Type) });
|
||||
if (from is null) return null;
|
||||
var args = from.GetParameters().Length == 2
|
||||
? new object[] { managed, true } : new object[] { managed };
|
||||
return from.Invoke(null, args);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private static MethodInfo? OneArgStatic(Type? t, string name, object arg)
|
||||
{
|
||||
if (t is null) return null;
|
||||
foreach (var m in t.GetMethods(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
if (m.Name != name) continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length == 1 && ps[0].ParameterType.IsInstanceOfType(arg)) return m;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user