Add EF migrations for the game and meta schema

This commit is contained in:
MpakobecEx 2026-10-21 15:47:47 +05:00
parent 6241225785
commit f702d11ae4
12 changed files with 1299 additions and 10 deletions

12
.config/dotnet-tools.json Normal file
View File

@ -0,0 +1,12 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.4",
"commands": [
"dotnet-ef"
]
}
}
}

View File

@ -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`.

View File

@ -17,7 +17,8 @@
| PostgreSQL (матчи), Redis (тикеты, очередь), Docker | готово |
| Мета-API: JWT, коллекция, колоды, магазин, история, реплеи | готово |
| CI (Forgejo Actions) | workflow добавлен, runner нужно привязать |
| Orleans и миграции | не начато |
| EF-миграции | готово (`InitialCreate`) |
| Orleans | не начато |
| Клиент и лаунчер | не начато |
## Требования

View File

@ -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` обязан совпасть, иначе матч пропускается с ошибкой.

View File

@ -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 <Name> ...`.
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/мин на остальные мутирующие запросы.

View File

@ -0,0 +1,463 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("CardsJson")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("IdempotencyKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Payment")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("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<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("PrintingId")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int>("Count")
.HasColumnType("integer");
b.HasKey("UserId", "PrintingId");
b.HasIndex("UserId");
b.ToTable("collection", (string)null);
});
modelBuilder.Entity("Vanguard.Server.Meta.DeckEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("CardsJson")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Format")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ShareCode")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FinishReason")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<int>("Gold")
.HasColumnType("integer");
b.Property<string>("MatchId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("MmrAfter")
.HasColumnType("integer");
b.Property<int>("MmrBefore")
.HasColumnType("integer");
b.Property<string>("Mode")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("OpponentName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Result")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<int>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReplacedByHash")
.HasColumnType("text");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("Amount")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("IdempotencyKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("MetadataJson")
.HasColumnType("text");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("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<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("DeckId")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("CasualMmr")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Matches")
.HasColumnType("integer");
b.Property<string>("NormalizedUserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("OnboardingCompleted")
.HasColumnType("boolean");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("RankedMmr")
.HasColumnType("integer");
b.Property<string>("SelectedTrialDeckId")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("Wins")
.HasColumnType("integer");
b.Property<int>("Xp")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("NormalizedUserName")
.IsUnique();
b.ToTable("users", (string)null);
});
modelBuilder.Entity("Vanguard.Server.Storage.MatchEntity", b =>
{
b.Property<string>("MatchId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("AbsentSince0")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("AbsentSince1")
.HasColumnType("timestamp with time zone");
b.Property<long>("Bank0Ms")
.HasColumnType("bigint");
b.Property<long>("Bank1Ms")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Deck0Json")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Deck1Json")
.IsRequired()
.HasColumnType("text");
b.Property<long>("EventSeq")
.HasColumnType("bigint");
b.Property<string>("FinishReason")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset?>("FinishedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsFinished")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("LastClockSent")
.HasColumnType("timestamp with time zone");
b.Property<string>("Mode")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Player0Id")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Player1Id")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Seed")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<bool>("Started")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("TurnDeadline")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Winner")
.HasColumnType("integer");
b.HasKey("MatchId");
b.HasIndex("IsFinished", "UpdatedAt");
b.ToTable("matches", (string)null);
});
modelBuilder.Entity("Vanguard.Server.Storage.MatchIntentEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<bool>("Accepted")
.HasColumnType("boolean");
b.Property<int>("Code")
.HasColumnType("integer");
b.Property<string>("Command")
.IsRequired()
.HasColumnType("text");
b.Property<long>("EventSeq")
.HasColumnType("bigint");
b.Property<string>("IntentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("MatchId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Reason")
.HasColumnType("text");
b.Property<int>("Seat")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("MatchId");
b.ToTable("match_intents", (string)null);
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,318 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Vanguard.Server.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "booster_openings",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
IdempotencyKey = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Payment = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
CardsJson = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false),
PrintingId = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Count = table.Column<int>(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<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Format = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
CardsJson = table.Column<string>(type: "text", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
ShareCode = table.Column<string>(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<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
MatchId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Mode = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
OpponentName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Result = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
Gold = table.Column<int>(type: "integer", nullable: false),
Xp = table.Column<int>(type: "integer", nullable: false),
MmrBefore = table.Column<int>(type: "integer", nullable: false),
MmrAfter = table.Column<int>(type: "integer", nullable: false),
FinishReason = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(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<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
MatchId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
IntentId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Seat = table.Column<int>(type: "integer", nullable: false),
Command = table.Column<string>(type: "text", nullable: false),
Accepted = table.Column<bool>(type: "boolean", nullable: false),
Code = table.Column<int>(type: "integer", nullable: false),
Reason = table.Column<string>(type: "text", nullable: true),
EventSeq = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_match_intents", x => x.Id);
});
migrationBuilder.CreateTable(
name: "matches",
columns: table => new
{
MatchId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Mode = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
Player0Id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Player1Id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Deck0Json = table.Column<string>(type: "text", nullable: false),
Deck1Json = table.Column<string>(type: "text", nullable: false),
Seed = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
Started = table.Column<bool>(type: "boolean", nullable: false),
Bank0Ms = table.Column<long>(type: "bigint", nullable: false),
Bank1Ms = table.Column<long>(type: "bigint", nullable: false),
TurnDeadline = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
AbsentSince0 = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
AbsentSince1 = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LastClockSent = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
IsFinished = table.Column<bool>(type: "boolean", nullable: false),
Winner = table.Column<int>(type: "integer", nullable: false),
FinishedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
FinishReason = table.Column<string>(type: "text", nullable: false),
EventSeq = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_matches", x => x.MatchId);
});
migrationBuilder.CreateTable(
name: "refresh_tokens",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
TokenHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
ExpiresAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
RevokedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
ReplacedByHash = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_refresh_tokens", x => x.Id);
});
migrationBuilder.CreateTable(
name: "transactions",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Currency = table.Column<string>(type: "character varying(16)", maxLength: 16, nullable: false),
Amount = table.Column<int>(type: "integer", nullable: false),
Reason = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
IdempotencyKey = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
MetadataJson = table.Column<string>(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<Guid>(type: "uuid", nullable: false),
DeckId = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
AcquiredAt = table.Column<DateTimeOffset>(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<Guid>(type: "uuid", nullable: false),
UserName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
NormalizedUserName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
PasswordHash = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
LastLoginAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
OnboardingCompleted = table.Column<bool>(type: "boolean", nullable: false),
SelectedTrialDeckId = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
RankedMmr = table.Column<int>(type: "integer", nullable: false),
CasualMmr = table.Column<int>(type: "integer", nullable: false),
Matches = table.Column<int>(type: "integer", nullable: false),
Wins = table.Column<int>(type: "integer", nullable: false),
Xp = table.Column<int>(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);
}
/// <inheritdoc />
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");
}
}
}

View File

@ -0,0 +1,460 @@
// <auto-generated />
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<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("CardsJson")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("IdempotencyKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Payment")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("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<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("PrintingId")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int>("Count")
.HasColumnType("integer");
b.HasKey("UserId", "PrintingId");
b.HasIndex("UserId");
b.ToTable("collection", (string)null);
});
modelBuilder.Entity("Vanguard.Server.Meta.DeckEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<string>("CardsJson")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Format")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ShareCode")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FinishReason")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<int>("Gold")
.HasColumnType("integer");
b.Property<string>("MatchId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("MmrAfter")
.HasColumnType("integer");
b.Property<int>("MmrBefore")
.HasColumnType("integer");
b.Property<string>("Mode")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("OpponentName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Result")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<int>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReplacedByHash")
.HasColumnType("text");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("Amount")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("IdempotencyKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("MetadataJson")
.HasColumnType("text");
b.Property<string>("Reason")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("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<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("DeckId")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTimeOffset>("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<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("CasualMmr")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Matches")
.HasColumnType("integer");
b.Property<string>("NormalizedUserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("OnboardingCompleted")
.HasColumnType("boolean");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<int>("RankedMmr")
.HasColumnType("integer");
b.Property<string>("SelectedTrialDeckId")
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("Wins")
.HasColumnType("integer");
b.Property<int>("Xp")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("NormalizedUserName")
.IsUnique();
b.ToTable("users", (string)null);
});
modelBuilder.Entity("Vanguard.Server.Storage.MatchEntity", b =>
{
b.Property<string>("MatchId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("AbsentSince0")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("AbsentSince1")
.HasColumnType("timestamp with time zone");
b.Property<long>("Bank0Ms")
.HasColumnType("bigint");
b.Property<long>("Bank1Ms")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Deck0Json")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Deck1Json")
.IsRequired()
.HasColumnType("text");
b.Property<long>("EventSeq")
.HasColumnType("bigint");
b.Property<string>("FinishReason")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset?>("FinishedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsFinished")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("LastClockSent")
.HasColumnType("timestamp with time zone");
b.Property<string>("Mode")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Player0Id")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Player1Id")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Seed")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<bool>("Started")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("TurnDeadline")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Winner")
.HasColumnType("integer");
b.HasKey("MatchId");
b.HasIndex("IsFinished", "UpdatedAt");
b.ToTable("matches", (string)null);
});
modelBuilder.Entity("Vanguard.Server.Storage.MatchIntentEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<bool>("Accepted")
.HasColumnType("boolean");
b.Property<int>("Code")
.HasColumnType("integer");
b.Property<string>("Command")
.IsRequired()
.HasColumnType("text");
b.Property<long>("EventSeq")
.HasColumnType("bigint");
b.Property<string>("IntentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("MatchId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Reason")
.HasColumnType("text");
b.Property<int>("Seat")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("MatchId");
b.ToTable("match_intents", (string)null);
});
#pragma warning restore 612, 618
}
}
}

View File

@ -199,6 +199,9 @@ foreach (var deck in app.Services.GetRequiredService<TrialDeckCatalog>().Decks)
var dbFactory = app.Services.GetRequiredService<IDbContextFactory<MatchDbContext>>();
await using (var db = await dbFactory.CreateDbContextAsync())
{
if (db.Database.IsRelational())
await db.Database.MigrateAsync();
else
await db.Database.EnsureCreatedAsync();
}

View File

@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace Vanguard.Server.Storage;
/// <summary>
/// Контекст для `dotnet ef`. EF не поднимает всё приложение: берёт фабрику
/// и генерирует миграцию по модели. Строка подключения нужна только
/// для выбора провайдера.
/// </summary>
public sealed class MatchDbContextDesignFactory : IDesignTimeDbContextFactory<MatchDbContext>
{
public MatchDbContext CreateDbContext(string[] args)
{
var connection = Environment.GetEnvironmentVariable("VANGUARD_DESIGN_CONNECTION")
?? "Host=localhost;Database=vanguard;Username=vanguard;Password=vanguard";
var options = new DbContextOptionsBuilder<MatchDbContext>()
.UseNpgsql(connection)
.Options;
return new MatchDbContext(options);
}
}

View File

@ -17,6 +17,10 @@
<PackageReference Include="BCrypt.Net-Next" Version="4.2.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.13" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageReference Include="StackExchange.Redis" Version="2.13.17" />

View File

@ -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);