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:
Alexa
2026-06-08 01:27:30 +03:00
parent 64746cdaa2
commit 482c73e1bc
15 changed files with 661 additions and 57 deletions
@@ -1200,7 +1200,7 @@ public class RoomsController(
Name = "Home",
IsSandbox = false,
DataBlobName = dataBlobName,
MaxPlayers = 8,
MaxPlayers = room.MaxCapacity,
CanMatchmakeInto = true,
// Use the room's actual UpdatedAt so the watch's
// "is my local cache stale?" check sees an accurate
@@ -903,7 +903,8 @@ public class AdminController(
string? TagsCsv,
double? HotScore,
int? State,
string? ImageName);
string? ImageName,
int? MaxCapacity);
/// <summary>POST <c>api/admin/v1/rooms/{id}/props</c> — partial
/// update of room properties. Only the non-null fields in the
@@ -926,6 +927,13 @@ public class AdminController(
if (body.HotScore is double h) { room.HotScore = h; changes.Add($"hot={h}"); }
if (body.State is int st) { room.State = st; changes.Add($"state={st}"); }
if (body.ImageName is string img) { room.ImageName = img; changes.Add($"image={img}"); }
if (body.MaxCapacity is int cap)
{
// Clamp to a sane range. Note: advertised cap only on the 2020.12
// client today (no Photon hard-enforce) — see RoomEntity.MaxCapacity.
room.MaxCapacity = Math.Clamp(cap, 1, 80);
changes.Add($"maxCapacity={room.MaxCapacity}");
}
if (changes.Count == 0) return Ok(new { room.Id, unchanged = true });
@@ -989,6 +997,7 @@ public class AdminController(
room.SupportsWalkVR,
room.SupportsTeleportVR,
room.AllowsJuniors,
room.MaxCapacity,
room.DisableMicAutoMute,
room.RoomWarningMask,
room.CustomRoomWarning,
@@ -1026,6 +1026,7 @@ public class GoToController(
// baked-in DormRoom mapping for unknown names.
long roomId;
string locationId;
var maxCapacity = 8; // admin-adjustable per-room cap; 8 = default/fallback
if (roomName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase))
{
// Each player has their own dorm row (created at signup,
@@ -1038,6 +1039,7 @@ public class GoToController(
var personal = await rooms.EnsurePersonalDormAsync(callerPid);
roomId = personal.Id;
locationId = personal.LocationReplicationId;
maxCapacity = personal.MaxCapacity;
}
else
{
@@ -1055,6 +1057,7 @@ public class GoToController(
{
roomId = room.Id;
locationId = room.LocationReplicationId;
maxCapacity = room.MaxCapacity;
}
else
{
@@ -1103,7 +1106,7 @@ public class GoToController(
PhotonRegionId = photonRegion,
PhotonRoomId = photonRoom,
Name = dormInstanceName,
MaxCapacity = 8,
MaxCapacity = maxCapacity,
IsFull = false,
IsPrivate = false,
IsInProgress = true,
@@ -351,7 +351,7 @@ public class MatchPlayerController(
location: location,
dataBlob: dataBlob,
photonRegion: photonRegion,
maxCapacity: 8,
maxCapacity: personal.MaxCapacity,
photonRoomId: photonRoomId);
return new RoomInstanceDto
@@ -363,7 +363,7 @@ public class MatchPlayerController(
PhotonRegionId = photonRegion,
PhotonRoomId = photonRoomId,
Name = dormInstanceName,
MaxCapacity = 8,
MaxCapacity = personal.MaxCapacity,
IsFull = false,
IsPrivate = true,
IsInProgress = true,
@@ -54,6 +54,15 @@ public class RoomEntity
public bool SupportsWalkVR { get; set; } = true;
public bool SupportsTeleportVR { get; set; } = true;
public bool AllowsJuniors { get; set; } = true;
/// <summary>Max players the room advertises per instance — flows into the
/// matchmaking RoomInstance.MaxCapacity and the v4/details synthesized
/// Scenes[0].MaxPlayers. Admin-adjustable. NOTE: the 2020.12 client
/// doesn't hard-enforce this (it never sets Photon RoomOptions.MaxPlayers),
/// so today it's the advertised/intended cap; true enforcement needs a
/// ClientMod Photon patch (tracked as a follow-up).</summary>
public int MaxCapacity { get; set; } = 8;
public int RoomWarningMask { get; set; } = 0;
[MaxLength(512)]
@@ -103,6 +103,7 @@ public static class DatabaseBootstrap
await EnsureServerSettingsColumnsAsync(db);
await EnsureInventorySchemaAsync(db);
await EnsureLeaderboardSchemaAsync(db);
await EnsureRoomColumnsAsync(db);
// Coach system account at Player.Id=1. The RR-Original room seeder
// below assigns CreatorPlayerId=1 to every canonical room
@@ -239,6 +240,30 @@ public static class DatabaseBootstrap
await db.Database.ExecuteSqlRawAsync(sql);
}
/// <summary>Add post-Initial columns to the Rooms table. Like the other
/// Ensure*Columns helpers, this exists because the Postgres path is
/// EnsureCreated-only (no migration replay), so a new entity property
/// won't appear on an existing DB without an idempotent ALTER.</summary>
private static async Task EnsureRoomColumnsAsync(DorkNetDbContext db)
{
var provider = db.Database.ProviderName ?? string.Empty;
if (provider.Contains("Npgsql", StringComparison.OrdinalIgnoreCase))
{
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Rooms"" ADD COLUMN IF NOT EXISTS ""MaxCapacity"" integer NOT NULL DEFAULT 8;");
return;
}
try
{
// SQLite has no ADD COLUMN IF NOT EXISTS; ignore the duplicate-
// column error when it already exists.
await db.Database.ExecuteSqlRawAsync(
@"ALTER TABLE ""Rooms"" ADD COLUMN ""MaxCapacity"" INTEGER NOT NULL DEFAULT 8;");
}
catch { }
}
private static async Task EnsureServerSettingsColumnsAsync(DorkNetDbContext db)
{
var provider = db.Database.ProviderName ?? string.Empty;
+1
View File
@@ -142,6 +142,7 @@ export interface RoomDetail {
supportsWalkVR: boolean;
supportsTeleportVR: boolean;
allowsJuniors: boolean;
maxCapacity: number;
disableMicAutoMute: boolean;
roomWarningMask: number;
customRoomWarning: string;
@@ -166,6 +166,7 @@ function GeneralTab({ room, onSaved }: { room: RoomDetail; onSaved: () => void }
const [tagsCsv, setTagsCsv] = useState(room.tagsCsv);
const [hotScore, setHotScore] = useState(String(room.hotScore));
const [imageName, setImageName] = useState(room.imageName);
const [maxCapacity, setMaxCapacity] = useState(String(room.maxCapacity ?? 8));
const [busy, setBusy] = useState(false);
// Re-sync local state if a refresh brings new server values in.
@@ -176,12 +177,14 @@ function GeneralTab({ room, onSaved }: { room: RoomDetail; onSaved: () => void }
setTagsCsv(room.tagsCsv);
setHotScore(String(room.hotScore));
setImageName(room.imageName);
setMaxCapacity(String(room.maxCapacity ?? 8));
}, [room.id, room.updatedAt]);
const save = async () => {
setBusy(true);
try {
const hot = Number(hotScore);
const cap = parseInt(maxCapacity, 10);
await api(`/rooms/${room.id}/props`, {
method: 'POST',
body: {
@@ -191,6 +194,7 @@ function GeneralTab({ room, onSaved }: { room: RoomDetail; onSaved: () => void }
TagsCsv: tagsCsv,
HotScore: Number.isFinite(hot) ? hot : undefined,
ImageName: imageName,
MaxCapacity: Number.isFinite(cap) ? cap : undefined,
},
});
toast.push('Saved', 'success');
@@ -236,6 +240,20 @@ function GeneralTab({ room, onSaved }: { room: RoomDetail; onSaved: () => void }
className="input tabular-nums"
/>
</label>
<label className="flex flex-col gap-1">
<span className="label">Max players (capacity)</span>
<input
type="number"
min={1}
max={80}
value={maxCapacity}
onChange={e => setMaxCapacity(e.target.value)}
className="input tabular-nums"
/>
<span className="text-[11px] text-ink-500">
Advertised cap on the 2020 client (not hard-enforced yet). 1–80.
</span>
</label>
<label className="flex flex-col gap-1">
<span className="label">Tags (comma-separated)</span>
<input