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
523 lines
24 KiB
C#
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;
|
|
}
|
|
}
|