Files
GmRelayBot/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs
T
Toutsu 15040eb954 fix(bot,discord): allow 'no player limit' option in /newsession wizard
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
2026-06-08 18:17:01 +03:00

523 lines
24 KiB
C#

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Shared.Domain;
using Microsoft.Extensions.Logging;
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Central state machine for the game/pool creation wizard. Lives in
/// <c>GmRelay.Shared</c> so it can be driven from any platform
/// messenger. Platform-specific code (<c>Telegram.Bot</c>,
/// <c>NetCord</c>, …) lives in the corresponding adapter and converts
/// its native update type into a <see cref="WizardInteraction"/> before
/// calling <see cref="HandleInteractionAsync"/>.
/// </summary>
public sealed class GameCreationWizard
{
private readonly IWizardDraftRepository _drafts;
private readonly IWizardMessenger _messenger;
private readonly ILogger<GameCreationWizard> _log;
public GameCreationWizard(
IWizardDraftRepository drafts,
IWizardMessenger messenger,
ILogger<GameCreationWizard> log)
{
_drafts = drafts;
_messenger = messenger;
_log = log;
}
/// <summary>
/// Handle a single user interaction with the wizard. Adapters should
/// map their native event (Telegram <c>Update</c>, Discord
/// interaction, …) into a <see cref="WizardInteraction"/> first.
/// </summary>
public async Task HandleInteractionAsync(
WizardInteraction interaction,
WizardDraft draft,
CancellationToken ct)
{
try
{
if (interaction.CallbackPayload is not null)
{
await HandleCallbackAsync(draft, interaction, ct);
}
else
{
await HandleTextAsync(draft, interaction, ct);
}
}
catch (WizardStorageException)
{
if (interaction.CallbackPayload is not null)
{
await _messenger.AnswerInteractionAsync(
interaction.InteractionId, "💥 Ошибка хранилища, попробуйте /newsession", ct);
}
}
catch (Exception ex)
{
_log.LogError(ex, "Wizard interaction failed for draft {DraftId}", draft.Id);
if (interaction.CallbackPayload is not null)
{
try
{
await _messenger.AnswerInteractionAsync(
interaction.InteractionId, "⚠️ Ошибка", ct);
}
catch
{
/* swallow — we're already in error path */
}
}
}
}
private async Task HandleCallbackAsync(
WizardDraft draft,
WizardInteraction interaction,
CancellationToken ct)
{
if (!WizardCallbackData.TryParse(interaction.CallbackPayload, out var action, out var step, out var choice))
{
await _messenger.AnswerInteractionAsync(interaction.InteractionId, "Неизвестная команда", ct);
return;
}
switch (action)
{
case "cancel":
await _drafts.DeleteAsync(draft.Id, ct);
await _messenger.EditDraftMessageAsync(
draft, "❌ Мастер отменён.", Array.Empty<WizardAction>(), ct);
await _messenger.AnswerInteractionAsync(interaction.InteractionId, null, ct);
return;
case "back":
ApplyBack(draft, step);
await PersistAndRenderAsync(draft, interaction.InteractionId, ct);
return;
case "create":
// Routed by the platform's CreateSessionHandler, not here.
await _messenger.AnswerInteractionAsync(interaction.InteractionId, null, ct);
return;
default:
// For "Choice" callbacks, action == step.
await ApplyChoiceAsync(draft, step, choice, interaction.InteractionId, ct);
return;
}
}
private async Task HandleTextAsync(
WizardDraft draft,
WizardInteraction interaction,
CancellationToken ct)
{
if (interaction.Text is not { } text)
{
// Photo or other non-text — handle cover step only.
if (interaction.PhotoFileId is { } fileId &&
draft.Step == WizardStepNames.Cover)
{
ApplyCoverPhoto(draft, fileId);
await PersistAndRenderAsync(draft, null, ct);
}
return;
}
var (nextStep, error, payload) = ApplyText(draft, text);
if (payload is { } p) SavePayload(draft, p);
if (error is { } errMsg)
{
// Re-render the same step with ⚠️ prefix.
var (rendered, actions) = WizardStepViewBuilder.Build(draft, LoadPayload(draft));
await _messenger.EditDraftMessageAsync(
draft, "⚠️ " + errMsg + "\n\n" + rendered, actions, ct);
return;
}
if (nextStep is { } step)
{
draft.Step = step;
}
await PersistAndRenderAsync(draft, null, ct);
}
private async Task ApplyChoiceAsync(
WizardDraft draft, string step, string choice, string interactionId, CancellationToken ct)
{
var (nextStep, error, payload) = ApplyChoice(draft, step, choice);
if (error is { } err)
{
await _messenger.AnswerInteractionAsync(interactionId, err, ct);
return;
}
if (payload is { } p) SavePayload(draft, p);
if (nextStep is { } s)
{
draft.Step = s;
}
await PersistAndRenderAsync(draft, interactionId, ct);
}
private async Task PersistAndRenderAsync(WizardDraft draft, string? interactionId, CancellationToken ct)
{
draft.UpdatedAt = DateTime.UtcNow;
await _drafts.UpsertAsync(draft, ct);
var payload = LoadPayload(draft);
IReadOnlyList<WizardClubOption>? clubs = null;
if (draft.Step == WizardStepNames.PickClub)
{
clubs = await _messenger.GetOwnerClubsAsync(draft.OwnerId, ct);
}
var (text, actions) = WizardStepViewBuilder.Build(draft, payload, clubs);
await _messenger.EditDraftMessageAsync(draft, text, actions, ct);
if (interactionId is { } id)
{
await _messenger.AnswerInteractionAsync(id, null, ct);
}
}
// ── Text input dispatcher ─────────────────────────────────────────
private static (string? nextStep, string? error, WizardPayload payload) ApplyText(WizardDraft draft, string input)
{
var payload = LoadPayload(draft);
switch (draft.Step)
{
case WizardStepNames.Title:
return ValidateText(input, WizardStepLimits.MaxTitleLength, "Название не может быть пустым", "Слишком длинное название", out var title)
? (WizardStepNames.Description, SetTitle(payload, title), payload)
: (null, title, payload);
case WizardStepNames.Description:
if (input == "-") return (WizardStepNames.Cover, SetDescription(payload, null), payload);
return ValidateText(input, WizardStepLimits.MaxDescriptionLength, "Описание не может быть пустым", "Слишком длинное описание", out var desc)
? (WizardStepNames.Cover, SetDescription(payload, desc), payload)
: (null, desc, payload);
case WizardStepNames.Cover:
if (input == "-") return (NextAfterCover(payload), SetImageUrl(payload, null), payload);
if (Uri.TryCreate(input, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
return (NextAfterCover(payload), SetImageUrl(payload, input), payload);
return (null, "Некорректный URL", payload);
case WizardStepNames.System when payload.System is null:
// "Other" branch — only active if free-text was offered.
return ValidateText(input, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var sys)
? (WizardStepNames.Duration, SetSystem(payload, sys), payload)
: (null, sys, payload);
case WizardStepNames.Duration when payload.DurationMinutes is null:
return TryParseHours(input, out var durMin)
? (WizardStepNames.DateTime, SetDurationMinutes(payload, durMin), payload)
: (null, "Неверная длительность (1..12 ч)", payload);
case WizardStepNames.DateTime:
return MoscowTime.TryParseMoscow(input, out var dt) && dt > DateTimeOffset.UtcNow
? (WizardStepNames.Capacity, SetScheduledAt(payload, dt), payload)
: (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null:
return int.TryParse(input, out var cap) && cap >= WizardStepLimits.MinCapacity && cap <= WizardStepLimits.MaxCapacity
? (WizardStepNames.Visibility, SetMaxPlayers(payload, cap), payload)
: (null, "Лимит должен быть 1..50", payload);
case WizardStepNames.PoolSystemDuration when payload.System is null:
return ValidateText(input, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys)
? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys), payload)
: (null, psys, payload);
case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null:
return TryParseHours(input, out var pdur)
? (WizardStepNames.Visibility, SetDurationMinutes(payload, pdur), payload)
: (null, "Неверная длительность (1..12 ч)", payload);
case WizardStepNames.PoolSlotDateTime:
return MoscowTime.TryParseMoscow(input, out var slotDt) && slotDt > DateTimeOffset.UtcNow
? (WizardStepNames.PoolSlotCapacity, SetCurrentSlotDateTime(payload, slotDt), payload)
: (null, slotDt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
case WizardStepNames.PoolSlotCapacity:
return int.TryParse(input, out var slotCap) && slotCap >= WizardStepLimits.MinCapacity && slotCap <= WizardStepLimits.MaxCapacity
? (WizardStepNames.PoolAddSlots, SetCurrentSlotMaxPlayers(payload, slotCap), payload)
: (null, "Лимит должен быть 1..50", payload);
default:
return (null, "Ожидается выбор кнопкой", payload);
}
}
// ── Callback (button) dispatcher ──────────────────────────────────
private static (string? nextStep, string? error, WizardPayload payload) ApplyChoice(WizardDraft draft, string step, string choice)
{
var payload = LoadPayload(draft);
var (next, err) = step switch
{
WizardStepNames.Type => ApplyTypeChoice(payload, choice),
WizardStepNames.System => ApplySystemChoice(payload, choice),
WizardStepNames.Duration => ApplyDurationChoice(payload, choice),
WizardStepNames.Capacity => ApplyCapacityChoice(payload, choice),
WizardStepNames.Visibility => ApplyVisibilityChoice(payload, choice),
WizardStepNames.PickClub => ApplyPickClubChoice(payload, choice),
WizardStepNames.Publish => ApplyPublishChoice(payload, choice),
WizardStepNames.PoolSystemDuration => ApplyPoolSystemDurationChoice(payload, choice),
WizardStepNames.PoolAddSlots => ApplyPoolAddSlotsChoice(payload, choice),
WizardStepNames.PoolSlotCapacity => ApplyPoolSlotCapacityChoice(payload, choice),
_ => (null, "Неизвестный шаг"),
};
return (next, err, payload);
}
private static (string?, string?) ApplyTypeChoice(WizardPayload p, string choice) => choice switch
{
"single" => (WizardStepNames.Title, SetType(p, WizardCreationType.Single)),
"pool" => (WizardStepNames.Title, SetType(p, WizardCreationType.Pool)),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplySystemChoice(WizardPayload p, string choice) => choice switch
{
"_other" => (WizardStepNames.System, null), // stay, await text
"_skip" => (NextAfterSystem(p), SetSystem(p, null)),
{ } code => (WizardStepNames.Duration, SetSystem(p, code)),
};
private static (string?, string?) ApplyDurationChoice(WizardPayload p, string choice) => choice switch
{
"_other" => (WizardStepNames.Duration, null),
"_skip" => (NextAfterDuration(p), SetDurationMinutes(p, null)),
{ } d => int.TryParse(d, out var min)
? (NextAfterDuration(p), SetDurationMinutes(p, min))
: (null, "Неверная длительность"),
};
private static (string?, string?) ApplyCapacityChoice(WizardPayload p, string choice) => choice switch
{
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)),
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)),
"no_limit" => (WizardStepNames.Visibility, SetMaxPlayers(p, null)),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplyVisibilityChoice(WizardPayload p, string choice) => choice switch
{
"public" => (NextAfterVisibility(p), SetVisibility(p, WizardVisibility.Public)),
"club" => (WizardStepNames.PickClub, SetVisibility(p, WizardVisibility.Club)),
"members" => (WizardStepNames.PickClub, SetVisibility(p, WizardVisibility.Members)),
"pickclub" => (WizardStepNames.PickClub, null),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplyPickClubChoice(WizardPayload p, string choice)
=> Guid.TryParse(choice, out var id)
? (NextAfterVisibility(p), SetClubId(p, id))
: (null, "Неверный идентификатор клуба");
private static (string?, string?) ApplyPublishChoice(WizardPayload p, string choice) => choice switch
{
"yes" => (WizardStepNames.Confirm, SetPublishInShowcase(p, true)),
"no" => (WizardStepNames.Confirm, SetPublishInShowcase(p, false)),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplyPoolSystemDurationChoice(WizardPayload p, string choice) => choice switch
{
"_custom" => (WizardStepNames.PoolSystemDuration, null),
{ } c when c.Contains(':') => SplitSystemDuration(c) is (var sys, var dur)
? (WizardStepNames.Visibility, SetSystem(p, sys) ?? SetDurationMinutes(p, dur))
: (null, "Неверный выбор"),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplyPoolAddSlotsChoice(WizardPayload p, string choice) => choice switch
{
"add" => BeginNewPoolSlot(p),
"done" => p.Pool?.Slots.Count > 0
? (WizardStepNames.PoolConfirm, null)
: (null, "Добавьте хотя бы один слот"),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplyPoolSlotCapacityChoice(WizardPayload p, string choice) => choice switch
{
"waitlist:on" => (WizardStepNames.PoolAddSlots, CommitCurrentPoolSlot(p, true)),
"waitlist:off" => (WizardStepNames.PoolAddSlots, CommitCurrentPoolSlot(p, false)),
_ => (null, "Неизвестный выбор"),
};
// ── Back navigation ───────────────────────────────────────────────
private static void ApplyBack(WizardDraft draft, string fromStep)
{
// The callback's "step" portion is the step the user is currently on (e.g. the
// Confirm button emits `wizard:back` with no step, in which case we fall back to
// the draft's current step). Both should produce the same result.
var current = string.IsNullOrEmpty(fromStep) ? draft.Step : fromStep;
var payload = LoadPayload(draft);
var previous = PreviousStep(current, payload);
if (previous is { } step) draft.Step = step;
}
private static string? PreviousStep(string step, WizardPayload p) => step switch
{
WizardStepNames.Title => null, // first step
WizardStepNames.Description => WizardStepNames.Title,
WizardStepNames.Cover => WizardStepNames.Description,
WizardStepNames.System => WizardStepNames.Cover,
WizardStepNames.Duration => WizardStepNames.System,
WizardStepNames.DateTime => WizardStepNames.Duration,
WizardStepNames.Capacity => WizardStepNames.DateTime,
WizardStepNames.Visibility => WizardStepNames.Capacity,
WizardStepNames.PickClub => WizardStepNames.Visibility,
WizardStepNames.Publish => WizardStepNames.PickClub,
WizardStepNames.Confirm => WizardStepNames.Publish,
WizardStepNames.PoolSystemDuration => null, // first pool step
WizardStepNames.PoolAddSlots => WizardStepNames.PoolSystemDuration,
WizardStepNames.PoolSlotDateTime => WizardStepNames.PoolAddSlots,
WizardStepNames.PoolSlotCapacity => WizardStepNames.PoolSlotDateTime,
WizardStepNames.PoolConfirm => WizardStepNames.PoolAddSlots,
_ => null,
};
// ── Payload I/O ───────────────────────────────────────────────────
public static WizardPayload LoadPayload(WizardDraft draft)
{
if (string.IsNullOrEmpty(draft.PayloadJson)) return new WizardPayload();
return System.Text.Json.JsonSerializer.Deserialize(
draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
}
private static void SavePayload(WizardDraft draft, WizardPayload payload)
{
draft.PayloadJson = System.Text.Json.JsonSerializer.Serialize(
payload, WizardPayloadJsonContext.Default.WizardPayload);
}
// Mutators — return the error message if any (kept here to centralise flow).
private static string? SetTitle(WizardPayload p, string v) { p.Title = v; return null; }
private static string? SetDescription(WizardPayload p, string? v) { p.Description = v; return null; }
private static string? SetImageUrl(WizardPayload p, string? v) { p.ImageUrl = v; p.ImageFileId = null; return null; }
private static void ApplyCoverPhoto(WizardDraft d, string fileId)
{
var p = LoadPayload(d);
p.ImageFileId = fileId;
p.ImageUrl = null;
SavePayload(d, p);
var next = NextAfterCover(p);
if (next is { } s) d.Step = s;
}
private static string? SetSystem(WizardPayload p, string? v) { p.System = v; return null; }
private static string? SetDurationMinutes(WizardPayload p, int? v) { p.DurationMinutes = v; return null; }
private static string? SetScheduledAt(WizardPayload p, DateTimeOffset v)
{ p.Single ??= new WizardSingleInput(); p.Single.ScheduledAt = v; return null; }
private static string? SetMaxPlayers(WizardPayload p, int? v)
{ p.Single ??= new WizardSingleInput(); p.Single.MaxPlayers = v; return null; }
private static string? SetWaitlist(WizardPayload p, bool v) { p.Waitlist = v; return null; }
private static string? SetVisibility(WizardPayload p, WizardVisibility? v) { p.Visibility = v; return null; }
private static string? SetClubId(WizardPayload p, Guid v) { p.ClubId = v; return null; }
private static string? SetType(WizardPayload p, WizardCreationType v) { p.Type = v; return null; }
private static string? SetPublishInShowcase(WizardPayload p, bool v) { p.PublishInShowcase = v; return null; }
private static string? SetCurrentSlotDateTime(WizardPayload p, DateTimeOffset v)
{
p.Pool ??= new WizardPoolInput();
var current = EnsureCurrentPoolSlot(p);
current.ScheduledAt = v;
return null;
}
private static string? SetCurrentSlotMaxPlayers(WizardPayload p, int v)
{
p.Pool ??= new WizardPoolInput();
var current = EnsureCurrentPoolSlot(p);
current.MaxPlayers = v;
return null;
}
private static string? CommitCurrentPoolSlot(WizardPayload p, bool waitlist)
{
p.Pool ??= new WizardPoolInput();
var current = EnsureCurrentPoolSlot(p);
current.Waitlist = waitlist;
return null;
}
private static (string? nextStep, string? error) BeginNewPoolSlot(WizardPayload p)
{
p.Pool ??= new WizardPoolInput();
p.Pool.Slots.Add(new WizardSlotInput());
return (WizardStepNames.PoolSlotDateTime, null);
}
private static WizardSlotInput EnsureCurrentPoolSlot(WizardPayload p)
{
// Slots added via BeginNewPoolSlot are always committed before they leave the
// PoolSlotCapacity step (CommitCurrentPoolSlot). If we somehow get here without
// a slot, start a new one to keep the flow recoverable.
p.Pool ??= new WizardPoolInput();
var last = p.Pool.Slots.LastOrDefault();
if (last is not null && last.MaxPlayers == 0) return last;
p.Pool.Slots.Add(new WizardSlotInput());
return p.Pool.Slots[^1];
}
// ── Flow helpers ──────────────────────────────────────────────────
private static string? NextAfterCover(WizardPayload p) => p.Type == WizardCreationType.Pool
? WizardStepNames.PoolSystemDuration : WizardStepNames.System;
private static string? NextAfterSystem(WizardPayload p) => WizardStepNames.Duration;
private static string? NextAfterDuration(WizardPayload p)
{
if (p.Type == WizardCreationType.Pool) return WizardStepNames.Visibility;
return p.Single?.MaxPlayers is not null ? WizardStepNames.Visibility : WizardStepNames.DateTime;
}
private static string? NextAfterVisibility(WizardPayload p)
{
if (p.Visibility is WizardVisibility.Club or WizardVisibility.Members)
{
if (p.ClubId is null) return WizardStepNames.PickClub;
}
return p.Type == WizardCreationType.Pool ? WizardStepNames.PoolAddSlots : WizardStepNames.Publish;
}
private static (string? sys, int? dur) SplitSystemDuration(string s)
{
var idx = s.IndexOf(':');
if (idx <= 0 || idx >= s.Length - 1) return (null, null);
var sys = s.Substring(0, idx);
if (!int.TryParse(s.Substring(idx + 1), out var durMin)) return (null, null);
return (sys, durMin);
}
private static bool ValidateText(
string input, int maxLength, string emptyMsg, string tooLongMsg, out string trimmed)
{
trimmed = input.Trim();
if (string.IsNullOrEmpty(trimmed))
{
trimmed = emptyMsg;
return false;
}
if (trimmed.Length > maxLength)
{
trimmed = tooLongMsg;
return false;
}
return true;
}
private static bool TryParseHours(string input, out int minutes)
{
minutes = 0;
var s = input.Trim();
if (s.EndsWith("h", StringComparison.OrdinalIgnoreCase)) s = s.Substring(0, s.Length - 1);
if (s.EndsWith("ч", StringComparison.OrdinalIgnoreCase)) s = s.Substring(0, s.Length - 1);
if (!double.TryParse(s, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var hours)) return false;
if (hours < WizardStepLimits.MinDurationHours || hours > WizardStepLimits.MaxDurationHours) return false;
minutes = (int)Math.Round(hours * 60);
return true;
}
}