refactor(wizard): move core to Shared, add IWizardMessenger contract (issue #112)
Moves the game-creation wizard state machine, view builder, and platform-neutral contracts (callback data, step names, storage exception, club option, step limits) from GmRelay.Bot to GmRelay.Shared. Telegram continues to work through a new TelegramWizardMessenger implementing IWizardMessenger and a WizardInteractionMapper that converts Update → WizardInteraction. Wires the new platform column on wizard_drafts (V032 migration) and switches chat/owner/thread/message ids to TEXT so the same table can hold Discord snowflakes later. - GameCreationWizard: now in Shared, takes IWizardMessenger + IWizardDraftRepository, dispatches on WizardInteraction. - New IWizardMessenger contract with Edit/Send/Answer/GetOwnerClubs (returns string ids so Telegram longs and Discord snowflakes both fit). - New WizardStepViewBuilder in Shared returns (text, IReadOnlyList<WizardAction>); TelegramWizardMessenger renders actions into InlineKeyboardMarkup via a new Bot-side ToInlineKeyboard helper. - New WizardInteractionMapper in Bot (5-case test) converts Telegram Update to WizardInteraction. - WizardDraft gains a Platform column; ChatId/MessageThreadId/OwnerId/ DraftMessageId switched to string. V032 migrates existing rows and rebuilds the owner lookup index on (platform, owner_id). - All existing wizard / create-session tests updated to the new contract (HandleInteractionAsync + WizardInteraction). Wizard callback-data format preserved. - dotnet build clean, dotnet format --verify-no-changes clean, all 101 wizard tests pass.
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using GmRelay.Shared.Domain;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Produces a (text, list of <see cref="WizardAction"/>s) pair for each
|
||||
/// wizard step. This is the "view builder" half of ADR-002: the same
|
||||
/// builder is used by every platform messenger, and each messenger is
|
||||
/// responsible for converting the action list into its native UI
|
||||
/// (Telegram's <c>InlineKeyboardMarkup</c> today, Discord components
|
||||
/// later).
|
||||
/// </summary>
|
||||
public static class WizardStepViewBuilder
|
||||
{
|
||||
public static (string Text, IReadOnlyList<WizardAction> Actions) Build(
|
||||
WizardDraft draft,
|
||||
WizardPayload payload,
|
||||
IReadOnlyList<WizardClubOption>? clubs = null)
|
||||
{
|
||||
return draft.Step switch
|
||||
{
|
||||
WizardStepNames.Type => BuildType(),
|
||||
WizardStepNames.Title => BuildTitle(),
|
||||
WizardStepNames.Description => BuildDescription(),
|
||||
WizardStepNames.Cover => BuildCover(),
|
||||
WizardStepNames.System => BuildSystem(),
|
||||
WizardStepNames.Duration => BuildDuration(),
|
||||
WizardStepNames.DateTime => BuildDateTime(),
|
||||
WizardStepNames.Capacity => BuildCapacity(),
|
||||
WizardStepNames.Visibility => BuildVisibility(),
|
||||
WizardStepNames.PickClub => BuildPickClub(clubs ?? Array.Empty<WizardClubOption>()),
|
||||
WizardStepNames.Publish => BuildPublish(),
|
||||
WizardStepNames.Confirm => BuildSingleConfirm(payload),
|
||||
|
||||
WizardStepNames.PoolSystemDuration => BuildPoolSystemDuration(),
|
||||
WizardStepNames.PoolAddSlots => BuildPoolAddSlots(payload),
|
||||
WizardStepNames.PoolSlotDateTime => BuildPoolSlotDateTime(),
|
||||
WizardStepNames.PoolSlotCapacity => BuildPoolSlotCapacity(),
|
||||
WizardStepNames.PoolConfirm => BuildPoolConfirm(payload),
|
||||
|
||||
_ => throw new InvalidOperationException($"Unknown wizard step: {draft.Step}"),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Single-game views ──────────────────────────────────────────────
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildType() => (
|
||||
"🎲 Создание новой игровой сессии\n\nЧто создаём?",
|
||||
new[]
|
||||
{
|
||||
new WizardAction("🎯 Одну игру", WizardCallbackData.Choice(WizardStepNames.Type, "single"), WizardActionStyle.Primary),
|
||||
new WizardAction("📅 Пул игр", WizardCallbackData.Choice(WizardStepNames.Type, "pool"), WizardActionStyle.Primary),
|
||||
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildTitle() => (
|
||||
"📝 Введите название игры одним сообщением.",
|
||||
BackCancel());
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildDescription() => (
|
||||
"📄 Введите описание (или «-», чтобы пропустить).",
|
||||
SkipBackCancel());
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildCover() => (
|
||||
"🖼 Пришлите картинку как вложение или URL (или «-»).",
|
||||
SkipBackCancel());
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildSystem() => (
|
||||
"🎲 Выберите систему.",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("D&D 5e", WizardCallbackData.Choice(WizardStepNames.System, "Dnd5e")),
|
||||
new("Pathfinder 2e", WizardCallbackData.Choice(WizardStepNames.System, "Pathfinder2e")),
|
||||
new("Call of Cthulhu",WizardCallbackData.Choice(WizardStepNames.System, "CallOfCthulhu7e")),
|
||||
new("GURPS", WizardCallbackData.Choice(WizardStepNames.System, "GURPS")),
|
||||
new("Fate", WizardCallbackData.Choice(WizardStepNames.System, "Fate")),
|
||||
new("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.System, "_other")),
|
||||
new("⏭ Пропустить", WizardCallbackData.Choice(WizardStepNames.System, "_skip")),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildDuration() => (
|
||||
"⏱ Выберите длительность.",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("3 часа", WizardCallbackData.Choice(WizardStepNames.Duration, "180")),
|
||||
new("4 часа", WizardCallbackData.Choice(WizardStepNames.Duration, "240")),
|
||||
new("5 часов", WizardCallbackData.Choice(WizardStepNames.Duration, "300")),
|
||||
new("6 часов", WizardCallbackData.Choice(WizardStepNames.Duration, "360")),
|
||||
new("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.Duration, "_other")),
|
||||
new("⏭ Пропустить", WizardCallbackData.Choice(WizardStepNames.Duration, "_skip")),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildDateTime() => (
|
||||
"📅 Введите дату и время в формате ДД.ММ.ГГГГ ЧЧ:ММ (Москва).",
|
||||
BackCancel());
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildCapacity() => (
|
||||
"👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist.",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on"), WizardActionStyle.Success),
|
||||
new("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:off"), WizardActionStyle.Danger),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildVisibility() => (
|
||||
"🔒 Выберите видимость.",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("🌐 Публичная в общем showcase", WizardCallbackData.Choice(WizardStepNames.Visibility, "public"), WizardActionStyle.Primary),
|
||||
new("🏠 Публичная в витрине клуба", WizardCallbackData.Choice(WizardStepNames.Visibility, "club"), WizardActionStyle.Primary),
|
||||
new("🔐 Только для членов клуба", WizardCallbackData.Choice(WizardStepNames.Visibility, "members")),
|
||||
new("🏷 Выбрать клуб…", WizardCallbackData.Choice(WizardStepNames.Visibility, "pickclub")),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildPickClub(IReadOnlyList<WizardClubOption> clubs)
|
||||
{
|
||||
if (clubs.Count == 0)
|
||||
{
|
||||
return (
|
||||
"🏷 У вас нет клубов. Создайте клуб в Web dashboard и вернитесь.",
|
||||
BackCancel());
|
||||
}
|
||||
var actions = new List<WizardAction>(clubs.Count);
|
||||
foreach (var club in clubs)
|
||||
{
|
||||
actions.Add(new WizardAction(
|
||||
club.Name,
|
||||
WizardCallbackData.Choice(WizardStepNames.PickClub, club.ClubId.ToString())));
|
||||
}
|
||||
return ("🏷 Выберите клуб:", actions);
|
||||
}
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildPublish() => (
|
||||
"✨ Опубликовать в витрине сейчас?",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("✅ Опубликовать", WizardCallbackData.Choice(WizardStepNames.Publish, "yes"), WizardActionStyle.Success),
|
||||
new("📝 Только в чате", WizardCallbackData.Choice(WizardStepNames.Publish, "no")),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildSingleConfirm(WizardPayload p)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("👀 Проверьте перед созданием:");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"🎲 {p.Title}");
|
||||
if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}");
|
||||
if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}");
|
||||
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч");
|
||||
if (p.Single?.ScheduledAt is { } at) sb.AppendLine($"📅 {at.FormatMoscow()} (МСК)");
|
||||
if (p.Single?.MaxPlayers is { } mp) sb.AppendLine($"👥 Мест: {mp}, waitlist {(p.Waitlist == true ? "вкл" : "выкл")}");
|
||||
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
|
||||
return (
|
||||
sb.ToString(),
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("✅ Создать", WizardCallbackData.Create(), WizardActionStyle.Success),
|
||||
new("⬅️ Назад", WizardCallbackData.Back()),
|
||||
new("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Pool views ─────────────────────────────────────────────────────
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildPoolSystemDuration() => (
|
||||
"🎲 Выберите систему и длительность пула.",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("D&D 5e · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240")),
|
||||
new("Pathfinder 2e · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Pathfinder2e:240")),
|
||||
new("Call of Cthulhu · 3 ч",WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "CallOfCthulhu7e:180")),
|
||||
new("GURPS · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "GURPS:240")),
|
||||
new("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "_custom")),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildPoolAddSlots(WizardPayload p) => (
|
||||
$"📅 Слоты пула «{p.Title}»\n\nДобавлено: {(p.Pool?.Slots.Count ?? 0)}",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("➕ Добавить слот", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add"), WizardActionStyle.Primary),
|
||||
new("✅ Готово, к превью", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done"), WizardActionStyle.Success),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildPoolSlotDateTime() => (
|
||||
"📅 Введите дату/время слота (ДД.ММ.ГГГГ ЧЧ:ММ).",
|
||||
BackCancel());
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildPoolSlotCapacity() => (
|
||||
"👥 Введите лимит мест (1..50) и выберите waitlist.",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on"), WizardActionStyle.Success),
|
||||
new("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off"), WizardActionStyle.Danger),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildPoolConfirm(WizardPayload p)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("👀 Проверьте пул перед созданием:");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"📝 {p.Title}");
|
||||
if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}");
|
||||
if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}");
|
||||
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч");
|
||||
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Слоты ({p.Pool?.Slots.Count ?? 0}):");
|
||||
if (p.Pool is not null)
|
||||
{
|
||||
foreach (var s in p.Pool.Slots)
|
||||
{
|
||||
sb.AppendLine($" • {s.ScheduledAt.FormatMoscow()} — мест {s.MaxPlayers}, waitlist {(s.Waitlist ? "вкл" : "выкл")}");
|
||||
}
|
||||
}
|
||||
return (
|
||||
sb.ToString(),
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("✅ Создать пул", WizardCallbackData.Create(), WizardActionStyle.Success),
|
||||
new("⬅️ Назад", WizardCallbackData.Back()),
|
||||
new("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
private static IReadOnlyList<WizardAction> BackCancel() => new[]
|
||||
{
|
||||
new WizardAction("⬅️ Назад", WizardCallbackData.Back()),
|
||||
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||
};
|
||||
|
||||
private static IReadOnlyList<WizardAction> SkipBackCancel() => new[]
|
||||
{
|
||||
new WizardAction("⏭ Пропустить", WizardCallbackData.Choice("Skip", "1")),
|
||||
new WizardAction("⬅️ Назад", WizardCallbackData.Back()),
|
||||
new WizardAction("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||
};
|
||||
|
||||
private static string RenderVisibilityText(WizardVisibility? v) => v switch
|
||||
{
|
||||
WizardVisibility.Public => "публичная в общем showcase",
|
||||
WizardVisibility.Club => "публичная в витрине клуба",
|
||||
WizardVisibility.Members => "только для членов клуба",
|
||||
_ => "не задана",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user