mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-27 07:58:45 -07:00
Refactor RelationshipsController: merge relationship rows for accurate status representation and enhance gifting functionality in AdminController and Gift component
This commit is contained in:
@@ -82,7 +82,13 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
|||||||
var rels = await db.Relationships
|
var rels = await db.Relationships
|
||||||
.Where(r => r.RequesterId == me || r.TargetId == me)
|
.Where(r => r.RequesterId == me || r.TargetId == me)
|
||||||
.ToListAsync();
|
.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);
|
return Ok(wire);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,9 +100,12 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
|||||||
var realRows = await db.Relationships
|
var realRows = await db.Relationships
|
||||||
.Where(r => r.RequesterId == me || r.TargetId == me)
|
.Where(r => r.RequesterId == me || r.TargetId == me)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var realByOther = new Dictionary<long, RelationshipEntity>();
|
// Group both directional rows per pair so the caller's favorite/mute/
|
||||||
foreach (var r in realRows)
|
// ignore (stored on the row where they're the requester) survives even
|
||||||
realByOther[r.RequesterId == me ? r.TargetId : r.RequesterId] = r;
|
// 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
|
var others = await db.Players
|
||||||
.Where(p => p.Id != me && p.Id != RelationshipQueries.SystemAccountId)
|
.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)
|
foreach (var other in others)
|
||||||
{
|
{
|
||||||
var otherId = other.Id;
|
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.
|
// A real block wins — that pair is genuinely not friends.
|
||||||
if (row.Status == EntityStatus.Blocked) { wire.Add(EmptyRelationship(otherId)); continue; }
|
if (pair.Any(r => r.Status == EntityStatus.Blocked)) { wire.Add(EmptyRelationship(otherId)); continue; }
|
||||||
wire.Add(SynthFriend(otherId, row, me));
|
// 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
|
else
|
||||||
{
|
{
|
||||||
@@ -149,22 +160,27 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
|||||||
{
|
{
|
||||||
var me = CurrentPlayerId;
|
var me = CurrentPlayerId;
|
||||||
if (playerId <= 0 || playerId == me) return Ok(EmptyRelationship(playerId));
|
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
|
// Global-friends mode: report Friend for any real account that isn't
|
||||||
// the system account and isn't explicitly blocked. Placeholder
|
// the system account and isn't explicitly blocked. Placeholder
|
||||||
// Player_NNN accounts are hidden unless there's a genuine row.
|
// Player_NNN accounts are hidden unless there's a genuine row.
|
||||||
if (playerId != RelationshipQueries.SystemAccountId
|
if (playerId != RelationshipQueries.SystemAccountId
|
||||||
&& (row is null || row.Status != EntityStatus.Blocked)
|
&& !blocked
|
||||||
&& await settings.IsGlobalFriendsEnabledAsync())
|
&& 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)
|
await db.Players.Where(p => p.Id == playerId)
|
||||||
.Select(p => p.Username).FirstOrDefaultAsync());
|
.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} ───────────────
|
// ── /api/relationships/v2/sendfriendrequest?id={N} ───────────────
|
||||||
@@ -180,17 +196,34 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
|||||||
if (id <= 0 || id == me)
|
if (id <= 0 || id == me)
|
||||||
return Ok(EmptyRelationship(id));
|
return Ok(EmptyRelationship(id));
|
||||||
|
|
||||||
var existing = await FindAsync(me, id);
|
var pair = await db.Relationships
|
||||||
if (existing is not null)
|
.Where(r => (r.RequesterId == me && r.TargetId == id) ||
|
||||||
return Ok(BuildRelationship(existing, me));
|
(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)
|
||||||
|
{
|
||||||
|
row.Status = EntityStatus.PendingSent;
|
||||||
|
row.UpdatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
row = new RelationshipEntity
|
||||||
{
|
{
|
||||||
RequesterId = me,
|
RequesterId = me,
|
||||||
TargetId = id,
|
TargetId = id,
|
||||||
Status = EntityStatus.PendingSent,
|
Status = EntityStatus.PendingSent,
|
||||||
};
|
};
|
||||||
db.Relationships.Add(row);
|
db.Relationships.Add(row);
|
||||||
|
}
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await notifications.FriendRequestReceived(id, me);
|
await notifications.FriendRequestReceived(id, me);
|
||||||
@@ -236,16 +269,26 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
|||||||
if (id <= 0)
|
if (id <= 0)
|
||||||
return Ok(EmptyRelationship(id));
|
return Ok(EmptyRelationship(id));
|
||||||
|
|
||||||
var rel = await FindAsync(me, id);
|
var pair = await db.Relationships
|
||||||
if (rel is null)
|
.Where(r => (r.RequesterId == me && r.TargetId == id) ||
|
||||||
|
(r.RequesterId == id && r.TargetId == me))
|
||||||
|
.ToListAsync();
|
||||||
|
if (pair.Count == 0)
|
||||||
return Ok(EmptyRelationship(id));
|
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 db.SaveChangesAsync();
|
||||||
await notifications.FriendRemoved(id, me);
|
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} ───────────────────────
|
// ── /api/relationships/v2/addfriend?id={N} ───────────────────────
|
||||||
@@ -260,7 +303,20 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
|||||||
if (id <= 0 || id == me)
|
if (id <= 0 || id == me)
|
||||||
return Ok(EmptyRelationship(id));
|
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)
|
if (existing is null)
|
||||||
{
|
{
|
||||||
existing = new RelationshipEntity
|
existing = new RelationshipEntity
|
||||||
@@ -271,7 +327,7 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
|||||||
};
|
};
|
||||||
db.Relationships.Add(existing);
|
db.Relationships.Add(existing);
|
||||||
}
|
}
|
||||||
else if (existing.Status != EntityStatus.Blocked)
|
else
|
||||||
{
|
{
|
||||||
existing.Status = EntityStatus.Friend;
|
existing.Status = EntityStatus.Friend;
|
||||||
existing.UpdatedAt = DateTime.UtcNow;
|
existing.UpdatedAt = DateTime.UtcNow;
|
||||||
@@ -285,8 +341,8 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
|||||||
// "api/relationships/v1/unfavorite?id={N}". These return a
|
// "api/relationships/v1/unfavorite?id={N}". These return a
|
||||||
// boolean-ish RecNetResult per the FavoritePlayer/UnfavoritePlayer
|
// boolean-ish RecNetResult per the FavoritePlayer/UnfavoritePlayer
|
||||||
// disassembly (Core.Post … ExpectHttpStatusSuccess), not a
|
// disassembly (Core.Post … ExpectHttpStatusSuccess), not a
|
||||||
// Relationship — they don't surface any state change in the
|
// Relationship. The flag is persisted on the caller's directional row
|
||||||
// 2020 storage model so we just acknowledge.
|
// and surfaced back through v2/get + personaldetails (see BuildMerged).
|
||||||
[HttpPost("api/relationships/v1/favorite")]
|
[HttpPost("api/relationships/v1/favorite")]
|
||||||
public Task<IActionResult> Favorite([FromQuery] long id) => SetPreference(id, favorited: true);
|
public Task<IActionResult> 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);
|
r.RequesterId == me && r.TargetId == otherId);
|
||||||
if (row is null)
|
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
|
row = new RelationshipEntity
|
||||||
{
|
{
|
||||||
RequesterId = me,
|
RequesterId = me,
|
||||||
TargetId = otherId,
|
TargetId = otherId,
|
||||||
Status = EntityStatus.Friend, // placeholder; favorite/mute/ignore don't require friend status
|
Status = EntityStatus.None,
|
||||||
};
|
};
|
||||||
db.Relationships.Add(row);
|
db.Relationships.Add(row);
|
||||||
}
|
}
|
||||||
@@ -378,11 +439,6 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
|||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────────
|
// ── helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private Task<RelationshipEntity?> FindAsync(long me, long other) =>
|
|
||||||
db.Relationships.FirstOrDefaultAsync(r =>
|
|
||||||
(r.RequesterId == me && r.TargetId == other) ||
|
|
||||||
(r.RequesterId == other && r.TargetId == me));
|
|
||||||
|
|
||||||
/// <summary>Map a storage row to the watch's wire RelationshipType
|
/// <summary>Map a storage row to the watch's wire RelationshipType
|
||||||
/// from <paramref name="me"/>'s perspective. PendingSent flips
|
/// from <paramref name="me"/>'s perspective. PendingSent flips
|
||||||
/// direction depending on which side originated.</summary>
|
/// direction depending on which side originated.</summary>
|
||||||
@@ -427,6 +483,32 @@ public class RelationshipsController(DorkNetDbContext db, NotificationService no
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Merge all rows for one pair (up to two — one per direction)
|
||||||
|
/// into a single wire object from <paramref name="me"/>'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).</summary>
|
||||||
|
private static Dictionary<string, object> BuildMerged(long me, long other, List<RelationshipEntity> 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>"None" relationship for a target the caller has nothing
|
/// <summary>"None" relationship for a target the caller has nothing
|
||||||
/// on file with — used as a no-op success response so the watch's
|
/// on file with — used as a no-op success response so the watch's
|
||||||
/// Relationship.Deserialize stays satisfied.</summary>
|
/// Relationship.Deserialize stays satisfied.</summary>
|
||||||
|
|||||||
@@ -2263,32 +2263,59 @@ public class AdminController(
|
|||||||
|
|
||||||
// ── Avatar catalog (gifting picker source) ───────────────────────────
|
// ── Avatar catalog (gifting picker source) ───────────────────────────
|
||||||
|
|
||||||
/// <summary>GET <c>api/admin/v1/avatar-items</c> — flat list of every
|
/// <summary>One giftable avatar item for the Gift picker. <c>Desc</c> is
|
||||||
/// renderable avatar item, keyed by the GUID the inventory grant
|
/// the exact RecNet item string to gift — a bare base GUID for the
|
||||||
/// endpoint expects. Backs the Gift page's item picker so admins can
|
/// default colour, or a <c>{guid},{swatch},{mask},</c> string for a
|
||||||
/// browse by friendly name instead of pasting GUIDs by hand. Read-only.
|
/// colour variant. <c>Slot</c> is the real OutfitType (head/torso/…),
|
||||||
/// Catalog source is <c>data/avatar_item_lookup.json</c>, parsed once
|
/// not the always-Outfit AvatarItemType.</summary>
|
||||||
/// at startup by <see cref="DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController"/>.</summary>
|
private sealed record GiftItemRow(
|
||||||
|
string Desc, string Guid, int Slot, string FriendlyName, string? Color, bool IsVariant);
|
||||||
|
|
||||||
|
/// <summary>GET <c>api/admin/v1/avatar-items</c> — every giftable avatar
|
||||||
|
/// item: each base item (default colour) PLUS every colour variant the
|
||||||
|
/// store seeds from <c>data/color_variants.json</c>. 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
|
||||||
|
/// <c>data/avatar_item_lookup.json</c>, parsed once at startup by
|
||||||
|
/// <see cref="DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController"/>;
|
||||||
|
/// variants come from <see cref="DorkNet.Server.Services.StoreService.ColorVariantCatalog"/>.</summary>
|
||||||
[HttpGet("avatar-items")]
|
[HttpGet("avatar-items")]
|
||||||
public ActionResult ListAvatarItems()
|
public ActionResult ListAvatarItems()
|
||||||
{
|
{
|
||||||
var catalog = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.Catalog;
|
var catalog = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.Catalog;
|
||||||
var safe = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.SafeGuids;
|
var safe = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController.SafeGuids;
|
||||||
var rows = catalog
|
var rows = new List<GiftItemRow>(catalog.Count + DorkNet.Server.Services.StoreService.ColorVariantCatalog.Count);
|
||||||
.Select(kv => new
|
|
||||||
|
// 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)
|
||||||
{
|
{
|
||||||
Guid = kv.Key,
|
if (!safe.Contains(kv.Key)) continue; // GUIDs the watch can't render
|
||||||
Slot = kv.Value.AvatarItemType,
|
var slot = DorkNet.Server.Controllers.API.Avatar.V4.AvatarItemsController
|
||||||
FriendlyName = kv.Value.FriendlyName,
|
.TryGetOutfitType(kv.Key, out var s) ? s : 0;
|
||||||
Tooltip = kv.Value.Tooltip,
|
rows.Add(new GiftItemRow(kv.Key, kv.Key, slot, kv.Value.FriendlyName, null, false));
|
||||||
Rarity = kv.Value.Rarity,
|
}
|
||||||
Safe = safe.Contains(kv.Key),
|
|
||||||
})
|
// Colour variants — the "dozens of headphones" the admin sees in the
|
||||||
.Where(r => r.Safe) // hide GUIDs that would crash the watch's parser
|
// 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)
|
.OrderBy(r => r.Slot)
|
||||||
.ThenBy(r => r.FriendlyName)
|
.ThenBy(r => r.FriendlyName, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToList();
|
.ToList());
|
||||||
return Ok(rows);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Send gift package (in-game gift box popup) ────────────────────────
|
// ── Send gift package (in-game gift box popup) ────────────────────────
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ namespace DorkNet.Server.Data.Entities;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public enum RelationshipStatus
|
public enum RelationshipStatus
|
||||||
{
|
{
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
None = 0,
|
||||||
|
|
||||||
/// <summary>Mutual friend (both directions accepted).</summary>
|
/// <summary>Mutual friend (both directions accepted).</summary>
|
||||||
Friend = 1,
|
Friend = 1,
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { Rooms } from './pages/Rooms';
|
|||||||
import { Room } from './pages/Room';
|
import { Room } from './pages/Room';
|
||||||
import { ImportRoom } from './pages/ImportRoom';
|
import { ImportRoom } from './pages/ImportRoom';
|
||||||
import { LegacyImportRoom } from './pages/LegacyImportRoom';
|
import { LegacyImportRoom } from './pages/LegacyImportRoom';
|
||||||
import { Store } from './pages/Store';
|
|
||||||
import { Broadcast } from './pages/Broadcast';
|
import { Broadcast } from './pages/Broadcast';
|
||||||
import { Content } from './pages/Content';
|
import { Content } from './pages/Content';
|
||||||
import { SettingsHome } from './pages/SettingsHome';
|
import { SettingsHome } from './pages/SettingsHome';
|
||||||
@@ -51,7 +50,6 @@ export default function App() {
|
|||||||
<Route path="leaderboards" element={<Navigate to="/rooms" replace />} />
|
<Route path="leaderboards" element={<Navigate to="/rooms" replace />} />
|
||||||
<Route path="import-room" element={<ImportRoom />} />
|
<Route path="import-room" element={<ImportRoom />} />
|
||||||
<Route path="import-room-legacy" element={<LegacyImportRoom />} />
|
<Route path="import-room-legacy" element={<LegacyImportRoom />} />
|
||||||
<Route path="store" element={<Store />} />
|
|
||||||
|
|
||||||
{/* Content hub — community board + loading-screen tips. */}
|
{/* Content hub — community board + loading-screen tips. */}
|
||||||
<Route path="content" element={<Content />} />
|
<Route path="content" element={<Content />} />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect } from 'react';
|
|||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Users, Building2, Activity,
|
LayoutDashboard, Users, Building2, Activity,
|
||||||
Megaphone, ShoppingBag, MessageSquare, Settings, X,
|
Megaphone, MessageSquare, Settings, X,
|
||||||
} from './Icons';
|
} from './Icons';
|
||||||
|
|
||||||
// Consolidated navigation. Per-player moderation (bans, grants, gifts,
|
// 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',
|
title: 'Content',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/rooms', label: 'Rooms', icon: <Building2 /> },
|
{ to: '/rooms', label: 'Rooms', icon: <Building2 /> },
|
||||||
{ to: '/store', label: 'Store', icon: <ShoppingBag /> },
|
|
||||||
{ to: '/content', label: 'Content', icon: <MessageSquare /> },
|
{ to: '/content', label: 'Content', icon: <MessageSquare /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,12 +25,14 @@ const RARITY_OPTIONS: Array<{ value: number; label: string }> = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
interface AvatarItem {
|
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;
|
guid: string;
|
||||||
slot: number;
|
slot: number;
|
||||||
friendlyName: string;
|
friendlyName: string;
|
||||||
tooltip: string;
|
color: string | null;
|
||||||
rarity: number;
|
isVariant: boolean;
|
||||||
safe: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gifting fires the in-game gift-box popup by inserting a GiftPackageEntity
|
// 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) {
|
if (!includeItem && !includeCurrency && !includeXp) {
|
||||||
return toast.push('Pick at least one reward (item / currency / xp)', 'error');
|
return toast.push('Pick at least one reward (item / currency / xp)', 'error');
|
||||||
}
|
}
|
||||||
const guid = includeItem ? (manualGuid.trim() || item?.guid) : null;
|
// The picked item carries the full desc (colour variants included);
|
||||||
if (includeItem && !guid) {
|
// 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');
|
return toast.push('Pick an avatar item from the list, or paste a GUID', 'error');
|
||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -77,7 +82,7 @@ export function GiftPanel({ playerId }: { playerId: number }) {
|
|||||||
const res = await api<{ id: number }>(`/players/${playerId}/gift`, {
|
const res = await api<{ id: number }>(`/players/${playerId}/gift`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
AvatarItemGuid: includeItem ? guid : null,
|
AvatarItemGuid: includeItem ? desc : null,
|
||||||
AvatarItemType: includeItem ? avatarItemType : null,
|
AvatarItemType: includeItem ? avatarItemType : null,
|
||||||
CurrencyType: includeCurrency ? currencyType : null,
|
CurrencyType: includeCurrency ? currencyType : null,
|
||||||
Currency: includeCurrency ? currencyAmount : null,
|
Currency: includeCurrency ? currencyAmount : null,
|
||||||
@@ -208,7 +213,10 @@ function AvatarItemPicker({ selected, onSelect }: { selected: AvatarItem | null;
|
|||||||
const term = search.trim().toLowerCase();
|
const term = search.trim().toLowerCase();
|
||||||
return (catalog ?? []).filter(it => {
|
return (catalog ?? []).filter(it => {
|
||||||
if (slotFilter !== 'all' && it.slot !== slotFilter) return false;
|
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;
|
return true;
|
||||||
});
|
});
|
||||||
}, [catalog, search, slotFilter]);
|
}, [catalog, search, slotFilter]);
|
||||||
@@ -233,21 +241,24 @@ function AvatarItemPicker({ selected, onSelect }: { selected: AvatarItem | null;
|
|||||||
{filtered.length > 0 && (
|
{filtered.length > 0 && (
|
||||||
<div className="table-scroll"><table className="w-full text-sm min-w-[560px]">
|
<div className="table-scroll"><table className="w-full text-sm min-w-[560px]">
|
||||||
<tbody className="divide-y divide-ink-800">
|
<tbody className="divide-y divide-ink-800">
|
||||||
{filtered.slice(0, 200).map(it => (
|
{filtered.slice(0, 500).map(it => (
|
||||||
<tr
|
<tr
|
||||||
key={it.guid}
|
key={it.desc}
|
||||||
onClick={() => onSelect(it)}
|
onClick={() => 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' : ''}`}
|
||||||
>
|
>
|
||||||
<td className="px-3 py-1.5">
|
<td className="px-3 py-1.5">
|
||||||
<div className="text-ink-100 text-sm">{it.friendlyName || <span className="text-ink-500">unnamed</span>}</div>
|
<div className="text-ink-100 text-sm flex items-center gap-1.5">
|
||||||
<div className="font-mono text-[10px] text-ink-500">{it.guid}</div>
|
{it.friendlyName || <span className="text-ink-500">unnamed</span>}
|
||||||
|
{it.isVariant && <span className="badge-neutral text-[10px]">{it.color}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[10px] text-ink-500">{it.desc}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-1.5 text-xs text-ink-300 whitespace-nowrap">{SLOT_NAMES[it.slot] ?? `slot ${it.slot}`}</td>
|
<td className="px-3 py-1.5 text-xs text-ink-300 whitespace-nowrap">{SLOT_NAMES[it.slot] ?? `slot ${it.slot}`}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{filtered.length > 200 && (
|
{filtered.length > 500 && (
|
||||||
<tr><td colSpan={2} className="p-3 text-center text-xs text-ink-500">+ {filtered.length - 200} more — refine the search.</td></tr>
|
<tr><td colSpan={2} className="p-3 text-center text-xs text-ink-500">+ {filtered.length - 500} more — refine the search.</td></tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table></div>
|
</table></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user