diff --git a/DorkNet.ClientMod/Mod.cs b/DorkNet.ClientMod/Mod.cs index 150cdb4..10d3f19 100644 --- a/DorkNet.ClientMod/Mod.cs +++ b/DorkNet.ClientMod/Mod.cs @@ -1142,6 +1142,95 @@ internal static class DevMenuProbe if (pn.Count > 0) Mod.Log.Msg($" [propnames] {string.Join(", ", pn.GetRange(0, Math.Min(40, pn.Count)))}"); DumpWatchButtons(t, inst); DumpCollections(t, inst, "HomeScreenFlow"); + DumpStackedUi(t, inst); + } + + // Walk HomeScreenFlow -> UIBase (StackedUIBase) and dump the data we need + // to build a CUSTOM watch page by cloning a real flow: + // * flowResources catalog — the TypeName of every PUSHABLE flow; these + // are our clone-target candidates (pick a simple self-contained one). + // * the Type->flow cache — what's already instantiated (do NOT mutate + // these cached singletons; clone an independent copy instead). + // * ScreenHeader presence — proves a cloned flow brings its own title + // bar + back button for free. + // Names are obfuscated, so we match by SHAPE (array-of-prefab-ref, + // Dictionary) rather than by field name. + private static void DumpStackedUi(Type homeType, object homeInst) + { + var ui = GetMemberValue(homeType, homeInst, "UIBase", "get_UIBase"); + if (ui is null) { Mod.Log.Msg(" [stackedui] UIBase: null (could not reach StackedUIBase)"); return; } + var uiType = ui.GetType(); + Mod.Log.Msg($" [stackedui] UIBase type={uiType.FullName}"); + + var hdr = GetMemberValue(homeType, homeInst, "ScreenHeader", "get_ScreenHeader"); + if (hdr != null) + { + var ht = hdr.GetType(); + var hp = new List(); foreach (var p in ht.GetProperties()) hp.Add(p.Name); + Mod.Log.Msg($" [stackedui] home ScreenHeader={ht.Name}; props={string.Join(",", hp.GetRange(0, Math.Min(30, hp.Count)))}"); + } + else Mod.Log.Msg(" [stackedui] home ScreenHeader: null"); + + foreach (var f in uiType.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) + { + object? v; try { v = f.GetValue(ui); } catch { continue; } + if (v is null) continue; + int n = ArrLen(v); + // Flow-prefab catalog: a non-empty array whose elements expose a + // TypeName string (FlowPrefabResourceReference). + if (n > 0 && n <= 300 && (v is Array || v.GetType().Name.IndexOf("Array", StringComparison.OrdinalIgnoreCase) >= 0)) + { + var first = ArrItem(v, 0); + var et = first?.GetType().Name ?? "?"; + if (et.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) < 0 && + et.IndexOf("Prefab", StringComparison.OrdinalIgnoreCase) < 0 && + et.IndexOf("Resource", StringComparison.OrdinalIgnoreCase) < 0 && + et.IndexOf("Reference", StringComparison.OrdinalIgnoreCase) < 0) continue; + Mod.Log.Msg($" [stackedui] flow catalog '{f.Name}': {et}[{n}]"); + for (int i = 0; i < n; i++) + { + var e = ArrItem(v, i); if (e is null) continue; + Mod.Log.Msg($" [{i}] {ReadStringMember(e)}"); + } + continue; + } + // Flow cache: Dictionary. + var vt = v.GetType(); + if (vt.IsGenericType && vt.Name.StartsWith("Dictionary")) + { + var ga = vt.GetGenericArguments(); + if (ga.Length == 2 && ga[1].Name.IndexOf("Flow", StringComparison.OrdinalIgnoreCase) >= 0) + { + var cp = vt.GetProperty("Count"); + Mod.Log.Msg($" [stackedui] flow cache '{f.Name}': Dictionary<{ga[0].Name},{ga[1].Name}> count={(cp?.GetValue(v) as int? ?? -1)}"); + } + } + } + } + + // Property by name, else parameterless getter method, else null. + private static object? GetMemberValue(Type t, object inst, string prop, string getter) + { + var (found, _, v) = GetMember(t, inst, prop); + if (found && v != null) return v; + var m = t.GetMethod(getter, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, + null, Type.EmptyTypes, null); + if (m != null) { try { return m.Invoke(inst, null); } catch { } } + return v; + } + + // First non-empty string field/property on an object — for obfuscated + // wrappers, that's the TypeName of a FlowPrefabResourceReference. + private static string ReadStringMember(object o) + { + var t = o.GetType(); + foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) + if (p.PropertyType == typeof(string) && p.GetIndexParameters().Length == 0) + { try { if (p.GetValue(o) is string s && s.Length > 0) return s; } catch { } } + foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) + if (f.FieldType == typeof(string)) + { try { if (f.GetValue(o) is string s && s.Length > 0) return s; } catch { } } + return "(no string member)"; } // Enumerate Button3D components under the HomeScreenFlow via Unity's diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index 173ac3f..a3f8f00 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using System.Text.Json; using DorkNet.Models.Notification; using DorkNet.Server.Auth; using DorkNet.Server.Controllers.Match; @@ -2476,22 +2477,35 @@ public class RoomsController( /// POST rooms/{id}/roles/{playerId} — owner grants /// a role to a player. Auto-accepted (no invite step). + [HttpPut("rooms/{roomId:long}/roles/{playerId:long}")] [HttpPost("rooms/{roomId:long}/roles/{playerId:long}")] + [HttpPut("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}")] + [HttpPost("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}")] + [HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")] + [HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")] [Authorize] - public async Task GrantRole(long roomId, long playerId, [FromBody] GrantRoleRequest req) + public async Task GrantRole(long roomId, long playerId) { var me = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); - if (room.CreatorPlayerId != me) return Forbid(); - var role = req.Role ?? 0; + if (!await CanManageRoomRolesAsync(room, me)) return Forbid(); + var role = await ReadClientRoomRoleAsync(); + if (role is null) return BadRequest(new { error = "missing_role" }); + if (role.Value == 0) + return await ClearRoomAccountRoleAsync(room, playerId); + + if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole)) + return BadRequest(new { error = "invalid_role", role }); + var existing = await db.RoomRoles - .FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == role); + .FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole); if (existing is null) { + await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole); db.RoomRoles.Add(new RoomRoleEntity { - RoomId = roomId, PlayerId = playerId, Role = role, + RoomId = roomId, PlayerId = playerId, Role = roomRole, Accepted = true, GrantedByPlayerId = me, }); } @@ -2500,29 +2514,42 @@ public class RoomsController( existing.Accepted = true; } await db.SaveChangesAsync(); - return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = role, Accepted = true }); + return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); } /// POST rooms/{id}/roles/{playerId}/invite — same /// as grant but Accepted=false; the target's accept-invite flow /// flips the flag (separate endpoint, not yet exposed). For now /// invited rows surface in RoomDetails.InvitedCoOwners etc. + [HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")] [HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")] + [HttpPut("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}/invite")] + [HttpPost("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}/invite")] + [HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")] + [HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")] [Authorize] - public async Task InviteRole(long roomId, long playerId, [FromBody] GrantRoleRequest req) + public async Task InviteRole(long roomId, long playerId) { var me = this.RequireCurrentPlayerId(); var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId); if (room is null) return NotFound(); - if (room.CreatorPlayerId != me) return Forbid(); - var role = req.Role ?? 0; + if (!await CanManageRoomRolesAsync(room, me)) return Forbid(); + var role = await ReadClientRoomRoleAsync(); + if (role is null) return BadRequest(new { error = "missing_role" }); + if (role.Value == 0) + return await ClearRoomAccountRoleAsync(room, playerId); + + if (!TryMapClientRoleToRoomRole(role.Value, out var roomRole)) + return BadRequest(new { error = "invalid_role", role }); + var existing = await db.RoomRoles - .FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == role); + .FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == roomRole); if (existing is null) { + await RemoveOtherRoomRolesAsync(roomId, playerId, roomRole); db.RoomRoles.Add(new RoomRoleEntity { - RoomId = roomId, PlayerId = playerId, Role = role, + RoomId = roomId, PlayerId = playerId, Role = roomRole, Accepted = false, GrantedByPlayerId = me, }); } @@ -2531,7 +2558,97 @@ public class RoomsController( existing.Accepted = false; } await db.SaveChangesAsync(); - return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = role, Accepted = false }); + return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId)); + } + + private async Task ClearRoomAccountRoleAsync(RoomEntity room, long playerId) + { + if (room.CreatorPlayerId == playerId) + return BadRequest(new { error = "cannot_change_creator_role" }); + + var rows = await db.RoomRoles + .Where(r => r.RoomId == room.Id && r.PlayerId == playerId) + .ToListAsync(); + if (rows.Count > 0) + db.RoomRoles.RemoveRange(rows); + + await db.SaveChangesAsync(); + return Ok(await BuildRoomServerDetailsWithRolesAsync(room.Id)); + } + + private async Task CanManageRoomRolesAsync(RoomEntity room, long playerId) + { + if (room.CreatorPlayerId == playerId) return true; + return await db.RoomRoles.AnyAsync(r => + r.RoomId == room.Id + && r.PlayerId == playerId + && r.Accepted + && r.Role == 0); + } + + private async Task ReadClientRoomRoleAsync() + { + int parsed; + if (Request.HasFormContentType) + { + var form = await Request.ReadFormAsync(); + if (int.TryParse(form["role"].FirstOrDefault(), out parsed)) return parsed; + if (int.TryParse(form["Role"].FirstOrDefault(), out parsed)) return parsed; + } + + if (int.TryParse(Request.Query["role"].FirstOrDefault(), out parsed)) return parsed; + if (int.TryParse(Request.Query["Role"].FirstOrDefault(), out parsed)) return parsed; + + if (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true) + { + try + { + var body = await JsonSerializer.DeserializeAsync( + Request.Body, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (body?.Role is int bodyRole) return bodyRole; + } + catch (JsonException) + { + return null; + } + } + + return null; + } + + private async Task RemoveOtherRoomRolesAsync(long roomId, long playerId, int keepRole) + { + var rows = await db.RoomRoles + .Where(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role != keepRole) + .ToListAsync(); + if (rows.Count > 0) + db.RoomRoles.RemoveRange(rows); + } + + private static bool TryMapClientRoleToRoomRole(int clientRole, out int roomRole) + { + roomRole = clientRole switch + { + 30 => 0, // CoOwner + 20 => 1, // Moderator + 10 => 2, // Host + _ => -1, + }; + return roomRole >= 0; + } + + private async Task BuildRoomServerDetailsWithRolesAsync(long roomId) + { + var room = await db.Rooms.FirstAsync(r => r.Id == roomId); + var scenes = await db.RoomScenes + .Where(s => s.RoomId == roomId) + .OrderBy(s => s.OrderIndex) + .ToListAsync(); + var roles = await db.RoomRoles + .Where(r => r.RoomId == roomId) + .ToListAsync(); + return BuildRoomServerDetails(room, scenes, roles: roles); } /// diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index bb986ba..1de4655 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -350,6 +350,7 @@ public class AdminController( { r.Id, r.Name, + r.ImageName, r.IsAGRoom, r.IsDormRoom, r.CreatorPlayerId, @@ -1032,6 +1033,12 @@ public class AdminController( if (body.PlayerId <= 0) return BadRequest("missing player"); if (body.Role is < 0 or > 2) return BadRequest("invalid role"); + var oldRoles = await db.RoomRoles + .Where(r => r.RoomId == id && r.PlayerId == body.PlayerId && r.Role != body.Role) + .ToListAsync(); + if (oldRoles.Count > 0) + db.RoomRoles.RemoveRange(oldRoles); + var existing = await db.RoomRoles.FirstOrDefaultAsync(r => r.RoomId == id && r.PlayerId == body.PlayerId && r.Role == body.Role); if (existing is null) diff --git a/DorkNet.Server/admin-ui/src/lib/types.ts b/DorkNet.Server/admin-ui/src/lib/types.ts index c082069..d45da60 100644 --- a/DorkNet.Server/admin-ui/src/lib/types.ts +++ b/DorkNet.Server/admin-ui/src/lib/types.ts @@ -73,6 +73,7 @@ function subdomainUrl(subdomain: string, path: string | null | undefined, query? export interface Room { id: number; name: string; + imageName: string | null; isAGRoom: boolean; isDormRoom: boolean; creatorPlayerId: number; diff --git a/DorkNet.Server/admin-ui/src/pages/Room.tsx b/DorkNet.Server/admin-ui/src/pages/Room.tsx index 12d6c07..29106ab 100644 --- a/DorkNet.Server/admin-ui/src/pages/Room.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Room.tsx @@ -1,13 +1,14 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { api } from '../lib/api'; -import type { RoomDetail, RoomInstance, RoomRoleGrant } from '../lib/types'; +import type { Player, RoomDetail, RoomInstance, RoomRoleGrant } from '../lib/types'; import { profileImageUrl } from '../lib/types'; import { useApi } from '../lib/useApi'; import { PageHeader } from '../components/PageHeader'; import { Empty } from '../components/Empty'; import { Modal } from '../components/Modal'; import { Confirm } from '../components/Confirm'; +import { PlayerAvatar } from '../components/PlayerAvatar'; import { useToast } from '../components/Toast'; import { ArrowLeft, RefreshCw, Trash } from '../components/Icons'; @@ -367,15 +368,40 @@ function PlayerRow({ player }: { player: { id: number; username: string; display function AddRoleModal({ roomId, role, onClose, onAdded }: { roomId: number; role: 0 | 1 | 2; onClose: () => void; onAdded: () => void; }) { - const [playerIdStr, setPlayerIdStr] = useState(''); + const { data: players, loading: playersLoading, error: playersError } = useApi('/players?take=500'); + const [playerQuery, setPlayerQuery] = useState(''); + const [selectedPlayerId, setSelectedPlayerId] = useState(''); const [accepted, setAccepted] = useState(true); const [busy, setBusy] = useState(false); const toast = useToast(); + const matchingPlayers = useMemo(() => { + const q = playerQuery.trim().toLowerCase(); + const source = players ?? []; + if (!q) return source; + return source.filter(p => + p.id.toString().includes(q) || + p.username.toLowerCase().includes(q) || + (p.displayName ?? '').toLowerCase().includes(q) + ); + }, [playerQuery, players]); + + const selectedPlayer = useMemo( + () => (players ?? []).find(p => p.id === Number(selectedPlayerId)) ?? null, + [players, selectedPlayerId] + ); + + const playerLabel = (p: Player) => { + const handle = p.username ? `@${p.username}` : `#${p.id}`; + return p.displayName && p.displayName !== p.username + ? `${handle} - ${p.displayName} - #${p.id}` + : `${handle} - #${p.id}`; + }; + const submit = async () => { - const pid = Number(playerIdStr); + const pid = Number(selectedPlayerId); if (!Number.isFinite(pid) || pid <= 0) { - toast.push('Enter a numeric player id', 'error'); + toast.push('Select a player', 'error'); return; } setBusy(true); @@ -384,7 +410,7 @@ function AddRoleModal({ roomId, role, onClose, onAdded }: { method: 'POST', body: { PlayerId: pid, Role: role, Accepted: accepted }, }); - toast.push(`Granted ${ROLE_LABEL[role]} to #${pid}`, 'success'); + toast.push(`Granted ${ROLE_LABEL[role]} to ${selectedPlayer ? playerLabel(selectedPlayer) : `#${pid}`}`, 'success'); onAdded(); } catch (e) { toast.push((e as Error).message, 'error'); @@ -400,22 +426,50 @@ function AddRoleModal({ roomId, role, onClose, onAdded }: { title={`Add ${ROLE_LABEL[role].toLowerCase()}`} footer={<> - } >
+ + {playersError &&
{playersError}
} + {selectedPlayer && ( +
+ +
+
{selectedPlayer.username ? `@${selectedPlayer.username}` : `#${selectedPlayer.id}`}
+
{selectedPlayer.displayName || `Player #${selectedPlayer.id}`}
+
+
+ )}