15040eb954
In the session creation wizard (Telegram + Discord), the Capacity step only exposed waitlist on/off buttons. The 'no waitlist' button silently advanced to the next step without setting MaxPlayers, so users who tried to create a session with no player cap were blocked with 'Не заполнены поля: лимит мест'. The DB contract and CreateSessionCommand already supported null MaxPlayers (int?, ck_sessions_max_players check in V006), and the web form already exposes 'Без лимита' as an empty InputNumber — only the wizard flow was broken. Changes: - Add '♾ Без лимита' choice button to Capacity in shared WizardStepViewBuilder.BuildCapacity (Telegram) and to RenderCapacity / RenderPoolSlotCapacity in DiscordWizardStep (Discord). - Add 'no_limit' branch to GameCreationWizard.ApplyCapacityChoice that sets MaxPlayers to null and advances to Visibility. - Change GameCreationWizard.SetMaxPlayers signature from int to int? so the 'no limit' branch compiles. - Change CreateSessionCommand builder in both Telegram and Discord submitters to take int? maxPlayers and drop the '?? 0' that would have turned null into 0 (violating the DB CHECK and the 'no limit' contract). - In Discord BuildConfirmDescription, render '👥 Без лимита, waitlist вкл/выкл' when MaxPlayers is null (the previous code silently omitted the line). - Expose BuildCommand as internal in both submitters and add InternalsVisibleTo('GmRelay.Bot.Tests') to the DiscordBot assembly for unit-test access. Tests (9 new): - WizardStepRenderTests.CapacityStep_HasWaitlistButtons — asserts the 'Без лимита' button is present. - GameCreationWizardStepTransitionsTests.NoLimitCapacityButton_… — asserts the choice advances to Visibility and leaves MaxPlayers null in the JSON draft. - GameCreationWizardStepTransitionsTests.ChoiceCallback_AdvancesToExpectedStep — new Theory row for Capacity/no_limit. - CreateSessionHandlerBuildCommandTests (new) — null/value propagation through the Telegram submitter's BuildCommand. - DiscordWizardStepCapacityRenderTests (new) — 'Без лимита' button is rendered for both Capacity and PoolSlotCapacity, with the expected custom-id shape. - DiscordWizardSubmitterBuildCommandTests (new) — null/value propagation through the Discord submitter's BuildCommand. Closes #123
249 lines
14 KiB
C#
249 lines
14 KiB
C#
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),
|
|
new("♾ Без лимита", WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit"), WizardActionStyle.Primary),
|
|
});
|
|
|
|
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 => "только для членов клуба",
|
|
_ => "не задана",
|
|
};
|
|
}
|