Files
2026-10-14 06:59:51 +02:00

508 lines
22 KiB
C#

using LiteDB;
// Club storage for the in-game Clubs feature. Ported to match the RecFlare reference
// (apps/clubs): clubs + membership rows, the { error, success, value } create envelope,
// and the ClubDetails payload the client renders a club screen from.
//
// Global namespace (like ImageMetadataDB) so controllers reference ClubDB.* unqualified.
public static class ClubDB
{
private static readonly string DbPath = Path.Combine("Data", "DBs", "Clubs.db");
private static readonly Lazy<LiteDatabase> _db = new(() => new LiteDatabase(DbPath));
private static LiteDatabase Db => _db.Value;
// Membership tiers (ClubMembershipType): -1 banned, 0 none, 1 pending request,
// 2 pending invite, 3 denied, 10 member, 20 moderator, 30 co-owner, 100 creator.
public const int TierNone = 0, TierMember = 10, TierModerator = 20, TierCoOwner = 30, TierCreator = 100;
public const string DefaultMainImage = "DefaultImgPurple";
public const int MaxNameLength = 40;
public const int MaxDescriptionLength = 512;
public class Club
{
public int ClubId { get; set; }
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public string Category { get; set; } = "Social";
public int Visibility { get; set; } = 1; // 0 Private, 1 Public
public int Joinability { get; set; } = 0; // 0 Open, 1 InviteOnly, 2 AskToJoin
public bool AllowJuniors { get; set; } = true;
public string MainImageName { get; set; } = DefaultMainImage;
public int ClubType { get; set; } = 0; // 0 regular (never 1 = subscription)
public int? ClubhouseRoomId { get; set; } = null;
public ulong CreatorAccountId { get; set; }
public bool IsRRO { get; set; } = false;
public int MinLevel { get; set; } = 0;
public int State { get; set; } = 0;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public List<string> CustomTags { get; set; } = new();
public List<string> AdditionalImageNames { get; set; } = new();
}
public class ClubMember
{
public int ClubMemberId { get; set; }
public int ClubId { get; set; }
public ulong AccountId { get; set; }
public int MembershipType { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>A club noticeboard post (reference: ClubAnnouncement).</summary>
public class ClubAnnouncement
{
public int AnnouncementId { get; set; }
public int ClubId { get; set; }
public ulong AccountId { get; set; }
public string Title { get; set; } = "";
public string Body { get; set; } = "";
public string ImageName { get; set; } = "";
public string Meta { get; set; } = "";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? ModifiedAt { get; set; }
}
/// <summary>The club a player spawns into (their "home club"); one row per account.</summary>
public class HomeClub
{
public int Id { get; set; }
public ulong AccountId { get; set; }
public int ClubId { get; set; }
}
private static ILiteCollection<HomeClub> Homes()
{
var c = Db.GetCollection<HomeClub>("home_clubs");
c.EnsureIndex(x => x.AccountId, true);
return c;
}
public static int GetHomeClubId(ulong accountId) => Homes().FindOne(h => h.AccountId == accountId)?.ClubId ?? 0;
public static void SetHomeClub(ulong accountId, int clubId)
{
var col = Homes();
var h = col.FindOne(x => x.AccountId == accountId);
if (h == null) col.Insert(new HomeClub { AccountId = accountId, ClubId = clubId });
else { h.ClubId = clubId; col.Update(h); }
}
public static void ClearHomeClub(ulong accountId) => Homes().DeleteMany(x => x.AccountId == accountId);
/// <summary>How far a player has read a club's noticeboard (the unread badge).</summary>
public class AnnouncementRead
{
public int Id { get; set; }
public int ClubId { get; set; }
public ulong AccountId { get; set; }
public int LastReadAnnouncementId { get; set; }
}
// ---- CREATOR (subscription) CLUBS ----
// Subscribing to a player joins that player's auto-created "creator club" (ClubType 1). They
// are not stored: the id is 5_000_000 + creator account id (ClubsController.CreatorClubId)
// and the club is synthesised from the account + its subscriber list whenever it's asked
// for, so /club/{id}, /details, /members and the noticeboard all work for it. Before this
// every one of those 404'd (the "click a player's subscribers" screen was blank).
public const long CreatorClubBase = 5_000_000L;
public static bool IsCreatorClub(long clubId) => clubId >= CreatorClubBase;
public static ulong CreatorOfClub(long clubId) => IsCreatorClub(clubId) ? (ulong)(clubId - CreatorClubBase) : 0;
public static Club? CreatorClub(ulong creatorAccountId)
{
var acc = Campus_live_rewrite.api.dbs.PlayerDB.GetAccountById(creatorAccountId);
if (acc == null) return null;
var full = Campus_live_rewrite.api.dbs.PlayerDB.GetFullAccount(creatorAccountId);
return new Club
{
ClubId = (int)(CreatorClubBase + (long)creatorAccountId),
Name = acc.Username ?? creatorAccountId.ToString(),
Description = "",
Category = "CreatorAutoTag",
Visibility = 1,
Joinability = 0,
AllowJuniors = true,
MainImageName = "DefaultClubImage2k.jpg",
ClubType = 1,
ClubhouseRoomId = null,
CreatorAccountId = creatorAccountId,
IsRRO = false,
MinLevel = 0,
State = 0,
CreatedAt = full?.player?.created_at ?? DateTime.UtcNow,
};
}
private static ILiteCollection<Club> Clubs()
{
var c = Db.GetCollection<Club>("clubs");
c.EnsureIndex(x => x.ClubId, true);
c.EnsureIndex(x => x.CreatorAccountId);
return c;
}
private static ILiteCollection<ClubMember> Members()
{
var c = Db.GetCollection<ClubMember>("members");
c.EnsureIndex(x => x.ClubId);
c.EnsureIndex(x => x.AccountId);
return c;
}
private static int NextId<T>(ILiteCollection<T> col, System.Linq.Expressions.Expression<System.Func<T, int>> key)
{
var last = col.Query().OrderByDescending(key).Limit(1).FirstOrDefault();
return last == null ? 1 : (key.Compile()(last) + 1);
}
public static int CountByCreator(ulong accountId) =>
Clubs().Count(x => x.CreatorAccountId == accountId);
/// <summary>Admin panel: every stored club, newest first, and how many there are.</summary>
public static int CountAll() => Clubs().Count();
public static List<Club> All(int take = 500) =>
Clubs().FindAll().OrderByDescending(c => c.CreatedAt).Take(take).ToList();
public static Club? GetById(int clubId) =>
IsCreatorClub(clubId) ? CreatorClub(CreatorOfClub(clubId)) : Clubs().FindById(clubId);
public static List<Club> GetByCreator(ulong accountId) =>
Clubs().Query().Where(x => x.CreatorAccountId == accountId && x.ClubType == 0)
.OrderByDescending(x => x.ClubId).ToList();
// Clubs the account belongs to (member tier or above), excluding subscription clubs.
public static List<Club> GetByMember(ulong accountId)
{
var ids = Members().Query()
.Where(m => m.AccountId == accountId && m.MembershipType >= TierMember)
.ToList().Select(m => m.ClubId).Distinct().ToList();
if (ids.Count == 0) return new();
return Clubs().Query().Where(x => ids.Contains(x.ClubId) && x.ClubType == 0)
.OrderByDescending(x => x.ClubId).ToList();
}
public static int MemberCount(int clubId) =>
IsCreatorClub(clubId)
? Campus_live_rewrite.api.dbs.PlayerDB.GetSubscriberIds(CreatorOfClub(clubId)).Count
: Members().Count(m => m.ClubId == clubId && m.MembershipType >= TierMember);
public static List<ClubMember> GetMembers(int clubId, int? membershipType, string? sortBy)
{
List<ClubMember> list;
if (IsCreatorClub(clubId))
{
// The creator owns their club; every subscriber is a plain member.
ulong creator = CreatorOfClub(clubId);
list = new List<ClubMember> { new() { ClubMemberId = 0, ClubId = clubId, AccountId = creator, MembershipType = TierCreator } };
int n = 1;
foreach (var sub in Campus_live_rewrite.api.dbs.PlayerDB.GetSubscriberIds(creator))
list.Add(new ClubMember { ClubMemberId = n++, ClubId = clubId, AccountId = sub, MembershipType = TierMember });
}
else list = Members().Query().Where(m => m.ClubId == clubId).ToList();
if (membershipType.HasValue) list = list.Where(m => m.MembershipType == membershipType.Value).ToList();
list = sortBy switch
{
"1" => list.OrderBy(m => m.AccountId).ToList(),
"2" => list.OrderBy(m => m.CreatedAt).ToList(),
_ => list.OrderByDescending(m => m.MembershipType).ThenBy(m => m.CreatedAt).ToList(),
};
return list;
}
// The caller's tier in a club (0 = none / signed out).
public static int MyMembership(int clubId, ulong accountId)
{
if (accountId == 0) return TierNone;
if (IsCreatorClub(clubId))
{
ulong creator = CreatorOfClub(clubId);
if (creator == accountId) return TierCreator;
return Campus_live_rewrite.api.dbs.PlayerDB.GetSubscribedToIds(accountId).Contains(creator) ? TierMember : TierNone;
}
var m = Members().FindOne(x => x.ClubId == clubId && x.AccountId == accountId);
return m?.MembershipType ?? TierNone;
}
// Create a club and seed the creator's membership row (tier 100). Returns the new club.
public static Club CreateClub(ulong creator, string name, string description, string category,
int visibility, int joinability, bool allowJuniors, int minLevel)
{
var clubs = Clubs();
var club = new Club
{
ClubId = NextId(clubs, x => x.ClubId),
Name = name,
Description = description ?? "",
Category = string.IsNullOrWhiteSpace(category) ? "Social" : category,
Visibility = visibility,
Joinability = joinability,
AllowJuniors = allowJuniors,
MainImageName = DefaultMainImage,
ClubType = 0,
CreatorAccountId = creator,
MinLevel = minLevel < 0 ? 0 : minLevel,
CreatedAt = DateTime.UtcNow,
CustomTags = new(),
};
clubs.Insert(club);
var members = Members();
members.Insert(new ClubMember
{
ClubMemberId = NextId(members, x => x.ClubMemberId),
ClubId = club.ClubId,
AccountId = creator,
MembershipType = TierCreator,
CreatedAt = DateTime.UtcNow,
});
return club;
}
// Set the club's main image (the club photo). Returns the updated club, or null.
public static Club? SetMainImage(int clubId, string imageName)
{
var col = Clubs();
var club = col.FindById(clubId);
if (club == null) return null;
club.MainImageName = imageName;
col.Update(club);
return club;
}
// Set one gallery image by position (packed: past-the-end appends). Returns updated club.
public static Club? SetAdditionalImage(int clubId, int index, string imageName)
{
var col = Clubs();
var club = col.FindById(clubId);
if (club == null) return null;
club.AdditionalImageNames ??= new();
if (index < 0 || index >= club.AdditionalImageNames.Count) club.AdditionalImageNames.Add(imageName);
else club.AdditionalImageNames[index] = imageName;
col.Update(club);
return club;
}
// Remove one gallery image and shift the rest up (no blank slot). Returns updated club.
public static Club? RemoveAdditionalImage(int clubId, int index)
{
var col = Clubs();
var club = col.FindById(clubId);
if (club == null) return null;
club.AdditionalImageNames ??= new();
if (index >= 0 && index < club.AdditionalImageNames.Count) club.AdditionalImageNames.RemoveAt(index);
col.Update(club);
return club;
}
// Edit a club's details (co-owner+). Every param is optional: null = leave unchanged.
// A non-null customTags REPLACES the tag set (capped at 3). Returns the updated club, or null.
public static Club? UpdateDetails(int clubId, string? name, string? description, string? category,
int? visibility, int? joinability, bool? allowJuniors, int? minLevel, List<string>? customTags)
{
var col = Clubs();
var club = col.FindById(clubId);
if (club == null) return null;
if (!string.IsNullOrWhiteSpace(name)) club.Name = name.Trim();
if (description != null) club.Description = description;
if (!string.IsNullOrWhiteSpace(category)) club.Category = category.Trim();
if (visibility.HasValue) club.Visibility = visibility.Value;
if (joinability.HasValue) club.Joinability = joinability.Value;
if (allowJuniors.HasValue) club.AllowJuniors = allowJuniors.Value;
if (minLevel.HasValue) club.MinLevel = minLevel.Value < 0 ? 0 : minLevel.Value;
if (customTags != null) club.CustomTags = customTags.Take(3).ToList();
col.Update(club);
return club;
}
// ---- ANNOUNCEMENTS (the club noticeboard) ----
private static ILiteCollection<ClubAnnouncement> Announcements()
{
var c = Db.GetCollection<ClubAnnouncement>("announcements");
c.EnsureIndex(x => x.AnnouncementId, true);
c.EnsureIndex(x => x.ClubId);
return c;
}
private static ILiteCollection<AnnouncementRead> Reads()
{
var c = Db.GetCollection<AnnouncementRead>("announcement_reads");
c.EnsureIndex(x => x.ClubId);
c.EnsureIndex(x => x.AccountId);
return c;
}
/// <summary>A club's noticeboard, newest first.</summary>
public static List<ClubAnnouncement> GetAnnouncements(int clubId) =>
Announcements().Query().Where(a => a.ClubId == clubId).ToList()
.OrderByDescending(a => a.AnnouncementId).ToList();
public static int LatestAnnouncementId(int clubId) =>
Announcements().Query().Where(a => a.ClubId == clubId).ToList()
.Select(a => a.AnnouncementId).DefaultIfEmpty(0).Max();
public static ClubAnnouncement PostAnnouncement(int clubId, ulong accountId, string? title, string? body, string? imageName, string? meta)
{
var col = Announcements();
var a = new ClubAnnouncement
{
AnnouncementId = NextId(col, x => x.AnnouncementId),
ClubId = clubId,
AccountId = accountId,
Title = (title ?? "").Trim(),
Body = (body ?? "").Trim(),
ImageName = (imageName ?? "").Trim(),
Meta = meta ?? "",
};
col.Insert(a);
// The poster has obviously read their own post.
MarkAnnouncementsRead(clubId, accountId, a.AnnouncementId);
return a;
}
public static ClubAnnouncement? EditAnnouncement(int announcementId, string? title, string? body, string? imageName, string? meta)
{
var col = Announcements();
var a = col.FindOne(x => x.AnnouncementId == announcementId);
if (a == null) return null;
if (title != null) a.Title = title.Trim();
if (body != null) a.Body = body;
if (imageName != null) a.ImageName = imageName.Trim();
if (meta != null) a.Meta = meta;
a.ModifiedAt = DateTime.UtcNow;
col.Update(a);
return a;
}
public static ClubAnnouncement? DeleteAnnouncement(int announcementId)
{
var col = Announcements();
var a = col.FindOne(x => x.AnnouncementId == announcementId);
if (a != null) col.DeleteMany(x => x.AnnouncementId == announcementId);
return a;
}
public static int LastReadAnnouncementId(int clubId, ulong accountId) =>
Reads().FindOne(r => r.ClubId == clubId && r.AccountId == accountId)?.LastReadAnnouncementId ?? 0;
public static void MarkAnnouncementsRead(int clubId, ulong accountId, int upToAnnouncementId)
{
if (accountId == 0) return;
var col = Reads();
var r = col.FindOne(x => x.ClubId == clubId && x.AccountId == accountId);
if (r == null) col.Insert(new AnnouncementRead { ClubId = clubId, AccountId = accountId, LastReadAnnouncementId = upToAnnouncementId });
else if (upToAnnouncementId > r.LastReadAnnouncementId) { r.LastReadAnnouncementId = upToAnnouncementId; col.Update(r); }
}
public static bool HasUnreadAnnouncements(int clubId, ulong accountId) =>
LatestAnnouncementId(clubId) > LastReadAnnouncementId(clubId, accountId);
public static void Checkpoint()
{
try { Db.Checkpoint(); } catch { /* best-effort */ }
}
// ---- browse ----------------------------------------------------------------------------
public const int TierBanned = -1, TierPendingRequest = 1, TierPendingInvite = 2, TierDenied = 3;
/// <summary>
/// The Clubs browse page (reference searchClubs): public, non-subscription clubs, optionally
/// one category (exact, case-insensitive) and a name/description substring; sort 1 = newest,
/// 2 = by name, anything else = most members first. Returns the page and the full match count.
/// </summary>
public static (List<Club> page, int total) Search(string? category, string? query, string? sort, int count)
{
var all = Clubs().Query().Where(x => x.Visibility == 1 && x.ClubType == 0).ToList();
string wanted = (category ?? "").Trim();
string term = (query ?? "").Trim();
var matched = all.Where(c =>
(wanted.Length == 0 || string.Equals(c.Category, wanted, StringComparison.OrdinalIgnoreCase)) &&
(term.Length == 0
|| (c.Name ?? "").Contains(term, StringComparison.OrdinalIgnoreCase)
|| (c.Description ?? "").Contains(term, StringComparison.OrdinalIgnoreCase))).ToList();
var counts = matched.ToDictionary(c => c.ClubId, c => MemberCount(c.ClubId));
matched = sort switch
{
"1" => matched.OrderByDescending(c => c.CreatedAt).ToList(),
"2" => matched.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList(),
_ => matched.OrderByDescending(c => counts[c.ClubId]).ThenByDescending(c => c.CreatedAt).ToList(),
};
return (matched.Take(count).ToList(), matched.Count);
}
/// <summary>Clubs an account created that anyone may see (public ones), for their profile.</summary>
public static List<Club> PublicByCreator(ulong accountId) =>
Clubs().Query().Where(x => x.CreatorAccountId == accountId && x.ClubType == 0 && x.Visibility == 1)
.OrderByDescending(x => x.ClubId).ToList();
// ---- membership writes -----------------------------------------------------------------
private static void Upsert(int clubId, ulong accountId, int tier)
{
var members = Members();
var m = members.FindOne(x => x.ClubId == clubId && x.AccountId == accountId);
if (m == null)
{
members.Insert(new ClubMember
{
ClubMemberId = NextId(members, x => x.ClubMemberId),
ClubId = clubId, AccountId = accountId, MembershipType = tier, CreatedAt = DateTime.UtcNow,
});
}
else { m.MembershipType = tier; members.Update(m); }
}
/// <summary>
/// The caller asks to join (reference requesttojoin / join): an Open club takes them in as a
/// Member, an AskToJoin club records a pending request, an InviteOnly club refuses. A pending
/// invite is accepted by asking. Banned stays banned. Returns the resulting tier.
/// </summary>
public static int RequestJoin(int clubId, ulong accountId)
{
var club = Clubs().FindById(clubId);
if (club == null) return TierNone;
int have = MyMembership(clubId, accountId);
if (have == TierBanned || have >= TierMember) return have;
if (have == TierPendingInvite) { Upsert(clubId, accountId, TierMember); return TierMember; }
if (club.Joinability == 0) { Upsert(clubId, accountId, TierMember); return TierMember; }
if (club.Joinability == 2) { Upsert(clubId, accountId, TierPendingRequest); return TierPendingRequest; }
return have; // InviteOnly: nothing changes
}
/// <summary>Leave (or withdraw a request). The creator can't; a ban stays. Returns the new tier.</summary>
public static int Leave(int clubId, ulong accountId)
{
int have = MyMembership(clubId, accountId);
if (have == TierCreator || have == TierBanned) return have;
Members().DeleteMany(x => x.ClubId == clubId && x.AccountId == accountId);
return TierNone;
}
/// <summary>A co-owner adds / promotes / demotes someone (reference members/invite). Never the creator, never to Creator.</summary>
public static bool SetTier(int clubId, ulong accountId, int tier)
{
var club = Clubs().FindById(clubId);
if (club == null || club.CreatorAccountId == accountId) return false;
if (tier != TierMember && tier != TierModerator && tier != TierCoOwner && tier != TierBanned
&& tier != TierPendingInvite && tier != TierNone) return false;
if (tier == TierNone) { Members().DeleteMany(x => x.ClubId == clubId && x.AccountId == accountId); return true; }
Upsert(clubId, accountId, tier);
return true;
}
public static Club? SetClubhouse(int clubId, int? roomId)
{
var clubs = Clubs();
var c = clubs.FindById(clubId);
if (c == null) return null;
c.ClubhouseRoomId = roomId;
clubs.Update(c);
return c;
}
/// <summary>Delete a club with its memberships and announcements (creator only - the caller checks).</summary>
public static bool Delete(int clubId)
{
var clubs = Clubs();
if (clubs.FindById(clubId) == null) return false;
Members().DeleteMany(x => x.ClubId == clubId);
try { Db.GetCollection<ClubAnnouncement>("announcements").DeleteMany(x => x.ClubId == clubId); } catch { }
clubs.Delete(clubId);
return true;
}
}