diff --git a/DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs b/DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs index 3ccd618..f652972 100644 --- a/DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs +++ b/DorkNet.Server/Controllers/API/Relationships/V2/RelationshipsController.cs @@ -82,7 +82,13 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no var rels = await db.Relationships .Where(r => r.RequesterId == me || r.TargetId == me) .ToListAsync(); - var wire = rels.Select(r => BuildRelationship(r, me)).ToList(); + // Merge the (up to two) rows per pair so a favorite/mute/ignore the + // caller set on a friendship the OTHER player initiated isn't dropped + // by the watch (which keys its list by PlayerID — duplicate ids lose). + var wire = rels + .GroupBy(r => r.RequesterId == me ? r.TargetId : r.RequesterId) + .Select(g => BuildMerged(me, g.Key, g.ToList())) + .ToList(); return Ok(wire); } @@ -94,9 +100,12 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no var realRows = await db.Relationships .Where(r => r.RequesterId == me || r.TargetId == me) .ToListAsync(); - var realByOther = new Dictionary(); - foreach (var r in realRows) - realByOther[r.RequesterId == me ? r.TargetId : r.RequesterId] = r; + // Group both directional rows per pair so the caller's favorite/mute/ + // ignore (stored on the row where they're the requester) survives even + // when the friendship row itself was created by the other player. + var realByOther = realRows + .GroupBy(r => r.RequesterId == me ? r.TargetId : r.RequesterId) + .ToDictionary(g => g.Key, g => g.ToList()); var others = await db.Players .Where(p => p.Id != me && p.Id != RelationshipQueries.SystemAccountId) @@ -107,11 +116,13 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no foreach (var other in others) { var otherId = other.Id; - if (realByOther.TryGetValue(otherId, out var row)) + if (realByOther.TryGetValue(otherId, out var pair)) { // A real block wins — that pair is genuinely not friends. - if (row.Status == EntityStatus.Blocked) { wire.Add(EmptyRelationship(otherId)); continue; } - wire.Add(SynthFriend(otherId, row, me)); + if (pair.Any(r => r.Status == EntityStatus.Blocked)) { wire.Add(EmptyRelationship(otherId)); continue; } + // Carry the caller's own preference bits from their row. + var mineRow = pair.FirstOrDefault(r => r.RequesterId == me); + wire.Add(SynthFriend(otherId, mineRow, me)); } else { @@ -149,22 +160,27 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no { var me = CurrentPlayerId; if (playerId <= 0 || playerId == me) return Ok(EmptyRelationship(playerId)); - var row = await FindAsync(me, playerId); + var pair = await db.Relationships + .Where(r => (r.RequesterId == me && r.TargetId == playerId) || + (r.RequesterId == playerId && r.TargetId == me)) + .ToListAsync(); + var blocked = pair.Any(r => r.Status == EntityStatus.Blocked); + var mineRow = pair.FirstOrDefault(r => r.RequesterId == me); // Global-friends mode: report Friend for any real account that isn't // the system account and isn't explicitly blocked. Placeholder // Player_NNN accounts are hidden unless there's a genuine row. if (playerId != RelationshipQueries.SystemAccountId - && (row is null || row.Status != EntityStatus.Blocked) + && !blocked && await settings.IsGlobalFriendsEnabledAsync()) { - var isPlaceholder = row is null && RelationshipQueries.IsPlaceholderUsername( + var isPlaceholder = pair.Count == 0 && RelationshipQueries.IsPlaceholderUsername( await db.Players.Where(p => p.Id == playerId) .Select(p => p.Username).FirstOrDefaultAsync()); - if (!isPlaceholder) return Ok(SynthFriend(playerId, row, me)); + if (!isPlaceholder) return Ok(SynthFriend(playerId, mineRow, me)); } - return Ok(row is null ? EmptyRelationship(playerId) : BuildRelationship(row, me)); + return Ok(pair.Count == 0 ? EmptyRelationship(playerId) : BuildMerged(me, playerId, pair)); } // ── /api/relationships/v2/sendfriendrequest?id={N} ─────────────── @@ -180,17 +196,34 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no if (id <= 0 || id == me) return Ok(EmptyRelationship(id)); - var existing = await FindAsync(me, id); - if (existing is not null) - return Ok(BuildRelationship(existing, me)); + var pair = await db.Relationships + .Where(r => (r.RequesterId == me && r.TargetId == id) || + (r.RequesterId == id && r.TargetId == me)) + .ToListAsync(); - var row = new RelationshipEntity + // Anything that already defines a status (friend/pending/block) wins — + // a None row is preference-only and doesn't count as a relationship. + if (pair.Any(r => r.Status != EntityStatus.None)) + return Ok(BuildMerged(me, id, pair)); + + // Promote the caller's own preference-only row if present (keeping its + // favorite/mute/ignore bits) rather than spawning a second row. + var row = pair.FirstOrDefault(r => r.RequesterId == me); + if (row is not null) { - RequesterId = me, - TargetId = id, - Status = EntityStatus.PendingSent, - }; - db.Relationships.Add(row); + row.Status = EntityStatus.PendingSent; + row.UpdatedAt = DateTime.UtcNow; + } + else + { + row = new RelationshipEntity + { + RequesterId = me, + TargetId = id, + Status = EntityStatus.PendingSent, + }; + db.Relationships.Add(row); + } await db.SaveChangesAsync(); await notifications.FriendRequestReceived(id, me); @@ -236,16 +269,26 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no if (id <= 0) return Ok(EmptyRelationship(id)); - var rel = await FindAsync(me, id); - if (rel is null) + var pair = await db.Relationships + .Where(r => (r.RequesterId == me && r.TargetId == id) || + (r.RequesterId == id && r.TargetId == me)) + .ToListAsync(); + if (pair.Count == 0) return Ok(EmptyRelationship(id)); - if (rel.Status == EntityStatus.Blocked) - return Ok(BuildRelationship(rel, me)); - db.Relationships.Remove(rel); + // Drop every non-block row for the pair (friendship row + any stray + // preference row), leaving a block intact — the watch unblocks + // through a separate path. + var removable = pair.Where(r => r.Status != EntityStatus.Blocked).ToList(); + if (removable.Count == 0) + return Ok(BuildMerged(me, id, pair)); + + db.Relationships.RemoveRange(removable); await db.SaveChangesAsync(); await notifications.FriendRemoved(id, me); - return Ok(EmptyRelationship(id)); + + var remaining = pair.Except(removable).ToList(); + return Ok(remaining.Count == 0 ? EmptyRelationship(id) : BuildMerged(me, id, remaining)); } // ── /api/relationships/v2/addfriend?id={N} ─────────────────────── @@ -260,7 +303,20 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no if (id <= 0 || id == me) return Ok(EmptyRelationship(id)); - var existing = await FindAsync(me, id); + var pair = await db.Relationships + .Where(r => (r.RequesterId == me && r.TargetId == id) || + (r.RequesterId == id && r.TargetId == me)) + .ToListAsync(); + + // Don't override a block, and don't spawn a duplicate when a + // friendship row already exists (in either direction). + if (pair.Any(r => r.Status == EntityStatus.Blocked) || + pair.Any(r => r.Status == EntityStatus.Friend)) + return Ok(BuildMerged(me, id, pair)); + + // Promote the caller's own row (preference-only or pending) to Friend, + // or create one if none exists. + var existing = pair.FirstOrDefault(r => r.RequesterId == me); if (existing is null) { existing = new RelationshipEntity @@ -271,7 +327,7 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no }; db.Relationships.Add(existing); } - else if (existing.Status != EntityStatus.Blocked) + else { existing.Status = EntityStatus.Friend; existing.UpdatedAt = DateTime.UtcNow; @@ -285,8 +341,8 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no // "api/relationships/v1/unfavorite?id={N}". These return a // boolean-ish RecNetResult per the FavoritePlayer/UnfavoritePlayer // disassembly (Core.Post … ExpectHttpStatusSuccess), not a - // Relationship — they don't surface any state change in the - // 2020 storage model so we just acknowledge. + // Relationship. The flag is persisted on the caller's directional row + // and surfaced back through v2/get + personaldetails (see BuildMerged). [HttpPost("api/relationships/v1/favorite")] public Task Favorite([FromQuery] long id) => SetPreference(id, favorited: true); @@ -323,11 +379,16 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no r.RequesterId == me && r.TargetId == otherId); if (row is null) { + // A preference (favorite/mute/ignore) is the caller's own bit and + // lives on their own directional row. Status None — favorite/mute/ + // ignore must NOT fabricate a friendship; the real friendship row + // (this direction or the other) carries the actual status and the + // read path merges the two. row = new RelationshipEntity { RequesterId = me, TargetId = otherId, - Status = EntityStatus.Friend, // placeholder; favorite/mute/ignore don't require friend status + Status = EntityStatus.None, }; db.Relationships.Add(row); } @@ -378,11 +439,6 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no // ── helpers ────────────────────────────────────────────────────── - private Task FindAsync(long me, long other) => - db.Relationships.FirstOrDefaultAsync(r => - (r.RequesterId == me && r.TargetId == other) || - (r.RequesterId == other && r.TargetId == me)); - /// Map a storage row to the watch's wire RelationshipType /// from 's perspective. PendingSent flips /// direction depending on which side originated. @@ -427,6 +483,32 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no }; } + /// Merge all rows for one pair (up to two — one per direction) + /// into a single wire object from 's view. The + /// relationship type comes from whichever row defines it (Blocked > + /// Friend > Pending); the favorite/mute/ignore bits are the caller's + /// own, read from the row where the caller is the requester. Without this + /// a favorite the caller set on a friendship the OTHER player initiated + /// lands on a separate row and is dropped by the watch (its list is keyed + /// by PlayerID, so a duplicate id loses). + private static Dictionary BuildMerged(long me, long other, List pair) + { + var statusRow = + pair.FirstOrDefault(r => r.Status == EntityStatus.Blocked) + ?? pair.FirstOrDefault(r => r.Status == EntityStatus.Friend) + ?? pair.FirstOrDefault(r => r.Status is EntityStatus.PendingSent or EntityStatus.PendingReceived) + ?? pair[0]; + var mineRow = pair.FirstOrDefault(r => r.RequesterId == me); + return new() + { + ["PlayerID"] = (int)other, + ["RelationshipType"] = MapType(statusRow, me), + ["Muted"] = mineRow is { Muted: true } ? 1 : 0, + ["Ignored"] = mineRow is { Ignored: true } ? 1 : 0, + ["Favorited"] = mineRow is { Favorited: true } ? 1 : 0, + }; + } + /// "None" relationship for a target the caller has nothing /// on file with — used as a no-op success response so the watch's /// Relationship.Deserialize stays satisfied. diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index 0fac837..f3b56cc 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -2263,32 +2263,59 @@ public class AdminController( // ── Avatar catalog (gifting picker source) ─────────────────────────── - /// GET api/admin/v1/avatar-items — flat list of every - /// renderable avatar item, keyed by the GUID the inventory grant - /// endpoint expects. Backs the Gift page's item picker so admins can - /// browse by friendly name instead of pasting GUIDs by hand. Read-only. - /// Catalog source is data/avatar_item_lookup.json, parsed once - /// at startup by . + /// One giftable avatar item for the Gift picker. Desc is + /// the exact RecNet item string to gift — a bare base GUID for the + /// default colour, or a {guid},{swatch},{mask}, string for a + /// colour variant. Slot is the real OutfitType (head/torso/…), + /// not the always-Outfit AvatarItemType. + private sealed record GiftItemRow( + string Desc, string Guid, int Slot, string FriendlyName, string? Color, bool IsVariant); + + /// GET api/admin/v1/avatar-items — every giftable avatar + /// item: each base item (default colour) PLUS every colour variant the + /// store seeds from data/color_variants.json. Backs the Gift + /// page's item picker so admins can browse by friendly name + colour + /// instead of pasting descs by hand. Read-only. Base catalog source is + /// data/avatar_item_lookup.json, parsed once at startup by + /// ; + /// variants come from . [HttpGet("avatar-items")] public ActionResult ListAvatarItems() { var catalog = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.Catalog; var safe = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.SafeGuids; - var rows = catalog - .Select(kv => new - { - Guid = kv.Key, - Slot = kv.Value.AvatarItemType, - FriendlyName = kv.Value.FriendlyName, - Tooltip = kv.Value.Tooltip, - Rarity = kv.Value.Rarity, - Safe = safe.Contains(kv.Key), - }) - .Where(r => r.Safe) // hide GUIDs that would crash the watch's parser + var rows = new List(catalog.Count + DorkNet.Server.Services.StoreService.ColorVariantCatalog.Count); + + // Base items (default colour). Real slot from the bundled lookup — + // the DTO's AvatarItemType is always Outfit, which is useless as a + // slot filter, so resolve the actual OutfitType per GUID. + foreach (var kv in catalog) + { + if (!safe.Contains(kv.Key)) continue; // GUIDs the watch can't render + var slot = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController + .TryGetOutfitType(kv.Key, out var s) ? s : 0; + rows.Add(new GiftItemRow(kv.Key, kv.Key, slot, kv.Value.FriendlyName, null, false)); + } + + // Colour variants — the "dozens of headphones" the admin sees in the + // store but not here. Each gifts as "{item},{swatch},{mask}," so the + // recipient gets the exact swatch, not the default colour. + foreach (var v in DorkNet.Server.Services.StoreService.ColorVariantCatalog.Values) + { + if (string.IsNullOrEmpty(v.item_guid) || string.IsNullOrEmpty(v.swatch_guid)) continue; + if (!safe.Contains(v.item_guid)) continue; // base must be renderable + var baseName = catalog.TryGetValue(v.item_guid, out var bd) && !string.IsNullOrEmpty(bd.FriendlyName) + ? bd.FriendlyName + : v.item_name; + var desc = $"{v.item_guid},{v.swatch_guid},{v.mask_guid},"; + rows.Add(new GiftItemRow(desc, v.item_guid, + v.item_outfit_type ?? 0, $"{baseName} — {v.color}", v.color, true)); + } + + return Ok(rows .OrderBy(r => r.Slot) - .ThenBy(r => r.FriendlyName) - .ToList(); - return Ok(rows); + .ThenBy(r => r.FriendlyName, StringComparer.OrdinalIgnoreCase) + .ToList()); } // ── Send gift package (in-game gift box popup) ──────────────────────── diff --git a/DorkNet.Server/Data/Entities/RelationshipEntity.cs b/DorkNet.Server/Data/Entities/RelationshipEntity.cs index 1e06a69..a7c81be 100644 --- a/DorkNet.Server/Data/Entities/RelationshipEntity.cs +++ b/DorkNet.Server/Data/Entities/RelationshipEntity.cs @@ -9,6 +9,12 @@ namespace DorkNet.Server.Data.Entities; /// public enum RelationshipStatus { + /// No friendship/block — the row exists only to hold the + /// requester's own favorite/mute/ignore preference about the target. + /// The actual relationship status (if any) lives on the paired row in + /// the other direction; the read path merges the two. + None = 0, + /// Mutual friend (both directions accepted). Friend = 1, diff --git a/DorkNet.Server/admin-ui/src/App.tsx b/DorkNet.Server/admin-ui/src/App.tsx index 566cc3a..f126664 100644 --- a/DorkNet.Server/admin-ui/src/App.tsx +++ b/DorkNet.Server/admin-ui/src/App.tsx @@ -10,7 +10,6 @@ import { Rooms } from './pages/Rooms'; import { Room } from './pages/Room'; import { ImportRoom } from './pages/ImportRoom'; import { LegacyImportRoom } from './pages/LegacyImportRoom'; -import { Store } from './pages/Store'; import { Broadcast } from './pages/Broadcast'; import { Content } from './pages/Content'; import { SettingsHome } from './pages/SettingsHome'; @@ -51,7 +50,6 @@ export default function App() { } /> } /> } /> - } /> {/* Content hub — community board + loading-screen tips. */} } /> diff --git a/DorkNet.Server/admin-ui/src/components/Sidebar.tsx b/DorkNet.Server/admin-ui/src/components/Sidebar.tsx index 7057bd6..0c4d437 100644 --- a/DorkNet.Server/admin-ui/src/components/Sidebar.tsx +++ b/DorkNet.Server/admin-ui/src/components/Sidebar.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react'; import { NavLink } from 'react-router-dom'; import { LayoutDashboard, Users, Building2, Activity, - Megaphone, ShoppingBag, MessageSquare, Settings, X, + Megaphone, MessageSquare, Settings, X, } from './Icons'; // Consolidated navigation. Per-player moderation (bans, grants, gifts, @@ -29,7 +29,6 @@ const groups: Array<{ title: string; items: Array<{ to: string; label: string; i title: 'Content', items: [ { to: '/rooms', label: 'Rooms', icon: }, - { to: '/store', label: 'Store', icon: }, { to: '/content', label: 'Content', icon: }, ], }, diff --git a/DorkNet.Server/admin-ui/src/pages/Gift.tsx b/DorkNet.Server/admin-ui/src/pages/Gift.tsx index a8b8e93..3b74cef 100644 --- a/DorkNet.Server/admin-ui/src/pages/Gift.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Gift.tsx @@ -25,12 +25,14 @@ const RARITY_OPTIONS: Array<{ value: number; label: string }> = [ ]; interface AvatarItem { + // The exact RecNet desc to gift: a bare base GUID (default colour) or a + // "{guid},{swatch},{mask}," string for a colour variant. + desc: string; guid: string; slot: number; friendlyName: string; - tooltip: string; - rarity: number; - safe: boolean; + color: string | null; + isVariant: boolean; } // Gifting fires the in-game gift-box popup by inserting a GiftPackageEntity @@ -68,8 +70,11 @@ export function GiftPanel({ playerId }: { playerId: number }) { if (!includeItem && !includeCurrency && !includeXp) { return toast.push('Pick at least one reward (item / currency / xp)', 'error'); } - const guid = includeItem ? (manualGuid.trim() || item?.guid) : null; - if (includeItem && !guid) { + // The picked item carries the full desc (colour variants included); + // a manually pasted value may be a bare GUID or a full desc — both are + // normalised server-side. + const desc = includeItem ? (manualGuid.trim() || item?.desc) : null; + if (includeItem && !desc) { return toast.push('Pick an avatar item from the list, or paste a GUID', 'error'); } setBusy(true); @@ -77,7 +82,7 @@ export function GiftPanel({ playerId }: { playerId: number }) { const res = await api<{ id: number }>(`/players/${playerId}/gift`, { method: 'POST', body: { - AvatarItemGuid: includeItem ? guid : null, + AvatarItemGuid: includeItem ? desc : null, AvatarItemType: includeItem ? avatarItemType : null, CurrencyType: includeCurrency ? currencyType : null, Currency: includeCurrency ? currencyAmount : null, @@ -208,7 +213,10 @@ function AvatarItemPicker({ selected, onSelect }: { selected: AvatarItem | null; const term = search.trim().toLowerCase(); return (catalog ?? []).filter(it => { if (slotFilter !== 'all' && it.slot !== slotFilter) return false; - if (term && !it.friendlyName.toLowerCase().includes(term) && !it.guid.toLowerCase().includes(term)) return false; + if (term + && !it.friendlyName.toLowerCase().includes(term) + && !(it.color ?? '').toLowerCase().includes(term) + && !it.guid.toLowerCase().includes(term)) return false; return true; }); }, [catalog, search, slotFilter]); @@ -233,21 +241,24 @@ function AvatarItemPicker({ selected, onSelect }: { selected: AvatarItem | null; {filtered.length > 0 && (
- {filtered.slice(0, 200).map(it => ( + {filtered.slice(0, 500).map(it => ( onSelect(it)} - className={`cursor-pointer table-row-hover ${selected?.guid === it.guid ? 'bg-brand-500/10' : ''}`} + className={`cursor-pointer table-row-hover ${selected?.desc === it.desc ? 'bg-brand-500/10' : ''}`} > ))} - {filtered.length > 200 && ( - + {filtered.length > 500 && ( + )}
-
{it.friendlyName || unnamed}
-
{it.guid}
+
+ {it.friendlyName || unnamed} + {it.isVariant && {it.color}} +
+
{it.desc}
{SLOT_NAMES[it.slot] ?? `slot ${it.slot}`}
+ {filtered.length - 200} more — refine the search.
+ {filtered.length - 500} more — refine the search.