mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
Import march-2020-03-10 from private repo (scrubbed 2026-05-27)
Targets Rec Room 2020.03.10 client (also serves 2020.03.06).
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 dorknet-clientpatch-{key}.zip
for v1-march-* Release uploads.
- .github/workflows/release.yml — on tag push publishes both
dorknet-server-march_2020_03_10-win-x64.zip and
dorknet-clientpatch-march_2020_03_10.zip as Release assets.
Same scrub rules as december; tools/build-patcher-zip.ps1 added to
the maintainer-side allow-list so it survives.
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Append-only audit row written every time an admin performs a
|
||||
/// privileged action (ban, unban, promote, demote, room delete,
|
||||
/// MOTD change, etc.). The admin watch UI's "audit" tab reads
|
||||
/// from this in reverse-chronological order.
|
||||
///
|
||||
/// Rows are never deleted from the application — privileged actions
|
||||
/// stay on the record. Storage cost is negligible at single-server
|
||||
/// scale.
|
||||
/// </summary>
|
||||
public class AdminActionEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>The admin who performed the action.</summary>
|
||||
public long AdminPlayerId { get; set; }
|
||||
|
||||
/// <summary>Free-form action name. Convention: lower-snake-case verb,
|
||||
/// e.g. <c>ban_player</c>, <c>unban_player</c>, <c>promote_admin</c>,
|
||||
/// <c>delete_room</c>, <c>set_motd</c>.</summary>
|
||||
[MaxLength(64)]
|
||||
public string Action { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Targeted entity type — <c>player</c>, <c>room</c>,
|
||||
/// <c>system</c>. Free-form so future actions can introduce new
|
||||
/// kinds without a migration.</summary>
|
||||
[MaxLength(32)]
|
||||
public string TargetType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Numeric id of the target. <c>0</c> for system-wide
|
||||
/// actions like setting the MOTD.</summary>
|
||||
public long TargetId { get; set; }
|
||||
|
||||
/// <summary>Optional human reason the admin supplied at action
|
||||
/// time. Surfaced to the audit log UI verbatim.</summary>
|
||||
[MaxLength(512)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per-player avatar state. The 2020 client persists a mix of structured
|
||||
/// and free-form blobs:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>OutfitSelections</c> — comma-separated GUIDs of equipped
|
||||
/// items per slot (head, torso, legs, feet, accessories). Sent
|
||||
/// verbatim to <c>POST api/avatar/v2</c>.</item>
|
||||
/// <item><c>FaceFeatures</c> — JSON blob with eyeId/eyePos/eyeScl/
|
||||
/// mouthId etc. produced by the in-game face editor.</item>
|
||||
/// <item><c>HairColor</c> / <c>SkinColor</c> — color vault GUIDs from
|
||||
/// the client's baked hair/skin color tables.</item>
|
||||
/// <item><c>SavedOutfitsJson</c> — list of named outfit presets the
|
||||
/// watch's Backpack tab shows under "saved".</item>
|
||||
/// <item><c>EquippedItemsJson</c> / <c>InventoryJson</c> — kept for the
|
||||
/// v3 wire shape (list of <see cref="DorkNet.Models.Avatar.AvatarItemInstance"/>).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public class AvatarEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
// JSON-serialised list of equipped item IDs (v3 shape)
|
||||
public string EquippedItemsJson { get; set; } = "[]";
|
||||
|
||||
// JSON-serialised full inventory (v3 shape)
|
||||
public string InventoryJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>v2 outfit selections — comma-separated GUIDs per slot.</summary>
|
||||
[MaxLength(2048)]
|
||||
public string OutfitSelections { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>v2 face features — opaque JSON written by the face
|
||||
/// editor; we never parse it server-side, just round-trip.</summary>
|
||||
[MaxLength(8192)]
|
||||
public string FaceFeatures { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(64)]
|
||||
public string HairColor { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(64)]
|
||||
public string SkinColor { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Saved outfit presets — JSON list of named slots so the
|
||||
/// watch's "Saved" tab can recall them.</summary>
|
||||
public string SavedOutfitsJson { get; set; } = "[]";
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public PlayerEntity? Player { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One bug report submitted via the in-game "Report a Bug" UI
|
||||
/// (<c>POST /api/bugreporting/v2/reportbug</c>). Surfaces in the
|
||||
/// admin moderation queue at <c>GET /api/admin/v1/bugreports</c>.
|
||||
/// </summary>
|
||||
public class BugReportEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long ReporterPlayerId { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(4000)]
|
||||
public string Body { get; set; } = string.Empty;
|
||||
|
||||
public long GameSessionId { get; set; }
|
||||
|
||||
[MaxLength(64)]
|
||||
public string ClientVersion { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(32)]
|
||||
public string Platform { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional category for routing — also reused by Phase
|
||||
/// 11's <c>POST /api/hile/v1/log</c> (huddle telemetry) which
|
||||
/// piggybacks on this table with Category=`hile`.</summary>
|
||||
[MaxLength(32)]
|
||||
public string Category { get; set; } = "bug";
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public DateTime? ReadAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// In-game text-chat message. Sent to the chat.rec.net/thread
|
||||
/// endpoint family; backed by a flat per-thread row table where the
|
||||
/// thread id is just the canonical (lower, higher) pair of player ids
|
||||
/// for DMs, or the room id for room-wide chats.
|
||||
///
|
||||
/// We keep a separate table from <see cref="MessageEntity"/> because
|
||||
/// the wire shape and history-fetch semantics differ — chat is
|
||||
/// append-and-read-recent, messages are inbox-style with read
|
||||
/// receipts.
|
||||
/// </summary>
|
||||
public class ChatMessageEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>String thread key. Format: <c>dm:<low>:<high></c> for
|
||||
/// direct messages between two players (lower id first), or
|
||||
/// <c>room:<roomId></c> for room-wide chats.</summary>
|
||||
[MaxLength(64)]
|
||||
public string ThreadKey { get; set; } = string.Empty;
|
||||
|
||||
public long SenderPlayerId { get; set; }
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string Body { get; set; } = string.Empty;
|
||||
|
||||
public DateTime SentAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Build the canonical DM thread key for two players.
|
||||
/// Order-independent so both sides hit the same row.</summary>
|
||||
public static string DmKey(long a, long b) =>
|
||||
a < b ? $"dm:{a}:{b}" : $"dm:{b}:{a}";
|
||||
|
||||
public static string RoomKey(long roomId) => $"room:{roomId}";
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per-thread metadata for chat: name (for group chats), creator, and
|
||||
/// timestamps. Rows here are sparse — a two-person DM doesn't need
|
||||
/// one unless the players want a custom thread name. Group chats
|
||||
/// always have a row so the member set + name are persisted.
|
||||
/// </summary>
|
||||
public class ChatThreadEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>Canonical thread key (matches <c>ChatMessageEntity.ThreadKey</c>).
|
||||
/// Format: <c>dm:<low>:<high></c> for DMs,
|
||||
/// <c>group:<guid></c> for group chats, <c>room:<id></c> for room-wide.</summary>
|
||||
[MaxLength(96)]
|
||||
public string ThreadKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional custom name. DMs default to empty (watch renders
|
||||
/// "Chat with <username>" client-side); group chats persist a
|
||||
/// name set via <c>PUT thread/{id}/rename</c>.</summary>
|
||||
[MaxLength(128)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public long CreatorPlayerId { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per-(thread, player) row: membership, per-player snooze, and the
|
||||
/// last-read message id used for unread counts and the inbox's "X
|
||||
/// unread" badge. One row per active participant. A "leave" deletes
|
||||
/// the row so the player no longer receives new messages on that
|
||||
/// thread.
|
||||
///
|
||||
/// For two-person DMs without a custom name, rows are created lazily
|
||||
/// on first MarkRead / Snooze / Leave — the thread itself still works
|
||||
/// fine without rows (the message table is the source of truth for
|
||||
/// who participated).
|
||||
/// </summary>
|
||||
public class ChatThreadMemberEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>The thread this membership applies to. Matches
|
||||
/// <c>ChatMessageEntity.ThreadKey</c> / <c>ChatThreadEntity.ThreadKey</c>.</summary>
|
||||
public string ThreadKey { get; set; } = string.Empty;
|
||||
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
public DateTime JoinedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>When set, the watch's inbox treats this thread as
|
||||
/// muted until the UTC timestamp. Cleared by re-snoozing with a
|
||||
/// null payload.</summary>
|
||||
public DateTime? SnoozeUntil { get; set; }
|
||||
|
||||
/// <summary>Id of the last <c>ChatMessageEntity</c> the player
|
||||
/// has acknowledged via <c>PUT thread/{id}/read</c>. Drives the
|
||||
/// per-thread unread counter on the watch's inbox tab.</summary>
|
||||
public long? LastReadMessageId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One cheer (the watch's positive-feedback button). Targets exactly
|
||||
/// one of: a player (stored on <see cref="TargetPlayerId"/>), a room
|
||||
/// (<see cref="TargetRoomId"/>), or a photo (<see cref="TargetPhotoId"/>).
|
||||
/// Unused targets are 0. Aggregated reputation queries SUM rows by
|
||||
/// target.
|
||||
///
|
||||
/// One row per (FromPlayer, target, Type) — re-cheering is idempotent
|
||||
/// (we upsert on the unique index).
|
||||
/// </summary>
|
||||
public class CheerEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public long FromPlayerId { get; set; }
|
||||
|
||||
public long TargetPlayerId { get; set; }
|
||||
public long TargetRoomId { get; set; }
|
||||
public long TargetPhotoId { get; set; }
|
||||
public long TargetInventionId { get; set; }
|
||||
|
||||
/// <summary>RoomieCheerType enum mirror: 0=General, 1=Helpful,
|
||||
/// 2=GreatHost, 3=Sportsman, 4=Creative, 5=Credit. Stored as int
|
||||
/// to leave room for future categories without a migration.</summary>
|
||||
public int Type { get; set; }
|
||||
|
||||
public DateTime CheeredAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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>). Field names + types match
|
||||
/// the client's <c>Group.Deserialize</c> shape so the watch round-
|
||||
/// trips cleanly. Surfaces in the watch's profile tab as the player's
|
||||
/// primary-group affiliation.
|
||||
/// </summary>
|
||||
public class ClubEntity
|
||||
{
|
||||
/// <summary>Mirrors wire field <c>GroupId</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> (Int32). Cast to int
|
||||
/// in serialiser.</summary>
|
||||
public long CreatorPlayerId { get; set; }
|
||||
|
||||
[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;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>One row per (club, player). Permissions is the
|
||||
/// <c>GroupMembershipPermissions</c> flags enum (Member=0, Moderator=24,
|
||||
/// CoOwner=124, Owner=127, Pending=128 for join-requests on private
|
||||
/// groups).</summary>
|
||||
public class ClubMembershipEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long ClubId { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
/// <summary>GroupMembershipPermissions flags enum value.</summary>
|
||||
public int Permissions { get; set; } = 0;
|
||||
|
||||
public DateTime JoinedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Single-row table holding the dorm community board state. Replaces
|
||||
/// <c>data/community_board.json</c> on disk + in-memory cache so admin
|
||||
/// edits propagate across replicas without a file-watcher invalidation
|
||||
/// dance.
|
||||
///
|
||||
/// Only one row exists, with <see cref="Id"/> = 1. Reading is a single
|
||||
/// SELECT (cheap; the board is one document, not paginated). Writing
|
||||
/// is a single UPDATE — no synchronization needed because there's only
|
||||
/// ever one row to contend on, and admin edits are rare.
|
||||
/// </summary>
|
||||
public class CommunityBoardEntity
|
||||
{
|
||||
/// <summary>Always 1. We model it as an int PK rather than a
|
||||
/// composite or singleton-table pattern because EF Core needs a key
|
||||
/// to track changes; pinning to id=1 keeps the upsert pattern
|
||||
/// simple.</summary>
|
||||
public int Id { get; set; } = 1;
|
||||
|
||||
/// <summary>JSON-serialised <c>CommunityBoardState</c>. Stored as
|
||||
/// text rather than jsonb because we never query inside it — it's
|
||||
/// loaded as a single blob and round-tripped to the client.</summary>
|
||||
public string Json { get; set; } = "{}";
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Admin-issued promo coupon. Players redeem by code via
|
||||
/// <c>POST /api/coupons/v1/redeem/{code}</c>. Each redemption
|
||||
/// inserts a <see cref="CouponRedemptionEntity"/> row (one per
|
||||
/// (coupon, player) pair so we can enforce one-redeem-per-player).
|
||||
/// </summary>
|
||||
public class CouponEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
[MaxLength(64)]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(256)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>RewardType: 0=Currency, 1=ItemSlug, 2=Subscription.</summary>
|
||||
public int RewardType { get; set; }
|
||||
|
||||
/// <summary>Currency type if RewardType=0; otherwise unused.</summary>
|
||||
public int RewardCurrencyType { get; set; }
|
||||
|
||||
public long RewardAmount { get; set; }
|
||||
|
||||
/// <summary>Item slug if RewardType=1.</summary>
|
||||
[MaxLength(64)]
|
||||
public string RewardItemSlug { get; set; } = string.Empty;
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
public int MaxRedemptions { get; set; } = 0;
|
||||
public int RedemptionCount { get; set; } = 0;
|
||||
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class CouponRedemptionEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long CouponId { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
public DateTime RedeemedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per-player wallet for a single currency. The 2020 client knows
|
||||
/// about a small set of <c>CurrencyType</c>s (1=Coins, 2=Tokens, …);
|
||||
/// each gets its own row keyed by (PlayerId, CurrencyType).
|
||||
///
|
||||
/// Updates go through <see cref="DorkNet.Server.Services.LevelService"/>
|
||||
/// or admin grants; reads back through the
|
||||
/// <c>storefronts/v4/balance/{currencyType}</c> endpoint.
|
||||
/// </summary>
|
||||
public class CurrencyBalanceEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
public int CurrencyType { get; set; }
|
||||
public long Balance { get; set; }
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per-player dorm state. Decouples each player's dorm save bytes from
|
||||
/// the canonical RoomEntity row at <c>RoomId=1</c> (which is shared
|
||||
/// across every dorm visit and would otherwise leak one player's save
|
||||
/// into every other player's dorm).
|
||||
///
|
||||
/// One row per account that has ever entered their dorm. Created on the
|
||||
/// first <c>/api/rooms/v4/details/1</c> hit and updated whenever the
|
||||
/// player saves their dorm via Maker Pen.
|
||||
/// </summary>
|
||||
public class DormStateEntity
|
||||
{
|
||||
/// <summary>The owning account id. Primary key — one dorm state row
|
||||
/// per player.</summary>
|
||||
[Key]
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
/// <summary>Blob name for the player's most recent dorm save. Format
|
||||
/// <c>dorm_p<accountId>_v<n>.dat</c>; matches the
|
||||
/// <see cref="RoomDataBlobEntity.BlobName"/> stored when the upload
|
||||
/// landed. Empty string before the player has saved.</summary>
|
||||
[MaxLength(128)]
|
||||
public string CurrentDataBlobName { get; set; } = string.Empty;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Active game session backing the matchmaking <c>JoinOrCreate</c> flow
|
||||
/// in <see cref="DorkNet.Server.Services.GameSessionService"/>. Replaces
|
||||
/// the in-process <c>ConcurrentDictionary</c> + atomic counter so:
|
||||
/// <list type="bullet">
|
||||
/// <item>Session ids are allocated by Postgres bigserial — no
|
||||
/// replica-local counter that collides with another replica's
|
||||
/// after restart.</item>
|
||||
/// <item>A "find a non-full session in this room+region" query
|
||||
/// coordinates across all replicas; without it, two players
|
||||
/// joining via different replicas could each create their own
|
||||
/// fresh session for the same room and end up isolated.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// Sessions are deleted when their PlayerCount hits 0 (handled in the
|
||||
/// service, no FK cascade needed). Brief mid-rejoin flicker is fine —
|
||||
/// the next JoinOrCreate just creates a new session with a new id.
|
||||
/// </summary>
|
||||
public class GameSessionEntity
|
||||
{
|
||||
/// <summary>bigserial — allocated by Postgres on insert. Crosses
|
||||
/// replicas safely because the sequence is shared.</summary>
|
||||
public long Id { get; set; }
|
||||
|
||||
[MaxLength(64)] public string RoomId { get; set; } = string.Empty;
|
||||
[MaxLength(64)] public string ActivityLevelId { get; set; } = string.Empty;
|
||||
[MaxLength(16)] public string Region { get; set; } = "us";
|
||||
[MaxLength(128)] public string PhotonRoomName { get; set; } = string.Empty;
|
||||
public int MaxCapacity { get; set; } = 8;
|
||||
public int PlayerCount { get; set; } = 0;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per <c>RecNet.Avatars+GiftPackage</c> wire shape (decompiled at
|
||||
/// <c>Cpp2IL_CS/.../RecNet/Avatars.cs:192-555</c>) — a queued gift the
|
||||
/// recipient can claim from the watch's "gifts" inbox. Used for both
|
||||
/// admin-granted gifts and gift-drop / mystery-box / level-up prizes.
|
||||
///
|
||||
/// Field names + types match the client's
|
||||
/// <c>GiftPackage.Deserialize(Dictionary<String, Object>)</c>
|
||||
/// directly so the watch round-trips cleanly. <see cref="Consumed"/>
|
||||
/// is the bool the client reads — we additionally stamp
|
||||
/// <see cref="ConsumedAt"/> for server-side bookkeeping (not on wire).
|
||||
/// </summary>
|
||||
public class GiftPackageEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>The player whose inbox this gift sits in.</summary>
|
||||
public long RecipientPlayerId { get; set; }
|
||||
|
||||
/// <summary>Wire field (Nullable<Int32>) — the player who
|
||||
/// sent the gift, or null for system / drop gifts.</summary>
|
||||
public int? FromPlayerId { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string ConsumableItemDesc { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Wire field <c>AvatarItemType</c> as Nullable<int>
|
||||
/// (0 = Outfit, 1 = HairDye). Null when the gift has no avatar
|
||||
/// item (currency-only, equipment-only, etc.).</summary>
|
||||
public int? AvatarItemType { get; set; }
|
||||
|
||||
/// <summary>Wire field name is exactly <c>AvatarItemDescOrHairDyeDesc</c>
|
||||
/// — content is either the AvatarItem GUID (when AvatarItemType=0)
|
||||
/// or the HairDye desc string (when AvatarItemType=1).</summary>
|
||||
[MaxLength(128)]
|
||||
public string AvatarItemDescOrHairDyeDesc { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(128)]
|
||||
public string EquipmentPrefabName { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(64)]
|
||||
public string EquipmentModificationGuid { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>CurrencyType enum: 0=Invalid, 1=LaserTagTickets,
|
||||
/// 2=RecCenterTokens, 100=LostSkullsGold, 101=DraculaSilver,
|
||||
/// 200=RecRoyale_Season1.</summary>
|
||||
public int CurrencyType { get; set; } = 0;
|
||||
|
||||
public int Currency { get; set; } = 0;
|
||||
public int Xp { get; set; } = 0;
|
||||
public int Level { get; set; } = 0;
|
||||
|
||||
/// <summary>GiftContext enum (large, see Avatars.cs comments).
|
||||
/// 0 = Default, 100 = LevelUp, 1000 = Holiday, etc.</summary>
|
||||
public int GiftContext { get; set; } = 0;
|
||||
|
||||
/// <summary>GiftRarity: -1=None, 0=Common, 10=Uncommon, 20=Rare,
|
||||
/// 30=Epic, 50=Legendary.</summary>
|
||||
public int GiftRarity { get; set; } = 0;
|
||||
|
||||
[MaxLength(512)]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>PlatformType: -1=All, 0=Steam, 1=Oculus, 2=PlayStation,
|
||||
/// 3=Microsoft, 4=HeadlessBot, 5=IOS.</summary>
|
||||
public int Platform { get; set; } = -1;
|
||||
|
||||
/// <summary>Asset name string for the Unity Material that the
|
||||
/// gift box renders with.</summary>
|
||||
[MaxLength(128)]
|
||||
public string PackageMaterial { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Asset name string for the GiftPackageVariant
|
||||
/// ScriptableObject (e.g. "Standard", "Holiday", etc.).</summary>
|
||||
[MaxLength(128)]
|
||||
public string PackageVariant { get; set; } = string.Empty;
|
||||
|
||||
public bool Consumed { get; set; } = false;
|
||||
public bool IsValid { get; set; } = true;
|
||||
|
||||
[MaxLength(512)]
|
||||
public string ErrorMessage { get; set; } = string.Empty;
|
||||
|
||||
public bool SupportsCurrentPlatform { get; set; } = true;
|
||||
public bool IsGifted { get; set; } = false;
|
||||
|
||||
/// <summary>Server-side audit timestamp; not part of wire
|
||||
/// shape.</summary>
|
||||
public DateTime? ConsumedAt { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A Maker-Pen creation that the player has saved as a reusable
|
||||
/// "invention" — can be re-spawned in any room they enter, shared with
|
||||
/// other players, or featured on the watch's Inventions tab. The
|
||||
/// underlying SpawnableTemplateData protobuf bytes are uploaded via
|
||||
/// <c>storage.rec.net/upload</c> with FileType=Invention (5).
|
||||
/// </summary>
|
||||
public class InventionEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public long CreatorPlayerId { get; set; }
|
||||
|
||||
/// <summary>Stable opaque GUID the client uses to deduplicate
|
||||
/// instantiated inventions across rooms (matches
|
||||
/// <c>Invention.ReplicationId</c>). Generated server-side at
|
||||
/// create time.</summary>
|
||||
[MaxLength(64)]
|
||||
public string ReplicationId { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(128)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(256)]
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Legacy permission column (private=0/friends=1/public=2)
|
||||
/// — kept for backward-compat with existing rows; new logic uses
|
||||
/// <see cref="GeneralPermission"/> + <see cref="CreatorPermission"/>
|
||||
/// (InventionPermission enum: Unassigned=0, LimitedOneUseOnly=10,
|
||||
/// UseOnly=20, EditAndSave=40, Publish=60, Charge=80,
|
||||
/// Unlimited=100).</summary>
|
||||
public int Permission { get; set; } = 0;
|
||||
|
||||
/// <summary>InventionPermission value the creator gets when
|
||||
/// spawning their own creation (default Unlimited=100).</summary>
|
||||
public int CreatorPermission { get; set; } = 100;
|
||||
|
||||
/// <summary>InventionPermission value other players get when
|
||||
/// spawning. Defaults Unassigned=0 (private).</summary>
|
||||
public int GeneralPermission { get; set; } = 0;
|
||||
|
||||
/// <summary>True once <c>POST api/inventions/v2/publish</c> sets
|
||||
/// a non-Unassigned <see cref="GeneralPermission"/>. Stays true
|
||||
/// across re-edits — the wire field tracks "has this ever been
|
||||
/// published".</summary>
|
||||
public bool IsPublished { get; set; } = false;
|
||||
|
||||
/// <summary>1-based latest version index. Bumped each time
|
||||
/// <c>POST api/inventions/v3/addversion</c> uploads new bytes.
|
||||
/// Mirrors <c>Invention.CurrentVersionNumber</c>.</summary>
|
||||
public int CurrentVersionNumber { get; set; } = 1;
|
||||
|
||||
/// <summary>Set on first publish. Null until then.</summary>
|
||||
public DateTime? FirstPublishedAt { get; set; }
|
||||
|
||||
/// <summary>If the creator was in a room when first saving,
|
||||
/// the originating room id for analytics.</summary>
|
||||
public long? CreationRoomId { get; set; }
|
||||
|
||||
/// <summary>How many distinct players have spawned this
|
||||
/// invention in any room (rolling counter — increments on
|
||||
/// download).</summary>
|
||||
public int NumPlayersHaveUsedInRoom { get; set; } = 0;
|
||||
|
||||
/// <summary>True for AG (community-built) inventions, false for
|
||||
/// "Rec Room Original" / staff-baked ones.</summary>
|
||||
public bool IsAgInvention { get; set; } = true;
|
||||
|
||||
/// <summary>Most recent uploaded blob name. Format
|
||||
/// <c>invention_<id>_v<n>.dat</c>; the cdn catch-all serves the
|
||||
/// matching <see cref="RoomDataBlobEntity"/> bytes.</summary>
|
||||
[MaxLength(128)]
|
||||
public string CurrentBlobName { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(1024)]
|
||||
public string TagsCsv { get; set; } = string.Empty;
|
||||
|
||||
public int CheerCount { get; set; } = 0;
|
||||
public int SpawnCount { get; set; } = 0;
|
||||
|
||||
/// <summary>Soft-delete flag. When set, the row is hidden from
|
||||
/// browse / search / detail endpoints but stays in the table so
|
||||
/// existing references (cheers, reports) keep resolving and the
|
||||
/// blob bytes can be restored if needed.</summary>
|
||||
public bool IsDeleted { get; set; } = false;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One historical version of an <see cref="InventionEntity"/>. Each
|
||||
/// time the creator uploads a new SpawnableTemplateData blob via
|
||||
/// <c>POST api/inventions/v3/addversion</c>, we snapshot the prior
|
||||
/// blob name into a new row here so the watch's "version history"
|
||||
/// tab and `GET api/inventions/v1/versions?inventionId=X` can list
|
||||
/// them. Indexed on (InventionId, VersionNumber DESC) for the
|
||||
/// common "show me the version list" query.
|
||||
/// </summary>
|
||||
public class InventionVersionEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long InventionId { get; set; }
|
||||
|
||||
/// <summary>Mirrors <c>InventionVersion.ReplicationId</c> — opaque
|
||||
/// GUID the client uses to deduplicate spawns of this specific
|
||||
/// version across rooms.</summary>
|
||||
[MaxLength(64)]
|
||||
public string ReplicationId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>1-based version index. Auto-assigned to
|
||||
/// max(VersionNumber) + 1 on insert.</summary>
|
||||
public int VersionNumber { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string BlobName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Tokens it costs to spawn this version of the
|
||||
/// invention (decompiled wire field
|
||||
/// <c>InventionVersion.InstantiationCost</c>).</summary>
|
||||
public int InstantiationCost { get; set; } = 0;
|
||||
|
||||
/// <summary>Watt budget the creation consumes
|
||||
/// (<c>InventionVersion.LightsCost</c>).</summary>
|
||||
public int LightsCost { get; set; } = 0;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// IP-level ban for blocking misbehaving traffic before the JWT-based
|
||||
/// player ban can apply. Checked by <c>IpBanCheckMiddleware</c> ahead
|
||||
/// of authentication; matching requests get a 403.
|
||||
///
|
||||
/// CIDR notation lets a single row block a /24 or /32. The expiry is
|
||||
/// optional — null means "permanent until manually removed".
|
||||
/// </summary>
|
||||
public class IpBanEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>CIDR or single-IP string (<c>1.2.3.4</c> or <c>1.2.3.0/24</c>).
|
||||
/// Compared against <c>HttpContext.Connection.RemoteIpAddress</c>.</summary>
|
||||
[MaxLength(64)]
|
||||
public string Cidr { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(512)]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
public long BannedByAdminId { get; set; }
|
||||
public DateTime BannedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Null = permanent.</summary>
|
||||
public DateTime? Until { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Admin-curated metadata for a leaderboard <c>StatChannel</c> id. The
|
||||
/// 2020 watch reports raw integer channel ids via
|
||||
/// <c>POST api/Leaderboard/v1/SetStats</c>; this table lets admins map
|
||||
/// each channel to a specific room (so the room detail page can list
|
||||
/// "its" leaderboards) and a human-readable name + ordering hint. Rows
|
||||
/// here override the hardcoded <c>AdminController.KnownStatChannels</c>
|
||||
/// fallback. Channels with no row stay listed as "Channel N".
|
||||
///
|
||||
/// One channel id, one row. Repeated <c>SetStats</c> calls for the same
|
||||
/// channel from any room write to that channel's value rows; the meta
|
||||
/// row only changes when an admin renames or re-maps it.
|
||||
/// </summary>
|
||||
public class LeaderboardChannelMetaEntity
|
||||
{
|
||||
/// <summary>Same int the watch reports for this channel. Primary
|
||||
/// key — there's only ever one meta row per channel.</summary>
|
||||
public int Channel { get; set; }
|
||||
|
||||
/// <summary>Room this channel "belongs to" for the per-room detail
|
||||
/// view. 0 = global / unscoped (e.g. cross-room player progression
|
||||
/// counters). The same room can own many channels (e.g. Stunt
|
||||
/// Runner has one channel per course).</summary>
|
||||
public long RoomId { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>When true, a lower stored value is a better score (race
|
||||
/// times, lap times, etc.). The list endpoint sorts ascending.</summary>
|
||||
public bool LowerIsBetter { get; set; }
|
||||
|
||||
/// <summary>UI hint: "count" (default), "time-ms" for elapsed-time
|
||||
/// channels, "score" for points. The SPA formats accordingly.</summary>
|
||||
public string ValueFormat { get; set; } = "count";
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Tip shown on the dorm-load splash. Served via
|
||||
/// <c>cdn.{rec.net,localhost}/config/LoadingScreenTipData</c>; edited via
|
||||
/// the admin SPA. Wire shape per <c>RecNet/LoadingScreenTip.cs</c>
|
||||
/// requires Title/Message/ImageName + Context/PlatformMask/HasImage
|
||||
/// flags + an optional RoomNames list (scopes the tip to specific
|
||||
/// rooms).
|
||||
/// </summary>
|
||||
public class LoadingScreenTipEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
[MaxLength(128)] public string Title { get; set; } = string.Empty;
|
||||
[MaxLength(512)] public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional uploaded image (RoomDataBlob filename). Empty
|
||||
/// when the tip is text-only; CdnController sets
|
||||
/// <c>HasImage</c> in the wire payload based on this.</summary>
|
||||
[MaxLength(128)] public string ImageName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>RecNet LoadingScreenContext enum (0 = Any). Controls
|
||||
/// which loading screens the tip is eligible for.</summary>
|
||||
public int Context { get; set; } = 0;
|
||||
|
||||
/// <summary>PlatformMask bitfield (-1 = every platform). Used by
|
||||
/// the watch to filter out tips that reference platform-specific
|
||||
/// features (e.g. mobile-only).</summary>
|
||||
public int PlatformMask { get; set; } = -1;
|
||||
|
||||
/// <summary>CSV of room names this tip is scoped to. Empty = show
|
||||
/// in any room's loading screen. Example: "DormRoom,MakerRoom".</summary>
|
||||
[MaxLength(512)] public string RoomNamesCsv { get; set; } = string.Empty;
|
||||
|
||||
public int SortOrder { get; set; } = 0;
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Direct message between two players. Backs the watch's
|
||||
/// <c>api/messages/v2</c> endpoints (inbox + send + mark-read).
|
||||
///
|
||||
/// One row per message. Threading happens at read time — the inbox
|
||||
/// query groups by (Sender, Recipient) pair to surface the most
|
||||
/// recent message per conversation.
|
||||
/// </summary>
|
||||
public class MessageEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public long SenderPlayerId { get; set; }
|
||||
public long RecipientPlayerId { get; set; }
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string Body { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>RecNet <c>Message.MessageType</c> wire value (see
|
||||
/// <c>Cpp2IL_CS/.../RecNet/Message.cs</c>). Default 30 =
|
||||
/// TextMessage. Other values used by the 2020 client: 0/6 =
|
||||
/// GameInvite[V2], 3/7 = PartyActivitySwitch[V2], 10 =
|
||||
/// RequestGameInvite (the "ask to join" flow), 1 =
|
||||
/// GameInviteDeclined, 11 = RequestGameInviteDeclined, 20 =
|
||||
/// FriendStatusOnline.</summary>
|
||||
public int Type { get; set; } = 30;
|
||||
|
||||
/// <summary>Optional RecNet room id carried by invite and party
|
||||
/// activity messages. The client uses this to render room-aware
|
||||
/// cards and passes it back when accepting V2 invites.</summary>
|
||||
public long? RoomId { get; set; }
|
||||
|
||||
public DateTime SentAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Null until the recipient hits the message in the
|
||||
/// watch UI; set to the read timestamp at that point.</summary>
|
||||
public DateTime? ReadAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per-player objective / achievement progress. The 2020 client's
|
||||
/// <c>Objectives.MyProgress</c> response is two arrays — Objectives
|
||||
/// and ObjectiveGroups — keyed by string id. We collapse that to one
|
||||
/// table here; group rows distinguish themselves with a leading
|
||||
/// <c>group:</c> prefix on <see cref="Key"/>.
|
||||
/// </summary>
|
||||
public class ObjectiveProgressEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
/// <summary>Objective id from the client's hardcoded objective
|
||||
/// table (e.g. <c>tutorial_dorm_decorate</c>). Group rows use
|
||||
/// <c>group:<groupId></c>.</summary>
|
||||
[MaxLength(128)]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
public bool IsCompleted { get; set; }
|
||||
public DateTime? ClearedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One in-game camera photo, posted to the social feed. The actual
|
||||
/// pixel bytes live in <see cref="RoomDataBlobEntity"/> keyed by
|
||||
/// <see cref="BlobName"/> (uploaded earlier via storage.rec.net's
|
||||
/// /upload endpoint with FileType=Image). This entity wraps that blob
|
||||
/// with the social metadata: who took it, where, who's tagged in it,
|
||||
/// when it was posted, and how many cheers it has.
|
||||
///
|
||||
/// Public photos are visible without auth on feed.rec.net; private
|
||||
/// photos are only listable by the uploader (and admins). Soft-delete
|
||||
/// via <see cref="DeletedAt"/> so moderation can take a photo down
|
||||
/// without losing the audit trail.
|
||||
/// </summary>
|
||||
public class PhotoEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public long UploaderPlayerId { get; set; }
|
||||
|
||||
/// <summary>The cdn filename — the same string returned by
|
||||
/// storage.rec.net/upload and used in the cdn.rec.net/{name}
|
||||
/// download path. Indexed because feed lookups join blob bytes
|
||||
/// via this key.</summary>
|
||||
[MaxLength(256)]
|
||||
public string BlobName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional caption typed on the watch's "Share" panel
|
||||
/// before posting. Limited so a single photo can't grow the
|
||||
/// table row unbounded.</summary>
|
||||
[MaxLength(2048)]
|
||||
public string Caption { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The room the photo was taken in (resolved from
|
||||
/// PlayerPresenceService at upload time). 0 if the player wasn't
|
||||
/// in any room (rare — only happens during loading).</summary>
|
||||
public long RoomId { get; set; }
|
||||
|
||||
/// <summary>Comma-separated list of tagged player IDs. The watch
|
||||
/// builds this from the players visible in the camera's frustum
|
||||
/// when the shutter was pressed. Tagged players show up in their
|
||||
/// own "Photos of me" feed.</summary>
|
||||
[MaxLength(2048)]
|
||||
public string TaggedPlayerIdsCsv { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>If false, photo only appears in the uploader's own
|
||||
/// "My photos" page (and admin views); if true, it shows up on
|
||||
/// the public feed.rec.net feed and any tagged player's
|
||||
/// "Photos of me" tab. Defaults to true since the camera's
|
||||
/// "Share" button is the natural action — making it private is
|
||||
/// an explicit toggle.</summary>
|
||||
public bool IsPublic { get; set; } = true;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Soft-delete sentinel — non-null means the photo is
|
||||
/// hidden from every feed but still in the table for audit.
|
||||
/// Admin moderation tools set this; the bytes blob is kept until
|
||||
/// a separate cleanup job decides the audit window has elapsed.</summary>
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
|
||||
/// <summary>Denormalised cheer counter — incremented when a
|
||||
/// CheerEntity row is inserted with TargetPhotoId=this.Id, so the
|
||||
/// feed sort doesn't have to JOIN+aggregate every render.</summary>
|
||||
public int CheerCount { get; set; }
|
||||
|
||||
public int ViewCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
public class PlayerEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
// Stable per-installation identifier the client sends as `deviceId` in
|
||||
// platformlogin / account-create form bodies. We deliberately use this
|
||||
// (not `platformId`) so accounts are NOT keyed to Steam IDs — anyone
|
||||
// running the client gets a unique persistent account based on the
|
||||
// hardware-derived device hash, regardless of which Steam emulator
|
||||
// (Goldberg, none, real Steam) is in front. Indexed unique below.
|
||||
[MaxLength(128)]
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
|
||||
// Last-seen platform info — informational, NOT part of account identity.
|
||||
public int LastPlatform { get; set; } = 0;
|
||||
|
||||
[MaxLength(128)]
|
||||
public string LastPlatformId { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(64)]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(64)]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(512)]
|
||||
public string Bio { get; set; } = string.Empty;
|
||||
|
||||
public int Level { get; set; } = 1;
|
||||
public int XP { get; set; } = 0;
|
||||
public int Reputation { get; set; } = 0;
|
||||
public bool IsVerified { get; set; } = false;
|
||||
|
||||
// New accounts default to non-developer. Admins grant the badge
|
||||
// privilege via the SPA's Players → Profile flags tab on a
|
||||
// per-player basis (used to default to true while we were
|
||||
// bootstrapping the emulator; flipped off now that the build is
|
||||
// stable and we don't want every walk-in to spawn with the dev
|
||||
// console unlocked).
|
||||
public bool IsDeveloper { get; set; } = false;
|
||||
// Mirrors IsDeveloper for the overhead badge slider — the 2020 watch
|
||||
// gates the in-settings "Developer Display Mode" slider on a single
|
||||
// role check (`role/developer/{id}`), and the slider's positions
|
||||
// render as "Community Team" / "Developer" above the player's head
|
||||
// (verified in Cpp2IL_ISIL/.../PlayerUI.txt:9085-9099 — slider value
|
||||
// 1 → "Community Team", 2 → "Developer"). Keep this field separate
|
||||
// from IsDeveloper so admins can mark someone as community-team
|
||||
// without granting full dev privileges; the role endpoint ORs both.
|
||||
public bool IsCommunityTeam { get; set; } = false;
|
||||
public bool CanReceiveInvites { get; set; } = true;
|
||||
public bool IsJunior { get; set; } = false;
|
||||
|
||||
[MaxLength(256)]
|
||||
public string? ProfileImageName { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// ── Phase 1 account hardening ─────────────────────────────────────────
|
||||
// Optional contact email. Nullable because the 2020 client never asks
|
||||
// for one; reserved for future password-reset / admin-recovery flows.
|
||||
[MaxLength(256)]
|
||||
public string? Email { get; set; }
|
||||
|
||||
// When non-null AND `> DateTime.UtcNow`, BanCheckMiddleware short-circuits
|
||||
// every authenticated request from this account with 401. Setting to a
|
||||
// past timestamp acts as a "previously banned" audit marker without
|
||||
// blocking access.
|
||||
public DateTime? BannedUntil { get; set; }
|
||||
|
||||
// Privilege flag for `[AdminOnly]` endpoints (Phase 5). Defaults false;
|
||||
// PlayerService bumps this to true when the very first account on a
|
||||
// fresh DB is created so there's always at least one root admin.
|
||||
public bool IsAdmin { get; set; } = false;
|
||||
|
||||
// Last IP that authenticated against this account. Updated by the
|
||||
// request-tracing pipeline so we don't write IPs from anonymous paths.
|
||||
[MaxLength(64)]
|
||||
public string? LastIp { get; set; }
|
||||
|
||||
// The deviceId that originally created this account. Immutable after
|
||||
// creation — exists for the audit trail in case the live DeviceId
|
||||
// rotates (e.g., user re-installs and the hardware hash shifts; the
|
||||
// account stays bound to whatever they typed in /account/create the
|
||||
// first time).
|
||||
[MaxLength(128)]
|
||||
public string? CreatedFromDeviceId { get; set; }
|
||||
|
||||
// BCP-47 locale tag (e.g. "en-US"). Optional; informs future
|
||||
// localisation work and is currently set from the client's accept-
|
||||
// language header at login.
|
||||
[MaxLength(16)]
|
||||
public string? Locale { get; set; }
|
||||
|
||||
/// <summary>BCrypt hash of the account password. Null when the
|
||||
/// account has never set one — the in-game settings screen calls
|
||||
/// /account/me/haspassword and prompts the user to create one
|
||||
/// before sensitive operations. Format: BCrypt's standard
|
||||
/// `$2a$<cost>$<salt><hash>` so the verifier
|
||||
/// reads cost + salt directly, no separate column needed.</summary>
|
||||
[MaxLength(128)]
|
||||
public string? PasswordHash { get; set; }
|
||||
|
||||
/// <summary>Player's birthday (date only; no timezone). Set during
|
||||
/// signup and used to derive <see cref="IsJunior"/> on Junior cutoff
|
||||
/// boundary crossing. Nullable for legacy accounts created before
|
||||
/// the field existed.</summary>
|
||||
public DateTime? Birthday { get; set; }
|
||||
|
||||
/// <summary>Optional phone number — used by the watch's
|
||||
/// account-recovery flow. Stored verbatim, never validated.</summary>
|
||||
[MaxLength(32)]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
// Navigation
|
||||
public AvatarEntity? Avatar { get; set; }
|
||||
public List<PlayerSettingEntity> Settings { get; set; } = [];
|
||||
public List<RelationshipEntity> Relationships { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A player-scheduled gathering ("event") in a specific room. Backs
|
||||
/// the watch's Events tab — players can RSVP via
|
||||
/// <see cref="PlayerEventResponseEntity"/>.
|
||||
/// </summary>
|
||||
public class PlayerEventEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public long CreatorPlayerId { get; set; }
|
||||
public long RoomId { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public DateTime StartsAt { get; set; }
|
||||
public DateTime EndsAt { get; set; }
|
||||
|
||||
public int Capacity { get; set; } = 0;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>One row per RSVP'd player + event.</summary>
|
||||
public class PlayerEventResponseEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long EventId { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
/// <summary>0=Going, 1=Maybe, 2=NotGoing — matches the watch's
|
||||
/// three-state RSVP toggle.</summary>
|
||||
public int Response { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One row per (player, item) pair. Backs both the
|
||||
/// <c>/api/equipment/v2/getUnlocked</c> and
|
||||
/// <c>/api/consumables/v1/getUnlocked</c> endpoints — the type comes
|
||||
/// from the joined <see cref="StoreItemEntity.Category"/>.
|
||||
///
|
||||
/// For equipment, <see cref="Quantity"/> is always 1 and
|
||||
/// <see cref="IsActive"/> tracks whether it's equipped. For
|
||||
/// consumables, <see cref="Quantity"/> is the stack size and
|
||||
/// <see cref="IsActive"/> tracks whether the player has it favourited /
|
||||
/// quick-equipped on the watch.
|
||||
/// </summary>
|
||||
public class PlayerInventoryEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
/// <summary>FK by string to <see cref="StoreItemEntity.Slug"/>.
|
||||
/// Stored as a string rather than an int FK because a player can
|
||||
/// be granted items that aren't in the live store (admin-gifted,
|
||||
/// legacy retired items, etc.).</summary>
|
||||
[MaxLength(64)]
|
||||
public string ItemSlug { get; set; } = string.Empty;
|
||||
|
||||
public int Quantity { get; set; } = 1;
|
||||
public bool IsActive { get; set; } = false;
|
||||
|
||||
public DateTime AcquiredAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
public class PlayerSettingEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(1024)]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
public PlayerEntity? Player { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One per (player, deviceId) — recorded on
|
||||
/// <c>POST /api/deviceid/v1/register</c>. Lets admins flag
|
||||
/// duplicate-account abuse: when a banned player creates a new
|
||||
/// account on the same device, the new account inherits the device
|
||||
/// ban automatically.
|
||||
/// </summary>
|
||||
public class PlayerDeviceEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(32)]
|
||||
public string Platform { get; set; } = string.Empty;
|
||||
|
||||
public DateTime FirstSeen { get; set; } = DateTime.UtcNow;
|
||||
public DateTime LastSeen { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Set by admin tooling — if true, every account using
|
||||
/// this device is auto-banned at login.</summary>
|
||||
public bool IsBanned { get; set; } = false;
|
||||
}
|
||||
|
||||
/// <summary>Per-player notification preferences. iOS-style toggle
|
||||
/// page; Android / Quest just persist the same shape.</summary>
|
||||
public class NotificationPrefsEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
[MaxLength(32)]
|
||||
public string Platform { get; set; } = string.Empty;
|
||||
|
||||
public bool AllowAll { get; set; } = true;
|
||||
public bool AllowFriendRequest { get; set; } = true;
|
||||
public bool AllowMessage { get; set; } = true;
|
||||
public bool AllowEventInvite { get; set; } = true;
|
||||
public bool AllowAnnouncements { get; set; } = true;
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>Sticky cohort assignment — one row per (player, cohort
|
||||
/// key). Created on first hit to <c>GET /api/cohort/v1/{key}</c> with
|
||||
/// a randomly selected variant; remains stable for the lifetime of
|
||||
/// the player so A/B tests don't flicker between sessions.</summary>
|
||||
public class CohortAssignmentEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
[MaxLength(64)]
|
||||
public string CohortKey { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(32)]
|
||||
public string Variant { get; set; } = string.Empty;
|
||||
|
||||
public DateTime AssignedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,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,60 @@
|
||||
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>Comma-separated list of file names referenced by the
|
||||
/// PersistedRoomData (e.g. invention thumbnails, audio clips). The
|
||||
/// client passes these via the upload's referencedFilenames argument;
|
||||
/// the server uses them later when GC'ing orphaned blobs.</summary>
|
||||
[MaxLength(2048)]
|
||||
public string ReferencedFilenamesCsv { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Legacy bytes column. The codebase no longer writes to this — S3
|
||||
/// is the canonical store — but rows imported / uploaded before the
|
||||
/// S3-only cutover still hold their bytes here, and the backfill
|
||||
/// migrator (<c>POST /api/admin/v1/storage/backfill</c>) reads them
|
||||
/// to upload into S3. After backfill is verified, a follow-up EF
|
||||
/// migration drops this column entirely and only metadata remains.
|
||||
/// Nullable so freshly-inserted rows can omit it without breaking
|
||||
/// SQLite's NOT NULL invariant.
|
||||
/// </summary>
|
||||
public byte[]? Bytes { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A user-creatable Rec Room "room" — has a name, a creator, a hub scene
|
||||
/// (the AGRoomRuntimeConfig.Locations entry it spawns into), and metadata
|
||||
/// for the watch UI's room browser.
|
||||
///
|
||||
/// Naming maps: in the wire JSON the client expects mostly PascalCase
|
||||
/// (Room.Deserialize at RVA 0x114E430). Property names match.
|
||||
///
|
||||
/// Two-way relationship with PlayerEntity for ownership / bookmark / role
|
||||
/// browsing — kept on the room side as ID columns rather than a navigation
|
||||
/// to keep PlayerEntity unchanged.
|
||||
/// </summary>
|
||||
public class RoomEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The player who created the room (NOT the dorm owner — every
|
||||
/// real Rec Room has a creator player id; for seeded "RecRoomOriginal"
|
||||
/// rooms we set this to 1 / the system account).</summary>
|
||||
public long CreatorPlayerId { get; set; }
|
||||
|
||||
[MaxLength(256)]
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>RoomState enum (int): 0 = Active, 1 = Archived.</summary>
|
||||
public int State { get; set; } = 0;
|
||||
|
||||
/// <summary>RoomAccessibility enum (int): 0 = Private, 1 = Public,
|
||||
/// 2 = LimitedAccessibility / FriendsOnly.</summary>
|
||||
public int Accessibility { get; set; } = 1;
|
||||
|
||||
public bool SupportsLevelVoting { get; set; } = false;
|
||||
|
||||
/// <summary>True for AG (user-built) rooms, false for "Rec Room
|
||||
/// Original" rooms. The watch tabs `#community` (AG) and
|
||||
/// `#recroomoriginal` (RR Originals) filter on this.</summary>
|
||||
public bool IsAGRoom { get; set; } = true;
|
||||
|
||||
public bool IsDormRoom { get; set; } = false;
|
||||
public bool CloningAllowed { get; set; } = false;
|
||||
public bool SupportsVRLow { get; set; } = true;
|
||||
public bool SupportsMobile { get; set; } = false;
|
||||
public bool SupportsScreens { get; set; } = true;
|
||||
public bool SupportsWalkVR { get; set; } = true;
|
||||
public bool SupportsTeleportVR { get; set; } = true;
|
||||
public bool AllowsJuniors { get; set; } = true;
|
||||
public int RoomWarningMask { get; set; } = 0;
|
||||
|
||||
[MaxLength(512)]
|
||||
public string CustomRoomWarning { get; set; } = string.Empty;
|
||||
|
||||
public bool DisableMicAutoMute { get; set; } = false;
|
||||
|
||||
/// <summary>RoomScene.RoomSceneLocationId GUID — foreign-keys to a
|
||||
/// Locations[] entry in resources.assets. This is what
|
||||
/// AGRoomSettings.TryGetRoomSceneLocationById matches.
|
||||
/// Default: DormRoom location GUID extracted from the asset.</summary>
|
||||
[MaxLength(64)]
|
||||
public string LocationReplicationId { get; set; } = "76d98498-60a1-430c-ab76-b54a29b7a163";
|
||||
|
||||
/// <summary>Comma-separated tags. The 2020 watch supports a small set
|
||||
/// (#community, #recroomoriginal, #featured, etc.) — keeping it as a
|
||||
/// flat string avoids a join table for the v0 watch implementation.</summary>
|
||||
[MaxLength(1024)]
|
||||
public string TagsCsv { get; set; } = string.Empty;
|
||||
|
||||
public int CheerCount { get; set; } = 0;
|
||||
public int FavoriteCount { get; set; } = 0;
|
||||
|
||||
/// <summary>Total joins across all players (every <c>/goto</c>
|
||||
/// counts; same player rejoining bumps it again). Mirrors
|
||||
/// official Rec.Net <c>Stats.VisitCount</c>.</summary>
|
||||
public int VisitCount { get; set; } = 0;
|
||||
|
||||
/// <summary>Distinct players who've ever visited (each player
|
||||
/// counts once no matter how many times they rejoin). Mirrors
|
||||
/// official Rec.Net <c>Stats.VisitorCount</c>. Bumped only when
|
||||
/// <see cref="RoomVisitEntity"/> inserts a new row for the
|
||||
/// (room, player) pair.</summary>
|
||||
public int VisitorCount { get; set; } = 0;
|
||||
|
||||
/// <summary>Score the watch uses to sort the "Hot" tab. Higher = more
|
||||
/// visible. Seeded rooms get bumped above empty user rooms so they show
|
||||
/// up first in the browser.</summary>
|
||||
public double HotScore { get; set; } = 0;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>True keeps the room out of room browse / search /
|
||||
/// "RR Originals" feeds while leaving <c>/goto</c>, clone, and
|
||||
/// admin-tool access intact. Used for rooms that exist in the seed
|
||||
/// (MakerRoom, EventRoom, paintball sub-maps merged into the
|
||||
/// Paintball lobby, etc.) but shouldn't show up to players because
|
||||
/// either there's a unified room hosting their scenes elsewhere or
|
||||
/// they're admin-only utility rooms.</summary>
|
||||
public bool HiddenFromBrowse { get; set; } = false;
|
||||
|
||||
/// <summary>Name of the latest <see cref="RoomDataBlobEntity"/> row
|
||||
/// for this room — also what we put in
|
||||
/// <c>RoomDetails.Scenes[0].DataBlobName</c> so the client knows which
|
||||
/// blob to download. Empty for AG-Original baked rooms (rec center,
|
||||
/// paintball, etc.) which have no per-room save bytes; the dorm and
|
||||
/// user-built / cloned rooms point at their latest save.</summary>
|
||||
[MaxLength(128)]
|
||||
public string CurrentDataBlobName { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,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,26 @@
|
||||
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; }
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One leaderboard stat row per (player, statChannel). Backs
|
||||
/// <c>api/Leaderboard/v1/SetStats</c> + <c>api/Leaderboard/v2/getPlayerRank</c>.
|
||||
///
|
||||
/// The 2020 watch's <c>RecNet.Leaderboards</c> uses an int channel
|
||||
/// id (e.g. 1=PaintballWins, 2=DodgeballHits, etc.) and a single
|
||||
/// long value per channel. A player's "rank" is computed as
|
||||
/// <c>1 + count(*)</c> of rows in the same channel with a higher
|
||||
/// value.
|
||||
/// </summary>
|
||||
public class LeaderboardStatEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
/// <summary>RecNet stat channel id (small int per game mode +
|
||||
/// stat type — e.g. paintball-CTF-wins, dodgeball-hits).</summary>
|
||||
public int StatChannel { get; set; }
|
||||
|
||||
/// <summary>Aggregate stat value. Set semantics depend on
|
||||
/// <see cref="SetMode"/>: 0=Always, 1=OnlyIfHigher, 2=OnlyIfLower,
|
||||
/// 3=Increment. Most stat channels are mode 1 (high score).</summary>
|
||||
public long Value { get; set; }
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Match-result Elo per (player, game mode). Backs
|
||||
/// <c>api/PlayerElo/v1/reportPlayerElo</c>. The 2020 watch reports
|
||||
/// an Elo delta after each ranked match (Royale, Paintball,
|
||||
/// Soccer); we accumulate it per game mode for matchmaking.
|
||||
/// </summary>
|
||||
public class PlayerEloEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long PlayerId { get; set; }
|
||||
|
||||
/// <summary>RecNet game mode id (small int). We don't enumerate
|
||||
/// these; the wire just sends an int and we pass it through.</summary>
|
||||
public int GameMode { get; set; }
|
||||
|
||||
/// <summary>Current Elo. Default 1000 on first row.</summary>
|
||||
public int Elo { get; set; } = 1000;
|
||||
|
||||
public int Wins { get; set; }
|
||||
public int Losses { get; set; }
|
||||
public int MatchesPlayed { get; set; }
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One purchasable item in the in-game store. The watch's Shop tab
|
||||
/// renders these, and the purchase flow (initiatepurchase →
|
||||
/// processpurchase → completepurchase) verifies the player has enough
|
||||
/// of <see cref="CurrencyType"/> currency, deducts <see cref="Price"/>,
|
||||
/// and appends <see cref="Slug"/> to the buyer's
|
||||
/// <see cref="AvatarEntity.InventoryJson"/> list so the customisation
|
||||
/// menu can equip it.
|
||||
///
|
||||
/// Item slots / categories track the avatar slots the watch's
|
||||
/// outfit-selector groups items by — Head, Torso, Legs, Feet,
|
||||
/// Accessory, Face, Hair, plus non-avatar categories (Consumable,
|
||||
/// RoomTemplate, Emote) that don't go into outfit selections but
|
||||
/// still show up in the player's inventory.
|
||||
/// </summary>
|
||||
public class StoreItemEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>Stable string identifier — what we put in
|
||||
/// InventoryJson and OutfitSelections. Format is a GUID for
|
||||
/// avatar items (so the watch's avatar resolver matches the
|
||||
/// baked AvatarItemDefinition catalog), or a kebab-case slug for
|
||||
/// non-avatar items (consumables / templates). Unique.</summary>
|
||||
// Widened from 64→128 in 2026-05 to accommodate the
|
||||
// wardrobe-colored-{guid}-{color} slugs the color-variant seeder
|
||||
// generates — longest in the catalog is ~68 chars (long color
|
||||
// names like "WhiteHatHacker" + 36-char GUID + prefix).
|
||||
[MaxLength(128)]
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(128)]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(1024)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Avatar slot or item type. One of: head, torso, legs,
|
||||
/// feet, accessory, face, hair, consumable, roomtemplate, emote.
|
||||
/// </summary>
|
||||
[MaxLength(32)]
|
||||
public string Category { get; set; } = "accessory";
|
||||
|
||||
/// <summary>cdn filename (relative to cdn.rec.net/) for the
|
||||
/// store-tile thumbnail. Empty = the watch falls back to a
|
||||
/// silhouette. Same format as RoomEntity.ImageName.</summary>
|
||||
[MaxLength(256)]
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Currency the item is priced in. 2 = standard tokens
|
||||
/// (the everyday currency earned from level-up + cheers); higher
|
||||
/// numbers reserved for premium currencies (e.g. 1 = real-money
|
||||
/// purchases — never enabled on a private server).</summary>
|
||||
public int CurrencyType { get; set; } = 2;
|
||||
|
||||
public long Price { get; set; }
|
||||
|
||||
/// <summary>Listing flag. False = hidden from the store but kept
|
||||
/// in the catalog so existing inventory references stay
|
||||
/// resolvable. Admin-only mutation.</summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>True for limited-time / event items. The watch
|
||||
/// renders an "ends in" countdown alongside these and pulls them
|
||||
/// from the catalog when AvailableUntil expires (we keep the row,
|
||||
/// just flip IsActive=false on expiry).</summary>
|
||||
public bool IsLimitedTime { get; set; }
|
||||
|
||||
public DateTime? AvailableUntil { get; set; }
|
||||
|
||||
/// <summary>Storefront grouping. <c>"main"</c> = the everyday
|
||||
/// shop; <c>"giftdrop:N"</c> = a specific rotating gift-drop shelf
|
||||
/// the 2020 client probes via /storefronts/v3/giftdropstore/{N};
|
||||
/// <c>"rro"</c> = every RRO/Rec Center in-room shelf;
|
||||
/// <c>"all"</c> = every in-room shelf; <c>"season:N"</c> =
|
||||
/// season-pass ladders. Default is main.
|
||||
/// </summary>
|
||||
[MaxLength(32)]
|
||||
public string Storefront { get; set; } = "main";
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Asymmetric "follow" relationship: <see cref="SubscriberPlayerId"/>
|
||||
/// follows <see cref="TargetPlayerId"/> for activity updates.
|
||||
/// Distinct from <see cref="RelationshipEntity"/>'s mutual friend
|
||||
/// model — subscriptions are one-way and don't require acceptance.
|
||||
/// </summary>
|
||||
public class SubscriptionEntity
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long SubscriberPlayerId { get; set; }
|
||||
public long TargetPlayerId { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace DorkNet.Server.Data.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// QA test case — wire type <c>RecNet.TestCase</c>
|
||||
/// (<c>Cpp2IL_CS/.../RecNet/TestCase.cs</c>). The 2020 Rec Room
|
||||
/// client uses this to surface JIRA-tracked test cases in an
|
||||
/// internal QA tab; users with the developer role claim a case,
|
||||
/// run it in the named room, then submit a Pass/Fail status.
|
||||
///
|
||||
/// Field names + types match the client's
|
||||
/// <c>TestCase.Deserialize</c> exactly. <see cref="Status"/> is the
|
||||
/// <c>TestCaseStatus</c> enum: 0=NotYetTested, 1=Claimed, 2=Failed,
|
||||
/// 3=Passed.
|
||||
/// </summary>
|
||||
public class TestCaseEntity
|
||||
{
|
||||
/// <summary>EF primary key (long autoincrement) — kept separate
|
||||
/// from the wire <see cref="Id"/> string so we can index cleanly.</summary>
|
||||
public long Pk { get; set; }
|
||||
|
||||
/// <summary>Wire field <c>Id</c> (string) — typically the JIRA
|
||||
/// issue key like <c>RR-1234</c>.</summary>
|
||||
[MaxLength(64)]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(64)]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(256)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(128)]
|
||||
public string RoomName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>TestCaseStatus enum: 0=NotYetTested, 1=Claimed,
|
||||
/// 2=Failed, 3=Passed.</summary>
|
||||
public int Status { get; set; } = 0;
|
||||
|
||||
public int MinNumAssignedPlayers { get; set; } = 1;
|
||||
|
||||
/// <summary>JSON list of player ids currently claimed on this
|
||||
/// test case. Mirrors wire <c>AssignedPlayerIds</c> (List<int>).</summary>
|
||||
public string AssignedPlayerIdsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>Sibling JSON list of names for display (kept in sync
|
||||
/// with <see cref="AssignedPlayerIdsJson"/>).</summary>
|
||||
public string AssignedPlayerNamesJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>JSON list of tag strings.</summary>
|
||||
public string TagsJson { get; set; } = "[]";
|
||||
|
||||
[MaxLength(512)]
|
||||
public string JiraUrl { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(512)]
|
||||
public string JiraBugUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional FK to the <see cref="TestPassEntity"/> this
|
||||
/// case belongs to (null = orphan / standalone).</summary>
|
||||
public uint? TestPassId { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>Wire type <c>RecNet.TestPass</c> — a versioned bundle of
|
||||
/// test cases the QA team groups for a release pass. Listed via
|
||||
/// <c>GET api/testcasemanagement/v1/testpasssummary</c>; one fetched
|
||||
/// in detail via <c>GET api/testcasemanagement/v1/testpass/{id}</c>.
|
||||
/// </summary>
|
||||
public class TestPassEntity
|
||||
{
|
||||
/// <summary>Wire <c>Id</c> is uint — store as long PK and cast.</summary>
|
||||
public uint Id { get; set; }
|
||||
|
||||
[MaxLength(128)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public DateTime StartDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Null while the pass is still active.</summary>
|
||||
public DateTime? EndDate { get; set; }
|
||||
|
||||
public bool WasManuallyClosed { get; set; } = false;
|
||||
|
||||
/// <summary>JSON list of tag strings.</summary>
|
||||
public string TagsJson { get; set; } = "[]";
|
||||
}
|
||||
Reference in New Issue
Block a user