Import december-2020-12-18 from private repo (scrubbed 2026-05-27)
Targets Rec Room 2020.12.18 client.
Re-import after adding:
- DorkNet.ClientMod/dorknet-clientmod.json.template — config schema
the launcher's manifest-driven patcher renders into UserData/.
- tools/build-patcher-zip.ps1 — produces the dorknet-clientpatch-{key}.zip
Release asset the launcher fetches.
- .github/workflows/release.yml — auto-publishes both Release assets
on v1-december-* tag push.
Scrubbed via maintainer-side scrub-for-public.ps1: same drop rules as
the prior import, plus build-patcher-zip.ps1 added to the tools/
allow-list so it survives the scrub.
@@ -0,0 +1,678 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Data;
|
||||
|
||||
public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<PlayerEntity> Players => Set<PlayerEntity>();
|
||||
public DbSet<AvatarEntity> Avatars => Set<AvatarEntity>();
|
||||
public DbSet<PlayerSettingEntity> PlayerSettings => Set<PlayerSettingEntity>();
|
||||
public DbSet<RelationshipEntity> Relationships => Set<RelationshipEntity>();
|
||||
public DbSet<RoomEntity> Rooms => Set<RoomEntity>();
|
||||
public DbSet<RoomRoleEntity> RoomRoles => Set<RoomRoleEntity>();
|
||||
public DbSet<LeaderboardChannelMetaEntity> LeaderboardChannelMeta => Set<LeaderboardChannelMetaEntity>();
|
||||
public DbSet<RoomBookmarkEntity> RoomBookmarks => Set<RoomBookmarkEntity>();
|
||||
public DbSet<RoomDataBlobEntity> RoomDataBlobs => Set<RoomDataBlobEntity>();
|
||||
public DbSet<DormStateEntity> DormStates => Set<DormStateEntity>();
|
||||
public DbSet<AdminActionEntity> AdminActions => Set<AdminActionEntity>();
|
||||
public DbSet<InventionEntity> Inventions => Set<InventionEntity>();
|
||||
public DbSet<ReportEntity> Reports => Set<ReportEntity>();
|
||||
public DbSet<IpBanEntity> IpBans => Set<IpBanEntity>();
|
||||
public DbSet<MessageEntity> Messages => Set<MessageEntity>();
|
||||
public DbSet<ChatMessageEntity> ChatMessages => Set<ChatMessageEntity>();
|
||||
public DbSet<ChatThreadEntity> ChatThreads => Set<ChatThreadEntity>();
|
||||
public DbSet<ChatThreadMemberEntity> ChatThreadMembers => Set<ChatThreadMemberEntity>();
|
||||
public DbSet<CheerEntity> Cheers => Set<CheerEntity>();
|
||||
public DbSet<CurrencyBalanceEntity> CurrencyBalances => Set<CurrencyBalanceEntity>();
|
||||
public DbSet<PlayerEventEntity> PlayerEvents => Set<PlayerEventEntity>();
|
||||
public DbSet<PlayerEventResponseEntity> PlayerEventResponses => Set<PlayerEventResponseEntity>();
|
||||
public DbSet<ObjectiveProgressEntity> ObjectiveProgress => Set<ObjectiveProgressEntity>();
|
||||
public DbSet<SubscriptionEntity> Subscriptions => Set<SubscriptionEntity>();
|
||||
public DbSet<PhotoEntity> Photos => Set<PhotoEntity>();
|
||||
public DbSet<StoreItemEntity> StoreItems => Set<StoreItemEntity>();
|
||||
public DbSet<InventionVersionEntity> InventionVersions => Set<InventionVersionEntity>();
|
||||
public DbSet<RoomBanEntity> RoomBans => Set<RoomBanEntity>();
|
||||
public DbSet<PlayerInventoryEntity> PlayerInventory => Set<PlayerInventoryEntity>();
|
||||
public DbSet<GiftPackageEntity> GiftPackages => Set<GiftPackageEntity>();
|
||||
public DbSet<BugReportEntity> BugReports => Set<BugReportEntity>();
|
||||
public DbSet<ClubEntity> Clubs => Set<ClubEntity>();
|
||||
public DbSet<ClubMembershipEntity> ClubMemberships => Set<ClubMembershipEntity>();
|
||||
public DbSet<RoyaleMatchEntity> RoyaleMatches => Set<RoyaleMatchEntity>();
|
||||
public DbSet<RoyaleMatchPlayerEntity> RoyaleMatchPlayers => Set<RoyaleMatchPlayerEntity>();
|
||||
public DbSet<RoyalePlayerProgressEntity> RoyalePlayerProgress => Set<RoyalePlayerProgressEntity>();
|
||||
public DbSet<TestCaseEntity> TestCases => Set<TestCaseEntity>();
|
||||
public DbSet<TestPassEntity> TestPasses => Set<TestPassEntity>();
|
||||
public DbSet<PlayerDeviceEntity> PlayerDevices => Set<PlayerDeviceEntity>();
|
||||
public DbSet<NotificationPrefsEntity> NotificationPrefs => Set<NotificationPrefsEntity>();
|
||||
public DbSet<CohortAssignmentEntity> CohortAssignments => Set<CohortAssignmentEntity>();
|
||||
public DbSet<CouponEntity> Coupons => Set<CouponEntity>();
|
||||
public DbSet<CouponRedemptionEntity> CouponRedemptions => Set<CouponRedemptionEntity>();
|
||||
public DbSet<LeaderboardStatEntity> LeaderboardStats => Set<LeaderboardStatEntity>();
|
||||
public DbSet<PlayerEloEntity> PlayerElo => Set<PlayerEloEntity>();
|
||||
public DbSet<PushTokenEntity> PushTokens => Set<PushTokenEntity>();
|
||||
public DbSet<PlatformIgnoreEntity> PlatformIgnores => Set<PlatformIgnoreEntity>();
|
||||
public DbSet<CardEntity> Cards => Set<CardEntity>();
|
||||
public DbSet<RoomVisitEntity> RoomVisits => Set<RoomVisitEntity>();
|
||||
public DbSet<RoomSceneEntity> RoomScenes => Set<RoomSceneEntity>();
|
||||
public DbSet<PrivateInstanceEntity> PrivateInstances => Set<PrivateInstanceEntity>();
|
||||
public DbSet<PrivateInstanceInviteeEntity> PrivateInstanceInvitees => Set<PrivateInstanceInviteeEntity>();
|
||||
public DbSet<GameSessionEntity> GameSessions => Set<GameSessionEntity>();
|
||||
public DbSet<CommunityBoardEntity> CommunityBoardRows => Set<CommunityBoardEntity>();
|
||||
public DbSet<LoadingScreenTipEntity> LoadingScreenTips => Set<LoadingScreenTipEntity>();
|
||||
public DbSet<ServerSettingsEntity> ServerSettings => Set<ServerSettingsEntity>();
|
||||
public DbSet<RoomKeyEntity> RoomKeys => Set<RoomKeyEntity>();
|
||||
public DbSet<RoomKeyPurchaseEntity> RoomKeyPurchases => Set<RoomKeyPurchaseEntity>();
|
||||
public DbSet<GameRewardSelectionEntity> GameRewardSelections => Set<GameRewardSelectionEntity>();
|
||||
public DbSet<PlaylistEntity> Playlists => Set<PlaylistEntity>();
|
||||
public DbSet<PlaylistRoomEntity> PlaylistRooms => Set<PlaylistRoomEntity>();
|
||||
public DbSet<PlaylistInteractionEntity> PlaylistInteractions => Set<PlaylistInteractionEntity>();
|
||||
public DbSet<ClubAnnouncementEntity> ClubAnnouncements => Set<ClubAnnouncementEntity>();
|
||||
public DbSet<ClubAnnouncementReadEntity> ClubAnnouncementReads => Set<ClubAnnouncementReadEntity>();
|
||||
public DbSet<ClubCategoryTagEntity> ClubCategoryTags => Set<ClubCategoryTagEntity>();
|
||||
public DbSet<ClubCategoryAssignmentEntity> ClubCategoryAssignments => Set<ClubCategoryAssignmentEntity>();
|
||||
public DbSet<ClubSubscriptionEntity> ClubSubscriptions => Set<ClubSubscriptionEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// SQLite-specific NOCASE collation works via UseCollation; Postgres
|
||||
// doesn't have NOCASE. Provider-conditional helper applied to the
|
||||
// three string columns the wire API matches case-insensitively
|
||||
// (Room.Name, RoomScene.Name, Club.Name).
|
||||
//
|
||||
// FOLLOW-UP: on Postgres we currently fall back to default case-
|
||||
// sensitive collation on these columns. In practice this is fine
|
||||
// because the wire API users always type the same casing as the
|
||||
// stored value, but if we want strict parity with SQLite NOCASE
|
||||
// semantics we should add a functional LOWER(...) unique index
|
||||
// via raw SQL in a Postgres migration, OR enable the citext
|
||||
// extension and switch the column type. Tracked for post-cutover
|
||||
// hardening; not a launch blocker.
|
||||
var isSqlite = Database.IsSqlite();
|
||||
modelBuilder.Entity<PlayerEntity>(e =>
|
||||
{
|
||||
e.HasKey(p => p.Id);
|
||||
e.HasIndex(p => p.Username).IsUnique();
|
||||
e.HasOne(p => p.Avatar).WithOne(a => a.Player).HasForeignKey<AvatarEntity>(a => a.PlayerId);
|
||||
e.HasMany(p => p.Settings).WithOne(s => s.Player).HasForeignKey(s => s.PlayerId);
|
||||
e.HasMany(p => p.Relationships).WithOne(r => r.Requester).HasForeignKey(r => r.RequesterId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RelationshipEntity>(e =>
|
||||
{
|
||||
e.HasKey(r => r.Id);
|
||||
e.HasIndex(r => new { r.RequesterId, r.TargetId }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PlayerSettingEntity>(e =>
|
||||
{
|
||||
e.HasKey(s => s.Id);
|
||||
e.HasIndex(s => new { s.PlayerId, s.Key }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RoomEntity>(e =>
|
||||
{
|
||||
e.HasKey(r => r.Id);
|
||||
// Room names are case-insensitive in the wire API
|
||||
// (api/rooms/v2/name/{name}) — store collation-insensitive so
|
||||
// GetByName lookups don't depend on the user's casing.
|
||||
e.HasIndex(r => r.Name).IsUnique();
|
||||
if (isSqlite) e.Property(r => r.Name).UseCollation("NOCASE");
|
||||
// Hot-feed sorts by HotScore desc — keep it indexed.
|
||||
e.HasIndex(r => r.HotScore);
|
||||
e.HasIndex(r => r.CreatorPlayerId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RoomBookmarkEntity>(e =>
|
||||
{
|
||||
e.HasKey(b => b.Id);
|
||||
e.HasIndex(b => new { b.PlayerId, b.RoomId }).IsUnique();
|
||||
e.HasIndex(b => b.PlayerId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RoomDataBlobEntity>(e =>
|
||||
{
|
||||
e.HasKey(b => b.Id);
|
||||
// BlobName is the cdn url segment the client requests; must
|
||||
// be unique across the table because the catch-all matches
|
||||
// by string.
|
||||
e.HasIndex(b => b.BlobName).IsUnique();
|
||||
// "All versions of this room" + "find current" both index
|
||||
// by RoomId. Sort by UploadedAt desc when listing history.
|
||||
e.HasIndex(b => b.RoomId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RoomSceneEntity>(e =>
|
||||
{
|
||||
e.HasKey(s => s.Id);
|
||||
// BuildRoomDetails fetches all scenes for one room ordered
|
||||
// by OrderIndex. Composite index covers both predicate and
|
||||
// sort.
|
||||
e.HasIndex(s => new { s.RoomId, s.OrderIndex }).IsUnique();
|
||||
// Sub-room goto resolves by (room, name). Case-insensitive
|
||||
// match — same NOCASE collation pattern as RoomEntity.Name.
|
||||
e.HasIndex(s => new { s.RoomId, s.Name });
|
||||
if (isSqlite) e.Property(s => s.Name).UseCollation("NOCASE");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<DormStateEntity>(e =>
|
||||
{
|
||||
// PlayerId is already declared as [Key] on the entity; explicit
|
||||
// here so EF doesn't expect an Id auto-pk.
|
||||
e.HasKey(d => d.PlayerId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<AdminActionEntity>(e =>
|
||||
{
|
||||
e.HasKey(a => a.Id);
|
||||
// The audit log is read in reverse-chronological order;
|
||||
// index Timestamp desc for the common "give me recent
|
||||
// admin actions" query.
|
||||
e.HasIndex(a => a.Timestamp);
|
||||
// Filter "all actions taken by admin X" or "all actions
|
||||
// targeting player Y" both want indexes.
|
||||
e.HasIndex(a => a.AdminPlayerId);
|
||||
e.HasIndex(a => new { a.TargetType, a.TargetId });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<InventionEntity>(e =>
|
||||
{
|
||||
e.HasKey(i => i.Id);
|
||||
e.HasIndex(i => i.CreatorPlayerId);
|
||||
// Popular tab orders by CheerCount desc; index it.
|
||||
e.HasIndex(i => i.CheerCount);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ReportEntity>(e =>
|
||||
{
|
||||
e.HasKey(r => r.Id);
|
||||
// Admin queue reads "open reports ordered by oldest" —
|
||||
// index ResolvedAt so the WHERE IS NULL filter is fast.
|
||||
e.HasIndex(r => r.ResolvedAt);
|
||||
e.HasIndex(r => r.TargetPlayerId);
|
||||
e.HasIndex(r => r.ReporterPlayerId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<IpBanEntity>(e =>
|
||||
{
|
||||
e.HasKey(b => b.Id);
|
||||
// Middleware queries by Cidr per request — keep it
|
||||
// indexed even though the table will be small.
|
||||
e.HasIndex(b => b.Cidr);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<MessageEntity>(e =>
|
||||
{
|
||||
e.HasKey(m => m.Id);
|
||||
// Inbox query: "messages I received, ordered by SentAt
|
||||
// desc". Index Recipient + SentAt for that path.
|
||||
e.HasIndex(m => new { m.RecipientPlayerId, m.SentAt });
|
||||
e.HasIndex(m => new { m.SenderPlayerId, m.SentAt });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ChatMessageEntity>(e =>
|
||||
{
|
||||
e.HasKey(c => c.Id);
|
||||
// Thread fetch is "ThreadKey + SentAt desc, take 50".
|
||||
e.HasIndex(c => new { c.ThreadKey, c.SentAt });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ChatThreadEntity>(e =>
|
||||
{
|
||||
e.HasKey(t => t.Id);
|
||||
// One metadata row per thread — concurrent rename calls
|
||||
// would otherwise duplicate-insert.
|
||||
e.HasIndex(t => t.ThreadKey).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ChatThreadMemberEntity>(e =>
|
||||
{
|
||||
e.HasKey(m => m.Id);
|
||||
// (Thread, Player) is the natural key — Snooze/MarkRead/
|
||||
// Leave race conditions would otherwise insert duplicate
|
||||
// membership rows under the same player.
|
||||
e.HasIndex(m => new { m.ThreadKey, m.PlayerId }).IsUnique();
|
||||
e.HasIndex(m => m.PlayerId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<CheerEntity>(e =>
|
||||
{
|
||||
e.HasKey(c => c.Id);
|
||||
// Re-cheer is idempotent on the (from, target, type)
|
||||
// tuple — unique index lets us upsert cleanly.
|
||||
e.HasIndex(c => new { c.FromPlayerId, c.TargetPlayerId, c.TargetRoomId, c.TargetPhotoId, c.Type })
|
||||
.IsUnique();
|
||||
e.HasIndex(c => c.TargetPlayerId);
|
||||
e.HasIndex(c => c.TargetRoomId);
|
||||
e.HasIndex(c => c.TargetPhotoId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PhotoEntity>(e =>
|
||||
{
|
||||
e.HasKey(p => p.Id);
|
||||
// Feed pagination is "WHERE IsPublic AND DeletedAt IS NULL
|
||||
// ORDER BY CreatedAt DESC" — index CreatedAt for that.
|
||||
e.HasIndex(p => p.CreatedAt);
|
||||
e.HasIndex(p => p.UploaderPlayerId);
|
||||
e.HasIndex(p => p.RoomId);
|
||||
// BlobName is the cdn URL the client requests; lookups by
|
||||
// it are common (admin moderation, dedupe checks).
|
||||
e.HasIndex(p => p.BlobName);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<StoreItemEntity>(e =>
|
||||
{
|
||||
e.HasKey(s => s.Id);
|
||||
// Slug is referenced from Avatar.InventoryJson — must be
|
||||
// unique so the inventory resolver gets a deterministic
|
||||
// hit when looking up "what does this slug mean?".
|
||||
e.HasIndex(s => s.Slug).IsUnique();
|
||||
// Storefront tab queries filter by storefront + active +
|
||||
// category in that order; index storefront for the most
|
||||
// selective predicate first.
|
||||
e.HasIndex(s => s.Storefront);
|
||||
e.HasIndex(s => new { s.IsActive, s.Category });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<CurrencyBalanceEntity>(e =>
|
||||
{
|
||||
e.HasKey(c => c.Id);
|
||||
// One wallet row per (player, currency) — unique on the
|
||||
// pair so AwardCurrency upserts cleanly.
|
||||
e.HasIndex(c => new { c.PlayerId, c.CurrencyType }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PlayerEventEntity>(e =>
|
||||
{
|
||||
e.HasKey(ev => ev.Id);
|
||||
e.HasIndex(ev => ev.CreatorPlayerId);
|
||||
e.HasIndex(ev => ev.StartsAt);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PlayerEventResponseEntity>(e =>
|
||||
{
|
||||
e.HasKey(r => r.Id);
|
||||
// One response per (event, player); upsert via this
|
||||
// unique index.
|
||||
e.HasIndex(r => new { r.EventId, r.PlayerId }).IsUnique();
|
||||
e.HasIndex(r => r.PlayerId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ObjectiveProgressEntity>(e =>
|
||||
{
|
||||
e.HasKey(o => o.Id);
|
||||
e.HasIndex(o => new { o.PlayerId, o.Key }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<SubscriptionEntity>(e =>
|
||||
{
|
||||
e.HasKey(s => s.Id);
|
||||
// One subscription per (subscriber, target) pair.
|
||||
e.HasIndex(s => new { s.SubscriberPlayerId, s.TargetPlayerId })
|
||||
.IsUnique();
|
||||
// SubscriberCount aggregations index by target.
|
||||
e.HasIndex(s => s.TargetPlayerId);
|
||||
e.HasIndex(s => s.SubscriberPlayerId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<InventionVersionEntity>(e =>
|
||||
{
|
||||
e.HasKey(v => v.Id);
|
||||
// Version-list query: WHERE InventionId = X ORDER BY
|
||||
// VersionNumber DESC. Index covers both predicates.
|
||||
e.HasIndex(v => new { v.InventionId, v.VersionNumber })
|
||||
.IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RoomBanEntity>(e =>
|
||||
{
|
||||
e.HasKey(b => b.Id);
|
||||
// Match-service join check: "is this player banned from
|
||||
// this room?". Compound index makes that O(log n).
|
||||
e.HasIndex(b => new { b.RoomId, b.BannedPlayerId });
|
||||
e.HasIndex(b => b.BannedPlayerId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PlayerInventoryEntity>(e =>
|
||||
{
|
||||
e.HasKey(p => p.Id);
|
||||
// One row per (player, item) pair.
|
||||
e.HasIndex(p => new { p.PlayerId, p.ItemSlug }).IsUnique();
|
||||
e.HasIndex(p => p.PlayerId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<GiftPackageEntity>(e =>
|
||||
{
|
||||
e.HasKey(g => g.Id);
|
||||
// Inbox query: unconsumed gifts for a player.
|
||||
e.HasIndex(g => new { g.RecipientPlayerId, g.Consumed });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<BugReportEntity>(e =>
|
||||
{
|
||||
e.HasKey(b => b.Id);
|
||||
// Admin queue reads "unread bugs ordered by oldest" —
|
||||
// ReadAt index for the WHERE IS NULL filter.
|
||||
e.HasIndex(b => b.ReadAt);
|
||||
e.HasIndex(b => b.ReporterPlayerId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ClubEntity>(e =>
|
||||
{
|
||||
e.HasKey(c => c.Id);
|
||||
e.HasIndex(c => c.Name).IsUnique();
|
||||
// GetByName lookups are case-insensitive on the wire.
|
||||
if (isSqlite) e.Property(c => c.Name).UseCollation("NOCASE");
|
||||
e.HasIndex(c => c.CreatorPlayerId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ClubMembershipEntity>(e =>
|
||||
{
|
||||
e.HasKey(m => m.Id);
|
||||
// One membership per (club, player) pair.
|
||||
e.HasIndex(m => new { m.ClubId, m.PlayerId }).IsUnique();
|
||||
e.HasIndex(m => m.PlayerId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ClubAnnouncementEntity>(e =>
|
||||
{
|
||||
e.HasKey(a => a.Id);
|
||||
// Per-club timeline query (newest first) — composite index
|
||||
// covers both predicate and sort.
|
||||
e.HasIndex(a => new { a.ClubId, a.CreatedAt });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ClubAnnouncementReadEntity>(e =>
|
||||
{
|
||||
e.HasKey(r => r.Id);
|
||||
// One read-marker per (announcement, player) — upsert via
|
||||
// this unique index so re-marking is a no-op.
|
||||
e.HasIndex(r => new { r.AnnouncementId, r.PlayerId }).IsUnique();
|
||||
// The unread feeds query "what have I read?" by player id.
|
||||
e.HasIndex(r => r.PlayerId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ClubCategoryTagEntity>(e =>
|
||||
{
|
||||
e.HasKey(t => t.Id);
|
||||
// Tag names are case-insensitive in admin tooling; not
|
||||
// unique because admins may temporarily reuse a soft-
|
||||
// deleted name when iterating.
|
||||
e.HasIndex(t => t.Name);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ClubCategoryAssignmentEntity>(e =>
|
||||
{
|
||||
e.HasKey(a => a.Id);
|
||||
// One assignment per (club, tag) pair — re-assigning the
|
||||
// same tag should be idempotent.
|
||||
e.HasIndex(a => new { a.ClubId, a.CategoryTagId }).IsUnique();
|
||||
e.HasIndex(a => a.CategoryTagId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ClubSubscriptionEntity>(e =>
|
||||
{
|
||||
e.HasKey(s => s.Id);
|
||||
// One subscription row per (player, club). Unique so the
|
||||
// subscribe-then-resubscribe path can upsert cleanly.
|
||||
e.HasIndex(s => new { s.PlayerId, s.ClubId }).IsUnique();
|
||||
e.HasIndex(s => s.ClubId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RoyaleMatchEntity>(e =>
|
||||
{
|
||||
e.HasKey(m => m.Id);
|
||||
e.HasIndex(m => m.CompletedAt);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RoyaleMatchPlayerEntity>(e =>
|
||||
{
|
||||
e.HasKey(p => p.Id);
|
||||
e.HasIndex(p => new { p.MatchId, p.PlayerId });
|
||||
e.HasIndex(p => p.PlayerId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RoyalePlayerProgressEntity>(e =>
|
||||
{
|
||||
e.HasKey(p => p.Id);
|
||||
e.HasIndex(p => p.PlayerId).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<TestCaseEntity>(e =>
|
||||
{
|
||||
e.HasKey(t => t.Pk);
|
||||
// Wire Id (string) is what the client uses in URL params;
|
||||
// unique so the lookup-by-string-id is fast.
|
||||
e.HasIndex(t => t.Id).IsUnique();
|
||||
e.HasIndex(t => t.TestPassId);
|
||||
e.HasIndex(t => t.Status);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<TestPassEntity>(e =>
|
||||
{
|
||||
e.HasKey(p => p.Id);
|
||||
// Don't auto-generate uint Id; we set it explicitly so it
|
||||
// matches the upstream JIRA pass id when seeded.
|
||||
e.Property(p => p.Id).ValueGeneratedNever();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PlayerDeviceEntity>(e =>
|
||||
{
|
||||
e.HasKey(d => d.Id);
|
||||
// Lookup "all accounts using this device" for ban
|
||||
// enforcement.
|
||||
e.HasIndex(d => d.DeviceId);
|
||||
e.HasIndex(d => d.PlayerId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<NotificationPrefsEntity>(e =>
|
||||
{
|
||||
e.HasKey(n => n.Id);
|
||||
// One pref row per (player, platform).
|
||||
e.HasIndex(n => new { n.PlayerId, n.Platform }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<CohortAssignmentEntity>(e =>
|
||||
{
|
||||
e.HasKey(c => c.Id);
|
||||
// Sticky assignment: one per (player, cohort).
|
||||
e.HasIndex(c => new { c.PlayerId, c.CohortKey }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<CouponEntity>(e =>
|
||||
{
|
||||
e.HasKey(c => c.Id);
|
||||
e.HasIndex(c => c.Code).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<CouponRedemptionEntity>(e =>
|
||||
{
|
||||
e.HasKey(r => r.Id);
|
||||
// Enforce one-redeem-per-player.
|
||||
e.HasIndex(r => new { r.CouponId, r.PlayerId }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<LeaderboardStatEntity>(e =>
|
||||
{
|
||||
e.HasKey(s => s.Id);
|
||||
// One row per (player, channel) — enforced unique so
|
||||
// SetStats can upsert without race conditions.
|
||||
e.HasIndex(s => new { s.PlayerId, s.StatChannel }).IsUnique();
|
||||
// Rank computation: ORDER BY Value DESC over a channel.
|
||||
e.HasIndex(s => new { s.StatChannel, s.Value });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PlayerEloEntity>(e =>
|
||||
{
|
||||
e.HasKey(p => p.Id);
|
||||
e.HasIndex(p => new { p.PlayerId, p.GameMode }).IsUnique();
|
||||
// Matchmaker reads ranked players ordered by Elo DESC.
|
||||
e.HasIndex(p => new { p.GameMode, p.Elo });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PushTokenEntity>(e =>
|
||||
{
|
||||
e.HasKey(t => t.Id);
|
||||
// One token per (player, platform) — APNS revokes the
|
||||
// old one when the user reinstalls, so we upsert.
|
||||
e.HasIndex(t => new { t.PlayerId, t.Platform }).IsUnique();
|
||||
e.HasIndex(t => t.Token);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PlatformIgnoreEntity>(e =>
|
||||
{
|
||||
e.HasKey(i => i.Id);
|
||||
// One ignore-row per (player, platform, platform user id).
|
||||
e.HasIndex(i => new { i.PlayerId, i.Platform, i.PlatformUserId }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<CardEntity>(e =>
|
||||
{
|
||||
e.HasKey(c => c.Id);
|
||||
// Home-screen query: WHERE PlayerId IS NULL OR = me
|
||||
// ORDER BY Priority DESC, CreatedAt DESC.
|
||||
e.HasIndex(c => c.PlayerId);
|
||||
e.HasIndex(c => c.ExpiresAt);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RoomVisitEntity>(e =>
|
||||
{
|
||||
e.HasKey(v => v.Id);
|
||||
// One row per (room, player) — enforced unique so the
|
||||
// visit-upsert doesn't create duplicates under concurrent
|
||||
// /goto calls. Lookup-by-pair is the hot path so the
|
||||
// composite index doubles as the SELECT support index.
|
||||
e.HasIndex(v => new { v.RoomId, v.PlayerId }).IsUnique();
|
||||
// Per-player history view (admin / dev console).
|
||||
e.HasIndex(v => v.PlayerId);
|
||||
// "Recent visitors of this room" view.
|
||||
e.HasIndex(v => new { v.RoomId, v.LastVisitAt });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RoomRoleEntity>(e =>
|
||||
{
|
||||
e.HasKey(r => r.Id);
|
||||
e.HasIndex(r => r.RoomId);
|
||||
e.HasIndex(r => new { r.RoomId, r.PlayerId, r.Role }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PrivateInstanceEntity>(e =>
|
||||
{
|
||||
// Id is server-allocated (computed nonce), not autoincrement.
|
||||
// Mark ValueGenerated.Never so EF doesn't try to round-trip
|
||||
// through a sequence on insert.
|
||||
e.HasKey(p => p.Id);
|
||||
e.Property(p => p.Id).ValueGeneratedNever();
|
||||
e.HasIndex(p => p.OwnerPlayerId);
|
||||
// Quick lookup for "is this player in any private instance"
|
||||
// queries used by NotificationService.
|
||||
e.HasIndex(p => new { p.RoomId, p.SubRoomId });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PrivateInstanceInviteeEntity>(e =>
|
||||
{
|
||||
// Composite PK: one row per (instance, invitee). Naturally
|
||||
// dedupes repeat-invite calls.
|
||||
e.HasKey(i => new { i.PrivateInstanceId, i.PlayerId });
|
||||
// Reverse-lookup index for "what private instances am I
|
||||
// invited to" queries (the watch's invite inbox).
|
||||
e.HasIndex(i => i.PlayerId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<GameSessionEntity>(e =>
|
||||
{
|
||||
// Postgres bigserial; SQLite gets INTEGER PRIMARY KEY
|
||||
// AUTOINCREMENT semantics. Both providers' sequences are
|
||||
// process-shared so two replicas don't allocate id=1.
|
||||
e.HasKey(s => s.Id);
|
||||
// The hot lookup is "find a non-full session in this room
|
||||
// and region" — composite index covers it.
|
||||
e.HasIndex(s => new { s.RoomId, s.Region });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<CommunityBoardEntity>(e =>
|
||||
{
|
||||
// Single-row table, id always 1. We don't autoincrement;
|
||||
// the row is upserted explicitly.
|
||||
e.HasKey(c => c.Id);
|
||||
e.Property(c => c.Id).ValueGeneratedNever();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ServerSettingsEntity>(e =>
|
||||
{
|
||||
// Single-row table, id always 1 — same pattern as
|
||||
// CommunityBoardRows above.
|
||||
e.HasKey(s => s.Id);
|
||||
e.Property(s => s.Id).ValueGeneratedNever();
|
||||
e.Property(s => s.WeeklyChallengesCompletedRequired).HasDefaultValue(true);
|
||||
e.Property(s => s.WeeklyChallengesJson).HasDefaultValue(string.Empty);
|
||||
e.Property(s => s.WeeklyChallengeRewardJson).HasDefaultValue(string.Empty);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RoomKeyEntity>(e =>
|
||||
{
|
||||
e.HasKey(k => k.Id);
|
||||
e.HasIndex(k => k.RoomId);
|
||||
e.HasIndex(k => new { k.RoomId, k.Name });
|
||||
e.HasIndex(k => k.CreatorPlayerId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RoomKeyPurchaseEntity>(e =>
|
||||
{
|
||||
e.HasKey(p => p.Id);
|
||||
e.HasIndex(p => new { p.RoomKeyId, p.PlayerId }).IsUnique();
|
||||
e.HasIndex(p => p.PlayerId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<GameRewardSelectionEntity>(e =>
|
||||
{
|
||||
e.HasKey(r => r.Id);
|
||||
e.HasIndex(r => new { r.PlayerId, r.SelectedAt });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PlaylistEntity>(e =>
|
||||
{
|
||||
e.HasKey(p => p.Id);
|
||||
// Playlist names share the same case-insensitive lookup
|
||||
// contract as Room.Name — the watch's search compares
|
||||
// user-typed strings directly against the stored value.
|
||||
e.HasIndex(p => p.Name);
|
||||
if (isSqlite) e.Property(p => p.Name).UseCollation("NOCASE");
|
||||
// Curated tab reads "WHERE IsCurated ORDER BY OrderIndex" —
|
||||
// index covers the predicate and the sort key.
|
||||
e.HasIndex(p => p.IsCurated);
|
||||
e.HasIndex(p => p.CreatorPlayerId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PlaylistRoomEntity>(e =>
|
||||
{
|
||||
e.HasKey(pr => pr.Id);
|
||||
// Member-rooms query: "WHERE PlaylistId = X ORDER BY
|
||||
// OrderIndex". Composite covers both predicate and sort.
|
||||
e.HasIndex(pr => new { pr.PlaylistId, pr.OrderIndex });
|
||||
e.HasIndex(pr => pr.PlaylistId);
|
||||
e.HasIndex(pr => pr.RoomId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PlaylistInteractionEntity>(e =>
|
||||
{
|
||||
e.HasKey(i => i.Id);
|
||||
// One row per (playlist, player) — toggle endpoints upsert.
|
||||
e.HasIndex(i => new { i.PlaylistId, i.PlayerId }).IsUnique();
|
||||
// "cheeredby/me" / "favoritedby/me" lists query by PlayerId
|
||||
// + the bool flag; PlayerId-leading index is enough since
|
||||
// the table is small.
|
||||
e.HasIndex(i => i.PlayerId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<LeaderboardChannelMetaEntity>(e =>
|
||||
{
|
||||
// Channel int IS the primary key — there's only one
|
||||
// metadata row per stat-channel id, and the channel id
|
||||
// is what the watch reports on POST /SetStats.
|
||||
e.HasKey(c => c.Channel);
|
||||
e.Property(c => c.Channel).ValueGeneratedNever();
|
||||
e.HasIndex(c => c.RoomId);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Append-only audit row written every time an admin performs a
|
||||
/// privileged action (ban, unban, promote, demote, room delete,
|
||||
/// MOTD change, etc.). The admin watch UI's "audit" tab reads
|
||||
/// from this in reverse-chronological order.
|
||||
///
|
||||
/// Rows are never deleted from the application — privileged actions
|
||||
/// stay on the record. Storage cost is negligible at single-server
|
||||
/// scale.
|
||||
/// </summary>
|
||||
public class AdminActionEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>The admin who performed the action.</summary>
|
||||
public long AdminPlayerId { get; set; }
|
||||
|
||||
/// <summary>Free-form action name. Convention: lower-snake-case verb,
|
||||
/// e.g. <c>ban_player</c>, <c>unban_player</c>, <c>promote_admin</c>,
|
||||
/// <c>delete_room</c>, <c>set_motd</c>.</summary>
|
||||
[MaxLength(64)]
|
||||
public string Action { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Targeted entity type — <c>player</c>, <c>room</c>,
|
||||
/// <c>system</c>. Free-form so future actions can introduce new
|
||||
/// kinds without a migration.</summary>
|
||||
[MaxLength(32)]
|
||||
public string TargetType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Numeric id of the target. <c>0</c> for system-wide
|
||||
/// actions like setting the MOTD.</summary>
|
||||
public long TargetId { get; set; }
|
||||
|
||||
/// <summary>Optional human reason the admin supplied at action
|
||||
/// time. Surfaced to the audit log UI verbatim.</summary>
|
||||
[MaxLength(512)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per-player avatar state. The 2020 client persists a mix of structured
|
||||
/// and free-form blobs:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>OutfitSelections</c> — comma-separated GUIDs of equipped
|
||||
/// items per slot (head, torso, legs, feet, accessories). Sent
|
||||
/// verbatim to <c>POST api/avatar/v2</c>.</item>
|
||||
/// <item><c>FaceFeatures</c> — JSON blob with eyeId/eyePos/eyeScl/
|
||||
/// mouthId etc. produced by the in-game face editor.</item>
|
||||
/// <item><c>HairColor</c> / <c>SkinColor</c> — color vault GUIDs from
|
||||
/// the client's baked hair/skin color tables.</item>
|
||||
/// <item><c>SavedOutfitsJson</c> — list of named outfit presets the
|
||||
/// watch's Backpack tab shows under "saved".</item>
|
||||
/// <item><c>EquippedItemsJson</c> / <c>InventoryJson</c> — kept for the
|
||||
/// v3 wire shape (list of <see cref="DorkNet.Models.Avatar.AvatarItemInstance"/>).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public class AvatarEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
// JSON-serialised list of equipped item IDs (v3 shape)
|
||||
public string EquippedItemsJson { get; set; } = "[]";
|
||||
|
||||
// JSON-serialised full inventory (v3 shape)
|
||||
public string InventoryJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>v2 outfit selections — comma-separated GUIDs per slot.</summary>
|
||||
[MaxLength(2048)]
|
||||
public string OutfitSelections { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>v2 face features — opaque JSON written by the face
|
||||
/// editor; we never parse it server-side, just round-trip.</summary>
|
||||
[MaxLength(8192)]
|
||||
public string FaceFeatures { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(64)]
|
||||
public string HairColor { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(64)]
|
||||
public string SkinColor { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Saved outfit presets — JSON list of named slots so the
|
||||
/// watch's "Saved" tab can recall them.</summary>
|
||||
public string SavedOutfitsJson { get; set; } = "[]";
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public PlayerEntity? Player { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One bug report submitted via the in-game "Report a Bug" UI
|
||||
/// (<c>POST /api/bugreporting/v2/reportbug</c>). Surfaces in the
|
||||
/// admin moderation queue at <c>GET /api/admin/v1/bugreports</c>.
|
||||
/// </summary>
|
||||
public class BugReportEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long ReporterPlayerId { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(4000)]
|
||||
public string Body { get; set; } = string.Empty;
|
||||
|
||||
public long GameSessionId { get; set; }
|
||||
|
||||
[MaxLength(64)]
|
||||
public string ClientVersion { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(32)]
|
||||
public string Platform { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional category for routing — also reused by Phase
|
||||
/// 11's <c>POST /api/hile/v1/log</c> (huddle telemetry) which
|
||||
/// piggybacks on this table with Category=`hile`.</summary>
|
||||
[MaxLength(32)]
|
||||
public string Category { get; set; } = "bug";
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public DateTime? ReadAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// In-game text-chat message. Sent to the chat.rec.net/thread
|
||||
/// endpoint family; backed by a flat per-thread row table where the
|
||||
/// thread id is just the canonical (lower, higher) pair of player ids
|
||||
/// for DMs, or the room id for room-wide chats.
|
||||
///
|
||||
/// We keep a separate table from <see cref="MessageEntity"/> because
|
||||
/// the wire shape and history-fetch semantics differ — chat is
|
||||
/// append-and-read-recent, messages are inbox-style with read
|
||||
/// receipts.
|
||||
/// </summary>
|
||||
public class ChatMessageEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>String thread key. Format: <c>dm:<low>:<high></c> for
|
||||
/// direct messages between two players (lower id first), or
|
||||
/// <c>room:<roomId></c> for room-wide chats.</summary>
|
||||
[MaxLength(64)]
|
||||
public string ThreadKey { get; set; } = string.Empty;
|
||||
|
||||
public long SenderPlayerId { get; set; }
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string Body { get; set; } = string.Empty;
|
||||
|
||||
public DateTime SentAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Build the canonical DM thread key for two players.
|
||||
/// Order-independent so both sides hit the same row.</summary>
|
||||
public static string DmKey(long a, long b) =>
|
||||
a < b ? $"dm:{a}:{b}" : $"dm:{b}:{a}";
|
||||
|
||||
public static string RoomKey(long roomId) => $"room:{roomId}";
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per-thread metadata for chat: name (for group chats), creator, and
|
||||
/// timestamps. Rows here are sparse — a two-person DM doesn't need
|
||||
/// one unless the players want a custom thread name. Group chats
|
||||
/// always have a row so the member set + name are persisted.
|
||||
/// </summary>
|
||||
public class ChatThreadEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>Canonical thread key (matches <c>ChatMessageEntity.ThreadKey</c>).
|
||||
/// Format: <c>dm:<low>:<high></c> for DMs,
|
||||
/// <c>group:<guid></c> for group chats, <c>room:<id></c> for room-wide.</summary>
|
||||
[MaxLength(96)]
|
||||
public string ThreadKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional custom name. DMs default to empty (watch renders
|
||||
/// "Chat with <username>" client-side); group chats persist a
|
||||
/// name set via <c>PUT thread/{id}/rename</c>.</summary>
|
||||
[MaxLength(128)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public long CreatorPlayerId { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per-(thread, player) row: membership, per-player snooze, and the
|
||||
/// last-read message id used for unread counts and the inbox's "X
|
||||
/// unread" badge. One row per active participant. A "leave" deletes
|
||||
/// the row so the player no longer receives new messages on that
|
||||
/// thread.
|
||||
///
|
||||
/// For two-person DMs without a custom name, rows are created lazily
|
||||
/// on first MarkRead / Snooze / Leave — the thread itself still works
|
||||
/// fine without rows (the message table is the source of truth for
|
||||
/// who participated).
|
||||
/// </summary>
|
||||
public class ChatThreadMemberEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>The thread this membership applies to. Matches
|
||||
/// <c>ChatMessageEntity.ThreadKey</c> / <c>ChatThreadEntity.ThreadKey</c>.</summary>
|
||||
public string ThreadKey { get; set; } = string.Empty;
|
||||
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
public DateTime JoinedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>When set, the watch's inbox treats this thread as
|
||||
/// muted until the UTC timestamp. Cleared by re-snoozing with a
|
||||
/// null payload.</summary>
|
||||
public DateTime? SnoozeUntil { get; set; }
|
||||
|
||||
/// <summary>Id of the last <c>ChatMessageEntity</c> the player
|
||||
/// has acknowledged via <c>PUT thread/{id}/read</c>. Drives the
|
||||
/// per-thread unread counter on the watch's inbox tab.</summary>
|
||||
public long? LastReadMessageId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One cheer (the watch's positive-feedback button). Targets exactly
|
||||
/// one of: a player (stored on <see cref="TargetPlayerId"/>), a room
|
||||
/// (<see cref="TargetRoomId"/>), or a photo (<see cref="TargetPhotoId"/>).
|
||||
/// Unused targets are 0. Aggregated reputation queries SUM rows by
|
||||
/// target.
|
||||
///
|
||||
/// One row per (FromPlayer, target, Type) — re-cheering is idempotent
|
||||
/// (we upsert on the unique index).
|
||||
/// </summary>
|
||||
public class CheerEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public long FromPlayerId { get; set; }
|
||||
|
||||
public long TargetPlayerId { get; set; }
|
||||
public long TargetRoomId { get; set; }
|
||||
public long TargetPhotoId { get; set; }
|
||||
public long TargetInventionId { get; set; }
|
||||
|
||||
/// <summary>RoomieCheerType enum mirror: 0=General, 1=Helpful,
|
||||
/// 2=GreatHost, 3=Sportsman, 4=Creative, 5=Credit. Stored as int
|
||||
/// to leave room for future categories without a migration.</summary>
|
||||
public int Type { get; set; }
|
||||
|
||||
public DateTime CheeredAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A club announcement — wire type <c>RecNet.ClubAnnouncement</c>
|
||||
/// (<c>Cpp2IL_ISIL/.../NFEMLMAFFIP.txt</c>). The 2020.12 watch reads
|
||||
/// the JSON keys
|
||||
/// <c>AnnouncementId, CreatorAccountId, ClubId, Title, Body, ImageName,
|
||||
/// CreatedAt, Meta</c> verbatim — <see cref="ClubsController.ToWireAnnouncement"/>
|
||||
/// emits exactly those, PascalCase. Drives the bell-icon "you have unread
|
||||
/// club announcements" feed under <c>announcements/v2/mine/unread</c> and
|
||||
/// <c>announcements/v2/subscription/mine/unread</c>.
|
||||
/// </summary>
|
||||
public class ClubAnnouncementEntity
|
||||
{
|
||||
/// <summary>Mirrors wire field <c>AnnouncementId</c> (Int64).</summary>
|
||||
public long Id { get; set; }
|
||||
|
||||
public long ClubId { get; set; }
|
||||
|
||||
/// <summary>Mirrors wire <c>CreatorAccountId</c> (Int32 in the
|
||||
/// payload — cast in the serialiser).</summary>
|
||||
public long AuthorPlayerId { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(4000)]
|
||||
public string Body { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(256)]
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One row per (announcement, player). Records which player has read
|
||||
/// which announcement so the unread feeds can filter out already-seen
|
||||
/// rows. Upserted by <c>ClubService.MarkAnnouncementReadAsync</c>.
|
||||
/// </summary>
|
||||
public class ClubAnnouncementReadEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long AnnouncementId { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
public DateTime ReadAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One row per available club category tag. Backs
|
||||
/// <c>GET /club/categoryTags</c> — the 2020.12 watch deserialises that
|
||||
/// response as <c>List<String></c> (<see
|
||||
/// cref="Controllers.Clubs.ClubsController.GetCategoryTags"/> projects
|
||||
/// just the <see cref="Name"/> column out per
|
||||
/// <c>Cpp2IL_ISIL/.../JDJGIBLMFKK.txt:>GetPrimaryTags</c>'s
|
||||
/// <c>Action<List<String>></c> callback signature), but the
|
||||
/// table stores Id + OrderIndex + Active so admins can re-order the
|
||||
/// list and soft-delete entries without losing referential history in
|
||||
/// <see cref="ClubCategoryAssignmentEntity"/>.
|
||||
/// </summary>
|
||||
public class ClubCategoryTagEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
[MaxLength(64)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Display order — lower values render first. Letters and
|
||||
/// re-ordering both flow through admin tooling; the wire response is
|
||||
/// emitted in <see cref="OrderIndex"/> order.</summary>
|
||||
public int OrderIndex { get; set; }
|
||||
|
||||
/// <summary>Soft-delete flag. Inactive tags stay in the table so
|
||||
/// historical assignments resolve, but are excluded from the
|
||||
/// <c>/club/categoryTags</c> response.</summary>
|
||||
public bool Active { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Junction row binding a <see cref="ClubEntity"/> to a
|
||||
/// <see cref="ClubCategoryTagEntity"/>. Many-to-many; a single club can
|
||||
/// carry multiple category tags (e.g. "Sports" + "Hangout"). The
|
||||
/// 2020.12 watch surfaces the Club's primary category via the
|
||||
/// <c>Category</c> string field on the Club wire type — assignments
|
||||
/// are admin-facing for now and feed future v2 endpoints that return
|
||||
/// the full tag list per club.
|
||||
/// </summary>
|
||||
public class ClubCategoryAssignmentEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long ClubId { get; set; }
|
||||
public long CategoryTagId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One row per (club, subscriber) — drives the
|
||||
/// <c>announcements/v2/subscription/mine/unread</c> feed. Distinct from
|
||||
/// <see cref="ClubMembershipEntity"/> because a player can subscribe to
|
||||
/// announcements without joining the club's roster (e.g. "follow this
|
||||
/// public club's announcements" without becoming a member).
|
||||
/// </summary>
|
||||
public class ClubSubscriptionEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long ClubId { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
public DateTime SubscribedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A "Group" / club — wire type <c>RecNet.Group</c>
|
||||
/// (<c>Cpp2IL_CS/.../RecNet/Group.cs</c>) for the legacy
|
||||
/// <c>api/groups/v1/*</c> surface, and the newer <c>RecNet.Club</c>
|
||||
/// (<c>Cpp2IL_ISIL/.../PLILLKHMNDA.txt</c>) for the
|
||||
/// <c>clubs.localhost/club/*</c> surface used by 2020.12. Both wire
|
||||
/// shapes are projected from this one entity by the respective
|
||||
/// controllers; field names + types match each client deserializer
|
||||
/// so the watch round-trips cleanly.
|
||||
///
|
||||
/// The 2020.12 Club wire keys (per ISIL deserializer):
|
||||
/// <c>ClubId, Name, Description, MainImageName, State, CreatorAccountId,
|
||||
/// Category, Visibility, Joinability, AllowJuniors, MemberCount,
|
||||
/// IsRRO, ClubhouseRoomId, ClubType</c>.
|
||||
/// MemberCount is derived live from <see cref="ClubMembershipEntity"/>
|
||||
/// at projection time so we never have to keep a denormalised counter
|
||||
/// in sync; the other fields are persisted columns on this row.
|
||||
/// </summary>
|
||||
public class ClubEntity
|
||||
{
|
||||
/// <summary>Mirrors wire field <c>GroupId</c> / <c>ClubId</c>.</summary>
|
||||
public long Id { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Mirrors wire <c>CreatorId</c> (legacy) / <c>CreatorAccountId</c>
|
||||
/// (Int32 in the 2020.12 payload). Cast to int in the wire mappers.</summary>
|
||||
public long CreatorPlayerId { get; set; }
|
||||
|
||||
/// <summary>Legacy <c>ImageName</c> for <c>api/groups/v1/*</c>. The 2020.12
|
||||
/// Club wire field is <c>MainImageName</c> — same column, two projection
|
||||
/// names.</summary>
|
||||
[MaxLength(256)]
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>GroupBanStatus enum: 0=GoodStanding, 1=InReview,
|
||||
/// 2=TempLock, 3=Permaban.</summary>
|
||||
public int BanStatus { get; set; } = 0;
|
||||
|
||||
/// <summary>Club state enum on the 2020.12 wire (<c>State</c>).
|
||||
/// 0=Active, 1=Disbanded, 2=Suspended. Defaults to Active so legacy
|
||||
/// rows created via <c>api/groups/v1/*</c> still surface as live.</summary>
|
||||
public int State { get; set; } = 0;
|
||||
|
||||
/// <summary>Primary category-tag name surfaced in the 2020.12 Club
|
||||
/// wire <c>Category</c> field. Empty string is valid — the watch
|
||||
/// renders "uncategorized" when blank.</summary>
|
||||
[MaxLength(64)]
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Club visibility enum on the wire (<c>Visibility</c>).
|
||||
/// 0=Public, 1=Unlisted, 2=Private.</summary>
|
||||
public int Visibility { get; set; } = 0;
|
||||
|
||||
/// <summary>Club joinability enum on the wire (<c>Joinability</c>).
|
||||
/// 0=Open, 1=RequestToJoin, 2=InviteOnly.</summary>
|
||||
public int Joinability { get; set; } = 0;
|
||||
|
||||
/// <summary>Drives the wire <c>AllowJuniors</c> bool. True means
|
||||
/// junior accounts can join + see member-only content.</summary>
|
||||
public bool AllowJuniors { get; set; } = true;
|
||||
|
||||
/// <summary>Drives the wire <c>IsRRO</c> bool — Rec Room Original
|
||||
/// stamp on first-party clubs. Default false; admin-toggle only.</summary>
|
||||
public bool IsRRO { get; set; } = false;
|
||||
|
||||
/// <summary>Drives the wire nullable <c>ClubhouseRoomId</c>. When
|
||||
/// set, the watch shows a "Go to clubhouse" CTA on the club
|
||||
/// detail screen that routes to this room's standard goto flow.</summary>
|
||||
public long? ClubhouseRoomId { get; set; }
|
||||
|
||||
/// <summary>Drives the wire <c>ClubType</c> int. 0=Standard,
|
||||
/// 1=Featured. The legacy api/groups/v1 surface ignores this; the
|
||||
/// 2020.12 watch uses it to sort featured clubs to the top of
|
||||
/// browse.</summary>
|
||||
public int ClubType { get; set; } = 0;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>One row per (club, player). Permissions is the
|
||||
/// <c>GroupMembershipPermissions</c> flags enum (Member=0, Moderator=24,
|
||||
/// CoOwner=124, Owner=127, Pending=128 for join-requests on private
|
||||
/// groups).</summary>
|
||||
public class ClubMembershipEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long ClubId { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
/// <summary>GroupMembershipPermissions flags enum value.</summary>
|
||||
public int Permissions { get; set; } = 0;
|
||||
|
||||
public DateTime JoinedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Single-row table holding the dorm community board state. Replaces
|
||||
/// <c>data/community_board.json</c> on disk + in-memory cache so admin
|
||||
/// edits propagate across replicas without a file-watcher invalidation
|
||||
/// dance.
|
||||
///
|
||||
/// Only one row exists, with <see cref="Id"/> = 1. Reading is a single
|
||||
/// SELECT (cheap; the board is one document, not paginated). Writing
|
||||
/// is a single UPDATE — no synchronization needed because there's only
|
||||
/// ever one row to contend on, and admin edits are rare.
|
||||
/// </summary>
|
||||
public class CommunityBoardEntity
|
||||
{
|
||||
/// <summary>Always 1. We model it as an int PK rather than a
|
||||
/// composite or singleton-table pattern because EF Core needs a key
|
||||
/// to track changes; pinning to id=1 keeps the upsert pattern
|
||||
/// simple.</summary>
|
||||
public int Id { get; set; } = 1;
|
||||
|
||||
/// <summary>JSON-serialised <c>CommunityBoardState</c>. Stored as
|
||||
/// text rather than jsonb because we never query inside it — it's
|
||||
/// loaded as a single blob and round-tripped to the client.</summary>
|
||||
public string Json { get; set; } = "{}";
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Admin-issued promo coupon. Players redeem by code via
|
||||
/// <c>POST /api/coupons/v1/redeem/{code}</c>. Each redemption
|
||||
/// inserts a <see cref="CouponRedemptionEntity"/> row (one per
|
||||
/// (coupon, player) pair so we can enforce one-redeem-per-player).
|
||||
/// </summary>
|
||||
public class CouponEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
[MaxLength(64)]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(256)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>RewardType: 0=Currency, 1=ItemSlug, 2=Subscription.</summary>
|
||||
public int RewardType { get; set; }
|
||||
|
||||
/// <summary>Currency type if RewardType=0; otherwise unused.</summary>
|
||||
public int RewardCurrencyType { get; set; }
|
||||
|
||||
public long RewardAmount { get; set; }
|
||||
|
||||
/// <summary>Item slug if RewardType=1.</summary>
|
||||
[MaxLength(64)]
|
||||
public string RewardItemSlug { get; set; } = string.Empty;
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
public int MaxRedemptions { get; set; } = 0;
|
||||
public int RedemptionCount { get; set; } = 0;
|
||||
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class CouponRedemptionEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long CouponId { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
public DateTime RedeemedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per-player wallet for a single currency. The 2020 client knows
|
||||
/// about a small set of <c>CurrencyType</c>s (1=Coins, 2=Tokens, …);
|
||||
/// each gets its own row keyed by (PlayerId, CurrencyType).
|
||||
///
|
||||
/// Updates go through <see cref="DorkNet.Server.Services.LevelService"/>
|
||||
/// or admin grants; reads back through the
|
||||
/// <c>storefronts/v4/balance/{currencyType}</c> endpoint.
|
||||
/// </summary>
|
||||
public class CurrencyBalanceEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
public int CurrencyType { get; set; }
|
||||
public long Balance { get; set; }
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per-player dorm state. Decouples each player's dorm save bytes from
|
||||
/// the canonical RoomEntity row at <c>RoomId=1</c> (which is shared
|
||||
/// across every dorm visit and would otherwise leak one player's save
|
||||
/// into every other player's dorm).
|
||||
///
|
||||
/// One row per account that has ever entered their dorm. Created on the
|
||||
/// first <c>/api/rooms/v4/details/1</c> hit and updated whenever the
|
||||
/// player saves their dorm via Maker Pen.
|
||||
/// </summary>
|
||||
public class DormStateEntity
|
||||
{
|
||||
/// <summary>The owning account id. Primary key — one dorm state row
|
||||
/// per player.</summary>
|
||||
[Key]
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
/// <summary>Blob name for the player's most recent dorm save. Format
|
||||
/// <c>dorm_p<accountId>_v<n>.dat</c>; matches the
|
||||
/// <see cref="RoomDataBlobEntity.BlobName"/> stored when the upload
|
||||
/// landed. Empty string before the player has saved.</summary>
|
||||
[MaxLength(128)]
|
||||
public string CurrentDataBlobName { get; set; } = string.Empty;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
public class GameRewardSelectionEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
public int RewardType { get; set; }
|
||||
public int GiftContext { get; set; }
|
||||
[MaxLength(256)] public string Message { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? SelectedAt { get; set; }
|
||||
public int? SelectedGiftDropId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Active game session backing the matchmaking <c>JoinOrCreate</c> flow
|
||||
/// in <see cref="DorkNet.Server.Services.GameSessionService"/>. Replaces
|
||||
/// the in-process <c>ConcurrentDictionary</c> + atomic counter so:
|
||||
/// <list type="bullet">
|
||||
/// <item>Session ids are allocated by Postgres bigserial — no
|
||||
/// replica-local counter that collides with another replica's
|
||||
/// after restart.</item>
|
||||
/// <item>A "find a non-full session in this room+region" query
|
||||
/// coordinates across all replicas; without it, two players
|
||||
/// joining via different replicas could each create their own
|
||||
/// fresh session for the same room and end up isolated.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// Sessions are deleted when their PlayerCount hits 0 (handled in the
|
||||
/// service, no FK cascade needed). Brief mid-rejoin flicker is fine —
|
||||
/// the next JoinOrCreate just creates a new session with a new id.
|
||||
/// </summary>
|
||||
public class GameSessionEntity
|
||||
{
|
||||
/// <summary>bigserial — allocated by Postgres on insert. Crosses
|
||||
/// replicas safely because the sequence is shared.</summary>
|
||||
public long Id { get; set; }
|
||||
|
||||
[MaxLength(64)] public string RoomId { get; set; } = string.Empty;
|
||||
[MaxLength(64)] public string ActivityLevelId { get; set; } = string.Empty;
|
||||
[MaxLength(16)] public string Region { get; set; } = "us";
|
||||
[MaxLength(128)] public string PhotonRoomName { get; set; } = string.Empty;
|
||||
public int MaxCapacity { get; set; } = 8;
|
||||
public int PlayerCount { get; set; } = 0;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per <c>RecNet.Avatars+GiftPackage</c> wire shape (decompiled at
|
||||
/// <c>Cpp2IL_CS/.../RecNet/Avatars.cs:192-555</c>) — a queued gift the
|
||||
/// recipient can claim from the watch's "gifts" inbox. Used for both
|
||||
/// admin-granted gifts and gift-drop / mystery-box / level-up prizes.
|
||||
///
|
||||
/// Field names + types match the client's
|
||||
/// <c>GiftPackage.Deserialize(Dictionary<String, Object>)</c>
|
||||
/// directly so the watch round-trips cleanly. <see cref="Consumed"/>
|
||||
/// is the bool the client reads — we additionally stamp
|
||||
/// <see cref="ConsumedAt"/> for server-side bookkeeping (not on wire).
|
||||
/// </summary>
|
||||
public class GiftPackageEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>The player whose inbox this gift sits in.</summary>
|
||||
public long RecipientPlayerId { get; set; }
|
||||
|
||||
/// <summary>Wire field (Nullable<Int32>) — the player who
|
||||
/// sent the gift, or null for system / drop gifts.</summary>
|
||||
public int? FromPlayerId { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string ConsumableItemDesc { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Wire field <c>AvatarItemType</c> as Nullable<int>
|
||||
/// (0 = Outfit, 1 = HairDye). Null when the gift has no avatar
|
||||
/// item (currency-only, equipment-only, etc.).</summary>
|
||||
public int? AvatarItemType { get; set; }
|
||||
|
||||
/// <summary>Wire field name is exactly <c>AvatarItemDescOrHairDyeDesc</c>
|
||||
/// — content is either the AvatarItem GUID (when AvatarItemType=0)
|
||||
/// or the HairDye desc string (when AvatarItemType=1).</summary>
|
||||
[MaxLength(128)]
|
||||
public string AvatarItemDescOrHairDyeDesc { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(128)]
|
||||
public string EquipmentPrefabName { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(64)]
|
||||
public string EquipmentModificationGuid { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>CurrencyType enum: 0=Invalid, 1=LaserTagTickets,
|
||||
/// 2=RecCenterTokens, 100=LostSkullsGold, 101=DraculaSilver,
|
||||
/// 200=RecRoyale_Season1.</summary>
|
||||
public int CurrencyType { get; set; } = 0;
|
||||
|
||||
public int Currency { get; set; } = 0;
|
||||
public int Xp { get; set; } = 0;
|
||||
public int Level { get; set; } = 0;
|
||||
|
||||
/// <summary>GiftContext enum (large, see Avatars.cs comments).
|
||||
/// 0 = Default, 100 = LevelUp, 1000 = Holiday, etc.</summary>
|
||||
public int GiftContext { get; set; } = 0;
|
||||
|
||||
/// <summary>GiftRarity: -1=None, 0=Common, 10=Uncommon, 20=Rare,
|
||||
/// 30=Epic, 50=Legendary.</summary>
|
||||
public int GiftRarity { get; set; } = 0;
|
||||
|
||||
[MaxLength(512)]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>PlatformType: -1=All, 0=Steam, 1=Oculus, 2=PlayStation,
|
||||
/// 3=Microsoft, 4=HeadlessBot, 5=IOS.</summary>
|
||||
public int Platform { get; set; } = -1;
|
||||
|
||||
/// <summary>Asset name string for the Unity Material that the
|
||||
/// gift box renders with.</summary>
|
||||
[MaxLength(128)]
|
||||
public string PackageMaterial { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Asset name string for the GiftPackageVariant
|
||||
/// ScriptableObject (e.g. "Standard", "Holiday", etc.).</summary>
|
||||
[MaxLength(128)]
|
||||
public string PackageVariant { get; set; } = string.Empty;
|
||||
|
||||
public bool Consumed { get; set; } = false;
|
||||
public bool IsValid { get; set; } = true;
|
||||
|
||||
[MaxLength(512)]
|
||||
public string ErrorMessage { get; set; } = string.Empty;
|
||||
|
||||
public bool SupportsCurrentPlatform { get; set; } = true;
|
||||
public bool IsGifted { get; set; } = false;
|
||||
|
||||
/// <summary>Server-side audit timestamp; not part of wire
|
||||
/// shape.</summary>
|
||||
public DateTime? ConsumedAt { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A Maker-Pen creation that the player has saved as a reusable
|
||||
/// "invention" — can be re-spawned in any room they enter, shared with
|
||||
/// other players, or featured on the watch's Inventions tab. The
|
||||
/// underlying SpawnableTemplateData protobuf bytes are uploaded via
|
||||
/// <c>storage.rec.net/upload</c> with FileType=Invention (5).
|
||||
/// </summary>
|
||||
public class InventionEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public long CreatorPlayerId { get; set; }
|
||||
|
||||
/// <summary>Stable opaque GUID the client uses to deduplicate
|
||||
/// instantiated inventions across rooms (matches
|
||||
/// <c>Invention.ReplicationId</c>). Generated server-side at
|
||||
/// create time.</summary>
|
||||
[MaxLength(64)]
|
||||
public string ReplicationId { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(128)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(256)]
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Legacy permission column (private=0/friends=1/public=2)
|
||||
/// — kept for backward-compat with existing rows; new logic uses
|
||||
/// <see cref="GeneralPermission"/> + <see cref="CreatorPermission"/>
|
||||
/// (InventionPermission enum: Unassigned=0, LimitedOneUseOnly=10,
|
||||
/// UseOnly=20, EditAndSave=40, Publish=60, Charge=80,
|
||||
/// Unlimited=100).</summary>
|
||||
public int Permission { get; set; } = 0;
|
||||
|
||||
/// <summary>InventionPermission value the creator gets when
|
||||
/// spawning their own creation (default Unlimited=100).</summary>
|
||||
public int CreatorPermission { get; set; } = 100;
|
||||
|
||||
/// <summary>InventionPermission value other players get when
|
||||
/// spawning. Defaults Unassigned=0 (private).</summary>
|
||||
public int GeneralPermission { get; set; } = 0;
|
||||
|
||||
/// <summary>True once <c>POST api/inventions/v2/publish</c> sets
|
||||
/// a non-Unassigned <see cref="GeneralPermission"/>. Stays true
|
||||
/// across re-edits — the wire field tracks "has this ever been
|
||||
/// published".</summary>
|
||||
public bool IsPublished { get; set; } = false;
|
||||
|
||||
/// <summary>1-based latest version index. Bumped each time
|
||||
/// <c>POST api/inventions/v3/addversion</c> uploads new bytes.
|
||||
/// Mirrors <c>Invention.CurrentVersionNumber</c>.</summary>
|
||||
public int CurrentVersionNumber { get; set; } = 1;
|
||||
|
||||
/// <summary>Set on first publish. Null until then.</summary>
|
||||
public DateTime? FirstPublishedAt { get; set; }
|
||||
|
||||
/// <summary>If the creator was in a room when first saving,
|
||||
/// the originating room id for analytics.</summary>
|
||||
public long? CreationRoomId { get; set; }
|
||||
|
||||
/// <summary>How many distinct players have spawned this
|
||||
/// invention in any room (rolling counter — increments on
|
||||
/// download).</summary>
|
||||
public int NumPlayersHaveUsedInRoom { get; set; } = 0;
|
||||
|
||||
/// <summary>True for AG (community-built) inventions, false for
|
||||
/// "Rec Room Original" / staff-baked ones.</summary>
|
||||
public bool IsAgInvention { get; set; } = true;
|
||||
|
||||
/// <summary>Most recent uploaded blob name. Format
|
||||
/// <c>invention_<id>_v<n>.dat</c>; the cdn catch-all serves the
|
||||
/// matching <see cref="RoomDataBlobEntity"/> bytes.</summary>
|
||||
[MaxLength(128)]
|
||||
public string CurrentBlobName { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(1024)]
|
||||
public string TagsCsv { get; set; } = string.Empty;
|
||||
|
||||
public int CheerCount { get; set; } = 0;
|
||||
public int SpawnCount { get; set; } = 0;
|
||||
|
||||
/// <summary>Soft-delete flag. When set, the row is hidden from
|
||||
/// browse / search / detail endpoints but stays in the table so
|
||||
/// existing references (cheers, reports) keep resolving and the
|
||||
/// blob bytes can be restored if needed.</summary>
|
||||
public bool IsDeleted { get; set; } = false;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One historical version of an <see cref="InventionEntity"/>. Each
|
||||
/// time the creator uploads a new SpawnableTemplateData blob via
|
||||
/// <c>POST api/inventions/v3/addversion</c>, we snapshot the prior
|
||||
/// blob name into a new row here so the watch's "version history"
|
||||
/// tab and `GET api/inventions/v1/versions?inventionId=X` can list
|
||||
/// them. Indexed on (InventionId, VersionNumber DESC) for the
|
||||
/// common "show me the version list" query.
|
||||
/// </summary>
|
||||
public class InventionVersionEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long InventionId { get; set; }
|
||||
|
||||
/// <summary>Mirrors <c>InventionVersion.ReplicationId</c> — opaque
|
||||
/// GUID the client uses to deduplicate spawns of this specific
|
||||
/// version across rooms.</summary>
|
||||
[MaxLength(64)]
|
||||
public string ReplicationId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>1-based version index. Auto-assigned to
|
||||
/// max(VersionNumber) + 1 on insert.</summary>
|
||||
public int VersionNumber { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string BlobName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Tokens it costs to spawn this version of the
|
||||
/// invention (decompiled wire field
|
||||
/// <c>InventionVersion.InstantiationCost</c>).</summary>
|
||||
public int InstantiationCost { get; set; } = 0;
|
||||
|
||||
/// <summary>Watt budget the creation consumes
|
||||
/// (<c>InventionVersion.LightsCost</c>).</summary>
|
||||
public int LightsCost { get; set; } = 0;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// IP-level ban for blocking misbehaving traffic before the JWT-based
|
||||
/// player ban can apply. Checked by <c>IpBanCheckMiddleware</c> ahead
|
||||
/// of authentication; matching requests get a 403.
|
||||
///
|
||||
/// CIDR notation lets a single row block a /24 or /32. The expiry is
|
||||
/// optional — null means "permanent until manually removed".
|
||||
/// </summary>
|
||||
public class IpBanEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>CIDR or single-IP string (<c>1.2.3.4</c> or <c>1.2.3.0/24</c>).
|
||||
/// Compared against <c>HttpContext.Connection.RemoteIpAddress</c>.</summary>
|
||||
[MaxLength(64)]
|
||||
public string Cidr { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(512)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
public long BannedByAdminId { get; set; }
|
||||
public DateTime BannedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Null = permanent.</summary>
|
||||
public DateTime? Until { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Admin-curated metadata for a leaderboard <c>StatChannel</c> id. The
|
||||
/// 2020 watch reports raw integer channel ids via
|
||||
/// <c>POST api/Leaderboard/v1/SetStats</c>; this table lets admins map
|
||||
/// each channel to a specific room (so the room detail page can list
|
||||
/// "its" leaderboards) and a human-readable name + ordering hint. Rows
|
||||
/// here override the hardcoded <c>AdminController.KnownStatChannels</c>
|
||||
/// fallback. Channels with no row stay listed as "Channel N".
|
||||
///
|
||||
/// One channel id, one row. Repeated <c>SetStats</c> calls for the same
|
||||
/// channel from any room write to that channel's value rows; the meta
|
||||
/// row only changes when an admin renames or re-maps it.
|
||||
/// </summary>
|
||||
public class LeaderboardChannelMetaEntity
|
||||
{
|
||||
/// <summary>Same int the watch reports for this channel. Primary
|
||||
/// key — there's only ever one meta row per channel.</summary>
|
||||
public int Channel { get; set; }
|
||||
|
||||
/// <summary>Room this channel "belongs to" for the per-room detail
|
||||
/// view. 0 = global / unscoped (e.g. cross-room player progression
|
||||
/// counters). The same room can own many channels (e.g. Stunt
|
||||
/// Runner has one channel per course).</summary>
|
||||
public long RoomId { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>When true, a lower stored value is a better score (race
|
||||
/// times, lap times, etc.). The list endpoint sorts ascending.</summary>
|
||||
public bool LowerIsBetter { get; set; }
|
||||
|
||||
/// <summary>UI hint: "count" (default), "time-ms" for elapsed-time
|
||||
/// channels, "score" for points. The SPA formats accordingly.</summary>
|
||||
public string ValueFormat { get; set; } = "count";
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Tip shown on the dorm-load splash. Served via
|
||||
/// <c>cdn.{rec.net,localhost}/config/LoadingScreenTipData</c>; edited via
|
||||
/// the admin SPA. Wire shape per <c>RecNet/LoadingScreenTip.cs</c>
|
||||
/// requires Title/Message/ImageName + Context/PlatformMask/HasImage
|
||||
/// flags + an optional RoomNames list (scopes the tip to specific
|
||||
/// rooms).
|
||||
/// </summary>
|
||||
public class LoadingScreenTipEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
[MaxLength(128)] public string Title { get; set; } = string.Empty;
|
||||
[MaxLength(512)] public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional uploaded image (RoomDataBlob filename). Empty
|
||||
/// when the tip is text-only; CdnController sets
|
||||
/// <c>HasImage</c> in the wire payload based on this.</summary>
|
||||
[MaxLength(128)] public string ImageName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>RecNet LoadingScreenContext enum (0 = Any). Controls
|
||||
/// which loading screens the tip is eligible for.</summary>
|
||||
public int Context { get; set; } = 0;
|
||||
|
||||
/// <summary>PlatformMask bitfield (-1 = every platform). Used by
|
||||
/// the watch to filter out tips that reference platform-specific
|
||||
/// features (e.g. mobile-only).</summary>
|
||||
public int PlatformMask { get; set; } = -1;
|
||||
|
||||
/// <summary>CSV of room names this tip is scoped to. Empty = show
|
||||
/// in any room's loading screen. Example: "DormRoom,MakerRoom".</summary>
|
||||
[MaxLength(512)] public string RoomNamesCsv { get; set; } = string.Empty;
|
||||
|
||||
public int SortOrder { get; set; } = 0;
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Direct message between two players. Backs the watch's
|
||||
/// <c>api/messages/v2</c> endpoints (inbox + send + mark-read).
|
||||
///
|
||||
/// One row per message. Threading happens at read time — the inbox
|
||||
/// query groups by (Sender, Recipient) pair to surface the most
|
||||
/// recent message per conversation.
|
||||
/// </summary>
|
||||
public class MessageEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public long SenderPlayerId { get; set; }
|
||||
public long RecipientPlayerId { get; set; }
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string Body { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>RecNet <c>Message.MessageType</c> wire value (see
|
||||
/// <c>Cpp2IL_CS/.../RecNet/Message.cs</c>). Default 30 =
|
||||
/// TextMessage. Other values used by the 2020 client: 0/6 =
|
||||
/// GameInvite[V2], 3/7 = PartyActivitySwitch[V2], 10 =
|
||||
/// RequestGameInvite (the "ask to join" flow), 1 =
|
||||
/// GameInviteDeclined, 11 = RequestGameInviteDeclined, 20 =
|
||||
/// FriendStatusOnline.</summary>
|
||||
public int Type { get; set; } = 30;
|
||||
|
||||
/// <summary>Optional RecNet room id carried by invite and party
|
||||
/// activity messages. The client uses this to render room-aware
|
||||
/// cards and passes it back when accepting V2 invites.</summary>
|
||||
public long? RoomId { get; set; }
|
||||
|
||||
public DateTime SentAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Null until the recipient hits the message in the
|
||||
/// watch UI; set to the read timestamp at that point.</summary>
|
||||
public DateTime? ReadAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per-player objective / achievement progress. The 2020 client's
|
||||
/// <c>Objectives.MyProgress</c> response is two arrays — Objectives
|
||||
/// and ObjectiveGroups — keyed by string id. We collapse that to one
|
||||
/// table here; group rows distinguish themselves with a leading
|
||||
/// <c>group:</c> prefix on <see cref="Key"/>.
|
||||
/// </summary>
|
||||
public class ObjectiveProgressEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
/// <summary>Objective id from the client's hardcoded objective
|
||||
/// table (e.g. <c>tutorial_dorm_decorate</c>). Group rows use
|
||||
/// <c>group:<groupId></c>.</summary>
|
||||
[MaxLength(128)]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
public bool IsCompleted { get; set; }
|
||||
public DateTime? ClearedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One in-game camera photo, posted to the social feed. The actual
|
||||
/// pixel bytes live in <see cref="RoomDataBlobEntity"/> keyed by
|
||||
/// <see cref="BlobName"/> (uploaded earlier via storage.rec.net's
|
||||
/// /upload endpoint with FileType=Image). This entity wraps that blob
|
||||
/// with the social metadata: who took it, where, who's tagged in it,
|
||||
/// when it was posted, and how many cheers it has.
|
||||
///
|
||||
/// Public photos are visible without auth on feed.rec.net; private
|
||||
/// photos are only listable by the uploader (and admins). Soft-delete
|
||||
/// via <see cref="DeletedAt"/> so moderation can take a photo down
|
||||
/// without losing the audit trail.
|
||||
/// </summary>
|
||||
public class PhotoEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public long UploaderPlayerId { get; set; }
|
||||
|
||||
/// <summary>The cdn filename — the same string returned by
|
||||
/// storage.rec.net/upload and used in the cdn.rec.net/{name}
|
||||
/// download path. Indexed because feed lookups join blob bytes
|
||||
/// via this key.</summary>
|
||||
[MaxLength(256)]
|
||||
public string BlobName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional caption typed on the watch's "Share" panel
|
||||
/// before posting. Limited so a single photo can't grow the
|
||||
/// table row unbounded.</summary>
|
||||
[MaxLength(2048)]
|
||||
public string Caption { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The room the photo was taken in (resolved from
|
||||
/// PlayerPresenceService at upload time). 0 if the player wasn't
|
||||
/// in any room (rare — only happens during loading).</summary>
|
||||
public long RoomId { get; set; }
|
||||
|
||||
/// <summary>Comma-separated list of tagged player IDs. The watch
|
||||
/// builds this from the players visible in the camera's frustum
|
||||
/// when the shutter was pressed. Tagged players show up in their
|
||||
/// own "Photos of me" feed.</summary>
|
||||
[MaxLength(2048)]
|
||||
public string TaggedPlayerIdsCsv { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>If false, photo only appears in the uploader's own
|
||||
/// "My photos" page (and admin views); if true, it shows up on
|
||||
/// the public feed.rec.net feed and any tagged player's
|
||||
/// "Photos of me" tab. Defaults to true since the camera's
|
||||
/// "Share" button is the natural action — making it private is
|
||||
/// an explicit toggle.</summary>
|
||||
public bool IsPublic { get; set; } = true;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Soft-delete sentinel — non-null means the photo is
|
||||
/// hidden from every feed but still in the table for audit.
|
||||
/// Admin moderation tools set this; the bytes blob is kept until
|
||||
/// a separate cleanup job decides the audit window has elapsed.</summary>
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
|
||||
/// <summary>Denormalised cheer counter — incremented when a
|
||||
/// CheerEntity row is inserted with TargetPhotoId=this.Id, so the
|
||||
/// feed sort doesn't have to JOIN+aggregate every render.</summary>
|
||||
public int CheerCount { get; set; }
|
||||
|
||||
public int ViewCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
public class PlayerEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
// Stable per-installation identifier the client sends as `deviceId` in
|
||||
// platformlogin / account-create form bodies. We deliberately use this
|
||||
// (not `platformId`) so accounts are NOT keyed to Steam IDs — anyone
|
||||
// running the client gets a unique persistent account based on the
|
||||
// hardware-derived device hash, regardless of which Steam emulator
|
||||
// (Goldberg, none, real Steam) is in front. Indexed unique below.
|
||||
[MaxLength(128)]
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
|
||||
// Last-seen platform info — informational, NOT part of account identity.
|
||||
public int LastPlatform { get; set; } = 0;
|
||||
|
||||
[MaxLength(128)]
|
||||
public string LastPlatformId { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(64)]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(64)]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(512)]
|
||||
public string Bio { get; set; } = string.Empty;
|
||||
|
||||
public int Level { get; set; } = 1;
|
||||
public int XP { get; set; } = 0;
|
||||
public int Reputation { get; set; } = 0;
|
||||
public bool IsVerified { get; set; } = false;
|
||||
|
||||
// New accounts default to non-developer. Admins grant the badge
|
||||
// privilege via the SPA's Players → Profile flags tab on a
|
||||
// per-player basis (used to default to true while we were
|
||||
// bootstrapping the emulator; flipped off now that the build is
|
||||
// stable and we don't want every walk-in to spawn with the dev
|
||||
// console unlocked).
|
||||
public bool IsDeveloper { get; set; } = false;
|
||||
// Mirrors IsDeveloper for the overhead badge slider — the 2020 watch
|
||||
// gates the in-settings "Developer Display Mode" slider on a single
|
||||
// role check (`role/developer/{id}`), and the slider's positions
|
||||
// render as "Community Team" / "Developer" above the player's head
|
||||
// (verified in Cpp2IL_ISIL/.../PlayerUI.txt:9085-9099 — slider value
|
||||
// 1 → "Community Team", 2 → "Developer"). Keep this field separate
|
||||
// from IsDeveloper so admins can mark someone as community-team
|
||||
// without granting full dev privileges; the role endpoint ORs both.
|
||||
public bool IsCommunityTeam { get; set; } = false;
|
||||
public bool CanReceiveInvites { get; set; } = true;
|
||||
public bool IsJunior { get; set; } = false;
|
||||
|
||||
[MaxLength(256)]
|
||||
public string? ProfileImageName { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// ── Phase 1 account hardening ─────────────────────────────────────────
|
||||
// Optional contact email. Nullable because the 2020 client never asks
|
||||
// for one; reserved for future password-reset / admin-recovery flows.
|
||||
[MaxLength(256)]
|
||||
public string? Email { get; set; }
|
||||
|
||||
// When non-null AND `> DateTime.UtcNow`, BanCheckMiddleware short-circuits
|
||||
// every authenticated request from this account with 401. Setting to a
|
||||
// past timestamp acts as a "previously banned" audit marker without
|
||||
// blocking access.
|
||||
public DateTime? BannedUntil { get; set; }
|
||||
|
||||
// Privilege flag for `[AdminOnly]` endpoints (Phase 5). Defaults false;
|
||||
// PlayerService bumps this to true when the very first account on a
|
||||
// fresh DB is created so there's always at least one root admin.
|
||||
public bool IsAdmin { get; set; } = false;
|
||||
|
||||
// Last IP that authenticated against this account. Updated by the
|
||||
// request-tracing pipeline so we don't write IPs from anonymous paths.
|
||||
[MaxLength(64)]
|
||||
public string? LastIp { get; set; }
|
||||
|
||||
// The deviceId that originally created this account. Immutable after
|
||||
// creation — exists for the audit trail in case the live DeviceId
|
||||
// rotates (e.g., user re-installs and the hardware hash shifts; the
|
||||
// account stays bound to whatever they typed in /account/create the
|
||||
// first time).
|
||||
[MaxLength(128)]
|
||||
public string? CreatedFromDeviceId { get; set; }
|
||||
|
||||
// BCP-47 locale tag (e.g. "en-US"). Optional; informs future
|
||||
// localisation work and is currently set from the client's accept-
|
||||
// language header at login.
|
||||
[MaxLength(16)]
|
||||
public string? Locale { get; set; }
|
||||
|
||||
/// <summary>BCrypt hash of the account password. Null when the
|
||||
/// account has never set one — the in-game settings screen calls
|
||||
/// /account/me/haspassword and prompts the user to create one
|
||||
/// before sensitive operations. Format: BCrypt's standard
|
||||
/// `$2a$<cost>$<salt><hash>` so the verifier
|
||||
/// reads cost + salt directly, no separate column needed.</summary>
|
||||
[MaxLength(128)]
|
||||
public string? PasswordHash { get; set; }
|
||||
|
||||
/// <summary>Player's birthday (date only; no timezone). Set during
|
||||
/// signup and used to derive <see cref="IsJunior"/> on Junior cutoff
|
||||
/// boundary crossing. Nullable for legacy accounts created before
|
||||
/// the field existed.</summary>
|
||||
public DateTime? Birthday { get; set; }
|
||||
|
||||
/// <summary>Optional phone number — used by the watch's
|
||||
/// account-recovery flow. Stored verbatim, never validated.</summary>
|
||||
[MaxLength(32)]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
// Navigation
|
||||
public AvatarEntity? Avatar { get; set; }
|
||||
public List<PlayerSettingEntity> Settings { get; set; } = [];
|
||||
public List<RelationshipEntity> Relationships { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A player-scheduled gathering ("event") in a specific room. Backs
|
||||
/// the watch's Events tab — players can RSVP via
|
||||
/// <see cref="PlayerEventResponseEntity"/>.
|
||||
/// </summary>
|
||||
public class PlayerEventEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public long CreatorPlayerId { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public DateTime StartsAt { get; set; }
|
||||
public DateTime EndsAt { get; set; }
|
||||
|
||||
public int Capacity { get; set; } = 0;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>One row per RSVP'd player + event.</summary>
|
||||
public class PlayerEventResponseEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long EventId { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
/// <summary>0=Going, 1=Maybe, 2=NotGoing — matches the watch's
|
||||
/// three-state RSVP toggle.</summary>
|
||||
public int Response { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One row per (player, item) pair. Backs both the
|
||||
/// <c>/api/equipment/v2/getUnlocked</c> and
|
||||
/// <c>/api/consumables/v1/getUnlocked</c> endpoints — the type comes
|
||||
/// from the joined <see cref="StoreItemEntity.Category"/>.
|
||||
///
|
||||
/// For equipment, <see cref="Quantity"/> is always 1 and
|
||||
/// <see cref="IsActive"/> tracks whether it's equipped. For
|
||||
/// consumables, <see cref="Quantity"/> is the stack size and
|
||||
/// <see cref="IsActive"/> tracks whether the player has it favourited /
|
||||
/// quick-equipped on the watch.
|
||||
/// </summary>
|
||||
public class PlayerInventoryEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
/// <summary>FK by string to <see cref="StoreItemEntity.Slug"/>.
|
||||
/// Stored as a string rather than an int FK because a player can
|
||||
/// be granted items that aren't in the live store (admin-gifted,
|
||||
/// legacy retired items, etc.).</summary>
|
||||
[MaxLength(64)]
|
||||
public string ItemSlug { get; set; } = string.Empty;
|
||||
|
||||
public int Quantity { get; set; } = 1;
|
||||
public bool IsActive { get; set; } = false;
|
||||
|
||||
public DateTime AcquiredAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
public class PlayerSettingEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(1024)]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
public PlayerEntity? Player { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One per (player, deviceId) — recorded on
|
||||
/// <c>POST /api/deviceid/v1/register</c>. Lets admins flag
|
||||
/// duplicate-account abuse: when a banned player creates a new
|
||||
/// account on the same device, the new account inherits the device
|
||||
/// ban automatically.
|
||||
/// </summary>
|
||||
public class PlayerDeviceEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(32)]
|
||||
public string Platform { get; set; } = string.Empty;
|
||||
|
||||
public DateTime FirstSeen { get; set; } = DateTime.UtcNow;
|
||||
public DateTime LastSeen { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Set by admin tooling — if true, every account using
|
||||
/// this device is auto-banned at login.</summary>
|
||||
public bool IsBanned { get; set; } = false;
|
||||
}
|
||||
|
||||
/// <summary>Per-player notification preferences. iOS-style toggle
|
||||
/// page; Android / Quest just persist the same shape.</summary>
|
||||
public class NotificationPrefsEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
[MaxLength(32)]
|
||||
public string Platform { get; set; } = string.Empty;
|
||||
|
||||
public bool AllowAll { get; set; } = true;
|
||||
public bool AllowFriendRequest { get; set; } = true;
|
||||
public bool AllowMessage { get; set; } = true;
|
||||
public bool AllowEventInvite { get; set; } = true;
|
||||
public bool AllowAnnouncements { get; set; } = true;
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>Sticky cohort assignment — one row per (player, cohort
|
||||
/// key). Created on first hit to <c>GET /api/cohort/v1/{key}</c> with
|
||||
/// a randomly selected variant; remains stable for the lifetime of
|
||||
/// the player so A/B tests don't flicker between sessions.</summary>
|
||||
public class CohortAssignmentEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
[MaxLength(64)]
|
||||
public string CohortKey { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(32)]
|
||||
public string Variant { get; set; } = string.Empty;
|
||||
|
||||
public DateTime AssignedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A curated or user-created list of rooms surfaced by the watch's
|
||||
/// /roomserver/roomsandplaylists/* endpoints. The 2020.12 watch
|
||||
/// deserializes union responses where each entry is either a Room
|
||||
/// (KLCOGEIGEBJ) or a Playlist (KMKPEOGJDFK) — the discriminator is
|
||||
/// the presence of the lowercase "roomId" key (factory at
|
||||
/// MKAMHOIHOJK.txt:621).
|
||||
///
|
||||
/// Required wire fields for the playlist subclass (KMKPEOGJDFK.txt:68
|
||||
/// reads PlaylistId; base keys at MKAMHOIHOJK.txt:516-612 cover
|
||||
/// Name/Description/ImageName/Stats/etc.).
|
||||
/// </summary>
|
||||
public class PlaylistEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(256)]
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Player who built / curates the list. Curated/system
|
||||
/// playlists are owned by the seed account (id=1).</summary>
|
||||
public long CreatorPlayerId { get; set; } = 1;
|
||||
|
||||
/// <summary>Surfaces in /api/curatedroomplaylists. Curated lists
|
||||
/// are sorted by <see cref="OrderIndex"/> and always come from the
|
||||
/// seed account.</summary>
|
||||
public bool IsCurated { get; set; } = false;
|
||||
|
||||
/// <summary>Sort key for curated lists — lower comes first.
|
||||
/// Ignored for user-created lists (which sort by HotScore proxy).</summary>
|
||||
public int OrderIndex { get; set; } = 0;
|
||||
|
||||
/// <summary>Comma-separated tags. Matches the same convention used
|
||||
/// by <see cref="RoomEntity.TagsCsv"/> — the hot endpoint applies
|
||||
/// a substring filter against this column.</summary>
|
||||
[MaxLength(1024)]
|
||||
public string TagsCsv { get; set; } = string.Empty;
|
||||
|
||||
public int CheerCount { get; set; } = 0;
|
||||
public int FavoriteCount { get; set; } = 0;
|
||||
|
||||
/// <summary>Unique visitors across the rooms this playlist points
|
||||
/// at — bumps when a player visits any member room.</summary>
|
||||
public int VisitorCount { get; set; } = 0;
|
||||
|
||||
/// <summary>Total joins across all member rooms.</summary>
|
||||
public int VisitCount { get; set; } = 0;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One row per (PlaylistId, PlayerId) tracking the player's cheer
|
||||
/// + favorite flags on a playlist. Drives <c>/playlists/{id}/interactionby/me</c>
|
||||
/// (read), the four interactionby/me mutation endpoints (cheer /
|
||||
/// uncheer / favorite / unfavorite toggles), and the
|
||||
/// <c>playlists/cheeredby/me</c> + <c>playlists/favoritedby/me</c>
|
||||
/// lists.
|
||||
///
|
||||
/// Counter columns on <see cref="PlaylistEntity"/> (CheerCount /
|
||||
/// FavoriteCount) are kept in sync by <see cref="DorkNet.Server.Services.PlaylistService"/>
|
||||
/// on each toggle so the hot ranking + tile badges read from the
|
||||
/// row without an aggregate join.
|
||||
/// </summary>
|
||||
public class PlaylistInteractionEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlaylistId { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
public bool Cheered { get; set; } = false;
|
||||
public bool Favorited { get; set; } = false;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Links a <see cref="PlaylistEntity"/> to one of its member rooms. The
|
||||
/// playlist's room ordering is driven by <see cref="OrderIndex"/> so a
|
||||
/// curator can promote/demote entries without renumbering rows.
|
||||
/// </summary>
|
||||
public class PlaylistRoomEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlaylistId { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public int OrderIndex { get; set; } = 0;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Durable record of a private (invite-only) Photon match created via
|
||||
/// <c>POST /goto/room/{name}?CreatePrivateInstance=True</c>. Replaces
|
||||
/// the in-process <c>ConcurrentDictionary</c> in
|
||||
/// <see cref="DorkNet.Server.Services.PrivateInstanceService"/> so
|
||||
/// invitations land on any replica.
|
||||
///
|
||||
/// Without this row in shared storage, an invite issued by Player A
|
||||
/// (replica 1) wouldn't be visible to Player B's <c>POST /goto/instance/{id}</c>
|
||||
/// when their request lands on replica 2 — they'd be routed to a fresh
|
||||
/// public lobby instead of the inviter's match.
|
||||
///
|
||||
/// The associated invite list lives in <see cref="PrivateInstanceInviteeEntity"/>;
|
||||
/// we keep it as a join table rather than a JSON column so the per-row
|
||||
/// "is X invited to Y?" check stays a fast indexed lookup.
|
||||
/// </summary>
|
||||
public class PrivateInstanceEntity
|
||||
{
|
||||
/// <summary>The instance id used as the wire <c>roomInstanceId</c>
|
||||
/// in matchmaking responses. Generated server-side at creation
|
||||
/// time; stable for the lifetime of the match. NOT autoincrement —
|
||||
/// the value is computed from owner/room/sub plus a nonce so the
|
||||
/// id is itself unforgeable without the nonce.</summary>
|
||||
public long Id { get; set; }
|
||||
|
||||
public long RoomId { get; set; }
|
||||
public long SubRoomId { get; set; }
|
||||
public long OwnerPlayerId { get; set; }
|
||||
|
||||
[MaxLength(256)] public string PhotonRoomId { get; set; } = string.Empty;
|
||||
[MaxLength(64)] public string Location { get; set; } = string.Empty;
|
||||
[MaxLength(256)] public string DataBlob { get; set; } = string.Empty;
|
||||
[MaxLength(16)] public string PhotonRegion { get; set; } = "us";
|
||||
public int MaxCapacity { get; set; } = 8;
|
||||
[MaxLength(128)] public string Name { get; set; } = string.Empty;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Join row marking that <see cref="PlayerId"/> is on the invite list
|
||||
/// for <see cref="PrivateInstanceId"/>. Created when the owner sends an
|
||||
/// invite via <c>POST /invite</c> (handled in <c>MessagesController</c>);
|
||||
/// checked on <c>POST /goto/instance/{id}</c> by
|
||||
/// <c>PrivateInstanceService.CanJoin</c>.
|
||||
///
|
||||
/// Composite PK on (PrivateInstanceId, PlayerId) — naturally unique;
|
||||
/// repeat-invite calls become idempotent no-ops.
|
||||
/// </summary>
|
||||
public class PrivateInstanceInviteeEntity
|
||||
{
|
||||
public long PrivateInstanceId { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
public DateTime InvitedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>The MessageEntity.Id of the latest invite for this
|
||||
/// (instance, player). Lets <c>GoToInvite</c> resolve the
|
||||
/// roomInstanceId even when the watch's accept flow has already
|
||||
/// raced through <c>POST /api/messages/v3/delete</c> and removed
|
||||
/// the underlying message row. Without this column the server
|
||||
/// returned ErrorCode 40 ("invite expired") for any successful
|
||||
/// accept where DELETE landed first — which was the common case
|
||||
/// because the 2020 watch fires DELETE and POST /goto/invite/{id}
|
||||
/// in parallel.</summary>
|
||||
public long? LatestInviteMessageId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One push-notification token per (player, platform). Backs
|
||||
/// <c>POST api/messages/v1/IOSSaveDeviceToken</c> +
|
||||
/// <c>IOSClearDeviceToken</c>. Stored so an APNS / FCM proxy could
|
||||
/// fan out push notifications to mobile clients; on a private
|
||||
/// server we just persist for audit + future use.
|
||||
/// </summary>
|
||||
public class PushTokenEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
/// <summary>Platform: "ios", "android", "fcm", etc.</summary>
|
||||
[MaxLength(16)]
|
||||
public string Platform { get; set; } = "ios";
|
||||
|
||||
[MaxLength(256)]
|
||||
public string Token { get; set; } = string.Empty;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One row per (player, platform user id) that the player has
|
||||
/// chosen to ignore even before they appear in
|
||||
/// <see cref="RelationshipEntity"/>. Backs
|
||||
/// <c>POST api/relationships/v1/bulkignoreplatformusers</c> — when
|
||||
/// a Steam friend joins later, the relationship is auto-set to
|
||||
/// Ignored. Lets the import stick across friend-discovery cycles.
|
||||
/// </summary>
|
||||
public class PlatformIgnoreEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
/// <summary>PlatformType enum: 0=Steam, 1=Oculus, 2=PlayStation,
|
||||
/// 3=Microsoft, 5=IOS.</summary>
|
||||
public int Platform { get; set; }
|
||||
|
||||
[MaxLength(64)]
|
||||
public string PlatformUserId { get; set; } = string.Empty;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One row per "card" displayed on the watch's home screen / cards
|
||||
/// tab. Backs <c>GET api/cards/v1/all</c>. Cards are short tappable
|
||||
/// tiles ("Daily Login", "New Room: Foo", "Friend invited you to
|
||||
/// X") seeded by the server and consumed by the client.
|
||||
/// </summary>
|
||||
public class CardEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>If null, the card is global (shown to everyone).
|
||||
/// If set, only visible to that specific player.</summary>
|
||||
public long? PlayerId { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string Body { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(256)]
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(512)]
|
||||
public string ActionUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Card category — "announcement", "daily", "event",
|
||||
/// "system". Drives the icon the watch renders.</summary>
|
||||
[MaxLength(32)]
|
||||
public string Category { get; set; } = "announcement";
|
||||
|
||||
public int Priority { get; set; } = 0;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Storage-side enum mirroring the int values previously hard-coded in
|
||||
/// <see cref="RelationshipEntity"/>. Stays separate from the on-the-wire
|
||||
/// <c>DorkNet.Models.Relationships.RelationshipStatus</c> so the contract
|
||||
/// can evolve without touching the DB representation. Underlying int
|
||||
/// values match — no migration data fixup needed.
|
||||
/// </summary>
|
||||
public enum RelationshipStatus
|
||||
{
|
||||
/// <summary>Mutual friend (both directions accepted).</summary>
|
||||
Friend = 1,
|
||||
|
||||
/// <summary>Outgoing request from <see cref="RelationshipEntity.RequesterId"/>
|
||||
/// awaiting accept/decline by <see cref="RelationshipEntity.TargetId"/>.</summary>
|
||||
PendingSent = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Reserved for the receiving-side view if we later denormalise. The
|
||||
/// backing column is still 1-2-4 in practice; PendingReceived is what
|
||||
/// the wire DTO computes from the caller's perspective when the row's
|
||||
/// PendingSent direction is "into" them.
|
||||
/// </summary>
|
||||
PendingReceived = 3,
|
||||
|
||||
/// <summary>Block held by <see cref="RelationshipEntity.RequesterId"/>
|
||||
/// against <see cref="RelationshipEntity.TargetId"/>. Hides the target
|
||||
/// from the requester's friend list and prevents new requests both ways.</summary>
|
||||
Blocked = 4,
|
||||
}
|
||||
|
||||
public class RelationshipEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long RequesterId { get; set; }
|
||||
public long TargetId { get; set; }
|
||||
public RelationshipStatus Status { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>True when <see cref="RequesterId"/> favorited
|
||||
/// <see cref="TargetId"/>. Favorited shows up first in the friend
|
||||
/// list and triggers an extra notification on "online".</summary>
|
||||
public bool Favorited { get; set; }
|
||||
|
||||
/// <summary>True when <see cref="RequesterId"/> muted text from
|
||||
/// <see cref="TargetId"/>. Drops chat-message notifications but
|
||||
/// keeps the friendship intact.</summary>
|
||||
public bool Muted { get; set; }
|
||||
|
||||
/// <summary>True when <see cref="RequesterId"/> ignored
|
||||
/// <see cref="TargetId"/> (a soft block — hides the player from
|
||||
/// the room browser without breaking the relationship row).</summary>
|
||||
public bool Ignored { get; set; }
|
||||
|
||||
public PlayerEntity? Requester { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One in-game player report. Created by any authenticated player via
|
||||
/// the watch's "report player" UI; surfaces in the admin moderation
|
||||
/// queue at <c>GET api/admin/v1/reports</c> until an admin resolves it.
|
||||
/// </summary>
|
||||
public class ReportEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>Account id of the player filing the report.</summary>
|
||||
public long ReporterPlayerId { get; set; }
|
||||
|
||||
/// <summary>Account id of the player being reported.</summary>
|
||||
public long TargetPlayerId { get; set; }
|
||||
|
||||
/// <summary>Free-form category the watch sends — convention is
|
||||
/// numeric matching the client's <c>RoomieReportCategory</c> enum
|
||||
/// (Harassment=1, Inappropriate=2, Cheating=3, Spam=4, Other=5).</summary>
|
||||
public int Category { get; set; }
|
||||
|
||||
/// <summary>The reporter's free-text description. Capped at 1000
|
||||
/// chars by the watch UI.</summary>
|
||||
[MaxLength(1000)]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional game session id for context — admins can
|
||||
/// correlate to room/server logs around the time of the report.</summary>
|
||||
public long GameSessionId { get; set; }
|
||||
|
||||
/// <summary>Room the offending behaviour happened in (0 if N/A).</summary>
|
||||
public long RoomId { get; set; }
|
||||
|
||||
/// <summary>If the report targets an invention, the invention id
|
||||
/// (0 otherwise).</summary>
|
||||
public long TargetInventionId { get; set; }
|
||||
|
||||
/// <summary>If the report targets a room (rather than the player
|
||||
/// in it), the room id (0 otherwise).</summary>
|
||||
public long TargetRoomId { get; set; }
|
||||
|
||||
/// <summary>If the report targets a player event, the event id
|
||||
/// (0 otherwise).</summary>
|
||||
public long TargetEventId { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Null while the report sits in the admin queue; set
|
||||
/// when an admin resolves it via
|
||||
/// <c>POST api/admin/v1/reports/{id}/resolve</c>. Together with
|
||||
/// <see cref="ResolverAdminId"/> + <see cref="ResolutionNote"/>
|
||||
/// forms the full audit trail.</summary>
|
||||
public DateTime? ResolvedAt { get; set; }
|
||||
|
||||
public long? ResolverAdminId { get; set; }
|
||||
|
||||
[MaxLength(512)]
|
||||
public string ResolutionNote { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One per-room ban issued by a room owner / admin via
|
||||
/// <c>POST /api/rooms/v2/banfromroom</c>. The match service consults
|
||||
/// these rows when resolving an instance join: if the joining player
|
||||
/// has an active row matching the room id, the join is refused.
|
||||
/// </summary>
|
||||
public class RoomBanEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public long BannedPlayerId { get; set; }
|
||||
public long BannedByPlayerId { get; set; }
|
||||
|
||||
/// <summary>BanType enum mirror: 0 = Soft (kick), 1 = Permanent,
|
||||
/// 2 = Temporary (until <see cref="Until"/>).</summary>
|
||||
public int BanType { get; set; }
|
||||
|
||||
public DateTime? Until { get; set; }
|
||||
|
||||
[MaxLength(512)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A "favorite" / bookmark — when a player taps the heart on a room in the
|
||||
/// watch. Backs `api/rooms/v2/mybookmarks`.
|
||||
/// </summary>
|
||||
public class RoomBookmarkEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for a single saved version of a room's PersistedRoomData
|
||||
/// protobuf. The actual bytes live in S3 at the canonical
|
||||
/// (bucket, key) returned by <see cref="Services.BlobRouter.Route"/>;
|
||||
/// this row only carries the lookup string + provenance.
|
||||
///
|
||||
/// One row per save. Visitors fetching the room get the row whose
|
||||
/// <see cref="BlobName"/> matches the current
|
||||
/// <c>RoomEntity.CurrentDataBlobName</c>; the CDN controller takes
|
||||
/// that name, routes it via <see cref="Services.BlobRouter"/>, and
|
||||
/// streams bytes out of S3.
|
||||
///
|
||||
/// Was originally a fat row with a <c>Bytes</c> column; that column
|
||||
/// was dropped once S3 became the canonical store. Only text columns
|
||||
/// remain in the DB.
|
||||
/// </summary>
|
||||
public class RoomDataBlobEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>FK to RoomEntity. Indexed for "give me all versions of
|
||||
/// this room" queries (history, GC). Set to 0 for shared assets
|
||||
/// not bound to a specific room (HTRs, PV images, polaroids).</summary>
|
||||
public long RoomId { get; set; }
|
||||
|
||||
/// <summary>The string the client uses as the cdn URL segment.
|
||||
/// <see cref="Services.BlobRouter.Route"/> converts this into a
|
||||
/// canonical (bucket, key) deterministically.</summary>
|
||||
[MaxLength(128)]
|
||||
public string BlobName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The player id of the uploader. Audit trail; also used by
|
||||
/// the GC to keep the room owner's saves longer than visitor saves.</summary>
|
||||
public long UploadedByPlayerId { get; set; }
|
||||
|
||||
public DateTime UploadedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>For per-subroom history blobs (the `Restore to old version`
|
||||
/// UI is per-subroom), the SubRoomId this snapshot belongs to. Null for
|
||||
/// canonical scene blobs and shared assets (HTRs, PV images, polaroids).
|
||||
/// Set by the zip-importer when ingesting <c>SubRooms/.../History/</c>
|
||||
/// folders, and by the live save flow if you ever wire that path up to
|
||||
/// stamp it.</summary>
|
||||
public long? SubRoomId { get; set; }
|
||||
|
||||
/// <summary>Comma-separated list of file names referenced by the
|
||||
/// PersistedRoomData (e.g. invention thumbnails, audio clips). The
|
||||
/// client passes these via the upload's referencedFilenames argument;
|
||||
/// the server uses them later when GC'ing orphaned blobs.</summary>
|
||||
[MaxLength(2048)]
|
||||
public string ReferencedFilenamesCsv { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Legacy bytes column. The codebase no longer writes to this — S3
|
||||
/// is the canonical store — but rows imported / uploaded before the
|
||||
/// S3-only cutover still hold their bytes here, and the backfill
|
||||
/// migrator (<c>POST /api/admin/v1/storage/backfill</c>) reads them
|
||||
/// to upload into S3. After backfill is verified, a follow-up EF
|
||||
/// migration drops this column entirely and only metadata remains.
|
||||
/// Nullable so freshly-inserted rows can omit it without breaking
|
||||
/// SQLite's NOT NULL invariant.
|
||||
/// </summary>
|
||||
public byte[]? Bytes { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A user-creatable Rec Room "room" — has a name, a creator, a hub scene
|
||||
/// (the AGRoomRuntimeConfig.Locations entry it spawns into), and metadata
|
||||
/// for the watch UI's room browser.
|
||||
///
|
||||
/// Naming maps: in the wire JSON the client expects mostly PascalCase
|
||||
/// (Room.Deserialize at RVA 0x114E430). Property names match.
|
||||
///
|
||||
/// Two-way relationship with PlayerEntity for ownership / bookmark / role
|
||||
/// browsing — kept on the room side as ID columns rather than a navigation
|
||||
/// to keep PlayerEntity unchanged.
|
||||
/// </summary>
|
||||
public class RoomEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The player who created the room (NOT the dorm owner — every
|
||||
/// real Rec Room has a creator player id; for seeded "RecRoomOriginal"
|
||||
/// rooms we set this to 1 / the system account).</summary>
|
||||
public long CreatorPlayerId { get; set; }
|
||||
|
||||
[MaxLength(256)]
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>RoomState enum (int): 0 = Active, 1 = Archived.</summary>
|
||||
public int State { get; set; } = 0;
|
||||
|
||||
/// <summary>RoomAccessibility enum (int): 0 = Private, 1 = Public,
|
||||
/// 2 = LimitedAccessibility / FriendsOnly.</summary>
|
||||
public int Accessibility { get; set; } = 1;
|
||||
|
||||
public bool SupportsLevelVoting { get; set; } = false;
|
||||
|
||||
/// <summary>True for AG (user-built) rooms, false for "Rec Room
|
||||
/// Original" rooms. The watch tabs `#community` (AG) and
|
||||
/// `#recroomoriginal` (RR Originals) filter on this.</summary>
|
||||
public bool IsAGRoom { get; set; } = true;
|
||||
|
||||
public bool IsDormRoom { get; set; } = false;
|
||||
public bool CloningAllowed { get; set; } = false;
|
||||
public bool SupportsVRLow { get; set; } = true;
|
||||
public bool SupportsMobile { get; set; } = false;
|
||||
public bool SupportsScreens { get; set; } = true;
|
||||
public bool SupportsWalkVR { get; set; } = true;
|
||||
public bool SupportsTeleportVR { get; set; } = true;
|
||||
public bool AllowsJuniors { get; set; } = true;
|
||||
public int RoomWarningMask { get; set; } = 0;
|
||||
|
||||
[MaxLength(512)]
|
||||
public string CustomRoomWarning { get; set; } = string.Empty;
|
||||
|
||||
public bool DisableMicAutoMute { get; set; } = false;
|
||||
|
||||
/// <summary>RoomScene.RoomSceneLocationId GUID — foreign-keys to a
|
||||
/// Locations[] entry in resources.assets. This is what
|
||||
/// AGRoomSettings.TryGetRoomSceneLocationById matches.
|
||||
/// Default: DormRoom location GUID extracted from the asset.</summary>
|
||||
[MaxLength(64)]
|
||||
public string LocationReplicationId { get; set; } = "76d98498-60a1-430c-ab76-b54a29b7a163";
|
||||
|
||||
/// <summary>Comma-separated tags. The 2020 watch supports a small set
|
||||
/// (#community, #recroomoriginal, #featured, etc.) — keeping it as a
|
||||
/// flat string avoids a join table for the v0 watch implementation.</summary>
|
||||
[MaxLength(1024)]
|
||||
public string TagsCsv { get; set; } = string.Empty;
|
||||
|
||||
public int CheerCount { get; set; } = 0;
|
||||
public int FavoriteCount { get; set; } = 0;
|
||||
|
||||
/// <summary>Total joins across all players (every <c>/goto</c>
|
||||
/// counts; same player rejoining bumps it again). Mirrors
|
||||
/// official Rec.Net <c>Stats.VisitCount</c>.</summary>
|
||||
public int VisitCount { get; set; } = 0;
|
||||
|
||||
/// <summary>Distinct players who've ever visited (each player
|
||||
/// counts once no matter how many times they rejoin). Mirrors
|
||||
/// official Rec.Net <c>Stats.VisitorCount</c>. Bumped only when
|
||||
/// <see cref="RoomVisitEntity"/> inserts a new row for the
|
||||
/// (room, player) pair.</summary>
|
||||
public int VisitorCount { get; set; } = 0;
|
||||
|
||||
/// <summary>Score the watch uses to sort the "Hot" tab. Higher = more
|
||||
/// visible. Seeded rooms get bumped above empty user rooms so they show
|
||||
/// up first in the browser.</summary>
|
||||
public double HotScore { get; set; } = 0;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>True keeps the room out of room browse / search /
|
||||
/// "RR Originals" feeds while leaving <c>/goto</c>, clone, and
|
||||
/// admin-tool access intact. Used for rooms that exist in the seed
|
||||
/// (MakerRoom, EventRoom, paintball sub-maps merged into the
|
||||
/// Paintball lobby, etc.) but shouldn't show up to players because
|
||||
/// either there's a unified room hosting their scenes elsewhere or
|
||||
/// they're admin-only utility rooms.</summary>
|
||||
public bool HiddenFromBrowse { get; set; } = false;
|
||||
|
||||
/// <summary>Name of the latest <see cref="RoomDataBlobEntity"/> row
|
||||
/// for this room — also what we put in
|
||||
/// <c>RoomDetails.Scenes[0].DataBlobName</c> so the client knows which
|
||||
/// blob to download. Empty for AG-Original baked rooms (rec center,
|
||||
/// paintball, etc.) which have no per-room save bytes; the dorm and
|
||||
/// user-built / cloned rooms point at their latest save.</summary>
|
||||
[MaxLength(128)]
|
||||
public string CurrentDataBlobName { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Paid room access key authored by a room owner/co-owner. The watch
|
||||
/// displays these in the room-key editor and purchase flow.
|
||||
/// </summary>
|
||||
public class RoomKeyEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
[MaxLength(64)] public string ReplicationId { get; set; } = Guid.NewGuid().ToString("D");
|
||||
public long RoomId { get; set; }
|
||||
public long CreatorPlayerId { get; set; }
|
||||
[MaxLength(40)] public string Name { get; set; } = string.Empty;
|
||||
[MaxLength(174)] public string Description { get; set; } = string.Empty;
|
||||
public int Price { get; set; }
|
||||
public bool IsDeleted { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>One purchased room key per player.</summary>
|
||||
public class RoomKeyPurchaseEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long RoomKeyId { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
public int PaidPrice { get; set; }
|
||||
public DateTime PurchasedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per-room player role grant. Drives RoomDetails.CoOwners /
|
||||
/// Moderators / Hosts arrays (and their Invited* counterparts when
|
||||
/// <see cref="Accepted"/> is false). Each (RoomId, PlayerId, Role)
|
||||
/// tuple is unique — granting the same role twice is a no-op.
|
||||
/// </summary>
|
||||
public class RoomRoleEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
/// <summary>RoleKind: 0 = CoOwner, 1 = Moderator, 2 = Host.</summary>
|
||||
public int Role { get; set; }
|
||||
|
||||
/// <summary>False while the invite is still pending. Accepted
|
||||
/// rows show up in CoOwners/Moderators/Hosts; pending rows show
|
||||
/// up in InvitedCoOwners/InvitedModerators/InvitedHosts.</summary>
|
||||
public bool Accepted { get; set; } = true;
|
||||
|
||||
public long? GrantedByPlayerId { get; set; }
|
||||
public System.DateTime GrantedAt { get; set; } = System.DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One scene within a multi-scene custom room. Most rooms historically
|
||||
/// had only Scenes[0] (synthesised in <c>BuildRoomDetails</c> from
|
||||
/// <see cref="RoomEntity.LocationReplicationId"/> +
|
||||
/// <see cref="RoomEntity.CurrentDataBlobName"/>); rooms imported from a
|
||||
/// multi-scene archive write one row per chapter here, and the wire-shape
|
||||
/// builder emits the full <c>Scenes[]</c> array from this table when any
|
||||
/// rows exist.
|
||||
///
|
||||
/// <para>The scene's <see cref="RoomSceneId"/> on the wire is this table's
|
||||
/// <see cref="OrderIndex"/> — the matchmaking <c>SubRoomId</c> the watch
|
||||
/// passes to <c>/goto/room/{name}/{subroom}</c> matches against
|
||||
/// <see cref="Name"/>, then resolves to <see cref="OrderIndex"/> for
|
||||
/// <c>RoomInstance.SubRoomId</c>.</para>
|
||||
/// </summary>
|
||||
public class RoomSceneEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>FK to <see cref="RoomEntity"/>. Indexed so the per-room
|
||||
/// fetch in <c>BuildRoomDetails</c> is one query.</summary>
|
||||
public long RoomId { get; set; }
|
||||
|
||||
/// <summary>0-based index this scene occupies in the wire
|
||||
/// <c>Scenes[]</c> array. Doubles as the <c>RoomSceneId</c> the
|
||||
/// matchmaking flow uses for <c>RoomInstance.SubRoomId</c>.</summary>
|
||||
public int OrderIndex { get; set; }
|
||||
|
||||
/// <summary>Display name (e.g. "Lobby", "Ch1_Headfirst"). Matched
|
||||
/// case-insensitively against the sub-room segment of
|
||||
/// <c>/goto/room/{name}/{subroom}</c>.</summary>
|
||||
[MaxLength(128)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The <c>RoomSceneLocationId</c> GUID the client uses to
|
||||
/// look up the bundled stage prefab via
|
||||
/// <c>AGRoomSettings.TryGetRoomSceneLocationById</c>. Defaults to
|
||||
/// the MakerRoom (Basement) scene GUID — the canonical "blank
|
||||
/// canvas" stage 2020 custom rooms were built on. Extracted from
|
||||
/// resources.assets via tools/extract-locations-binary.py: the
|
||||
/// "Maker Room" Location entry has SceneName=Basement and
|
||||
/// ReplicationId=a75f7547-79eb-47c6-8986-6767abcb4f92.</summary>
|
||||
[MaxLength(64)]
|
||||
public string RoomSceneLocationId { get; set; } = "a75f7547-79eb-47c6-8986-6767abcb4f92";
|
||||
|
||||
/// <summary>The <see cref="RoomDataBlobEntity.BlobName"/> the watch
|
||||
/// downloads as the persisted state for this scene.</summary>
|
||||
[MaxLength(128)]
|
||||
public string DataBlobName { get; set; } = string.Empty;
|
||||
|
||||
public int MaxPlayers { get; set; } = 8;
|
||||
public bool IsSandbox { get; set; } = false;
|
||||
public bool CanMatchmakeInto { get; set; } = true;
|
||||
|
||||
public DateTime DataModifiedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One row per (Room, Player) recording the player's visit history
|
||||
/// to that room. Backs the proper <c>RoomEntity.VisitorCount</c>
|
||||
/// (count of unique players who've visited) vs <c>VisitCount</c>
|
||||
/// (total joins, including the same player multiple times) split
|
||||
/// the official Rec.Net stats column distinguishes.
|
||||
///
|
||||
/// Upserted by <c>GoToController</c> on every <c>POST /goto/room</c>
|
||||
/// so the per-room "who's been here, when" history is queryable for
|
||||
/// the Hot-rooms ranking + admin diagnostics.
|
||||
/// </summary>
|
||||
public class RoomVisitEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
/// <summary>Set once on the player's first visit; immutable after.
|
||||
/// Used to decide whether to bump RoomEntity.VisitorCount.</summary>
|
||||
public DateTime FirstVisitAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Updated on every visit. Drives "recent visitors"
|
||||
/// queries.</summary>
|
||||
public DateTime LastVisitAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Per-player visit count (this player visited this room
|
||||
/// N times). Sum across all rows = RoomEntity.VisitCount.</summary>
|
||||
public int VisitCount { get; set; } = 0;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One Rec Royale match. Inserted by the server when
|
||||
/// <c>POST api/royale/v2/matchcomplete</c> arrives so we keep an
|
||||
/// audit trail (the client doesn't read this table — it only reads
|
||||
/// the aggregated <see cref="RoyalePlayerProgressEntity"/>).
|
||||
/// </summary>
|
||||
public class RoyaleMatchEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public DateTime CompletedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>From <c>MatchCompleteStats.Rank</c> — the player's
|
||||
/// finish position (1 = Victory Royale).</summary>
|
||||
public int Rank { get; set; }
|
||||
|
||||
public int NumEliminations { get; set; }
|
||||
public int SecondsAlive { get; set; }
|
||||
public bool WalkGame { get; set; }
|
||||
public bool CustomGame { get; set; }
|
||||
public int ChestsOpened { get; set; }
|
||||
public int ShieldPotionsConsumed { get; set; }
|
||||
public int HealthPotionsConsumed { get; set; }
|
||||
public int SecondsInAir { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One per (match, player) — server-side audit only; not
|
||||
/// exposed to client. Lets us aggregate per-match contributions
|
||||
/// retroactively.</summary>
|
||||
public class RoyaleMatchPlayerEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long MatchId { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
public int Rank { get; set; }
|
||||
public int NumEliminations { get; set; }
|
||||
public int SecondsAlive { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One per player — mirrors wire type
|
||||
/// <c>RecRoyalePlayerProgress</c> (TotalXP/Level/RankIdx/RankName +
|
||||
/// per-level XP thresholds and acorn reward). The watch reads this
|
||||
/// from <c>GET api/royale/v1/current</c>.</summary>
|
||||
public class RoyalePlayerProgressEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
public long TotalXP { get; set; }
|
||||
public int Level { get; set; }
|
||||
public int RankIdx { get; set; }
|
||||
|
||||
[MaxLength(64)]
|
||||
public string RankName { get; set; } = "Recruit";
|
||||
|
||||
public long CurrentLevelXPThreshold { get; set; }
|
||||
public long NextLevelXPThreshold { get; set; } = 100;
|
||||
public int NextLevelAcornReward { get; set; } = 50;
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Single-row table holding server-wide runtime toggles that admins can
|
||||
/// flip from the SPA without a redeploy. Modelled after
|
||||
/// <see cref="CommunityBoardEntity"/>: <see cref="Id"/> is pinned to 1
|
||||
/// so EF Core has a key to track changes against, and reads/writes
|
||||
/// touch one row.
|
||||
///
|
||||
/// Add new boolean (or scalar) columns directly to this entity as more
|
||||
/// toggles appear — keeping them strongly typed beats a generic
|
||||
/// key/value table for the dozen-or-so global switches we'll ever have.
|
||||
/// </summary>
|
||||
public class ServerSettingsEntity
|
||||
{
|
||||
public int Id { get; set; } = 1;
|
||||
|
||||
/// <summary>When true, the watch's account-creation endpoints
|
||||
/// (<c>POST /account/create</c> and <c>POST /api/account/v1/create</c>)
|
||||
/// short-circuit with <c>Success=false</c> and the watch surfaces
|
||||
/// the error to the player. Existing logins are unaffected; only
|
||||
/// brand-new account requests are blocked.</summary>
|
||||
public bool SignupsDisabled { get; set; }
|
||||
|
||||
/// <summary>When true, the challenge map tells the 2020 watch that
|
||||
/// weekly challenge completion is required for the weekly reward flow.
|
||||
/// Individual player challenge rows still default to incomplete until
|
||||
/// the client reports progress.</summary>
|
||||
public bool WeeklyChallengesCompletedRequired { get; set; } = true;
|
||||
|
||||
/// <summary>Admin-managed weekly challenge templates as JSON. Challenge
|
||||
/// ids are generated per weekly map so old progress rows stay scoped to
|
||||
/// that week's map id.</summary>
|
||||
public string WeeklyChallengesJson { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Admin-managed weekly reward JSON. The progression API uses
|
||||
/// this for both the watch's ChallengeGift payload and the once-per-week
|
||||
/// server-side XP/token grant.</summary>
|
||||
public string WeeklyChallengeRewardJson { get; set; } = string.Empty;
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One leaderboard stat row per (player, statChannel). Backs
|
||||
/// <c>api/Leaderboard/v1/SetStats</c> + <c>api/Leaderboard/v2/getPlayerRank</c>.
|
||||
///
|
||||
/// The 2020 watch's <c>RecNet.Leaderboards</c> uses an int channel
|
||||
/// id (e.g. 1=PaintballWins, 2=DodgeballHits, etc.) and a single
|
||||
/// long value per channel. A player's "rank" is computed as
|
||||
/// <c>1 + count(*)</c> of rows in the same channel with a higher
|
||||
/// value.
|
||||
/// </summary>
|
||||
public class LeaderboardStatEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
/// <summary>RecNet stat channel id (small int per game mode +
|
||||
/// stat type — e.g. paintball-CTF-wins, dodgeball-hits).</summary>
|
||||
public int StatChannel { get; set; }
|
||||
|
||||
/// <summary>Aggregate stat value. Set semantics depend on
|
||||
/// <see cref="SetMode"/>: 0=Always, 1=OnlyIfHigher, 2=OnlyIfLower,
|
||||
/// 3=Increment. Most stat channels are mode 1 (high score).</summary>
|
||||
public long Value { get; set; }
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Match-result Elo per (player, game mode). Backs
|
||||
/// <c>api/PlayerElo/v1/reportPlayerElo</c>. The 2020 watch reports
|
||||
/// an Elo delta after each ranked match (Royale, Paintball,
|
||||
/// Soccer); we accumulate it per game mode for matchmaking.
|
||||
/// </summary>
|
||||
public class PlayerEloEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
/// <summary>RecNet game mode id (small int). We don't enumerate
|
||||
/// these; the wire just sends an int and we pass it through.</summary>
|
||||
public int GameMode { get; set; }
|
||||
|
||||
/// <summary>Current Elo. Default 1000 on first row.</summary>
|
||||
public int Elo { get; set; } = 1000;
|
||||
|
||||
public int Wins { get; set; }
|
||||
public int Losses { get; set; }
|
||||
public int MatchesPlayed { get; set; }
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One purchasable item in the in-game store. The watch's Shop tab
|
||||
/// renders these, and the purchase flow (initiatepurchase →
|
||||
/// processpurchase → completepurchase) verifies the player has enough
|
||||
/// of <see cref="CurrencyType"/> currency, deducts <see cref="Price"/>,
|
||||
/// and appends <see cref="Slug"/> to the buyer's
|
||||
/// <see cref="AvatarEntity.InventoryJson"/> list so the customisation
|
||||
/// menu can equip it.
|
||||
///
|
||||
/// Item slots / categories track the avatar slots the watch's
|
||||
/// outfit-selector groups items by — Head, Torso, Legs, Feet,
|
||||
/// Accessory, Face, Hair, plus non-avatar categories (Consumable,
|
||||
/// RoomTemplate, Emote) that don't go into outfit selections but
|
||||
/// still show up in the player's inventory.
|
||||
/// </summary>
|
||||
public class StoreItemEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>Stable string identifier — what we put in
|
||||
/// InventoryJson and OutfitSelections. Format is a GUID for
|
||||
/// avatar items (so the watch's avatar resolver matches the
|
||||
/// baked AvatarItemDefinition catalog), or a kebab-case slug for
|
||||
/// non-avatar items (consumables / templates). Unique.</summary>
|
||||
// Widened from 64→128 in 2026-05 to accommodate the
|
||||
// wardrobe-colored-{guid}-{color} slugs the color-variant seeder
|
||||
// generates — longest in the catalog is ~68 chars (long color
|
||||
// names like "WhiteHatHacker" + 36-char GUID + prefix).
|
||||
[MaxLength(128)]
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(128)]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(1024)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Avatar slot or item type. One of: head, torso, legs,
|
||||
/// feet, accessory, face, hair, consumable, roomtemplate, emote.
|
||||
/// </summary>
|
||||
[MaxLength(32)]
|
||||
public string Category { get; set; } = "accessory";
|
||||
|
||||
/// <summary>cdn filename (relative to cdn.rec.net/) for the
|
||||
/// store-tile thumbnail. Empty = the watch falls back to a
|
||||
/// silhouette. Same format as RoomEntity.ImageName.</summary>
|
||||
[MaxLength(256)]
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Currency the item is priced in. 2 = standard tokens
|
||||
/// (the everyday currency earned from level-up + cheers); higher
|
||||
/// numbers reserved for premium currencies (e.g. 1 = real-money
|
||||
/// purchases — never enabled on a private server).</summary>
|
||||
public int CurrencyType { get; set; } = 2;
|
||||
|
||||
public long Price { get; set; }
|
||||
|
||||
/// <summary>Listing flag. False = hidden from the store but kept
|
||||
/// in the catalog so existing inventory references stay
|
||||
/// resolvable. Admin-only mutation.</summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>True for limited-time / event items. The watch
|
||||
/// renders an "ends in" countdown alongside these and pulls them
|
||||
/// from the catalog when AvailableUntil expires (we keep the row,
|
||||
/// just flip IsActive=false on expiry).</summary>
|
||||
public bool IsLimitedTime { get; set; }
|
||||
|
||||
public DateTime? AvailableUntil { get; set; }
|
||||
|
||||
/// <summary>Storefront grouping. <c>"main"</c> = the everyday
|
||||
/// shop; <c>"giftdrop:N"</c> = a specific rotating gift-drop shelf
|
||||
/// the 2020 client probes via /storefronts/v3/giftdropstore/{N};
|
||||
/// <c>"rro"</c> = every RRO/Rec Center in-room shelf;
|
||||
/// <c>"all"</c> = every in-room shelf; <c>"season:N"</c> =
|
||||
/// season-pass ladders. Default is main.
|
||||
/// </summary>
|
||||
[MaxLength(32)]
|
||||
public string Storefront { get; set; } = "main";
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Asymmetric "follow" relationship: <see cref="SubscriberPlayerId"/>
|
||||
/// follows <see cref="TargetPlayerId"/> for activity updates.
|
||||
/// Distinct from <see cref="RelationshipEntity"/>'s mutual friend
|
||||
/// model — subscriptions are one-way and don't require acceptance.
|
||||
/// </summary>
|
||||
public class SubscriptionEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long SubscriberPlayerId { get; set; }
|
||||
public long TargetPlayerId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// QA test case — wire type <c>RecNet.TestCase</c>
|
||||
/// (<c>Cpp2IL_CS/.../RecNet/TestCase.cs</c>). The 2020 Rec Room
|
||||
/// client uses this to surface JIRA-tracked test cases in an
|
||||
/// internal QA tab; users with the developer role claim a case,
|
||||
/// run it in the named room, then submit a Pass/Fail status.
|
||||
///
|
||||
/// Field names + types match the client's
|
||||
/// <c>TestCase.Deserialize</c> exactly. <see cref="Status"/> is the
|
||||
/// <c>TestCaseStatus</c> enum: 0=NotYetTested, 1=Claimed, 2=Failed,
|
||||
/// 3=Passed.
|
||||
/// </summary>
|
||||
public class TestCaseEntity
|
||||
{
|
||||
/// <summary>EF primary key (long autoincrement) — kept separate
|
||||
/// from the wire <see cref="Id"/> string so we can index cleanly.</summary>
|
||||
public long Pk { get; set; }
|
||||
|
||||
/// <summary>Wire field <c>Id</c> (string) — typically the JIRA
|
||||
/// issue key like <c>RR-1234</c>.</summary>
|
||||
[MaxLength(64)]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(64)]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(256)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(128)]
|
||||
public string RoomName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>TestCaseStatus enum: 0=NotYetTested, 1=Claimed,
|
||||
/// 2=Failed, 3=Passed.</summary>
|
||||
public int Status { get; set; } = 0;
|
||||
|
||||
public int MinNumAssignedPlayers { get; set; } = 1;
|
||||
|
||||
/// <summary>JSON list of player ids currently claimed on this
|
||||
/// test case. Mirrors wire <c>AssignedPlayerIds</c> (List<int>).</summary>
|
||||
public string AssignedPlayerIdsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>Sibling JSON list of names for display (kept in sync
|
||||
/// with <see cref="AssignedPlayerIdsJson"/>).</summary>
|
||||
public string AssignedPlayerNamesJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>JSON list of tag strings.</summary>
|
||||
public string TagsJson { get; set; } = "[]";
|
||||
|
||||
[MaxLength(512)]
|
||||
public string JiraUrl { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(512)]
|
||||
public string JiraBugUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional FK to the <see cref="TestPassEntity"/> this
|
||||
/// case belongs to (null = orphan / standalone).</summary>
|
||||
public uint? TestPassId { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>Wire type <c>RecNet.TestPass</c> — a versioned bundle of
|
||||
/// test cases the QA team groups for a release pass. Listed via
|
||||
/// <c>GET api/testcasemanagement/v1/testpasssummary</c>; one fetched
|
||||
/// in detail via <c>GET api/testcasemanagement/v1/testpass/{id}</c>.
|
||||
/// </summary>
|
||||
public class TestPassEntity
|
||||
{
|
||||
/// <summary>Wire <c>Id</c> is uint — store as long PK and cast.</summary>
|
||||
public uint Id { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public DateTime StartDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Null while the pass is still active.</summary>
|
||||
public DateTime? EndDate { get; set; }
|
||||
|
||||
public bool WasManuallyClosed { get; set; } = false;
|
||||
|
||||
/// <summary>JSON list of tag strings.</summary>
|
||||
public string TagsJson { get; set; } = "[]";
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DorkNet.Server.Data;
|
||||
|
||||
/// <summary>Idempotent startup pass that applies data transforms which
|
||||
/// can't be derived from the entity model (i.e. don't belong in an EF
|
||||
/// migration). Runs once on every server boot after
|
||||
/// <c>db.Database.MigrateAsync()</c>; each upgrade is structured as
|
||||
/// "read current state → apply if needed → log outcome", so re-running
|
||||
/// on a fully-migrated DB is a no-op (just a string of "skipped, already
|
||||
/// migrated" log lines).
|
||||
///
|
||||
/// <para>This file is the home for transforms that previously lived in
|
||||
/// individual EF migrations (bucket C of the migration consolidation).
|
||||
/// New entries go here when:
|
||||
/// <list type="bullet">
|
||||
/// <item>A seed row's <c>Name</c> changes between releases (existing
|
||||
/// deployments need a UPDATE; fresh installs already see the new
|
||||
/// name in seed data).</item>
|
||||
/// <item>Existing rows need a content fix-up that the entity model
|
||||
/// can't express (e.g. backfilling a computed column).</item>
|
||||
/// </list>
|
||||
/// Every upgrade method MUST be:
|
||||
/// <list type="bullet">
|
||||
/// <item>Idempotent — safe to call repeatedly</item>
|
||||
/// <item>Self-checking — reads the current DB state first and only
|
||||
/// modifies if the transform hasn't already been applied</item>
|
||||
/// <item>Logged — emits one "applied" or "skipped, already migrated"
|
||||
/// line so the boot log shows exactly which upgrades fired</item>
|
||||
/// </list></para>
|
||||
/// </summary>
|
||||
public static class LegacyUpgrades
|
||||
{
|
||||
public static async Task RunAsync(DorkNetDbContext db, IConfiguration config, ILogger logger, CancellationToken ct = default)
|
||||
{
|
||||
await RenameBloodMoonToCrescendoAsync(db, logger, ct);
|
||||
await CoercePhotonRegionAsync(db, config, logger, ct);
|
||||
// Future data transforms go here, one per release. Keep them
|
||||
// narrowly scoped — anything that can be expressed as a
|
||||
// schema change should be a migration instead.
|
||||
}
|
||||
|
||||
/// <summary>Coerce every <see cref="Entities.PrivateInstanceEntity.PhotonRegion"/>
|
||||
/// row to the apex's current <c>Photon:CloudRegion</c>.
|
||||
///
|
||||
/// <para>Background: older dorm rows still carry whatever region was
|
||||
/// in config when their dorm was first registered. Without this
|
||||
/// rewrite, an invitee whose <c>/goto/invite</c> resolves to a stale
|
||||
/// row gets sent to the old region while the inviter's most recent
|
||||
/// <c>/goto/room/DormRoom</c> puts them on the current region — two
|
||||
/// parallel Photon rooms, players can't see each other.
|
||||
/// <c>PrivateInstanceService.EnsureForDormAsync</c> now refreshes
|
||||
/// PhotonRegion on each owner /goto so going forward this drift is
|
||||
/// impossible; this is the one-shot cleanup for the pre-fix backlog.</para>
|
||||
///
|
||||
/// <para>Originally ran as a RunPatchAsync UPDATE in the Postgres-only
|
||||
/// schema-patch block in Program.cs; consolidated here so SQLite
|
||||
/// dev DBs (which can drift the same way if you change Photon:CloudRegion
|
||||
/// between boots) get the same fix-up.</para>
|
||||
/// </summary>
|
||||
private static async Task CoercePhotonRegionAsync(
|
||||
DorkNetDbContext db, IConfiguration config, ILogger logger, CancellationToken ct)
|
||||
{
|
||||
var region = (config["Photon:CloudRegion"] ?? "us").ToLowerInvariant();
|
||||
var updated = await db.PrivateInstances
|
||||
.Where(p => p.PhotonRegion != region)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(p => p.PhotonRegion, region), ct);
|
||||
if (updated == 0)
|
||||
{
|
||||
logger.LogInformation("[legacy-upgrade] CoercePhotonRegion: skipped (all PrivateInstances already on {Region})", region);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("[legacy-upgrade] CoercePhotonRegion: applied — {Count} PrivateInstances coerced to {Region}", updated, region);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The Crimson Cauldron / Blood Moon seed room was renamed
|
||||
/// to Crescendo in May 2026 (and re-themed: new description, new
|
||||
/// image, no longer hidden from browse). Old deployments still have
|
||||
/// the BloodMoon row by primary key — rename it in place and clear
|
||||
/// the hidden flag. Fresh installs ship with the Crescendo seed
|
||||
/// directly and this upgrade no-ops.
|
||||
///
|
||||
/// <para>Originally ran as
|
||||
/// <c>20260521155605_RenameBloodMoonToCrescendo</c>; consolidated
|
||||
/// here when the per-feature migrations were collapsed into a
|
||||
/// single Initial.</para>
|
||||
///
|
||||
/// <para>Concurrency: <c>Rooms.Name</c> has a unique index
|
||||
/// (DorkNetDbContext.cs HasIndex(r=>r.Name).IsUnique()). Two
|
||||
/// replicas booting in parallel against a legacy DB could both see
|
||||
/// BloodMoon + no Crescendo and race to do the rename — the loser
|
||||
/// would hit a unique-constraint exception on SaveChangesAsync.
|
||||
/// We catch that, refresh, and re-evaluate so the race is
|
||||
/// observationally a single rename.</para>
|
||||
/// </summary>
|
||||
private static async Task RenameBloodMoonToCrescendoAsync(
|
||||
DorkNetDbContext db, ILogger logger, CancellationToken ct)
|
||||
{
|
||||
// Re-runnable loop: if a concurrent boot beats us to the rename,
|
||||
// catch the unique-constraint exception, drop the cached entity,
|
||||
// and re-read the DB state. After at most 2 iterations we're
|
||||
// either no-op (Crescendo now exists) or the only writer.
|
||||
for (var attempt = 1; attempt <= 2; attempt++)
|
||||
{
|
||||
var crescendoExists = await db.Rooms.AnyAsync(r => r.Name == "Crescendo", ct);
|
||||
var bloodMoon = await db.Rooms.FirstOrDefaultAsync(r => r.Name == "BloodMoon", ct);
|
||||
|
||||
if (bloodMoon is null && crescendoExists)
|
||||
{
|
||||
// Most common path on a freshly-deployed server: seed
|
||||
// shipped Crescendo, nothing to migrate.
|
||||
logger.LogInformation("[legacy-upgrade] RenameBloodMoonToCrescendo: skipped (already on Crescendo)");
|
||||
return;
|
||||
}
|
||||
|
||||
if (bloodMoon is null && !crescendoExists)
|
||||
{
|
||||
// Neither exists — non-seeded test DB. No-op.
|
||||
logger.LogInformation("[legacy-upgrade] RenameBloodMoonToCrescendo: skipped (no BloodMoon and no Crescendo present)");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (bloodMoon is not null && !crescendoExists)
|
||||
{
|
||||
bloodMoon.Name = "Crescendo";
|
||||
bloodMoon.Description = "Brave the haunted halls of Castle Dracula and survive the night.";
|
||||
bloodMoon.ImageName = "image_Crescendo.png";
|
||||
bloodMoon.HiddenFromBrowse = false;
|
||||
await db.SaveChangesAsync(ct);
|
||||
logger.LogInformation(
|
||||
"[legacy-upgrade] RenameBloodMoonToCrescendo: applied — renamed Rooms.Id={Id} BloodMoon → Crescendo",
|
||||
bloodMoon.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (bloodMoon is not null && crescendoExists)
|
||||
{
|
||||
// Both exist — the rename ran on a different row at
|
||||
// some point, and the original BloodMoon row is now
|
||||
// a stale duplicate. Park it under CrescendoLegacy
|
||||
// and hide from browse so /goto/name/... still
|
||||
// resolves Crescendo to the right row.
|
||||
bloodMoon.Name = "CrescendoLegacy";
|
||||
bloodMoon.HiddenFromBrowse = true;
|
||||
await db.SaveChangesAsync(ct);
|
||||
logger.LogInformation(
|
||||
"[legacy-upgrade] RenameBloodMoonToCrescendo: applied — Rooms.Id={Id} parked as CrescendoLegacy (Crescendo already existed on a different row)",
|
||||
bloodMoon.Id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (DbUpdateException ex) when (attempt == 1)
|
||||
{
|
||||
// Another replica won the rename race. Detach the
|
||||
// stale entity and loop — second iteration will see
|
||||
// Crescendo exists and exit cleanly.
|
||||
logger.LogInformation(
|
||||
"[legacy-upgrade] RenameBloodMoonToCrescendo: lost race on attempt {Attempt}, retrying ({Message})",
|
||||
attempt, ex.GetBaseException().Message);
|
||||
if (bloodMoon is not null) db.Entry(bloodMoon).State = EntityState.Detached;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
# Migrations & legacy data upgrades
|
||||
|
||||
## Current state (2026-05-25 — post-consolidation)
|
||||
|
||||
- **One migration** under `Migrations/`: `20260525181055_Initial.cs`
|
||||
(generated by `dotnet ef migrations add Initial` against the
|
||||
current entity model — captures all 67 `DbSet<>` tables).
|
||||
- **`LegacyUpgrades.cs`** holds idempotent data transforms that can't
|
||||
be derived from the entity model. Runs once per server boot, after
|
||||
EF migrations complete and before `RoomService.SeedAsync()`.
|
||||
|
||||
## What changed in the consolidation
|
||||
|
||||
Before this pass, the codebase had two parallel schema-management
|
||||
mechanisms accumulating per-feature noise:
|
||||
|
||||
1. **34 EF migrations** under `Migrations/` — applied to SQLite via
|
||||
`db.Database.Migrate()`.
|
||||
2. **65 `RunPatchAsync` calls** in `Program.cs:574-1011` — idempotent
|
||||
Postgres `ALTER`/`CREATE` statements layered on top of
|
||||
`EnsureCreated()`, because the Postgres path skipped EF migrations.
|
||||
|
||||
Both were collapsed in 2026-05-25:
|
||||
|
||||
- The 34 migrations were deleted and regenerated as a single
|
||||
`20260525181055_Initial`. The model snapshot was regenerated to match.
|
||||
- The `RunPatchAsync` block was deleted entirely. Postgres still uses
|
||||
`EnsureCreated` (which now reads the same up-to-date entity model);
|
||||
no per-feature patches needed.
|
||||
- The PhotonRegion coerce (the only data transform in the block)
|
||||
was moved into `LegacyUpgrades.CoercePhotonRegionAsync`.
|
||||
- The Storage admin page (`admin-ui/src/pages/Storage.tsx`) and
|
||||
`StorageBackfillController.cs` were deleted — they were the UI for
|
||||
draining the legacy `RoomDataBlobs.Bytes` column into S3, which is
|
||||
no longer needed.
|
||||
|
||||
## Production cutover (one-time SQL)
|
||||
|
||||
Existing prod databases need a one-time fixup to tell EF "the new
|
||||
Initial is already applied here" so the next boot doesn't try to
|
||||
`CREATE TABLE` over existing data.
|
||||
|
||||
### Postgres path
|
||||
|
||||
The Postgres production schema was built via `EnsureCreated()` plus
|
||||
the (now-deleted) `RunPatchAsync` block. **No `__EFMigrationsHistory`
|
||||
table exists** because EF Core only creates it when `Migrate()` runs.
|
||||
Postgres continues to use `EnsureCreated()` post-consolidation, so
|
||||
there's *nothing to do* — `EnsureCreated()` reads the entity model,
|
||||
sees the schema already matches, and no-ops. Just deploy the new
|
||||
build.
|
||||
|
||||
If you ever switch the Postgres path to `Migrate()`, run this once
|
||||
before the cutover deploy:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
|
||||
"MigrationId" character varying(150) NOT NULL,
|
||||
"ProductVersion" character varying(32) NOT NULL,
|
||||
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
|
||||
);
|
||||
|
||||
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
|
||||
VALUES ('20260525181055_Initial', '10.0.0');
|
||||
```
|
||||
|
||||
### SQLite path (dev DBs)
|
||||
|
||||
Handled automatically by `BaselineExistingSchemaIfNeeded()` in
|
||||
`Program.cs`. On boot it detects two cutover scenarios and reconciles
|
||||
the migration-history table to match the assembly's current migration
|
||||
list:
|
||||
|
||||
- **Legacy EnsureCreated** — schema present, history table absent.
|
||||
Creates the history table and stamps the Initial.
|
||||
- **Stale history post-consolidation** — schema present, history has
|
||||
entries from the old 34 per-feature migrations. Wipes them and
|
||||
stamps the Initial.
|
||||
|
||||
`Migrate()` then sees the reconciled history and no-ops. **No manual
|
||||
SQL needed.**
|
||||
|
||||
#### Gotcha: partial-migration dev DBs
|
||||
|
||||
`BaselineExistingSchemaIfNeeded` assumes the on-disk schema matches
|
||||
the current entity model. If a dev DB was created mid-cycle and
|
||||
missed some of the later migrations (e.g. `AddPlaylists`,
|
||||
`AddClubAnnouncementsAndCategories`, `AddWeeklyChallengeSettings`),
|
||||
the reconciled boot will succeed but the missing tables will cause
|
||||
queries to fail at runtime (`no such table: Playlists`).
|
||||
|
||||
Fix: either delete the dev DB and re-seed, or apply the missing
|
||||
migrations against your DB *before* upgrading to the consolidated
|
||||
build. The latter requires checking out the pre-consolidation commit,
|
||||
running `dotnet ef database update`, then checking back out.
|
||||
|
||||
## Adding migrations after consolidation
|
||||
|
||||
> One release = one migration. Don't accumulate per-feature micros.
|
||||
|
||||
The post-consolidation discipline:
|
||||
|
||||
1. **Iterate freely** between releases — edit entities, run the server
|
||||
against your dev DB (sqlite under `bin/Debug/.../data/`). EF will
|
||||
reject the schema mismatch and you fix it locally however you like.
|
||||
Local DBs are disposable.
|
||||
2. **At release cut**, run `dotnet ef migrations add Release_v{N}_{M}`
|
||||
once to bundle all the schema deltas from the iteration window into
|
||||
a single migration.
|
||||
3. **Data transforms** (renames of seed rows, backfills, etc.) go in
|
||||
`LegacyUpgrades.cs` as new private methods, NOT as new migrations.
|
||||
They run on every boot and are idempotent.
|
||||
|
||||
## Regenerating Initial from scratch
|
||||
|
||||
If the model drifts far enough that you want to re-collapse to one
|
||||
Initial again:
|
||||
|
||||
```pwsh
|
||||
# 1. Delete all migration files + the model snapshot.
|
||||
Remove-Item DorkNet.Server\Migrations\2026*.cs
|
||||
Remove-Item DorkNet.Server\Migrations\DorkNetDbContextModelSnapshot.cs
|
||||
|
||||
# 2. Regenerate Initial from the current entity model.
|
||||
dotnet ef migrations add Initial `
|
||||
--project DorkNet.Server `
|
||||
--context DorkNetDbContext
|
||||
|
||||
# 3. Eyeball the generated Up() body before committing — verify it
|
||||
# captures every DbSet<> in DorkNetDbContext plus every
|
||||
# relationship and index.
|
||||
```
|
||||
|
||||
Existing DBs auto-reconcile via `BaselineExistingSchemaIfNeeded`
|
||||
(SQLite) or no-op via `EnsureCreated` (Postgres). No manual cutover.
|
||||
@@ -0,0 +1,2646 @@
|
||||
{
|
||||
"matches": [
|
||||
{
|
||||
"swatch_name": "Windbreaker_Red_Swatch",
|
||||
"swatch_guid": "grutFwJD_0Cb3t6fddNg6w",
|
||||
"color": "Red",
|
||||
"item_name": "(Wrist_80sWindbreaker)",
|
||||
"item_guid": "52VsI_lKlkSUAOJlfDnyrQ",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Windbreaker_Blue_Swatch",
|
||||
"swatch_guid": "EbVDqEJQOk6j0UvX3t-8rQ",
|
||||
"color": "Blue",
|
||||
"item_name": "(Wrist_80sWindbreaker)",
|
||||
"item_guid": "52VsI_lKlkSUAOJlfDnyrQ",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Windbreaker_Yellow_Swatch",
|
||||
"swatch_guid": "C3ll7uPfUkeZ1QLX80_BpQ",
|
||||
"color": "Yellow",
|
||||
"item_name": "(Wrist_80sWindbreaker)",
|
||||
"item_guid": "52VsI_lKlkSUAOJlfDnyrQ",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Windbreaker_Salmon_Swatch",
|
||||
"swatch_guid": "JS7aa4QSUUOpUYPqRZEb5g",
|
||||
"color": "Salmon",
|
||||
"item_name": "(Wrist_80sWindbreaker)",
|
||||
"item_guid": "52VsI_lKlkSUAOJlfDnyrQ",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Knight_SRank_Swatch",
|
||||
"swatch_guid": "ITH2OpzjP022vZ5JgxV_iQ",
|
||||
"color": "SRank",
|
||||
"item_name": "(Shirt_KnightTorso)",
|
||||
"item_guid": "739fd42a-8a8c-44a5-afa3-e0974802c6ae",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Knight_Blue_Swatch",
|
||||
"swatch_guid": "ed134bee-d199-43eb-98bd-206571603f40",
|
||||
"color": "Blue",
|
||||
"item_name": "(Shirt_KnightTorso)",
|
||||
"item_guid": "739fd42a-8a8c-44a5-afa3-e0974802c6ae",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Knight_Red_Swatch",
|
||||
"swatch_guid": "59866578-d4e0-417b-9483-233ab108b1ac",
|
||||
"color": "Red",
|
||||
"item_name": "(Shirt_KnightTorso)",
|
||||
"item_guid": "739fd42a-8a8c-44a5-afa3-e0974802c6ae",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Knight_Black_Swatch",
|
||||
"swatch_guid": "e1f49b74-b071-4bd1-9c4b-af36d24d45c5",
|
||||
"color": "Black",
|
||||
"item_name": "(Shirt_KnightTorso)",
|
||||
"item_guid": "739fd42a-8a8c-44a5-afa3-e0974802c6ae",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Knight_Green_Swatch",
|
||||
"swatch_guid": "PMa9370LPEig_7pKwCceEQ",
|
||||
"color": "Green",
|
||||
"item_name": "(Shirt_KnightTorso)",
|
||||
"item_guid": "739fd42a-8a8c-44a5-afa3-e0974802c6ae",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Knight_White_Swatch",
|
||||
"swatch_guid": "22dc463a-a89b-46ad-9a0d-557c742b4a80",
|
||||
"color": "White",
|
||||
"item_name": "(Shirt_KnightTorso)",
|
||||
"item_guid": "739fd42a-8a8c-44a5-afa3-e0974802c6ae",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "LumberJack_Blue_Swatch",
|
||||
"swatch_guid": "cKHAetNcgUOxFfDkAPPF9w",
|
||||
"color": "Blue",
|
||||
"item_name": "(Shirt_Lumberjack)",
|
||||
"item_guid": "350c5c1e-318b-4233-a7e5-1f49909d8e65",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "LumberJack_Green_Swatch",
|
||||
"swatch_guid": "7amHTgEv-UOJSGl0ofo4mw",
|
||||
"color": "Green",
|
||||
"item_name": "(Shirt_Lumberjack)",
|
||||
"item_guid": "350c5c1e-318b-4233-a7e5-1f49909d8e65",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "SpaceMarine_Red_Swatch",
|
||||
"swatch_guid": "d78643e4-c0d2-4836-bf22-b5a0e2d65b28",
|
||||
"color": "Red",
|
||||
"item_name": "(Shirt_SpaceMarine)",
|
||||
"item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "SpaceMarine_Pink_Swatch",
|
||||
"swatch_guid": "a36d252b-16ac-4a82-bece-de145147e7d9",
|
||||
"color": "Pink",
|
||||
"item_name": "(Shirt_SpaceMarine)",
|
||||
"item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "SpaceMarine_Cyan_Swatch",
|
||||
"swatch_guid": "c8cc9589-4831-4e73-a865-ef8c139a2ba7",
|
||||
"color": "Cyan",
|
||||
"item_name": "(Shirt_SpaceMarine)",
|
||||
"item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "SpaceMarine_Light_Swatch",
|
||||
"swatch_guid": "68273c9e-96d6-4ddb-aaab-d99229e864d5",
|
||||
"color": "Light",
|
||||
"item_name": "(Shirt_SpaceMarine)",
|
||||
"item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "SpaceMarine_Dark_Swatch",
|
||||
"swatch_guid": "a089bcc6-798d-4777-98e2-7afd290dc6b6",
|
||||
"color": "Dark",
|
||||
"item_name": "(Shirt_SpaceMarine)",
|
||||
"item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "SpaceMarine_SRank_Swatch",
|
||||
"swatch_guid": "4ElTihrMA0ipcgeb9UG01Q",
|
||||
"color": "SRank",
|
||||
"item_name": "(Shirt_SpaceMarine)",
|
||||
"item_guid": "4ce7a70f-15a0-4cad-98ee-5cbb40827338",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Hibiscus_Yellow_Swatch",
|
||||
"swatch_guid": "596097a8-d545-4256-959d-1d3cd96fc64c",
|
||||
"color": "Yellow",
|
||||
"item_name": "(Hat_Hibiscus)",
|
||||
"item_guid": "a61e86cd-f7e7-4ac7-a6e8-df269bc5cb01",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Hibiscus_White_Swatch",
|
||||
"swatch_guid": "ce465d53-3807-423f-baa0-bdba2a75ceb3",
|
||||
"color": "White",
|
||||
"item_name": "(Hat_Hibiscus)",
|
||||
"item_guid": "a61e86cd-f7e7-4ac7-a6e8-df269bc5cb01",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Blazer_CandyCane_Swatch",
|
||||
"swatch_guid": "_ycDQ6RcyEGAntVlSDpPeA",
|
||||
"color": "CandyCane",
|
||||
"item_name": "(Blazer)",
|
||||
"item_guid": "de0ac50d-2adb-4114-bd2e-68953b13d706",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54"
|
||||
},
|
||||
{
|
||||
"swatch_name": "Blazer_Shamrock_Swatch",
|
||||
"swatch_guid": "7WrSbK4Ml0K2z-ig5HIUCw",
|
||||
"color": "Shamrock",
|
||||
"item_name": "(Blazer)",
|
||||
"item_guid": "de0ac50d-2adb-4114-bd2e-68953b13d706",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54"
|
||||
},
|
||||
{
|
||||
"swatch_name": "Raincoat_Shirt_Blue_Swatch",
|
||||
"swatch_guid": "GH9q8_0O9EqjGdz7lGWD2g",
|
||||
"color": "Blue",
|
||||
"item_name": "(Shirt_Raincoat)",
|
||||
"item_guid": "9e6397dd-b7b0-4d32-8c86-1910106a8478",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "T7zwcIIc8Ee3ud8a2JCuwQ"
|
||||
},
|
||||
{
|
||||
"swatch_name": "Boxer_Green_Swatch",
|
||||
"swatch_guid": "rzUd1IMTKUmW0HeMPSIOBQ",
|
||||
"color": "Green",
|
||||
"item_name": "(Shirt_Boxer)",
|
||||
"item_guid": "ff15e25d-d467-47a8-9ecc-e415d5acb90c",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Boxer_Red_Swatch",
|
||||
"swatch_guid": "8c7b09f2-6213-4ef9-87ab-a2df72d07a22",
|
||||
"color": "Red",
|
||||
"item_name": "(Shirt_Boxer)",
|
||||
"item_guid": "ff15e25d-d467-47a8-9ecc-e415d5acb90c",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Headphones_Red_Swatch",
|
||||
"swatch_guid": "viL4CPch2ECwG-RNaWcyZw",
|
||||
"color": "Red",
|
||||
"item_name": "(Hat_Headphones)",
|
||||
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Headphones_EmissiveBlack_Swatch",
|
||||
"swatch_guid": "Uz2hrptCa0erjqyPoiZxew",
|
||||
"color": "EmissiveBlack",
|
||||
"item_name": "(Hat_Headphones)",
|
||||
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Headphones_EmissiveRed_Swatch",
|
||||
"swatch_guid": "I1J-xY4YNU2dB_od4P_7Dg",
|
||||
"color": "EmissiveRed",
|
||||
"item_name": "(Hat_Headphones)",
|
||||
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Headphones_TwoRed_Swatch",
|
||||
"swatch_guid": "hf0gkFVS8ESvceRsC3LAXQ",
|
||||
"color": "TwoRed",
|
||||
"item_name": "(Hat_Headphones)",
|
||||
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Headphones_EmissiveBlue_Swatch",
|
||||
"swatch_guid": "2sf-FU4WckGBDWUkVz4z1g",
|
||||
"color": "EmissiveBlue",
|
||||
"item_name": "(Hat_Headphones)",
|
||||
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Headphones_Purple_Swatch",
|
||||
"swatch_guid": "a5nUvJZgTEGBUvdHx0Y6Ng",
|
||||
"color": "Purple",
|
||||
"item_name": "(Hat_Headphones)",
|
||||
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Headphones_EmissiveOrange_Swatch",
|
||||
"swatch_guid": "d9mwowRelE2lDKEA0UQMZQ",
|
||||
"color": "EmissiveOrange",
|
||||
"item_name": "(Hat_Headphones)",
|
||||
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Headphones_Orange_Swatch",
|
||||
"swatch_guid": "JWBK5v78Y0uRVEXlD-Qk6Q",
|
||||
"color": "Orange",
|
||||
"item_name": "(Hat_Headphones)",
|
||||
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Headphones_Blue_Swatch",
|
||||
"swatch_guid": "8ANbOhNnO0yLQOCIBsDwfg",
|
||||
"color": "Blue",
|
||||
"item_name": "(Hat_Headphones)",
|
||||
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Headphones_TwoBlue_Swatch",
|
||||
"swatch_guid": "4scG_UBlzE-5DWRhSrtm6g",
|
||||
"color": "TwoBlue",
|
||||
"item_name": "(Hat_Headphones)",
|
||||
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Headphones_TwoOrange_Swatch",
|
||||
"swatch_guid": "nX7krD8e2km_ZlOLWcoxdQ",
|
||||
"color": "TwoOrange",
|
||||
"item_name": "(Hat_Headphones)",
|
||||
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Headphones_TwoPurple_Swatch",
|
||||
"swatch_guid": "OqsbZ8Xls0i8uA6ha7H3qQ",
|
||||
"color": "TwoPurple",
|
||||
"item_name": "(Hat_Headphones)",
|
||||
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Headphones_EmissivePurple_Swatch",
|
||||
"swatch_guid": "3PfP661Fg0CruzRxis9EiA",
|
||||
"color": "EmissivePurple",
|
||||
"item_name": "(Hat_Headphones)",
|
||||
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Headphones_TwoBlack_Swatch",
|
||||
"swatch_guid": "ns5tIRishUOp-nYqLedcwA",
|
||||
"color": "TwoBlack",
|
||||
"item_name": "(Hat_Headphones)",
|
||||
"item_guid": "UcPo9SsF3EGTCBB2J-Efdw",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "JackfrostHair_Hat_Lilac_Swatch",
|
||||
"swatch_guid": "KwhdQ1deNE6Y6jzO9vaAnQ",
|
||||
"color": "Lilac",
|
||||
"item_name": "(Hat_JackfrostHair)",
|
||||
"item_guid": "e6216087-fb37-46f1-bda4-a88c4640f339",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Baseball_Red_Swatch",
|
||||
"swatch_guid": "de0a5a3a-8aff-4799-8269-9efa3aeb383a",
|
||||
"color": "Red",
|
||||
"item_name": "(Hat_Baseball)",
|
||||
"item_guid": "14ef6b00-debf-4a85-9755-b4d37df496d3",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Baseball_Green_Swatch",
|
||||
"swatch_guid": "e5de2e45-207c-46af-9a5e-eb01a0c621fe",
|
||||
"color": "Green",
|
||||
"item_name": "(Hat_Baseball)",
|
||||
"item_guid": "14ef6b00-debf-4a85-9755-b4d37df496d3",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "ButtonTop_Orange_Swatch",
|
||||
"swatch_guid": "e0397982-c2c2-4733-9a40-46e18675b5af",
|
||||
"color": "Orange",
|
||||
"item_name": "(ButtonTop)",
|
||||
"item_guid": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "ButtonTop_Tan_Swatch",
|
||||
"swatch_guid": "49f5864f-9d40-497c-88c8-e87f64d41d74",
|
||||
"color": "Tan",
|
||||
"item_name": "(ButtonTop)",
|
||||
"item_guid": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "MagicalGirl_Blue_Swatch",
|
||||
"swatch_guid": "VQjkyb5aoEKHKo_eZsq9ug",
|
||||
"color": "Blue",
|
||||
"item_name": "(Wrist_MagicalGirl)",
|
||||
"item_guid": "eS_or4AnMka9M_bD7YN28g",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "MagicalGirl_Red_Swatch",
|
||||
"swatch_guid": "FE7_gQNBt0CXzMN6di5HWA",
|
||||
"color": "Red",
|
||||
"item_name": "(Wrist_MagicalGirl)",
|
||||
"item_guid": "eS_or4AnMka9M_bD7YN28g",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "MagicalGirl_Green_Swatch",
|
||||
"swatch_guid": "kTP3xKhzBkuQF3SqvmYFKA",
|
||||
"color": "Green",
|
||||
"item_name": "(Wrist_MagicalGirl)",
|
||||
"item_guid": "eS_or4AnMka9M_bD7YN28g",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "MagicalGirl_Orange_Swatch",
|
||||
"swatch_guid": "hYu9itGe1Ee5Neanhj9VtQ",
|
||||
"color": "Orange",
|
||||
"item_name": "(Wrist_MagicalGirl)",
|
||||
"item_guid": "eS_or4AnMka9M_bD7YN28g",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Wizard_SRank_Swatch",
|
||||
"swatch_guid": "RKmRHk2N_U2gd91nybo6vg",
|
||||
"color": "SRank",
|
||||
"item_name": "(Hat_Wizard)",
|
||||
"item_guid": "01689baf-405e-4b38-9f09-b624e03865b8",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Wizard_Purple_Swatch",
|
||||
"swatch_guid": "d3KmqVuMQkCYK6sVTMOTOA",
|
||||
"color": "Purple",
|
||||
"item_name": "(Hat_Wizard)",
|
||||
"item_guid": "01689baf-405e-4b38-9f09-b624e03865b8",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Wizard_White_Swatch",
|
||||
"swatch_guid": "d3a9b576-5433-4305-bd96-b08820c09212",
|
||||
"color": "White",
|
||||
"item_name": "(Hat_Wizard)",
|
||||
"item_guid": "01689baf-405e-4b38-9f09-b624e03865b8",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Wizard_Green_Swatch",
|
||||
"swatch_guid": "357eb160-360b-4a63-840d-65d11cc8ffc2",
|
||||
"color": "Green",
|
||||
"item_name": "(Hat_Wizard)",
|
||||
"item_guid": "01689baf-405e-4b38-9f09-b624e03865b8",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Wizard_Black_Swatch",
|
||||
"swatch_guid": "7afe1bb0-5ca9-4263-8f85-49d3a18353c7",
|
||||
"color": "Black",
|
||||
"item_name": "(Hat_Wizard)",
|
||||
"item_guid": "01689baf-405e-4b38-9f09-b624e03865b8",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Wizard_Red_Swatch",
|
||||
"swatch_guid": "a38254c0-00e3-429b-9d5d-b9e6a9812e69",
|
||||
"color": "Red",
|
||||
"item_name": "(Hat_Wizard)",
|
||||
"item_guid": "01689baf-405e-4b38-9f09-b624e03865b8",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Punk_Pink_Swatch",
|
||||
"swatch_guid": "4169bfed-8403-4590-a29d-4941275ac0b6",
|
||||
"color": "Pink",
|
||||
"item_name": "(Wrist_Punk)",
|
||||
"item_guid": "28374ebc-4050-4ea0-b3b6-41ebe75cedf7",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Punk_WristPink_Swatch",
|
||||
"swatch_guid": "f3b2330a-b981-4c7e-902f-1f57382f2388",
|
||||
"color": "WristPink",
|
||||
"item_name": "(Wrist_Punk)",
|
||||
"item_guid": "28374ebc-4050-4ea0-b3b6-41ebe75cedf7",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Punk_WristRed_Swatch",
|
||||
"swatch_guid": "baffdefe-de21-4551-9330-b50873965bdd",
|
||||
"color": "WristRed",
|
||||
"item_name": "(Wrist_Punk)",
|
||||
"item_guid": "28374ebc-4050-4ea0-b3b6-41ebe75cedf7",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Punk_Green_Swatch",
|
||||
"swatch_guid": "0724b166-9ace-46ff-a191-b13f135b00a8",
|
||||
"color": "Green",
|
||||
"item_name": "(Wrist_Punk)",
|
||||
"item_guid": "28374ebc-4050-4ea0-b3b6-41ebe75cedf7",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Punk_WristGreen_Swatch",
|
||||
"swatch_guid": "a6796264-9c7e-4605-830c-06db8d0dc780",
|
||||
"color": "WristGreen",
|
||||
"item_name": "(Wrist_Punk)",
|
||||
"item_guid": "28374ebc-4050-4ea0-b3b6-41ebe75cedf7",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Hat_GraduationCap_Green_Swatch",
|
||||
"swatch_guid": "FaWMLkJJgEKFIO493fzGxg",
|
||||
"color": "Green",
|
||||
"item_name": "(Hat_GraduationCap)",
|
||||
"item_guid": "KPmC_wyX_ECe7fdKwoLMGg",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": "yzL0M_N5Q0eAa8IxACHPAQ"
|
||||
},
|
||||
{
|
||||
"swatch_name": "EquestrianJacket_Maroon_Swatch",
|
||||
"swatch_guid": "d6823e01-69f0-4f85-b94a-74894356a2cf",
|
||||
"color": "Maroon",
|
||||
"item_name": "(EquestrianJacket)",
|
||||
"item_guid": "896c2491-2f96-4986-9cbd-b3b31ef5d8c5",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "EquestrianJacket_Gray_Swatch",
|
||||
"swatch_guid": "4828b50c-95b6-466a-bb25-514891d78202",
|
||||
"color": "Gray",
|
||||
"item_name": "(EquestrianJacket)",
|
||||
"item_guid": "896c2491-2f96-4986-9cbd-b3b31ef5d8c5",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Shirt_VampireHunter_White_Swatch",
|
||||
"swatch_guid": "9QG6qp5FBU-u0-JK-yMMqA",
|
||||
"color": "White",
|
||||
"item_name": "(Shirt_VampireHunter)",
|
||||
"item_guid": "JWfs9IiQs0Weu8MmyGpqLQ",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "x10FeRBsQU2DIIpG8XIlwg"
|
||||
},
|
||||
{
|
||||
"swatch_name": "Shirt_VampireHunter_Midnight_Swatch",
|
||||
"swatch_guid": "CavRDqIlmUeTZBtCLn1hZQ",
|
||||
"color": "Midnight",
|
||||
"item_name": "(Shirt_VampireHunter)",
|
||||
"item_guid": "JWfs9IiQs0Weu8MmyGpqLQ",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "x10FeRBsQU2DIIpG8XIlwg"
|
||||
},
|
||||
{
|
||||
"swatch_name": "Shirt_VampireHunter_Leather_Swatch",
|
||||
"swatch_guid": "Z8Bfk0MTb0evZehvvgzuBA",
|
||||
"color": "Leather",
|
||||
"item_name": "(Shirt_VampireHunter)",
|
||||
"item_guid": "JWfs9IiQs0Weu8MmyGpqLQ",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "x10FeRBsQU2DIIpG8XIlwg"
|
||||
},
|
||||
{
|
||||
"swatch_name": "Scallywag_Bracelet_SRank_Swatch",
|
||||
"swatch_guid": "X3MkhL55Rk-0UK6h4dw0Eg",
|
||||
"color": "SRank",
|
||||
"item_name": "(Wrist_Scallywag_Bracelet)",
|
||||
"item_guid": "b9e0f03b-2609-4911-82e9-1e324dbbe8be",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Scallywag_Bracelet_Red_Swatch",
|
||||
"swatch_guid": "62bd5dda-08da-4528-83c1-f0ca3812dc6e",
|
||||
"color": "Red",
|
||||
"item_name": "(Wrist_Scallywag_Bracelet)",
|
||||
"item_guid": "b9e0f03b-2609-4911-82e9-1e324dbbe8be",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Scallywag_Bracelet_Blue_Swatch",
|
||||
"swatch_guid": "cdca593a-8840-4140-9145-fe962732040c",
|
||||
"color": "Blue",
|
||||
"item_name": "(Wrist_Scallywag_Bracelet)",
|
||||
"item_guid": "b9e0f03b-2609-4911-82e9-1e324dbbe8be",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "PurpleJacket_Red_Swatch",
|
||||
"swatch_guid": "657f28d5-672a-4f27-86c6-3cb00f25bb84",
|
||||
"color": "Red",
|
||||
"item_name": "(PurpleJacket)",
|
||||
"item_guid": "745c5110-104e-4b37-aabf-7c690fd54811",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "3ba69a39-a4e7-4505-bae7-8db31360ec1c"
|
||||
},
|
||||
{
|
||||
"swatch_name": "PurpleJacket_Blue_Swatch",
|
||||
"swatch_guid": "b42d903b-b393-4f49-af5e-38d0fb6c42b0",
|
||||
"color": "Blue",
|
||||
"item_name": "(PurpleJacket)",
|
||||
"item_guid": "745c5110-104e-4b37-aabf-7c690fd54811",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "3ba69a39-a4e7-4505-bae7-8db31360ec1c"
|
||||
},
|
||||
{
|
||||
"swatch_name": "PurpleJacket_Black_Swatch",
|
||||
"swatch_guid": "8cab8cc6-9a2d-434c-8b72-e66edbdd223c",
|
||||
"color": "Black",
|
||||
"item_name": "(PurpleJacket)",
|
||||
"item_guid": "745c5110-104e-4b37-aabf-7c690fd54811",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "3ba69a39-a4e7-4505-bae7-8db31360ec1c"
|
||||
},
|
||||
{
|
||||
"swatch_name": "FighterPilot_Orange_Swatch",
|
||||
"swatch_guid": "cea53c7f-24a9-4e3b-80b9-fbf95738f973",
|
||||
"color": "Orange",
|
||||
"item_name": "(Wrist_FighterPilot)",
|
||||
"item_guid": "5800d2b5-75b8-442f-8b10-63bd3d73a1b8",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": "435eba0d-d8f9-43cc-91aa-2959d63c6fa7"
|
||||
},
|
||||
{
|
||||
"swatch_name": "FighterPilot_Black_Swatch",
|
||||
"swatch_guid": "8c74ee27-3d56-401b-8a8c-7868a80770e8",
|
||||
"color": "Black",
|
||||
"item_name": "(Wrist_FighterPilot)",
|
||||
"item_guid": "5800d2b5-75b8-442f-8b10-63bd3d73a1b8",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": "435eba0d-d8f9-43cc-91aa-2959d63c6fa7"
|
||||
},
|
||||
{
|
||||
"swatch_name": "FighterPilot_Blue_Swatch",
|
||||
"swatch_guid": "e37b07d1-6b9d-49b9-94ca-1c728e228cfe",
|
||||
"color": "Blue",
|
||||
"item_name": "(Wrist_FighterPilot)",
|
||||
"item_guid": "5800d2b5-75b8-442f-8b10-63bd3d73a1b8",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": "435eba0d-d8f9-43cc-91aa-2959d63c6fa7"
|
||||
},
|
||||
{
|
||||
"swatch_name": "Neck_Grillmaster_Denim_Swatch",
|
||||
"swatch_guid": "eV3Nv6SWS0yl3f-fgjPb-w",
|
||||
"color": "Denim",
|
||||
"item_name": "(Neck_Grillmaster)",
|
||||
"item_guid": "tTKv7eRODka2KC_hsY2O3Q",
|
||||
"item_outfit_type": 100,
|
||||
"mask_guid": "t-ZEWJCgFEyGP2PjNblQMA"
|
||||
},
|
||||
{
|
||||
"swatch_name": "Neck_Grillmaster_White_Swatch",
|
||||
"swatch_guid": "Lxx3q6JRnUW_cPG_kGG4ag",
|
||||
"color": "White",
|
||||
"item_name": "(Neck_Grillmaster)",
|
||||
"item_guid": "tTKv7eRODka2KC_hsY2O3Q",
|
||||
"item_outfit_type": 100,
|
||||
"mask_guid": "t-ZEWJCgFEyGP2PjNblQMA"
|
||||
},
|
||||
{
|
||||
"swatch_name": "BabydollDress_Green_Swatch",
|
||||
"swatch_guid": "c9589974-c261-485e-b571-cb2f34fb178f",
|
||||
"color": "Green",
|
||||
"item_name": "(BabydollDress)",
|
||||
"item_guid": "da55bfc3-47b7-46f6-b548-7fb2c2a5e0bf",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "BabydollDress_Black_Swatch",
|
||||
"swatch_guid": "e13cf33d-7588-4e2e-955e-f0a29d6380b7",
|
||||
"color": "Black",
|
||||
"item_name": "(BabydollDress)",
|
||||
"item_guid": "da55bfc3-47b7-46f6-b548-7fb2c2a5e0bf",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "BabydollDress_Pink_Swatch",
|
||||
"swatch_guid": "da6d0ced-3d43-4878-a868-925f2ed6fd5b",
|
||||
"color": "Pink",
|
||||
"item_name": "(BabydollDress)",
|
||||
"item_guid": "da55bfc3-47b7-46f6-b548-7fb2c2a5e0bf",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "WitchHunter_Purple_Swatch",
|
||||
"swatch_guid": "71e5a0fe-fc8d-420b-af3b-e74160e0d1f3",
|
||||
"color": "Purple",
|
||||
"item_name": "(Shirt_WitchHunter)",
|
||||
"item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "WitchHunter_Green_Swatch",
|
||||
"swatch_guid": "64545dcc-a8eb-4c69-a539-78ee73a2d327",
|
||||
"color": "Green",
|
||||
"item_name": "(Shirt_WitchHunter)",
|
||||
"item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "WitchHunter_Blue_Swatch",
|
||||
"swatch_guid": "aBQ3R9boek-mYeSYSNvDbQ",
|
||||
"color": "Blue",
|
||||
"item_name": "(Shirt_WitchHunter)",
|
||||
"item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "WitchHunter_SRank_Swatch",
|
||||
"swatch_guid": "Tbrc2MlSlE6mKua8qSoluQ",
|
||||
"color": "SRank",
|
||||
"item_name": "(Shirt_WitchHunter)",
|
||||
"item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "WitchHunter_White_Swatch",
|
||||
"swatch_guid": "e02838f8-4029-455a-b35c-dd40f972b05b",
|
||||
"color": "White",
|
||||
"item_name": "(Shirt_WitchHunter)",
|
||||
"item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "WitchHunter_Red_Swatch",
|
||||
"swatch_guid": "909ed249-ce09-4304-8b02-56794fa7567d",
|
||||
"color": "Red",
|
||||
"item_name": "(Shirt_WitchHunter)",
|
||||
"item_guid": "418db0e0-c7af-40c2-9cac-d461ce41e210",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Smuggler_Green_Swatch",
|
||||
"swatch_guid": "076c96ae-9f9b-45bf-9dc7-ce73d5ddf422",
|
||||
"color": "Green",
|
||||
"item_name": "(Shirt_Smuggler)",
|
||||
"item_guid": "25d4c652-1730-4925-925e-31a290a2272a",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Smuggler_SRank_Swatch",
|
||||
"swatch_guid": "bzYdPb8GWUS-CgT4wrziIA",
|
||||
"color": "SRank",
|
||||
"item_name": "(Shirt_Smuggler)",
|
||||
"item_guid": "25d4c652-1730-4925-925e-31a290a2272a",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Smuggler_Grey_Swatch",
|
||||
"swatch_guid": "90869e86-cbc5-4024-9d08-fcdc41c64eb5",
|
||||
"color": "Grey",
|
||||
"item_name": "(Shirt_Smuggler)",
|
||||
"item_guid": "25d4c652-1730-4925-925e-31a290a2272a",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Smuggler_Dark_Swatch",
|
||||
"swatch_guid": "e821aa74-2d47-4388-bcfb-1893b8a83a52",
|
||||
"color": "Dark",
|
||||
"item_name": "(Shirt_Smuggler)",
|
||||
"item_guid": "25d4c652-1730-4925-925e-31a290a2272a",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Smuggler_Cherry_Swatch",
|
||||
"swatch_guid": "fed760b2-15e2-46b2-b1e3-cc3c8d780d5d",
|
||||
"color": "Cherry",
|
||||
"item_name": "(Shirt_Smuggler)",
|
||||
"item_guid": "25d4c652-1730-4925-925e-31a290a2272a",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Smuggler_Yellow_Swatch",
|
||||
"swatch_guid": "002b07bb-4256-44ce-8ca4-25510e70b6b5",
|
||||
"color": "Yellow",
|
||||
"item_name": "(Shirt_Smuggler)",
|
||||
"item_guid": "25d4c652-1730-4925-925e-31a290a2272a",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "SodaJerk_Green_Swatch",
|
||||
"swatch_guid": "oKxmH-1s2EyIDkZoOQSQQw",
|
||||
"color": "Green",
|
||||
"item_name": "(Shirt_SodaJerk)",
|
||||
"item_guid": "iai6P7dDTUq8S4hqQmY1YA",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "SodaJerk_Mustard_Swatch",
|
||||
"swatch_guid": "MxnAq_XOw0uTzQ8IIxqb_Q",
|
||||
"color": "Mustard",
|
||||
"item_name": "(Shirt_SodaJerk)",
|
||||
"item_guid": "iai6P7dDTUq8S4hqQmY1YA",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "SodaJerk_Blue_Swatch",
|
||||
"swatch_guid": "XFCpIdeDXUqeYCIgh4fGtg",
|
||||
"color": "Blue",
|
||||
"item_name": "(Shirt_SodaJerk)",
|
||||
"item_guid": "iai6P7dDTUq8S4hqQmY1YA",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Hat_TutorialCap_2_Swatch",
|
||||
"swatch_guid": "gDH1WmW5rk2RdY4wJYYZow",
|
||||
"color": "2",
|
||||
"item_name": "(Hat_TutorialCap)",
|
||||
"item_guid": "M9weaekvIUimiJSVJqZl-w",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": "GV8z2dlqGEqIAYaGrF1ZYQ"
|
||||
},
|
||||
{
|
||||
"swatch_name": "Hat_TutorialCap_3_Swatch",
|
||||
"swatch_guid": "wHJnbDOo8ESmUbQDr0k31w",
|
||||
"color": "3",
|
||||
"item_name": "(Hat_TutorialCap)",
|
||||
"item_guid": "M9weaekvIUimiJSVJqZl-w",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": "GV8z2dlqGEqIAYaGrF1ZYQ"
|
||||
},
|
||||
{
|
||||
"swatch_name": "BaseballHelmet_Red_Swatch",
|
||||
"swatch_guid": "a1c51d1e-1d5f-4f8a-bc0f-6a20eddfe58a",
|
||||
"color": "Red",
|
||||
"item_name": "(Hat_BaseballHelmet)",
|
||||
"item_guid": "293e7ad3-01c9-43af-96e3-06c34145ff12",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "BaseballHelmet_Green_Swatch",
|
||||
"swatch_guid": "9967ce54-c2b7-437a-946e-11f8ee6d6fdd",
|
||||
"color": "Green",
|
||||
"item_name": "(Hat_BaseballHelmet)",
|
||||
"item_guid": "293e7ad3-01c9-43af-96e3-06c34145ff12",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "TruckerOveralls_Mustard_Swatch",
|
||||
"swatch_guid": "nKauYAyWhkm2AMHFyC-fLg",
|
||||
"color": "Mustard",
|
||||
"item_name": "(Shirt_TruckerOveralls)",
|
||||
"item_guid": "b080e2f4-c699-4cca-bb9e-7cfc4bafcbfc",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "TruckerOveralls_Red_Swatch",
|
||||
"swatch_guid": "iokdlBfjuEuAwqIMOQwxdA",
|
||||
"color": "Red",
|
||||
"item_name": "(Shirt_TruckerOveralls)",
|
||||
"item_guid": "b080e2f4-c699-4cca-bb9e-7cfc4bafcbfc",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "TruckerOveralls_Green_Swatch",
|
||||
"swatch_guid": "EiGgN7uVukqoRE7UJcMLTw",
|
||||
"color": "Green",
|
||||
"item_name": "(Shirt_TruckerOveralls)",
|
||||
"item_guid": "b080e2f4-c699-4cca-bb9e-7cfc4bafcbfc",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "PirateCaptain_Black_Swatch",
|
||||
"swatch_guid": "d81d0b62-e052-46c2-9252-4bc5fb219f53",
|
||||
"color": "Black",
|
||||
"item_name": "(Neck_PirateCaptain)",
|
||||
"item_guid": "38b38e24-d636-41c6-b80e-a285ae3106dc",
|
||||
"item_outfit_type": 102,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "PirateCaptain_SRank_Swatch",
|
||||
"swatch_guid": "xGmnUGF-Ck-jP43r8tVVjg",
|
||||
"color": "SRank",
|
||||
"item_name": "(Neck_PirateCaptain)",
|
||||
"item_guid": "38b38e24-d636-41c6-b80e-a285ae3106dc",
|
||||
"item_outfit_type": 102,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "PirateCaptain_Red_Swatch",
|
||||
"swatch_guid": "556141f7-6fbc-4065-9faa-8d8240a93d7f",
|
||||
"color": "Red",
|
||||
"item_name": "(Neck_PirateCaptain)",
|
||||
"item_guid": "38b38e24-d636-41c6-b80e-a285ae3106dc",
|
||||
"item_outfit_type": 102,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Bowtie_SodaJerk_Swatch",
|
||||
"swatch_guid": "e4IDo-fuH0C9YByzcX5k-A",
|
||||
"color": "SodaJerk",
|
||||
"item_name": "(Bowtie)",
|
||||
"item_guid": "83be5ba4-525a-4781-a5f6-839c22e4d1d3",
|
||||
"item_outfit_type": 100,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "FlapperGlove_Black_Swatch",
|
||||
"swatch_guid": "30c61e63-5e09-4a6f-8915-1e1fe4d55cf0",
|
||||
"color": "Black",
|
||||
"item_name": "(Wrist_FlapperGlove)",
|
||||
"item_guid": "473c672d-2b79-48a2-a49d-fd68160c5bde",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "FlapperGlove_Gold_Swatch",
|
||||
"swatch_guid": "2cf2b644-3fa6-449c-9fe8-d0ab917f3106",
|
||||
"color": "Gold",
|
||||
"item_name": "(Wrist_FlapperGlove)",
|
||||
"item_guid": "473c672d-2b79-48a2-a49d-fd68160c5bde",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "BusinessTie_Valentine_Swatch",
|
||||
"swatch_guid": "31jelNWFyk6FlWt4HyPAlw",
|
||||
"color": "Valentine",
|
||||
"item_name": "(BusinessTie)",
|
||||
"item_guid": "c6c08eb5-381a-4193-9722-80da95d62abe",
|
||||
"item_outfit_type": 100,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "BusinessTie_CandyCane_Swatch",
|
||||
"swatch_guid": "W4a144so90eRzCojUnDowA",
|
||||
"color": "CandyCane",
|
||||
"item_name": "(BusinessTie)",
|
||||
"item_guid": "c6c08eb5-381a-4193-9722-80da95d62abe",
|
||||
"item_outfit_type": 100,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Vest_Red_Swatch",
|
||||
"swatch_guid": "ffHKr5EcIU24PbAtWSnalw",
|
||||
"color": "Red",
|
||||
"item_name": "(Shirt_Vest)",
|
||||
"item_guid": "3d9d52cb-6a3d-436b-90e7-e76f238329ee",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "HeadBackBow_Blue_Swatch",
|
||||
"swatch_guid": "GmS0wKwzVEqdf3NoTMHG5A",
|
||||
"color": "Blue",
|
||||
"item_name": "(Hat_HeadBackBow)",
|
||||
"item_guid": "JTrfBXhIT02o2-ssP94zew",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "HeadBackBow_Red_Swatch",
|
||||
"swatch_guid": "ZNKGkou_gU-1cxBFy7pDKw",
|
||||
"color": "Red",
|
||||
"item_name": "(Hat_HeadBackBow)",
|
||||
"item_guid": "JTrfBXhIT02o2-ssP94zew",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "HeadBackBow_White_Swatch",
|
||||
"swatch_guid": "-Iqmonry4EKoqAULCKzRPA",
|
||||
"color": "White",
|
||||
"item_name": "(Hat_HeadBackBow)",
|
||||
"item_guid": "JTrfBXhIT02o2-ssP94zew",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "HeadBackBow_SolidBlack_Swatch",
|
||||
"swatch_guid": "f_c50saStkaGJuhLjJ9lfA",
|
||||
"color": "SolidBlack",
|
||||
"item_name": "(Hat_HeadBackBow)",
|
||||
"item_guid": "JTrfBXhIT02o2-ssP94zew",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "HeadBackBow_Hearts_Swatch",
|
||||
"swatch_guid": "D-SngK7hhkOpoZ2aMWv9EA",
|
||||
"color": "Hearts",
|
||||
"item_name": "(Hat_HeadBackBow)",
|
||||
"item_guid": "JTrfBXhIT02o2-ssP94zew",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "HeadBackBow_SolidBlue_Swatch",
|
||||
"swatch_guid": "8iZO162KNUiMC8l_z3OC_A",
|
||||
"color": "SolidBlue",
|
||||
"item_name": "(Hat_HeadBackBow)",
|
||||
"item_guid": "JTrfBXhIT02o2-ssP94zew",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "HeadBackBow_SolidPink_Swatch",
|
||||
"swatch_guid": "FUP4iRnoc0ikNmjrFD06lg",
|
||||
"color": "SolidPink",
|
||||
"item_name": "(Hat_HeadBackBow)",
|
||||
"item_guid": "JTrfBXhIT02o2-ssP94zew",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "FurCoat_Black_Swatch",
|
||||
"swatch_guid": "43501b0c-eaaa-4ab3-9d03-2ca61d2e8fc9",
|
||||
"color": "Black",
|
||||
"item_name": "(FurCoat)",
|
||||
"item_guid": "77c37481-a185-47ff-af55-65f9fdfcf9e5",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "2c0d912d-c184-4eab-8ee4-73b90ba7b0a6"
|
||||
},
|
||||
{
|
||||
"swatch_name": "FurCoat_White_Swatch",
|
||||
"swatch_guid": "643b5f2d-0666-4869-b699-03742de34d16",
|
||||
"color": "White",
|
||||
"item_name": "(FurCoat)",
|
||||
"item_guid": "77c37481-a185-47ff-af55-65f9fdfcf9e5",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "2c0d912d-c184-4eab-8ee4-73b90ba7b0a6"
|
||||
},
|
||||
{
|
||||
"swatch_name": "FlapperHeadband_Gold_Swatch",
|
||||
"swatch_guid": "b6c25a3b-125b-4aab-bed0-e6e5ced2c21c",
|
||||
"color": "Gold",
|
||||
"item_name": "(Hat_FlapperHeadband)",
|
||||
"item_guid": "18feb3c2-049f-4f91-9f4e-6b5fcb4ab70c",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "FlapperHeadband_Black_Swatch",
|
||||
"swatch_guid": "565c17e0-496d-45bd-a2c3-564e118c06cc",
|
||||
"color": "Black",
|
||||
"item_name": "(Hat_FlapperHeadband)",
|
||||
"item_guid": "18feb3c2-049f-4f91-9f4e-6b5fcb4ab70c",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Wrist_VampireHunter_Red_Swatch",
|
||||
"swatch_guid": "iJW8XGJfU0G12iXarjxhdQ",
|
||||
"color": "Red",
|
||||
"item_name": "(Wrist_VampireHunter)",
|
||||
"item_guid": "_OWVy3z6iU-M3-zbQgSLig",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": "N5wN1l0tYEau5np-9PoIiA"
|
||||
},
|
||||
{
|
||||
"swatch_name": "Wrist_VampireHunter_Black_Swatch",
|
||||
"swatch_guid": "Vxq6v55a_kGPODzwB5MC1g",
|
||||
"color": "Black",
|
||||
"item_name": "(Wrist_VampireHunter)",
|
||||
"item_guid": "_OWVy3z6iU-M3-zbQgSLig",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": "N5wN1l0tYEau5np-9PoIiA"
|
||||
},
|
||||
{
|
||||
"swatch_name": "Wrist_VampireHunter_Leather_Swatch",
|
||||
"swatch_guid": "D0tP58wC30mJ00vNxw8hqA",
|
||||
"color": "Leather",
|
||||
"item_name": "(Wrist_VampireHunter)",
|
||||
"item_guid": "_OWVy3z6iU-M3-zbQgSLig",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": "N5wN1l0tYEau5np-9PoIiA"
|
||||
},
|
||||
{
|
||||
"swatch_name": "PeaCoat_Purple_Swatch",
|
||||
"swatch_guid": "uiBklotTUEqRI2F67vyJyA",
|
||||
"color": "Purple",
|
||||
"item_name": "(Shirt_Peacoat)",
|
||||
"item_guid": "ab15ef15-a97c-4ab1-896b-bd4d7e1cd9f5",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "PeaCoat_Navy_Swatch",
|
||||
"swatch_guid": "fvoaMN4M10WS6jiA6XlOJQ",
|
||||
"color": "Navy",
|
||||
"item_name": "(Shirt_Peacoat)",
|
||||
"item_guid": "ab15ef15-a97c-4ab1-896b-bd4d7e1cd9f5",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "PeaCoat_Tan_Swatch",
|
||||
"swatch_guid": "PwsLxCnzOkSW-RGz3tNb1w",
|
||||
"color": "Tan",
|
||||
"item_name": "(Shirt_Peacoat)",
|
||||
"item_guid": "ab15ef15-a97c-4ab1-896b-bd4d7e1cd9f5",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "PeaCoat_Red_Swatch",
|
||||
"swatch_guid": "jXBi28cRV0CEQgKIrpD0_A",
|
||||
"color": "Red",
|
||||
"item_name": "(Shirt_Peacoat)",
|
||||
"item_guid": "ab15ef15-a97c-4ab1-896b-bd4d7e1cd9f5",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Hat_CatEars_Gray_Swatch",
|
||||
"swatch_guid": "96e42c7a-2881-4299-a2fe-80c0d4d5ca26",
|
||||
"color": "Gray",
|
||||
"item_name": "(Hat_CatEars)",
|
||||
"item_guid": "3d5184e1-0201-4476-bf4b-4eb28190de62",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": "1e3195ab-f7f6-41f4-a5ec-378d09ecdf69"
|
||||
},
|
||||
{
|
||||
"swatch_name": "Hat_CatEars_Brown_Swatch",
|
||||
"swatch_guid": "713781e5-ce47-41c6-a24f-94d42a565e30",
|
||||
"color": "Brown",
|
||||
"item_name": "(Hat_CatEars)",
|
||||
"item_guid": "3d5184e1-0201-4476-bf4b-4eb28190de62",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": "1e3195ab-f7f6-41f4-a5ec-378d09ecdf69"
|
||||
},
|
||||
{
|
||||
"swatch_name": "Hat_CatEars_White_Swatch",
|
||||
"swatch_guid": "c1e430c5-fd8d-4032-9e3f-8d87cc8784da",
|
||||
"color": "White",
|
||||
"item_name": "(Hat_CatEars)",
|
||||
"item_guid": "3d5184e1-0201-4476-bf4b-4eb28190de62",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": "1e3195ab-f7f6-41f4-a5ec-378d09ecdf69"
|
||||
},
|
||||
{
|
||||
"swatch_name": "Hat_CatEars_Gold_Swatch",
|
||||
"swatch_guid": "37f8378b-b98c-477a-b8c9-9340772800b5",
|
||||
"color": "Gold",
|
||||
"item_name": "(Hat_CatEars)",
|
||||
"item_guid": "3d5184e1-0201-4476-bf4b-4eb28190de62",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": "1e3195ab-f7f6-41f4-a5ec-378d09ecdf69"
|
||||
},
|
||||
{
|
||||
"swatch_name": "TruckerJacket_White_Swatch",
|
||||
"swatch_guid": "yA0KhVg19kWqG1UkyxQogQ",
|
||||
"color": "White",
|
||||
"item_name": "(Shirt_TruckerJacket)",
|
||||
"item_guid": "8b065e2a-b106-4f11-b415-4c47ce87993e",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "TruckerJacket_Tan_Swatch",
|
||||
"swatch_guid": "o2WfRyjJy0CKoUg8YIxNjQ",
|
||||
"color": "Tan",
|
||||
"item_name": "(Shirt_TruckerJacket)",
|
||||
"item_guid": "8b065e2a-b106-4f11-b415-4c47ce87993e",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "TruckerJacket_RebelRose_Swatch",
|
||||
"swatch_guid": "4Eck_dNL1UKlIZWsA1YppQ",
|
||||
"color": "RebelRose",
|
||||
"item_name": "(Shirt_TruckerJacket)",
|
||||
"item_guid": "8b065e2a-b106-4f11-b415-4c47ce87993e",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "TruckerJacket_Black_Swatch",
|
||||
"swatch_guid": "P9bBSL0YLEyjN8PxMfgbKQ",
|
||||
"color": "Black",
|
||||
"item_name": "(Shirt_TruckerJacket)",
|
||||
"item_guid": "8b065e2a-b106-4f11-b415-4c47ce87993e",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "TruckerJacket_Olive_Swatch",
|
||||
"swatch_guid": "bgRKH1wfqkWVHcuSnFYnlQ",
|
||||
"color": "Olive",
|
||||
"item_name": "(Shirt_TruckerJacket)",
|
||||
"item_guid": "8b065e2a-b106-4f11-b415-4c47ce87993e",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "80sSpandex_White_Swatch",
|
||||
"swatch_guid": "zu0Jk24WTkan6XWSwGU1Uw",
|
||||
"color": "White",
|
||||
"item_name": "(Shirt_80sSpandex)",
|
||||
"item_guid": "17236d76-e3b6-42f6-9179-6fcf0479436f",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "80sSpandex_Red_Swatch",
|
||||
"swatch_guid": "6t2rCb6WWEW6pJbcjsAFCw",
|
||||
"color": "Red",
|
||||
"item_name": "(Shirt_80sSpandex)",
|
||||
"item_guid": "17236d76-e3b6-42f6-9179-6fcf0479436f",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "80sSpandex_Green_Swatch",
|
||||
"swatch_guid": "3frH6KxZ9kGUYkjeCDpylQ",
|
||||
"color": "Green",
|
||||
"item_name": "(Shirt_80sSpandex)",
|
||||
"item_guid": "17236d76-e3b6-42f6-9179-6fcf0479436f",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "80sSpandex_Blue_Swatch",
|
||||
"swatch_guid": "Lf4FjROSAEqXedFxpXFQfg",
|
||||
"color": "Blue",
|
||||
"item_name": "(Shirt_80sSpandex)",
|
||||
"item_guid": "17236d76-e3b6-42f6-9179-6fcf0479436f",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "80sSpandex_Yellow_Swatch",
|
||||
"swatch_guid": "i1XbjBqTu0CILIuJClWimA",
|
||||
"color": "Yellow",
|
||||
"item_name": "(Shirt_80sSpandex)",
|
||||
"item_guid": "17236d76-e3b6-42f6-9179-6fcf0479436f",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "WaitressApron_Red_Swatch",
|
||||
"swatch_guid": "HdwZhjrqxE6wuf68MnDhDw",
|
||||
"color": "Red",
|
||||
"item_name": "(Belt_WaitressApron)",
|
||||
"item_guid": "204bc384-e6d2-41b8-8ce9-0b26be5d85b7",
|
||||
"item_outfit_type": 102,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "WaitressApron_Mustard_Swatch",
|
||||
"swatch_guid": "b7oobDcDtE-69fhM-5g5AQ",
|
||||
"color": "Mustard",
|
||||
"item_name": "(Belt_WaitressApron)",
|
||||
"item_guid": "204bc384-e6d2-41b8-8ce9-0b26be5d85b7",
|
||||
"item_outfit_type": 102,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "WaitressApron_Green_Swatch",
|
||||
"swatch_guid": "Blev_VFjRUysY_nymGWAVg",
|
||||
"color": "Green",
|
||||
"item_name": "(Belt_WaitressApron)",
|
||||
"item_guid": "204bc384-e6d2-41b8-8ce9-0b26be5d85b7",
|
||||
"item_outfit_type": 102,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Shirt_MartyRecFly_Hipster_Swatch",
|
||||
"swatch_guid": "kkvNbVRjcEKtChpy3W1bqQ",
|
||||
"color": "Hipster",
|
||||
"item_name": "(Shirt_MartyRecFly)",
|
||||
"item_guid": "74c65554-0d15-409b-b9a8-0b2620a7d7e2",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Hat_SkiBuddy_White_Swatch",
|
||||
"swatch_guid": "KwCEye0nsESFs-yj4h9sjQ",
|
||||
"color": "White",
|
||||
"item_name": "(Hat_SkiBuddy)",
|
||||
"item_guid": "6QT99hx6DE6t9cKAs61i3w",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": "c_t9XDbn-Eqml1O-wC5zJQ"
|
||||
},
|
||||
{
|
||||
"swatch_name": "Wrist_Grillmaster_Blue_Swatch",
|
||||
"swatch_guid": "90qQe0AR3kOou0MsuoTmPA",
|
||||
"color": "Blue",
|
||||
"item_name": "(Wrist_Grillmaster)",
|
||||
"item_guid": "q7pSsRMNHkqEJmYHuhhG6g",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Wrist_Grillmaster_Black_Swatch",
|
||||
"swatch_guid": "hZKPr4sg10SRj9DLuEgOrw",
|
||||
"color": "Black",
|
||||
"item_name": "(Wrist_Grillmaster)",
|
||||
"item_guid": "q7pSsRMNHkqEJmYHuhhG6g",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Scientist_Blue_Swatch",
|
||||
"swatch_guid": "KTidYfH0ckWxJGs5qzUBTQ",
|
||||
"color": "Blue",
|
||||
"item_name": "(Hair_Scientist)",
|
||||
"item_guid": "f17a2f9e-1eab-44e8-b10a-1f53c5d0778c",
|
||||
"item_outfit_type": 2,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "RedFlowerDress_Gray_Swatch",
|
||||
"swatch_guid": "1acb1f56-4509-449f-9c6a-065bdeaa9ee3",
|
||||
"color": "Gray",
|
||||
"item_name": "(RedFlowerDress)",
|
||||
"item_guid": "515dfc36-7f7b-47bc-abee-380d68f41be6",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "2d7361d2-31a2-404b-90da-10e89114e1f2"
|
||||
},
|
||||
{
|
||||
"swatch_name": "RedFlowerDress_Yellow_Swatch",
|
||||
"swatch_guid": "e98c3f98-7844-4d6e-a21c-05027033c18c",
|
||||
"color": "Yellow",
|
||||
"item_name": "(RedFlowerDress)",
|
||||
"item_guid": "515dfc36-7f7b-47bc-abee-380d68f41be6",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "2d7361d2-31a2-404b-90da-10e89114e1f2"
|
||||
},
|
||||
{
|
||||
"swatch_name": "YellowBlueDress_White_Swatch",
|
||||
"swatch_guid": "bbfa08e3-8e6b-4e0f-b264-1b398d7cd44a",
|
||||
"color": "White",
|
||||
"item_name": "(YellowBlueDress)",
|
||||
"item_guid": "2296ed0d-df56-4d46-b33a-aae9230a47fc",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "YellowBlueDress_Purple_Swatch",
|
||||
"swatch_guid": "830be2fa-60a5-48cc-931f-34b670eae4bd",
|
||||
"color": "Purple",
|
||||
"item_name": "(YellowBlueDress)",
|
||||
"item_guid": "2296ed0d-df56-4d46-b33a-aae9230a47fc",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "TennisSkirt_Yellow_Swatch",
|
||||
"swatch_guid": "c5deba2a-6e35-4b13-8e94-8ba5457f39df",
|
||||
"color": "Yellow",
|
||||
"item_name": "(TennisSkirt)",
|
||||
"item_guid": "21caa68e-c3fa-474c-af5e-af1e742b7a60",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "b75ef67d-00c3-4ac1-9b72-212032460294"
|
||||
},
|
||||
{
|
||||
"swatch_name": "TennisSkirt_Red_Swatch",
|
||||
"swatch_guid": "758752bd-db2f-43d2-b580-55b3e1efffd5",
|
||||
"color": "Red",
|
||||
"item_name": "(TennisSkirt)",
|
||||
"item_guid": "21caa68e-c3fa-474c-af5e-af1e742b7a60",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "b75ef67d-00c3-4ac1-9b72-212032460294"
|
||||
},
|
||||
{
|
||||
"swatch_name": "LaserTag_Cyan_Swatch",
|
||||
"swatch_guid": "08b7c12d-3317-4d27-9b40-34737a709454",
|
||||
"color": "Cyan",
|
||||
"item_name": "(Hat_LaserTag)",
|
||||
"item_guid": "123de433-a005-4994-8a11-d2d84ad66b11",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "LaserTag_Green_Swatch",
|
||||
"swatch_guid": "2Z_FxSPiyUODtlFZWF9DFQ",
|
||||
"color": "Green",
|
||||
"item_name": "(Hat_LaserTag)",
|
||||
"item_guid": "123de433-a005-4994-8a11-d2d84ad66b11",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "LaserTag_Orange_Swatch",
|
||||
"swatch_guid": "jkuw9EhCpEKn-2lKJXWd3Q",
|
||||
"color": "Orange",
|
||||
"item_name": "(Hat_LaserTag)",
|
||||
"item_guid": "123de433-a005-4994-8a11-d2d84ad66b11",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Letterman_Red_Swatch",
|
||||
"swatch_guid": "VpqHNpPiY0CfRJEjRRM-mw",
|
||||
"color": "Red",
|
||||
"item_name": "(Wrist_Letterman)",
|
||||
"item_guid": "2dFMy4zc7ky_Fd5fzfgInw",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Letterman_Purple_Swatch",
|
||||
"swatch_guid": "iufpnteVCEiiZu_m-yPovQ",
|
||||
"color": "Purple",
|
||||
"item_name": "(Wrist_Letterman)",
|
||||
"item_guid": "2dFMy4zc7ky_Fd5fzfgInw",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Letterman_Black_Swatch",
|
||||
"swatch_guid": "Z1F9rjKqCE-Hd2-Ip5g3gA",
|
||||
"color": "Black",
|
||||
"item_name": "(Wrist_Letterman)",
|
||||
"item_guid": "2dFMy4zc7ky_Fd5fzfgInw",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Letterman_Green_Swatch",
|
||||
"swatch_guid": "apSQiQ1eO0mEzRsbugeu9w",
|
||||
"color": "Green",
|
||||
"item_name": "(Wrist_Letterman)",
|
||||
"item_guid": "2dFMy4zc7ky_Fd5fzfgInw",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "TrackSuit_Pink_Swatch",
|
||||
"swatch_guid": "026ca50e-3a81-43cf-87f5-950687b7bbdd",
|
||||
"color": "Pink",
|
||||
"item_name": "(TrackSuit)",
|
||||
"item_guid": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "adae82eb-a4b9-4541-acf2-76cea839a593"
|
||||
},
|
||||
{
|
||||
"swatch_name": "QuiltedCoat_Orange_Swatch",
|
||||
"swatch_guid": "-HYrY2A5Tk2UWMi4_hmYbg",
|
||||
"color": "Orange",
|
||||
"item_name": "(Shirt_Quiltedcoat)",
|
||||
"item_guid": "0ab5790f-a537-4306-ba66-5eb87c02ec7d",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "QuiltedCoat_Green_Swatch",
|
||||
"swatch_guid": "QBglgj80zkuvUkk55Y5Feg",
|
||||
"color": "Green",
|
||||
"item_name": "(Shirt_Quiltedcoat)",
|
||||
"item_guid": "0ab5790f-a537-4306-ba66-5eb87c02ec7d",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "QuiltedCoat_Plum_Swatch",
|
||||
"swatch_guid": "IjRMopsYlUqSE6327I9TIQ",
|
||||
"color": "Plum",
|
||||
"item_name": "(Shirt_Quiltedcoat)",
|
||||
"item_guid": "0ab5790f-a537-4306-ba66-5eb87c02ec7d",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Shirt_SkiBuddy_Blue_Swatch",
|
||||
"swatch_guid": "fXNkMWFMV0aXalK_y_8SHw",
|
||||
"color": "Blue",
|
||||
"item_name": "(Shirt_SkiBuddy)",
|
||||
"item_guid": "6RhwWExXekaYpK0NGHSXGQ",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "HQQWccdyakaEmntPhvC6CQ"
|
||||
},
|
||||
{
|
||||
"swatch_name": "Dodgeball_Gray_Swatch",
|
||||
"swatch_guid": "0ba92db2-690a-44ce-92f4-130e9503b6c1",
|
||||
"color": "Gray",
|
||||
"item_name": "(Shirt_Dodgeball)",
|
||||
"item_guid": "d3d69bee-60bc-44a3-ad7c-65b3b69d1e59",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "944d934d-5e21-42d7-8196-fff9a81ee469"
|
||||
},
|
||||
{
|
||||
"swatch_name": "Dodgeball_Black_Swatch",
|
||||
"swatch_guid": "4a85c181-92fe-47a5-925e-05f0ccb25006",
|
||||
"color": "Black",
|
||||
"item_name": "(Shirt_Dodgeball)",
|
||||
"item_guid": "d3d69bee-60bc-44a3-ad7c-65b3b69d1e59",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "944d934d-5e21-42d7-8196-fff9a81ee469"
|
||||
},
|
||||
{
|
||||
"swatch_name": "Hoodie_Gray_Swatch",
|
||||
"swatch_guid": "JTgXKeT7w0K2Mw4Jk2HCqg",
|
||||
"color": "Gray",
|
||||
"item_name": "(Shirt_Hoodie)",
|
||||
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Hoodie_Green_Swatch",
|
||||
"swatch_guid": "ytmSzjhCr0Sm58MSNbwJUA",
|
||||
"color": "Green",
|
||||
"item_name": "(Shirt_Hoodie)",
|
||||
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Hoodie_Blue_Swatch",
|
||||
"swatch_guid": "u_Gzj9Nro0WS48xKnH9few",
|
||||
"color": "Blue",
|
||||
"item_name": "(Shirt_Hoodie)",
|
||||
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Hoodie_Pink_Swatch",
|
||||
"swatch_guid": "TEgugZRs80iJbhL87RdYwg",
|
||||
"color": "Pink",
|
||||
"item_name": "(Shirt_Hoodie)",
|
||||
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Hoodie_Orange_Swatch",
|
||||
"swatch_guid": "rNhOQoP3XkOBHV28ze2NpA",
|
||||
"color": "Orange",
|
||||
"item_name": "(Shirt_Hoodie)",
|
||||
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Hoodie_Purple_Swatch",
|
||||
"swatch_guid": "mvMeyStxGUGlNLSYNeSubQ",
|
||||
"color": "Purple",
|
||||
"item_name": "(Shirt_Hoodie)",
|
||||
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Hoodie_Red_Swatch",
|
||||
"swatch_guid": "yM5xRrd9OkmDGyQddwOP6g",
|
||||
"color": "Red",
|
||||
"item_name": "(Shirt_Hoodie)",
|
||||
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Hoodie_White_Swatch",
|
||||
"swatch_guid": "-Nf7WM2TeEKujB637h3tRA",
|
||||
"color": "White",
|
||||
"item_name": "(Shirt_Hoodie)",
|
||||
"item_guid": "b6rLwzD4NkKV7xKn9ZYVkA",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "DeepSeaDiver_Black_Swatch",
|
||||
"swatch_guid": "CahBzgN3D0y46AQsvGC14A",
|
||||
"color": "Black",
|
||||
"item_name": "(Hat_DeepSeaDiver)",
|
||||
"item_guid": "4fcb5b84-13a9-43e6-9680-72b17874ae53",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "DeepSeaDiver_Beige_Swatch",
|
||||
"swatch_guid": "Su7kiW24d0KapNns96JoFQ",
|
||||
"color": "Beige",
|
||||
"item_name": "(Hat_DeepSeaDiver)",
|
||||
"item_guid": "4fcb5b84-13a9-43e6-9680-72b17874ae53",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "QueenCrown_Black_Swatch",
|
||||
"swatch_guid": "rmq1AOQvtUGOHvbPUximwg",
|
||||
"color": "Black",
|
||||
"item_name": "(Hat_QueenCrown)",
|
||||
"item_guid": "9d395a84-4342-4b1c-940c-213ec2eb56f9",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "QueenCrown_White_Swatch",
|
||||
"swatch_guid": "Zljmtj5WqUaRvB3XQTgJXg",
|
||||
"color": "White",
|
||||
"item_name": "(Hat_QueenCrown)",
|
||||
"item_guid": "9d395a84-4342-4b1c-940c-213ec2eb56f9",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Jazzercise_Black_Swatch",
|
||||
"swatch_guid": "3b2cfc85-4e2a-4e0d-b738-c296ae8692f8",
|
||||
"color": "Black",
|
||||
"item_name": "(Hair_Jazzercise)",
|
||||
"item_guid": "49397e16-1027-4286-9bc4-b59eae565ff0",
|
||||
"item_outfit_type": 2,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Jazzercise_Yellow_Swatch",
|
||||
"swatch_guid": "78eb0969-f246-4cac-acd6-295df7c092e4",
|
||||
"color": "Yellow",
|
||||
"item_name": "(Hair_Jazzercise)",
|
||||
"item_guid": "49397e16-1027-4286-9bc4-b59eae565ff0",
|
||||
"item_outfit_type": 2,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "FieldHockey_Blue_Swatch",
|
||||
"swatch_guid": "7b0d6661-e856-4ec1-ab90-d89c28a70826",
|
||||
"color": "Blue",
|
||||
"item_name": "(FieldHockey)",
|
||||
"item_guid": "a986ff33-9e16-4219-8ebb-2e7b4c772ca7",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "1fede91a-434e-49a5-882e-e6db107112a8"
|
||||
},
|
||||
{
|
||||
"swatch_name": "FlowerSwimsuit_Green_Swatch",
|
||||
"swatch_guid": "cf515c87-3c0a-4443-9f63-5ea1bdbaa61a",
|
||||
"color": "Green",
|
||||
"item_name": "(FlowerSwimsuit)",
|
||||
"item_guid": "f41653f9-ae02-443c-aaf4-1c6f4e49035d",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "3248a3ec-50d1-4c0a-96ae-25eb9d8ed622"
|
||||
},
|
||||
{
|
||||
"swatch_name": "FlowerSwimsuit_Pink_Swatch",
|
||||
"swatch_guid": "27680277-aec1-41be-a476-9f51cf775580",
|
||||
"color": "Pink",
|
||||
"item_name": "(FlowerSwimsuit)",
|
||||
"item_guid": "f41653f9-ae02-443c-aaf4-1c6f4e49035d",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "3248a3ec-50d1-4c0a-96ae-25eb9d8ed622"
|
||||
},
|
||||
{
|
||||
"swatch_name": "FlowerSwimsuit_Gray_Swatch",
|
||||
"swatch_guid": "5a96ac4d-03c3-4214-9dcb-c2044ff28f2e",
|
||||
"color": "Gray",
|
||||
"item_name": "(FlowerSwimsuit)",
|
||||
"item_guid": "f41653f9-ae02-443c-aaf4-1c6f4e49035d",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "3248a3ec-50d1-4c0a-96ae-25eb9d8ed622"
|
||||
},
|
||||
{
|
||||
"swatch_name": "Angler_Blue_Swatch",
|
||||
"swatch_guid": "6e0f3af8-297f-4ea7-8f9e-4a1bc57d3e35",
|
||||
"color": "Blue",
|
||||
"item_name": "(Hat_Angler)",
|
||||
"item_guid": "03f8c394-28fa-4087-978b-8d108f0bd969",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Angler_Tan_Swatch",
|
||||
"swatch_guid": "cac27853-e2df-4d77-b3fd-af3bc0813f01",
|
||||
"color": "Tan",
|
||||
"item_name": "(Hat_Angler)",
|
||||
"item_guid": "03f8c394-28fa-4087-978b-8d108f0bd969",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Angler_Gray_Swatch",
|
||||
"swatch_guid": "97938f4c-52b8-4c85-8575-856654666316",
|
||||
"color": "Gray",
|
||||
"item_name": "(Hat_Angler)",
|
||||
"item_guid": "03f8c394-28fa-4087-978b-8d108f0bd969",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Artist_Blue_Swatch",
|
||||
"swatch_guid": "8c94e45c-6605-4b4f-9ba4-2c6c6e557228",
|
||||
"color": "Blue",
|
||||
"item_name": "(Shirt_Artist)",
|
||||
"item_guid": "02077915-57d3-4e26-b4fe-8e0399cbb293",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Artist_Red_Swatch",
|
||||
"swatch_guid": "17b16982-871c-476a-860f-f6953c89ee8c",
|
||||
"color": "Red",
|
||||
"item_name": "(Shirt_Artist)",
|
||||
"item_guid": "02077915-57d3-4e26-b4fe-8e0399cbb293",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "HockeyShirt_Red_Swatch",
|
||||
"swatch_guid": "b1ea158d-1663-4db1-bb09-9847f6e9d1e6",
|
||||
"color": "Red",
|
||||
"item_name": "(HockeyShirt)",
|
||||
"item_guid": "d6b0a1e7-e918-43e5-8191-3a8d9d01df7c",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "e5635772-4e84-4b30-b77f-4a1196cb599b"
|
||||
},
|
||||
{
|
||||
"swatch_name": "HockeyShirt_Teal_Swatch",
|
||||
"swatch_guid": "fcbdfbfd-254a-4538-b119-43e23404b8b5",
|
||||
"color": "Teal",
|
||||
"item_name": "(HockeyShirt)",
|
||||
"item_guid": "d6b0a1e7-e918-43e5-8191-3a8d9d01df7c",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "e5635772-4e84-4b30-b77f-4a1196cb599b"
|
||||
},
|
||||
{
|
||||
"swatch_name": "HockeyShirt_Purple_Swatch",
|
||||
"swatch_guid": "d5efaf80-7f34-4c9a-bc3a-b80b1ac70ace",
|
||||
"color": "Purple",
|
||||
"item_name": "(HockeyShirt)",
|
||||
"item_guid": "d6b0a1e7-e918-43e5-8191-3a8d9d01df7c",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "e5635772-4e84-4b30-b77f-4a1196cb599b"
|
||||
},
|
||||
{
|
||||
"swatch_name": "Hat_SeaCaptain_Contest_Swatch",
|
||||
"swatch_guid": "hhs9GY3XZkWb-8TXODvo6A",
|
||||
"color": "Contest",
|
||||
"item_name": "(Hat_SeaCaptain)",
|
||||
"item_guid": "f5c9743f-e77c-42f2-ba63-9843342cbf8e",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": "a4e51b6b-fdfd-47dc-9d0e-f26f383e03e9"
|
||||
},
|
||||
{
|
||||
"swatch_name": "LaserTagGlove_Orange_Swatch",
|
||||
"swatch_guid": "EC0ttvk6eU2HEMacLAx2Iw",
|
||||
"color": "Orange",
|
||||
"item_name": "(Wrist_LaserTagGlove)",
|
||||
"item_guid": "3044adce-90c0-4f66-81f5-39c4fca86fdf",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "LaserTagGlove_Green_Swatch",
|
||||
"swatch_guid": "mhsglVEtREmElF5wuLw3NQ",
|
||||
"color": "Green",
|
||||
"item_name": "(Wrist_LaserTagGlove)",
|
||||
"item_guid": "3044adce-90c0-4f66-81f5-39c4fca86fdf",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "50sDress_Red_Swatch",
|
||||
"swatch_guid": "Yr0mF1EWzEaIyRMOWFiY6w",
|
||||
"color": "Red",
|
||||
"item_name": "(Shirt_50sDress)",
|
||||
"item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "50sDress_Pink_Swatch",
|
||||
"swatch_guid": "eb3SmJTIh0628LrZUkI4KQ",
|
||||
"color": "Pink",
|
||||
"item_name": "(Shirt_50sDress)",
|
||||
"item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "50sDress_Teal_Swatch",
|
||||
"swatch_guid": "QzjakcO8tUaPeiI5KBLKtg",
|
||||
"color": "Teal",
|
||||
"item_name": "(Shirt_50sDress)",
|
||||
"item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "50sDress_White_Swatch",
|
||||
"swatch_guid": "BLgkgLeS-EyazgPQxGcavg",
|
||||
"color": "White",
|
||||
"item_name": "(Shirt_50sDress)",
|
||||
"item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "50sDress_CandyCane_Swatch",
|
||||
"swatch_guid": "PEGqjoxP6E2yRx_A-HphrQ",
|
||||
"color": "CandyCane",
|
||||
"item_name": "(Shirt_50sDress)",
|
||||
"item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "50sDress_Halloween_Swatch",
|
||||
"swatch_guid": "ZvPidQ57rUS50Qz89ERmLw",
|
||||
"color": "Halloween",
|
||||
"item_name": "(Shirt_50sDress)",
|
||||
"item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "50sDress_Shamrock_Swatch",
|
||||
"swatch_guid": "K17dIz_TRUq5D50NBfLjyw",
|
||||
"color": "Shamrock",
|
||||
"item_name": "(Shirt_50sDress)",
|
||||
"item_guid": "439d1e24-d67f-4b8a-8fb3-8bf589d5a8ee",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "TopHat_White_Swatch",
|
||||
"swatch_guid": "DzgN67HoikexE2rTlZLdYQ",
|
||||
"color": "White",
|
||||
"item_name": "(Hat_TopHat)",
|
||||
"item_guid": "102c625b-b988-4bf8-a2aa-a31ad7029cdc",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Shirt_GraduationGown_Green_Swatch",
|
||||
"swatch_guid": "iZy7RmfAz0603oMO-jD-WA",
|
||||
"color": "Green",
|
||||
"item_name": "(Shirt_GraduationGown)",
|
||||
"item_guid": "U_H976qYRUmg9e2clsAflg",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "ica0LZyM_kOox8gxW7CaMw"
|
||||
},
|
||||
{
|
||||
"swatch_name": "DrumMajor_Orange_Swatch",
|
||||
"swatch_guid": "82de1ca3-67d6-454d-84fb-0eeece71fcbc",
|
||||
"color": "Orange",
|
||||
"item_name": "(Hat_DrumMajor)",
|
||||
"item_guid": "1a028850-64ee-40ab-b547-ebbcfa1b04b3",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "DrumMajor_Green_Swatch",
|
||||
"swatch_guid": "fe5288bc-0e02-4ea3-b17d-dee0e576979d",
|
||||
"color": "Green",
|
||||
"item_name": "(Hat_DrumMajor)",
|
||||
"item_guid": "1a028850-64ee-40ab-b547-ebbcfa1b04b3",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "DrumMajor_Purple_Swatch",
|
||||
"swatch_guid": "54d3c589-30ba-44c1-a77c-8f9e16eb0b20",
|
||||
"color": "Purple",
|
||||
"item_name": "(Hat_DrumMajor)",
|
||||
"item_guid": "1a028850-64ee-40ab-b547-ebbcfa1b04b3",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Harness_Teal_Swatch",
|
||||
"swatch_guid": "TnEDe1QoMUCUdqPL_7CxTA",
|
||||
"color": "Teal",
|
||||
"item_name": "(Shirt_Harness)",
|
||||
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Harness_Black_Swatch",
|
||||
"swatch_guid": "AC3zfm38JEyBZWuXDh4Ukg",
|
||||
"color": "Black",
|
||||
"item_name": "(Shirt_Harness)",
|
||||
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Harness_Yellow_Swatch",
|
||||
"swatch_guid": "dyEaPloE8kazWC7Zblv8uQ",
|
||||
"color": "Yellow",
|
||||
"item_name": "(Shirt_Harness)",
|
||||
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Harness_Blue_Swatch",
|
||||
"swatch_guid": "RfZNDPUok0WyQEa3Ko08ow",
|
||||
"color": "Blue",
|
||||
"item_name": "(Shirt_Harness)",
|
||||
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Harness_Orange_Swatch",
|
||||
"swatch_guid": "sX0ZZDcCUkyFSJISfvq0vg",
|
||||
"color": "Orange",
|
||||
"item_name": "(Shirt_Harness)",
|
||||
"item_guid": "ca7a3ccb-2bc9-4c93-a6a8-6f22b38526af",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Unicorn_Pastel_Swatch",
|
||||
"swatch_guid": "FNe9LkWJMUeC3frYJMyEaQ",
|
||||
"color": "Pastel",
|
||||
"item_name": "(Hat_Unicorn)",
|
||||
"item_guid": "2caHvVQ1vECrPv-YcdxdyA",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Unicorn_Glowing_Swatch",
|
||||
"swatch_guid": "mudt-Z5-5USdnIq-xaDgOw",
|
||||
"color": "Glowing",
|
||||
"item_name": "(Hat_Unicorn)",
|
||||
"item_guid": "2caHvVQ1vECrPv-YcdxdyA",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Countess_Black_Swatch",
|
||||
"swatch_guid": "i5RKLo-RbEqisnGII1wpaQ",
|
||||
"color": "Black",
|
||||
"item_name": "(Shirt_Countess)",
|
||||
"item_guid": "QCtqxbvYOkan79YFugH_Cw",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Countess_Purple_Swatch",
|
||||
"swatch_guid": "IzkFARlv6EenmrfcNkcOuw",
|
||||
"color": "Purple",
|
||||
"item_name": "(Shirt_Countess)",
|
||||
"item_guid": "QCtqxbvYOkan79YFugH_Cw",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Countess_Green_Swatch",
|
||||
"swatch_guid": "LyVf9lnzdku1F4iPBGqk-g",
|
||||
"color": "Green",
|
||||
"item_name": "(Shirt_Countess)",
|
||||
"item_guid": "QCtqxbvYOkan79YFugH_Cw",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "CheerDress_Blue_Swatch",
|
||||
"swatch_guid": "faedf215-289b-40f7-90cc-f5b98517d133",
|
||||
"color": "Blue",
|
||||
"item_name": "(FemaleCheerTorso)",
|
||||
"item_guid": "657efe89-620a-46a2-8b58-cafd855c7cd5",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "93987d32-7a6e-45df-af56-76f912969ce7"
|
||||
},
|
||||
{
|
||||
"swatch_name": "CheerDress_Red_Swatch",
|
||||
"swatch_guid": "1773d5d8-4714-4721-b30c-97e25d6f2a5a",
|
||||
"color": "Red",
|
||||
"item_name": "Cheerful Dress",
|
||||
"item_guid": "b02053b3-013f-42b0-8881-85f5be2b8cda",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "93987d32-7a6e-45df-af56-76f912969ce7"
|
||||
},
|
||||
{
|
||||
"swatch_name": "Mobster_Valentine_Swatch",
|
||||
"swatch_guid": "4VkPVbBVLE-eJHilwGMAbQ",
|
||||
"color": "Valentine",
|
||||
"item_name": "(Wrist_Mobster)",
|
||||
"item_guid": "08d56627-4500-4724-8692-a23c96ddb3ec",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Mobster_WhiteHatHacker_Swatch",
|
||||
"swatch_guid": "40283127-2ffc-4989-82f6-29c3381bf9e2",
|
||||
"color": "WhiteHatHacker",
|
||||
"item_name": "(Wrist_Mobster)",
|
||||
"item_guid": "08d56627-4500-4724-8692-a23c96ddb3ec",
|
||||
"item_outfit_type": 200,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Luchador_Red_Swatch",
|
||||
"swatch_guid": "916384ca-e209-42a0-bd64-c64f5a40b56f",
|
||||
"color": "Red",
|
||||
"item_name": "(Hat_Luchador)",
|
||||
"item_guid": "1596c113-aa3a-4f5c-b398-e5c3575548e7",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Luchador_Blue_Swatch",
|
||||
"swatch_guid": "0217fd2e-ff9d-49f7-9bc8-8d0d6b8bf250",
|
||||
"color": "Blue",
|
||||
"item_name": "(Hat_Luchador)",
|
||||
"item_guid": "1596c113-aa3a-4f5c-b398-e5c3575548e7",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "BlueJacket_Mint_Swatch",
|
||||
"swatch_guid": "cd5d7285-202d-42d0-b93f-04245875793e",
|
||||
"color": "Mint",
|
||||
"item_name": "(BlueJacket)",
|
||||
"item_guid": "62ce4109-8dee-4895-bf1b-bfa143db4c7e",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Steampunk_Contest_Swatch",
|
||||
"swatch_guid": "fD5vtKxBWECH54Jj4FbnUg",
|
||||
"color": "Contest",
|
||||
"item_name": "(Shirt_SteamPunk)",
|
||||
"item_guid": "E9VTiIVFPEa-OuQRto0e_Q",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "GoldMedal_Bronze_Swatch",
|
||||
"swatch_guid": "iaDL2im4lkCXUo0NWM-F3g",
|
||||
"color": "Bronze",
|
||||
"item_name": "(GoldMedal)",
|
||||
"item_guid": "bf13fa1c-f991-4aaa-9402-aac109be9562",
|
||||
"item_outfit_type": 100,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "GoldMedal_Silver_Swatch",
|
||||
"swatch_guid": "ERnUPsyT6kuEnIRZF8zPwg",
|
||||
"color": "Silver",
|
||||
"item_name": "(GoldMedal)",
|
||||
"item_guid": "bf13fa1c-f991-4aaa-9402-aac109be9562",
|
||||
"item_outfit_type": 100,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Turtleneck_White_Swatch",
|
||||
"swatch_guid": "Zz4XatKfUEapReRAQHxKKA",
|
||||
"color": "White",
|
||||
"item_name": "(Shirt_Turtleneck)",
|
||||
"item_guid": "7e22a4bd-3e87-4d50-8843-9e2e3cf45285",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Turtleneck_Yellow_Swatch",
|
||||
"swatch_guid": "G4HIPUjekUigiU0-Kr9mdA",
|
||||
"color": "Yellow",
|
||||
"item_name": "(Shirt_Turtleneck)",
|
||||
"item_guid": "7e22a4bd-3e87-4d50-8843-9e2e3cf45285",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Turtleneck_Green_Swatch",
|
||||
"swatch_guid": "Jt0QqNrAp0qzUR_RQoivVg",
|
||||
"color": "Green",
|
||||
"item_name": "(Shirt_Turtleneck)",
|
||||
"item_guid": "7e22a4bd-3e87-4d50-8843-9e2e3cf45285",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Turtleneck_Red_Swatch",
|
||||
"swatch_guid": "lBePHNL9LkyaAjEFyFE8Sw",
|
||||
"color": "Red",
|
||||
"item_name": "(Shirt_Turtleneck)",
|
||||
"item_guid": "7e22a4bd-3e87-4d50-8843-9e2e3cf45285",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Turtleneck_Orange_Swatch",
|
||||
"swatch_guid": "9b9t8_aKH0-gQXMhZycaOA",
|
||||
"color": "Orange",
|
||||
"item_name": "(Shirt_Turtleneck)",
|
||||
"item_guid": "7e22a4bd-3e87-4d50-8843-9e2e3cf45285",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "PB_Trucker_Red_Swatch",
|
||||
"swatch_guid": "lDXambcP60CeY2C8qeGDFw",
|
||||
"color": "Red",
|
||||
"item_name": "(Hat_PB_TruckerHat)",
|
||||
"item_guid": "0c537a84-9095-406f-8c87-1464f3a774ba",
|
||||
"item_outfit_type": 0,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Witch_Contest_Swatch",
|
||||
"swatch_guid": "uTHwo8qd6Ei3Oy4gz_j9Ow",
|
||||
"color": "Contest",
|
||||
"item_name": "(Shirt_Witch)",
|
||||
"item_guid": "7LSm-xCYakmgcAr--Fn-JA",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": ""
|
||||
},
|
||||
{
|
||||
"swatch_name": "Tennis_Pink_Swatch",
|
||||
"swatch_guid": "304e5cf1-5c94-481f-8e49-e6a943e6b300",
|
||||
"color": "Pink",
|
||||
"item_name": "(Shirt_Tennis)",
|
||||
"item_guid": "74b91f2b-d4d3-4e41-8946-b1296ab7a773",
|
||||
"item_outfit_type": 101,
|
||||
"mask_guid": "90e0da8b-1941-4ca8-b7e5-b4e31921873c"
|
||||
}
|
||||
],
|
||||
"unmatched": [
|
||||
"TShirt_SquadGoals_Swatch",
|
||||
"TShirt_HangInThere_Swatch",
|
||||
"TShirt_Orange_Swatch",
|
||||
"ZebraWhite_Swatch",
|
||||
"Winter_Gloves_Tree_Green_Swatch",
|
||||
"Cyber_Trash_Yellow_Swatch",
|
||||
"Cyber_Trash_Black_Swatch",
|
||||
"PoofyDress_Purple_Swatch",
|
||||
"PoofyDress_Orange_Swatch",
|
||||
"CowboyCherry_Swatch",
|
||||
"Mobster_HerringBone_Tan_Swatch",
|
||||
"Laser_Graffiti_Swatch",
|
||||
"Laser_Ham_Swatch",
|
||||
"Shuttershades_Salmon_Swatch",
|
||||
"Shuttershades_Red_Swatch",
|
||||
"Shuttershades_Blue_Swatch",
|
||||
"Shuttershades_Yellow_Swatch",
|
||||
"Countess_Wrist_Purple_Swatch",
|
||||
"Class_Of_2018_Swatch",
|
||||
"FighterPilot_Gloves_Orange_Swatch",
|
||||
"FighterPilot_Gloves_Blue_Swatch",
|
||||
"Aviator_Gloves_Tan_Swatch",
|
||||
"Aviator_Gloves_Black_Swatch",
|
||||
"Plaid_LightBlue_Swatch",
|
||||
"Plaid_Red_Swatch",
|
||||
"Astronaut_Glove_Orange_Swatch",
|
||||
"Fatigue_Swatch",
|
||||
"Wrist_CowboyWhite_Swatch",
|
||||
"Wrist_CowboyCherry_Swatch",
|
||||
"Wrist_CowboyBlack_Swatch",
|
||||
"Wrist_CowboyLight_Swatch",
|
||||
"Wrist_CowboyDark_Swatch",
|
||||
"Winter_Hat_Tree_Swatch",
|
||||
"Winter_Hat_Red_Swatch",
|
||||
"Harness_Hat_Teal_Swatch",
|
||||
"Harness_Hat_Yellow_Swatch",
|
||||
"Harness_Hat_White_Swatch",
|
||||
"Harness_Hat_Orange_Swatch",
|
||||
"Harness_Hat_Black_Swatch",
|
||||
"Sherlock_Torso_Gray_Swatch",
|
||||
"Sherlock_Torso_Black_Swatch",
|
||||
"BowlingShirt_CenterStripe_Burgandy_Swatch",
|
||||
"BowlingShirt_CenterStripe_Black_Swatch",
|
||||
"BowlingShirt_CenterStripe_Yellow_Swatch",
|
||||
"Scuba_Neck_Blue_Swatch",
|
||||
"Scuba_Neck_Contest_Swatch",
|
||||
"Cream_Swatch",
|
||||
"ArtistHat_Red_Swatch",
|
||||
"ArtistHat_Blue_Swatch",
|
||||
"ArcherBlack_Quiver_Swatch",
|
||||
"ArcherBlack_Swatch",
|
||||
"ArcherBlack_Glove_Swatch",
|
||||
"ArcherYellow_Swatch",
|
||||
"ArcherYellow_Glove_Swatch",
|
||||
"ArcherYellow_Quiver_Swatch",
|
||||
"Hat_SeaCaptainDarkDarkGold_Swatch",
|
||||
"Hat_SeaCaptainFushia_Swatch",
|
||||
"Hat_SeaCaptainDarkGold_Swatch",
|
||||
"Hat_SeaCaptainDarkSilver_Swatch",
|
||||
"PolkaBlue_Swatch",
|
||||
"Letterman_Wrist_Red_Swatch",
|
||||
"Letterman_Wrist_Purple_Swatch",
|
||||
"Letterman_Wrist_Green_Swatch",
|
||||
"Letterman_Wrist_Black_Swatch",
|
||||
"Yellow_Swatch",
|
||||
"Windbreaker_Wrist_Yellow_Swatch",
|
||||
"Windbreaker_Wrist_Red_Swatch",
|
||||
"Windbreaker_Wrist_Blue_Swatch",
|
||||
"Windbreaker_Wrist_Salmon_Swatch",
|
||||
"Beekeeper_Shirt_Teal_Swatch",
|
||||
"Beekeeper_Shirt_Lavender_Swatch",
|
||||
"Beekeeper_Shirt_Gold_Swatch",
|
||||
"2D_Contest_Winner_Swatch",
|
||||
"Scarecrow_Hat_Contest_Swatch",
|
||||
"Red_Swatch",
|
||||
"Hat_Baseball_ThreeColor_BlueRed_Swatch",
|
||||
"PirateCaptain_Hat_Black_Swatch",
|
||||
"PirateCaptain_Hat_SRank_Swatch",
|
||||
"PirateCaptain_Hat_Red_Swatch",
|
||||
"RoyalCape_Green_Swatch",
|
||||
"RoyalCape_Blue_Swatch",
|
||||
"RoyalCape_Purple_Swatch",
|
||||
"Grey_Swatch",
|
||||
"BlazerGreyDark_Swatch",
|
||||
"MagicalGirl_Wrist_Red_Swatch",
|
||||
"MagicalGirl_Wrist_Orange_Swatch",
|
||||
"MagicalGirl_Wrist_Green_Swatch",
|
||||
"MagicalGirl_Wrist_Blue_Swatch",
|
||||
"PirateCaptain_Belt_Black_Swatch",
|
||||
"PirateCaptain_Belt_Red_Swatch",
|
||||
"BowlingShirt_Flames_Red_Swatch",
|
||||
"BowlingShirt_Flames_Green_Swatch",
|
||||
"BowlingShirt_Flames_Pink_Swatch",
|
||||
"BowlingShirt_Flames_Blue_Swatch",
|
||||
"RoyalCape_Contest_White_Swatch",
|
||||
"Barber_Torso_Formal_Swatch",
|
||||
"Barber_Torso_Green_Swatch",
|
||||
"Aviator_Goggles_Gray_Swatch",
|
||||
"Aviator_Goggles_Black_Swatch",
|
||||
"Neck_TutorialGown3_Black_Swatch",
|
||||
"SafariTorso_Green_Swatch",
|
||||
"Scallywag_Hat_Blue_Swatch",
|
||||
"Scallywag_Hat_Red_Swatch",
|
||||
"TShirt_Laser_Unicorn_Purple_Swatch",
|
||||
"Astronaut_Helmet_Orange_Swatch",
|
||||
"ArcherGray_Quiver_Swatch",
|
||||
"ArcherGray_Swatch",
|
||||
"ArcherGray_Glove_Swatch",
|
||||
"Hoodie_Skeleton_NoGlow_Swatch",
|
||||
"AnglerHat_Blue_Swatch",
|
||||
"AnglerHat_Gray_Swatch",
|
||||
"AnglerHat_Tan_Swatch",
|
||||
"Scuba_eye_Contest_Swatch",
|
||||
"Scuba_eye_Blue_Swatch",
|
||||
"BowlingShirt_OneStripe_Green_Swatch",
|
||||
"BowlingShirt_OneStripe_Pink_Swatch",
|
||||
"BowlingShirt_OneStripe_Blue_Swatch",
|
||||
"Golfer_Shirt_Blue_Swatch",
|
||||
"Golfer_Shirt_Grey_Swatch",
|
||||
"PaintBallHat_Red_Swatch",
|
||||
"PaintBallHat_Green_Swatch",
|
||||
"PaintBallHat_Mustard_Swatch",
|
||||
"Shirt_TutorialGown3_Black_Swatch",
|
||||
"Winter_Scarf_Tree_Green_Swatch",
|
||||
"Blue_Swatch",
|
||||
"Barber_Hat_Formal_Swatch",
|
||||
"Barber_Hat_Green_Swatch",
|
||||
"Vest_HoundsTooth_Black_Swatch",
|
||||
"Neck_VampiteHunter_Opal_Swatch",
|
||||
"Neck_VampiteHunter_Ruby_Swatch",
|
||||
"Neck_VampiteHunter_Amethyst_Swatch",
|
||||
"Neck_VampiteHunter_Diamond_Swatch",
|
||||
"Neck_VampiteHunter_Emerald_Swatch",
|
||||
"Neck_VampiteHunter_Amber_Swatch",
|
||||
"Neck_VampiteHunter_Topaz_Swatch",
|
||||
"Winter_Scarf_Red_Swatch",
|
||||
"Winter_Scarf_Tree_Swatch",
|
||||
"Floatie_Donut_Swatch",
|
||||
"Floatie_Dinosaur_Swatch",
|
||||
"Rainhat_Hat_Red_Swatch",
|
||||
"Winter_Gloves_Red_Swatch",
|
||||
"Winter_Gloves_Tree_Swatch",
|
||||
"Scout_Sash_Blue_Swatch",
|
||||
"Scout_Sash_Brown_Swatch",
|
||||
"Scout_Sash_Red_Swatch",
|
||||
"RoyalLady_Green_Swatch",
|
||||
"RoyalLady_Black_Swatch",
|
||||
"RoyalLady_Burgandy_Swatch",
|
||||
"Mobster_Hat_Brown_Swatch",
|
||||
"Mobster_Hat_Purple_Swatch",
|
||||
"Mobster_Hat_Gray_Swatch",
|
||||
"ElectricianTorso_Gray_Swatch",
|
||||
"ElectricianTorso_Yellow_Swatch",
|
||||
"ElectricianTorso_Green_Swatch",
|
||||
"ElectricianTorso_Beige_Swatch",
|
||||
"White_Swatch",
|
||||
"BeltDress_Green_Swatch",
|
||||
"BeltDress_Black_Swatch",
|
||||
"BeltDress_Yellow_Swatch",
|
||||
"FootballHelmet_Orange_Swatch",
|
||||
"FootballHelmet_Dark_Swatch",
|
||||
"Aviator_Jacket_Tan_Swatch",
|
||||
"Aviator_Jacket_Black_Swatch",
|
||||
"RoyalCrown_Contest_Swatch",
|
||||
"RoyalCrown_White_Swatch",
|
||||
"RoyalCrown_Black_Swatch",
|
||||
"MagicalGirl_Hat_Blue_Swatch",
|
||||
"MagicalGirl_Hat_Green_Swatch",
|
||||
"MagicalGirl_Hat_Red_Swatch",
|
||||
"MagicalGirl_Hat_Orange_Swatch",
|
||||
"TShirt_KO_Skull_Swatch",
|
||||
"RoyalLord_Black_Swatch",
|
||||
"RoyalLord_Burgandy_Swatch",
|
||||
"RoyalLord_Green_Swatch",
|
||||
"Hat_Baseball_Logo_Ramen_Swatch",
|
||||
"Hat_Baseball_Logo_Bucketeers_Swatch",
|
||||
"Hat_Baseball_Logo_Goblins_Swatch",
|
||||
"TShirt_Orange_Video_Contest_Generic_Swatch",
|
||||
"TShirt_Scifi_Face_BLue_Swatch",
|
||||
"TShirt_Scifi_Face_Pink_Swatch",
|
||||
"Green_Swatch",
|
||||
"BlazerDark_Swatch",
|
||||
"Witch_Contest_Hat_Swatch",
|
||||
"Witch_Contest_Wrist_Swatch",
|
||||
"CowboyWhite_Swatch",
|
||||
"Aviator_Hat_Tan_Swatch",
|
||||
"Aviator_Hat_Black_Swatch",
|
||||
"GreenFlower_Swatch",
|
||||
"BowlingShirt_Diamonds_Purple_Swatch",
|
||||
"BowlingShirt_Diamonds_Teal_Swatch",
|
||||
"BowlingShirt_Diamonds_Blue_Swatch",
|
||||
"FormalDress_Black_Swatch",
|
||||
"Birthday01_Swatch",
|
||||
"Scallywag_Eye_SRank_Swatch",
|
||||
"Ranger_Shirt_Green_Swatch",
|
||||
"SciFiVisor_Peach_Swatch",
|
||||
"SciFiVisor_Blue_Swatch",
|
||||
"SciFiVisor_Orange_Swatch",
|
||||
"Shirt_TutorialGown2_Orange_Swatch",
|
||||
"Scifi_Carpet_Swatch",
|
||||
"Teal_Swatch",
|
||||
"Charcoal_Swatch",
|
||||
"Paintball_Red_Swatch",
|
||||
"Paintball_Blue_Swatch",
|
||||
"ArcherTan_Glove_Swatch",
|
||||
"ArcherTan_Quiver_Swatch",
|
||||
"ArcherTan_Swatch",
|
||||
"BicycleJacket_Black_Swatch",
|
||||
"BicycleJacket_DarkBlue_Swatch",
|
||||
"BicycleJacket_Blue_Swatch",
|
||||
"Argyle_Brown_Swatch",
|
||||
"Argyle_Red_Swatch",
|
||||
"BasketballShirt_Purple_Swatch",
|
||||
"BasketballShirt_Blue_Swatch",
|
||||
"Purple_Swatch",
|
||||
"ScoutSkirt_Shirt_Red_Swatch",
|
||||
"LaserTagVisor_Green_Swatch",
|
||||
"LaserTagVisor_Orange_Swatch",
|
||||
"LaserTagVisor_Cyan_Swatch",
|
||||
"TShirt_KO_Skull_Pink_Swatch",
|
||||
"CollarShirt_Torso_Red_Swatch",
|
||||
"CollarShirt_Torso_Blue_Swatch",
|
||||
"CollarShirt_Torso_Pink_Swatch",
|
||||
"CollarShirt_Torso_GoldYellow_Swatch",
|
||||
"CollarShirt_Torso_GreenFlower_Swatch",
|
||||
"CollarShirt_Torso_White_Swatch",
|
||||
"Pearls_Neck_Black_Swatch",
|
||||
"MagicalGirl_Neck_Orange_Swatch",
|
||||
"MagicalGirl_Neck_Red_Swatch",
|
||||
"MagicalGirl_Neck_Green_Swatch",
|
||||
"MagicalGirl_Neck_Blue_Swatch",
|
||||
"Cyber_Junk_City_Swatch",
|
||||
"Firefighter_Hat_Yellow_Swatch",
|
||||
"Firefighter_Hat_White_Swatch",
|
||||
"Scout_RaccoonHat_Gray_Swatch",
|
||||
"JackfrostWings_Neck_Lilac_Swatch",
|
||||
"CowboyBlack_Swatch",
|
||||
"Torso_SeaCaptainBlueWhite_Swatch",
|
||||
"Torso_SeaCaptainBlueDark2_Swatch",
|
||||
"Torso_SeaCaptainDark_Swatch",
|
||||
"Torso_SeaCaptainWhiteDark_Swatch",
|
||||
"Torso_SeaCaptainBlackDark_Swatch",
|
||||
"Torso_SeaCaptainBlueDark_Swatch",
|
||||
"Torso_SeaCaptainBlue_Swatch",
|
||||
"Astronaut_Torso_Orange_Swatch",
|
||||
"CowboyDark_Swatch",
|
||||
"Denim_Swatch",
|
||||
"FootballPads_Orange_Swatch",
|
||||
"FootballPads_Dark_Swatch",
|
||||
"RoyalLady_Contest_White_Swatch",
|
||||
"WaitressDress_Mustard_Swatch",
|
||||
"WaitressDress_Green_Swatch",
|
||||
"WaitressDress_Red_Swatch",
|
||||
"CollarDress_Pink_Swatch",
|
||||
"CollarDress_LightBlue_Swatch",
|
||||
"Scarecrow_Wrist_Contest_Swatch",
|
||||
"Pink_Swatch",
|
||||
"Mobster_Fashion_Gold_Swatch",
|
||||
"ArcherSRank_Glove_Swatch",
|
||||
"ArcherSRank_Quiver_Swatch",
|
||||
"ArcherSRank_Swatch",
|
||||
"Royal_Swatch",
|
||||
"GoldYellow_Swatch",
|
||||
"Sherlock_Hat_Gray_Swatch",
|
||||
"Sherlock_Hat_Black_Swatch",
|
||||
"Happy_Tree_Purple_Swatch",
|
||||
"Golfer_Glove_Blue_Swatch",
|
||||
"Golfer_Glove_Grey_Swatch",
|
||||
"Steampunk_Femme_Contest_Hat_Swatch",
|
||||
"TShirt_RECS_Logo_Blue_Swatch",
|
||||
"TShirt_RECS_Logo_Green_Swatch",
|
||||
"Scout_Shirt_Blue_Swatch",
|
||||
"DrumMajorGlove_Purple_Swatch",
|
||||
"DrumMajorGlove_Orange_Swatch",
|
||||
"ArcherRed_Glove_Swatch",
|
||||
"ArcherRed_Swatch",
|
||||
"ArcherRed_Quiver_Swatch",
|
||||
"Lacrosse_Stripe_Purple_Swatch",
|
||||
"Winter_Torso_Tree_Swatch",
|
||||
"Winter_Torso_Red_Swatch",
|
||||
"Scuba_Shirt_Contest_Swatch",
|
||||
"Scuba_Shirt_Blue_Swatch",
|
||||
"LightBlue_Swatch",
|
||||
"Winter_Hat_Tree_Green_Swatch",
|
||||
"CowboyLight_Swatch",
|
||||
"Happy_Tree_Swatch",
|
||||
"Winter_Torso_Tree_Green_Swatch",
|
||||
"BlazerMidnightBlue_Swatch",
|
||||
"Scifi_Carpet_TopHat_Swatch",
|
||||
"Rebel_Rose_Swatch",
|
||||
"Mobster_PinStripe_Purple_Swatch",
|
||||
"Soccer_Shirt_Blue_Swatch",
|
||||
"Barber_Wrist_Formal_Swatch",
|
||||
"Steampunk_Femme_Contest_Swatch",
|
||||
"StollDress_Green_Swatch",
|
||||
"StollDress_Blue_Swatch",
|
||||
"BlazerSeaDark_Swatch",
|
||||
"Orange_Swatch",
|
||||
"DrumMajorHat_Green_Swatch",
|
||||
"Golfer_Hat_Blue_Swatch",
|
||||
"Golfer_Hat_Grey_Swatch",
|
||||
"RoyalLord_Contest_White_Swatch",
|
||||
"Black_Swatch",
|
||||
"Jackfrost_Shirt_Lilac_Swatch",
|
||||
"CollarShirt_Torso_Plaid_Red_Swatch",
|
||||
"TShirt_LaserTag_Alpha_Swatch",
|
||||
"Countess_Green_Wrist_Swatch",
|
||||
"Hyper_Noodle_Ramen_Swatch",
|
||||
"Scarecrow_Shirt_Contest_Swatch",
|
||||
"BicycleHelmet_Yellow_Swatch",
|
||||
"Soccer_Glove_Blue_Swatch",
|
||||
"Steampunk_Contest_Hat_Swatch",
|
||||
"Emerald_Syndicate_Swatch",
|
||||
"Neck_TutorialGown2_Orange_Swatch",
|
||||
"Shirt_TutorialGown1_Purple_Swatch",
|
||||
"2D_Contest_Winner_Yellow_Swatch",
|
||||
"BlazerGrey_Swatch",
|
||||
"Stripe3_Swatch",
|
||||
"Ranger_Hat_Green_Swatch"
|
||||
],
|
||||
"ambiguous": [
|
||||
{
|
||||
"prefix": "windbreaker",
|
||||
"candidates": [
|
||||
"52VsI_lKlkSUAOJlfDnyrQ",
|
||||
"AIpF1QUeMUWytpHYBDBAkg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prefix": "lumberjack",
|
||||
"candidates": [
|
||||
"350c5c1e-318b-4233-a7e5-1f49909d8e65",
|
||||
"61ba3c90-c81e-4deb-bc79-50c0f1fe3e83",
|
||||
"b537c30c-faef-4649-9a1f-be3985936f93"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prefix": "magicalgirl",
|
||||
"candidates": [
|
||||
"eS_or4AnMka9M_bD7YN28g",
|
||||
"hT7nSD7Ts06F_1SNlOMLwg",
|
||||
"MJkDYDH510izAab13ZfXQw",
|
||||
"WuShlrJnHUKuscn8e6Rt2g"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prefix": "wizard",
|
||||
"candidates": [
|
||||
"01689baf-405e-4b38-9f09-b624e03865b8",
|
||||
"22ef1089-d073-4fe8-b038-6f34adf0b85f",
|
||||
"97eae086-cfea-45ff-9b77-1d184256d493"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prefix": "punk",
|
||||
"candidates": [
|
||||
"28374ebc-4050-4ea0-b3b6-41ebe75cedf7",
|
||||
"ce250e13-8137-4f62-9e52-2b1cebe7d0ad",
|
||||
"e4ba395e-f291-4e7b-8193-45f4422f41ce"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prefix": "fighterpilot",
|
||||
"candidates": [
|
||||
"5800d2b5-75b8-442f-8b10-63bd3d73a1b8",
|
||||
"ec836d2a-b1b8-4566-b8eb-123ae2845956",
|
||||
"ff7e6d41-5c27-4516-930c-ad0e9a23cce2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prefix": "witchhunter",
|
||||
"candidates": [
|
||||
"418db0e0-c7af-40c2-9cac-d461ce41e210",
|
||||
"6d36091e-7083-4e3d-8729-94612602c8cb"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prefix": "smuggler",
|
||||
"candidates": [
|
||||
"25d4c652-1730-4925-925e-31a290a2272a",
|
||||
"3dec4865-cf23-4c19-a460-dd2db80ee851"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prefix": "sodajerk",
|
||||
"candidates": [
|
||||
"iai6P7dDTUq8S4hqQmY1YA",
|
||||
"Wua43ED71UqL71ATtyS0iQ"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prefix": "piratecaptain",
|
||||
"candidates": [
|
||||
"38b38e24-d636-41c6-b80e-a285ae3106dc",
|
||||
"3b00c71f-79c2-4a04-9fc0-a23b4c85562f",
|
||||
"3df394de-9c3e-4141-aba4-12eac4742102",
|
||||
"eceb7d49-be41-4228-86a9-2973d62e7135"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prefix": "truckerjacket",
|
||||
"candidates": [
|
||||
"8b065e2a-b106-4f11-b415-4c47ce87993e",
|
||||
"973d84ee-5b88-475c-b579-a76c34a7c533"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prefix": "scientist",
|
||||
"candidates": [
|
||||
"f17a2f9e-1eab-44e8-b10a-1f53c5d0778c",
|
||||
"fe34adaf-47d3-435b-b2fd-b2fee05fceae"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prefix": "lasertag",
|
||||
"candidates": [
|
||||
"123de433-a005-4994-8a11-d2d84ad66b11",
|
||||
"24d94673-f394-42e6-a3c8-276f8b8aa722"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prefix": "letterman",
|
||||
"candidates": [
|
||||
"2dFMy4zc7ky_Fd5fzfgInw",
|
||||
"C4uuztTiYUe20PACMARksA"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prefix": "deepseadiver",
|
||||
"candidates": [
|
||||
"4fcb5b84-13a9-43e6-9680-72b17874ae53",
|
||||
"8d97303c-5138-4da1-a74d-378da19d11cb",
|
||||
"b1c3ccc8-91d7-434e-b9cc-bd632fffc01b",
|
||||
"c3745cee-d87e-4a23-b6e2-64ebf05c8b11"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prefix": "jazzercise",
|
||||
"candidates": [
|
||||
"49397e16-1027-4286-9bc4-b59eae565ff0",
|
||||
"5f492f8f-ab61-484a-9587-747132b54309"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prefix": "angler",
|
||||
"candidates": [
|
||||
"03f8c394-28fa-4087-978b-8d108f0bd969",
|
||||
"b1bfe0b4-1ff6-420f-acfc-2331d34246dc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prefix": "drummajor",
|
||||
"candidates": [
|
||||
"1a028850-64ee-40ab-b547-ebbcfa1b04b3",
|
||||
"a6f1825e-1647-4d06-85d7-ac2d139612c0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prefix": "countess",
|
||||
"candidates": [
|
||||
"QCtqxbvYOkan79YFugH_Cw",
|
||||
"t9co2516nkuJO4LIJA1bJA"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prefix": "mobster",
|
||||
"candidates": [
|
||||
"08d56627-4500-4724-8692-a23c96ddb3ec",
|
||||
"4308da04-af84-40bb-845a-7a9e1a2726ec",
|
||||
"76369aef-aeda-46d2-996a-cd00594d0543"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prefix": "steampunk",
|
||||
"candidates": [
|
||||
"E9VTiIVFPEa-OuQRto0e_Q",
|
||||
"gwLiwmqBIk6glFxvaTzxeA",
|
||||
"qxovV6Vym0ufL2cTCyKjOQ",
|
||||
"sOREgAGs-06CeGGsG-WIkw"
|
||||
]
|
||||
},
|
||||
{
|
||||
"prefix": "witch",
|
||||
"candidates": [
|
||||
"7LSm-xCYakmgcAr--Fn-JA",
|
||||
"s6mUIqiPCk6e57UYCx3SZA",
|
||||
"wy2cfgb8mUqjMrIOwHH2ew"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"items": []
|
||||
}
|
||||
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 329 KiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 341 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 313 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 311 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"3DCharades": "aj7w8q5nrfyt2ssfoovawtz4l.png",
|
||||
"BowlingAlley": "8gwqibu0anm4j0rw54xo2y0yd.png",
|
||||
"CrimsonCauldron": "ae6yh8p6y8p3cuyqkc4l8wi4z.png",
|
||||
"DiscGolfLake": "86s8y4ihyhx7n9ac41gwahwze.png",
|
||||
"DiscGolfPropulsion": "6wvnxmrmsw98wsttajp0nreci.png",
|
||||
"Dodgeball": "086ug40lugly2prjest6kw4dz.png",
|
||||
"GoldenTrophy": "1g7pq04z9xseircwla8sf9py5.png",
|
||||
"IsleOfLostSkulls": "3v6ak7k2ljui4ubj0xh21sjys.png",
|
||||
"LaserTag": "4xy3n0voagf6t9bbfnxft1txt.png",
|
||||
"Paddleball": "61qgagldqpy5opgpbrbg5wxvo.png",
|
||||
"Paintball": "d1es44q1u6hlhykxpy8uq0lci.png",
|
||||
"Park": "4o5lschc01nani8xeywao622n.png",
|
||||
"RecCenter": "50xrbhogfdae0mcgzfkomlghu.png",
|
||||
"RecRoyaleSolos": "8ners1eg89dy974ixbim40sky.png",
|
||||
"RecRoyaleSquads": "0c1qdk77fbl8c5slphl3k1t8e.png",
|
||||
"Soccer": "apo2sd115tuwueyoprp8jhx7b.png",
|
||||
"StuntRunner": "8j4f516axpnpjgnj342t85vkd.png",
|
||||
"TheRiseofJumbotron": "8x6qaj4nlbudf1412tupxkq9n.png"
|
||||
}
|
||||