diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..f7c35c1 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.4", + "commands": [ + "dotnet-ef" + ] + } + } +} diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index 27954e4..c46c19c 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -184,6 +184,7 @@ - [x] Таймер хода: 90 с + банк 5 мин. - [x] Снапшот матча в PostgreSQL + восстановление grain после рестарта. — заголовок + журнал интентов, реплей команд при старте. +- [x] EF-миграции общей схемы (матчи + мета), `Migrate` при старте; InMemory — `EnsureCreated`. - [x] Redis: одноразовые тикеты и очередь. - [x] Idempotency по `intentId`. - [x] HTTP-очередь и тикеты: `docs/server-api.md`. diff --git a/README.md b/README.md index a10b5d0..508ee21 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ | PostgreSQL (матчи), Redis (тикеты, очередь), Docker | готово | | Мета-API: JWT, коллекция, колоды, магазин, история, реплеи | готово | | CI (Forgejo Actions) | workflow добавлен, runner нужно привязать | -| Orleans и миграции | не начато | +| EF-миграции | готово (`InitialCreate`) | +| Orleans | не начато | | Клиент и лаунчер | не начато | ## Требования diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 691870c..0cd9a15 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -248,6 +248,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. принятых команд на детерминированном движке, включая ожидания выбора: делегаты движка не сериализуются. Журнал реплея сверяется по `eventSeq`. Банк и дедлайн пишутся каждые ~5 с; потеря при аварии — не больше тика. + Схема — EF-миграции (`Migrations/InitialCreate`), на старте `Migrate`. - **Redis**: тикеты и записи очереди (JSON + sorted set по режимам, TTL); очередь поднимается при старте. Ключи с префиксом `vanguard:`. - **Конфигурация**: `Storage:Provider` = `None` / `Memory` / `Postgres`, @@ -462,7 +463,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. Критерий готовности серверного блока выполнен: два клиента играют полный матч по сети; kill клиента восстанавливается через новый тикет и `resumeFromSeq`; рестарт сервера восстанавливает матчи из PostgreSQL. -Остались Orleans, миграции и привязка CI-runner. +Остались Orleans и привязка CI-runner. ### 7.2. Мета-API @@ -519,7 +520,7 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. `GameConnection`, логин, коллекция, декбилдер, магазин, очередь, история и реплей. Сервер и мета-API готовы: `docs/protocol.md`, `docs/server-api.md`, `Vanguard.Shared`. -4. Orleans, миграции и CI-runner — отдельные серверные долги, не блокируют клиент. +4. Orleans и CI-runner — отдельные серверные долги, не блокируют клиент. 5. После каждого блока: тесты, `validate`, `translations check`, коммит, push. 6. Не ломать `Vanguard.Rules`: сервер должен зависеть от него, а не наоборот. @@ -544,8 +545,8 @@ Solution — **`server/Vanguard.slnx`**, не `.sln`. - **Сервер: хранилище.** По умолчанию `Storage:Provider=None`: матчи живут в памяти (`NullMatchStore`), мета-API — в EF InMemory. Для рестарта нужен `Postgres` + `Storage:ConnectionString`; `Memory` — EF InMemory для всего. - Схема создаётся `EnsureCreated` для одного общего `MatchDbContext`; - миграций пока нет, при изменении схемы тестовую БД нужно пересоздать. + Схема — EF-миграции (`Migrations/InitialCreate`), на старте `MigrateAsync`; + для InMemory — `EnsureCreated`. Миграции применяются автоматически. - **Сервер: снапшот матча.** Сериализуется не состояние движка, а seed, колоды, игроки, часы и журнал интентов. При восстановлении команды проигрываются заново; `EventSeq` обязан совпасть, иначе матч пропускается с ошибкой. diff --git a/docs/server-api.md b/docs/server-api.md index 0888bea..f250520 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -409,8 +409,10 @@ GET /api/replays/{matchId} → реплей Восстановление матча: при старте `MatchRecoveryService` поднимает незавершённые матчи и проигрывает принятые команды на детерминированном -движке; `eventSeq` сверяется. Схема создаётся `EnsureCreated`; миграций -пока нет, при изменении схемы тестовую БД нужно пересоздать. +движке; `eventSeq` сверяется. Схема — EF-миграции +(`server/src/Vanguard.Server/Migrations`); на старте PostgreSQL выполняется +`MigrateAsync`, для InMemory — `EnsureCreated`. Новая миграция: +`dotnet ef migrations add ...`. Redis: тикеты (`GETDEL`, TTL 60 с) и очередь (JSON + sorted set). @@ -421,7 +423,7 @@ Redis: тикеты (`GETDEL`, TTL 60 с) и очередь (JSON + sorted set). - `Storage:Provider=None` — всё в памяти, рестарт теряет данные. История матча пишется, но реплей при `None` недоступен: строки матча нет. Для истории и реплеев нужен `Memory` или `Postgres`. -- `EnsureCreated`, а не миграции: схема меняется вместе с пересозданием БД. +- `EnsureCreated` используется только для InMemory; PostgreSQL — миграции. - Один процесс: Orleans-хостинг ещё впереди; `IMatchGrain` уже отделён. - Нет магазина косметики, квестов, баттлпасса, Draft, друзей и чата. - Rate limit: 60/мин на auth, 300/мин на остальные мутирующие запросы. diff --git a/server/src/Vanguard.Server/Migrations/20261021104418_InitialCreate.Designer.cs b/server/src/Vanguard.Server/Migrations/20261021104418_InitialCreate.Designer.cs new file mode 100644 index 0000000..868d059 --- /dev/null +++ b/server/src/Vanguard.Server/Migrations/20261021104418_InitialCreate.Designer.cs @@ -0,0 +1,463 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Vanguard.Server.Storage; + +#nullable disable + +namespace Vanguard.Server.Migrations +{ + [DbContext(typeof(MatchDbContext))] + [Migration("20261021104418_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Vanguard.Server.Meta.BoosterOpeningEntity", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CardsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Payment") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IdempotencyKey") + .IsUnique(); + + b.ToTable("booster_openings", (string)null); + }); + + modelBuilder.Entity("Vanguard.Server.Meta.CollectionEntity", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("PrintingId") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Count") + .HasColumnType("integer"); + + b.HasKey("UserId", "PrintingId"); + + b.HasIndex("UserId"); + + b.ToTable("collection", (string)null); + }); + + modelBuilder.Entity("Vanguard.Server.Meta.DeckEntity", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CardsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Format") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ShareCode") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ShareCode") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("decks", (string)null); + }); + + modelBuilder.Entity("Vanguard.Server.Meta.MatchHistoryEntity", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FinishReason") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Gold") + .HasColumnType("integer"); + + b.Property("MatchId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MmrAfter") + .HasColumnType("integer"); + + b.Property("MmrBefore") + .HasColumnType("integer"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("OpponentName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Result") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Xp") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt"); + + b.HasIndex("UserId", "MatchId") + .IsUnique(); + + b.ToTable("match_history", (string)null); + }); + + modelBuilder.Entity("Vanguard.Server.Meta.RefreshTokenEntity", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("Vanguard.Server.Meta.TransactionEntity", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("MetadataJson") + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Currency"); + + b.HasIndex("UserId", "IdempotencyKey") + .IsUnique(); + + b.ToTable("transactions", (string)null); + }); + + modelBuilder.Entity("Vanguard.Server.Meta.TrialDeckOwnershipEntity", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("DeckId") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AcquiredAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "DeckId"); + + b.ToTable("trial_deck_ownership", (string)null); + }); + + modelBuilder.Entity("Vanguard.Server.Meta.UserEntity", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CasualMmr") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Matches") + .HasColumnType("integer"); + + b.Property("NormalizedUserName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OnboardingCompleted") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RankedMmr") + .HasColumnType("integer"); + + b.Property("SelectedTrialDeckId") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Wins") + .HasColumnType("integer"); + + b.Property("Xp") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUserName") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Vanguard.Server.Storage.MatchEntity", b => + { + b.Property("MatchId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AbsentSince0") + .HasColumnType("timestamp with time zone"); + + b.Property("AbsentSince1") + .HasColumnType("timestamp with time zone"); + + b.Property("Bank0Ms") + .HasColumnType("bigint"); + + b.Property("Bank1Ms") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Deck0Json") + .IsRequired() + .HasColumnType("text"); + + b.Property("Deck1Json") + .IsRequired() + .HasColumnType("text"); + + b.Property("EventSeq") + .HasColumnType("bigint"); + + b.Property("FinishReason") + .IsRequired() + .HasColumnType("text"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsFinished") + .HasColumnType("boolean"); + + b.Property("LastClockSent") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Player0Id") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Player1Id") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Seed") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Started") + .HasColumnType("boolean"); + + b.Property("TurnDeadline") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Winner") + .HasColumnType("integer"); + + b.HasKey("MatchId"); + + b.HasIndex("IsFinished", "UpdatedAt"); + + b.ToTable("matches", (string)null); + }); + + modelBuilder.Entity("Vanguard.Server.Storage.MatchIntentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Accepted") + .HasColumnType("boolean"); + + b.Property("Code") + .HasColumnType("integer"); + + b.Property("Command") + .IsRequired() + .HasColumnType("text"); + + b.Property("EventSeq") + .HasColumnType("bigint"); + + b.Property("IntentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MatchId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Reason") + .HasColumnType("text"); + + b.Property("Seat") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("MatchId"); + + b.ToTable("match_intents", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Vanguard.Server/Migrations/20261021104418_InitialCreate.cs b/server/src/Vanguard.Server/Migrations/20261021104418_InitialCreate.cs new file mode 100644 index 0000000..5701702 --- /dev/null +++ b/server/src/Vanguard.Server/Migrations/20261021104418_InitialCreate.cs @@ -0,0 +1,318 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Vanguard.Server.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "booster_openings", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + IdempotencyKey = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Payment = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + CardsJson = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_booster_openings", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "collection", + columns: table => new + { + UserId = table.Column(type: "uuid", nullable: false), + PrintingId = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Count = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_collection", x => new { x.UserId, x.PrintingId }); + }); + + migrationBuilder.CreateTable( + name: "decks", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Format = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + CardsJson = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ShareCode = table.Column(type: "character varying(16)", maxLength: 16, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_decks", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "match_history", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + MatchId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Mode = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + OpponentName = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Result = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + Gold = table.Column(type: "integer", nullable: false), + Xp = table.Column(type: "integer", nullable: false), + MmrBefore = table.Column(type: "integer", nullable: false), + MmrAfter = table.Column(type: "integer", nullable: false), + FinishReason = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_match_history", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "match_intents", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + MatchId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + IntentId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Seat = table.Column(type: "integer", nullable: false), + Command = table.Column(type: "text", nullable: false), + Accepted = table.Column(type: "boolean", nullable: false), + Code = table.Column(type: "integer", nullable: false), + Reason = table.Column(type: "text", nullable: true), + EventSeq = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_match_intents", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "matches", + columns: table => new + { + MatchId = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Mode = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Player0Id = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Player1Id = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Deck0Json = table.Column(type: "text", nullable: false), + Deck1Json = table.Column(type: "text", nullable: false), + Seed = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + Started = table.Column(type: "boolean", nullable: false), + Bank0Ms = table.Column(type: "bigint", nullable: false), + Bank1Ms = table.Column(type: "bigint", nullable: false), + TurnDeadline = table.Column(type: "timestamp with time zone", nullable: true), + AbsentSince0 = table.Column(type: "timestamp with time zone", nullable: true), + AbsentSince1 = table.Column(type: "timestamp with time zone", nullable: true), + LastClockSent = table.Column(type: "timestamp with time zone", nullable: false), + IsFinished = table.Column(type: "boolean", nullable: false), + Winner = table.Column(type: "integer", nullable: false), + FinishedAt = table.Column(type: "timestamp with time zone", nullable: true), + FinishReason = table.Column(type: "text", nullable: false), + EventSeq = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_matches", x => x.MatchId); + }); + + migrationBuilder.CreateTable( + name: "refresh_tokens", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + TokenHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true), + ReplacedByHash = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_refresh_tokens", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "transactions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Currency = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + Amount = table.Column(type: "integer", nullable: false), + Reason = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + IdempotencyKey = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + MetadataJson = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_transactions", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "trial_deck_ownership", + columns: table => new + { + UserId = table.Column(type: "uuid", nullable: false), + DeckId = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + AcquiredAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_trial_deck_ownership", x => new { x.UserId, x.DeckId }); + }); + + migrationBuilder.CreateTable( + name: "users", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserName = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + NormalizedUserName = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + PasswordHash = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + LastLoginAt = table.Column(type: "timestamp with time zone", nullable: true), + OnboardingCompleted = table.Column(type: "boolean", nullable: false), + SelectedTrialDeckId = table.Column(type: "character varying(32)", maxLength: 32, nullable: true), + RankedMmr = table.Column(type: "integer", nullable: false), + CasualMmr = table.Column(type: "integer", nullable: false), + Matches = table.Column(type: "integer", nullable: false), + Wins = table.Column(type: "integer", nullable: false), + Xp = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_users", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_booster_openings_UserId_IdempotencyKey", + table: "booster_openings", + columns: new[] { "UserId", "IdempotencyKey" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_collection_UserId", + table: "collection", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_decks_ShareCode", + table: "decks", + column: "ShareCode", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_decks_UserId", + table: "decks", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_decks_UserId_Name", + table: "decks", + columns: new[] { "UserId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_match_history_UserId_CreatedAt", + table: "match_history", + columns: new[] { "UserId", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_match_history_UserId_MatchId", + table: "match_history", + columns: new[] { "UserId", "MatchId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_match_intents_MatchId", + table: "match_intents", + column: "MatchId"); + + migrationBuilder.CreateIndex( + name: "IX_matches_IsFinished_UpdatedAt", + table: "matches", + columns: new[] { "IsFinished", "UpdatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_refresh_tokens_TokenHash", + table: "refresh_tokens", + column: "TokenHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_refresh_tokens_UserId", + table: "refresh_tokens", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_transactions_UserId_Currency", + table: "transactions", + columns: new[] { "UserId", "Currency" }); + + migrationBuilder.CreateIndex( + name: "IX_transactions_UserId_IdempotencyKey", + table: "transactions", + columns: new[] { "UserId", "IdempotencyKey" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_users_NormalizedUserName", + table: "users", + column: "NormalizedUserName", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "booster_openings"); + + migrationBuilder.DropTable( + name: "collection"); + + migrationBuilder.DropTable( + name: "decks"); + + migrationBuilder.DropTable( + name: "match_history"); + + migrationBuilder.DropTable( + name: "match_intents"); + + migrationBuilder.DropTable( + name: "matches"); + + migrationBuilder.DropTable( + name: "refresh_tokens"); + + migrationBuilder.DropTable( + name: "transactions"); + + migrationBuilder.DropTable( + name: "trial_deck_ownership"); + + migrationBuilder.DropTable( + name: "users"); + } + } +} diff --git a/server/src/Vanguard.Server/Migrations/MatchDbContextModelSnapshot.cs b/server/src/Vanguard.Server/Migrations/MatchDbContextModelSnapshot.cs new file mode 100644 index 0000000..802f390 --- /dev/null +++ b/server/src/Vanguard.Server/Migrations/MatchDbContextModelSnapshot.cs @@ -0,0 +1,460 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Vanguard.Server.Storage; + +#nullable disable + +namespace Vanguard.Server.Migrations +{ + [DbContext(typeof(MatchDbContext))] + partial class MatchDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Vanguard.Server.Meta.BoosterOpeningEntity", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CardsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Payment") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IdempotencyKey") + .IsUnique(); + + b.ToTable("booster_openings", (string)null); + }); + + modelBuilder.Entity("Vanguard.Server.Meta.CollectionEntity", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("PrintingId") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Count") + .HasColumnType("integer"); + + b.HasKey("UserId", "PrintingId"); + + b.HasIndex("UserId"); + + b.ToTable("collection", (string)null); + }); + + modelBuilder.Entity("Vanguard.Server.Meta.DeckEntity", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CardsJson") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Format") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ShareCode") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ShareCode") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("decks", (string)null); + }); + + modelBuilder.Entity("Vanguard.Server.Meta.MatchHistoryEntity", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FinishReason") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Gold") + .HasColumnType("integer"); + + b.Property("MatchId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MmrAfter") + .HasColumnType("integer"); + + b.Property("MmrBefore") + .HasColumnType("integer"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("OpponentName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Result") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Xp") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt"); + + b.HasIndex("UserId", "MatchId") + .IsUnique(); + + b.ToTable("match_history", (string)null); + }); + + modelBuilder.Entity("Vanguard.Server.Meta.RefreshTokenEntity", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("Vanguard.Server.Meta.TransactionEntity", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("MetadataJson") + .HasColumnType("text"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Currency"); + + b.HasIndex("UserId", "IdempotencyKey") + .IsUnique(); + + b.ToTable("transactions", (string)null); + }); + + modelBuilder.Entity("Vanguard.Server.Meta.TrialDeckOwnershipEntity", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("DeckId") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AcquiredAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "DeckId"); + + b.ToTable("trial_deck_ownership", (string)null); + }); + + modelBuilder.Entity("Vanguard.Server.Meta.UserEntity", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CasualMmr") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Matches") + .HasColumnType("integer"); + + b.Property("NormalizedUserName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("OnboardingCompleted") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RankedMmr") + .HasColumnType("integer"); + + b.Property("SelectedTrialDeckId") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Wins") + .HasColumnType("integer"); + + b.Property("Xp") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUserName") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Vanguard.Server.Storage.MatchEntity", b => + { + b.Property("MatchId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("AbsentSince0") + .HasColumnType("timestamp with time zone"); + + b.Property("AbsentSince1") + .HasColumnType("timestamp with time zone"); + + b.Property("Bank0Ms") + .HasColumnType("bigint"); + + b.Property("Bank1Ms") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Deck0Json") + .IsRequired() + .HasColumnType("text"); + + b.Property("Deck1Json") + .IsRequired() + .HasColumnType("text"); + + b.Property("EventSeq") + .HasColumnType("bigint"); + + b.Property("FinishReason") + .IsRequired() + .HasColumnType("text"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsFinished") + .HasColumnType("boolean"); + + b.Property("LastClockSent") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Player0Id") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Player1Id") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Seed") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Started") + .HasColumnType("boolean"); + + b.Property("TurnDeadline") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Winner") + .HasColumnType("integer"); + + b.HasKey("MatchId"); + + b.HasIndex("IsFinished", "UpdatedAt"); + + b.ToTable("matches", (string)null); + }); + + modelBuilder.Entity("Vanguard.Server.Storage.MatchIntentEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Accepted") + .HasColumnType("boolean"); + + b.Property("Code") + .HasColumnType("integer"); + + b.Property("Command") + .IsRequired() + .HasColumnType("text"); + + b.Property("EventSeq") + .HasColumnType("bigint"); + + b.Property("IntentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MatchId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Reason") + .HasColumnType("text"); + + b.Property("Seat") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("MatchId"); + + b.ToTable("match_intents", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Vanguard.Server/Program.cs b/server/src/Vanguard.Server/Program.cs index 07d6312..0a15b37 100644 --- a/server/src/Vanguard.Server/Program.cs +++ b/server/src/Vanguard.Server/Program.cs @@ -199,7 +199,10 @@ foreach (var deck in app.Services.GetRequiredService().Decks) var dbFactory = app.Services.GetRequiredService>(); await using (var db = await dbFactory.CreateDbContextAsync()) { - await db.Database.EnsureCreatedAsync(); + if (db.Database.IsRelational()) + await db.Database.MigrateAsync(); + else + await db.Database.EnsureCreatedAsync(); } // незавершённые матчи поднимаем до того, как сервер начнёт принимать подключения diff --git a/server/src/Vanguard.Server/Storage/MatchDbContextDesignFactory.cs b/server/src/Vanguard.Server/Storage/MatchDbContextDesignFactory.cs new file mode 100644 index 0000000..f204177 --- /dev/null +++ b/server/src/Vanguard.Server/Storage/MatchDbContextDesignFactory.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Vanguard.Server.Storage; + +/// +/// Контекст для `dotnet ef`. EF не поднимает всё приложение: берёт фабрику +/// и генерирует миграцию по модели. Строка подключения нужна только +/// для выбора провайдера. +/// +public sealed class MatchDbContextDesignFactory : IDesignTimeDbContextFactory +{ + public MatchDbContext CreateDbContext(string[] args) + { + var connection = Environment.GetEnvironmentVariable("VANGUARD_DESIGN_CONNECTION") + ?? "Host=localhost;Database=vanguard;Username=vanguard;Password=vanguard"; + + var options = new DbContextOptionsBuilder() + .UseNpgsql(connection) + .Options; + + return new MatchDbContext(options); + } +} diff --git a/server/src/Vanguard.Server/Vanguard.Server.csproj b/server/src/Vanguard.Server/Vanguard.Server.csproj index 9efb0fa..f1d05f2 100644 --- a/server/src/Vanguard.Server/Vanguard.Server.csproj +++ b/server/src/Vanguard.Server/Vanguard.Server.csproj @@ -17,6 +17,10 @@ + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/server/tests/Vanguard.Server.Tests/ExternalStorageTests.cs b/server/tests/Vanguard.Server.Tests/ExternalStorageTests.cs index 0de6d1c..379df25 100644 --- a/server/tests/Vanguard.Server.Tests/ExternalStorageTests.cs +++ b/server/tests/Vanguard.Server.Tests/ExternalStorageTests.cs @@ -37,7 +37,7 @@ public class ExternalStorageTests await using (var db = new MatchDbContext(options)) { await db.Database.EnsureDeletedAsync(); - await db.Database.EnsureCreatedAsync(); + await db.Database.MigrateAsync(); } var store = new EfMatchStore(new TestDbContextFactory(options), time);