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.
This commit is contained in:
Alexa
2026-05-27 01:04:12 +03:00
commit 4edf25b2d5
375 changed files with 201291 additions and 0 deletions
+678
View File
@@ -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:&lt;low&gt;:&lt;high&gt;</c> for DMs,
/// <c>group:&lt;guid&gt;</c> for group chats, <c>room:&lt;id&gt;</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 &lt;username&gt;" 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&lt;String&gt;</c> (<see
/// cref="Controllers.Clubs.ClubsController.GetCategoryTags"/> projects
/// just the <see cref="Name"/> column out per
/// <c>Cpp2IL_ISIL/.../JDJGIBLMFKK.txt:&gt;GetPrimaryTags</c>'s
/// <c>Action&lt;List&lt;String&gt;&gt;</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;
}
+104
View File
@@ -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&lt;accountId&gt;_v&lt;n&gt;.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&lt;String, Object&gt;)</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&lt;Int32&gt;) — 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&lt;int&gt;
/// (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:&lt;groupId&gt;</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$&lt;cost&gt;$&lt;salt&gt;&lt;hash&gt;` 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; }
}
+117
View File
@@ -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&lt;int&gt;).</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; } = "[]";
}
+168
View File
@@ -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;
}
}
}
}
+135
View File
@@ -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.
File diff suppressed because one or more lines are too long
+2646
View File
@@ -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"
]
}
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"items": []
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+20
View File
@@ -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"
}

Some files were not shown because too many files have changed in this diff Show More