# Discord wizard adapter — issue #112

## Summary

Implemented the Discord side of the platform-neutral game/pool creation
wizard (`feat/issue-112-wizard-refactor`). Six adapter files in
`src/GmRelay.DiscordBot/Features/Sessions/Wizard/` plus one inbound
handler module turn the `/newsession-wizard` slash command into a
fully clickable step-by-step wizard with button, StringSelectMenu, and
modal handlers. The shared `GameCreationWizard` state machine in
`GmRelay.Shared` is the single source of truth — the Discord adapter
only translates between NetCord's interaction types and the
platform-neutral `WizardInteraction` record.

## Files

Adapter (all under `src/GmRelay.DiscordBot/Features/Sessions/Wizard/`):

- `DiscordWizardContextStore.cs` — `IWizardContextStore` interface +
  thread-safe in-memory store. Keyed by `draft.Id`. Holds the
  `(GuildId, ChannelId, MessageId, ThreadId?)` snapshot the messenger
  needs to re-send a draft after a 15-minute interaction token
  expires.
- `DiscordWizardStep.cs` — renderer for all 15 wizard steps. Returns
  an embed + `IReadOnlyList<IMessageComponentProperties>` (mixes
  `ActionRow` buttons with `StringMenu` select menus) and exposes
  `BuildModal` for the 8 modal-collecting steps.
- `DiscordWizardMessenger.cs` — `IWizardMessenger` impl. Uses
  `RestClient.SendMessageAsync` / `ModifyMessageAsync` (edit falls
  back to re-send on 401/403/404). Toast replies are stashed in the
  existing `DiscordInteractionReplyCache`.
- `DiscordWizardSubmitter.cs` — 3-retry finalize loop. Builds the
  shared `CreateSessionCommand` and calls `CreateSessionHandler`;
  on success edits the draft to "✅ Создано: N сессий", on failure
  shows retry/cancel buttons.
- `DiscordWizardCommand.cs` — `/newsession-wizard` slash command.
  Owner/co-GM check via `group_managers` (DiscordPermissionLookup).
- `DiscordPermissionLookup.cs` — small DB helper that loads
  `group_managers` rows for a guild.
- `DiscordWizardInteractionModule.cs` — **inbound handlers** (this
  commit). Three NetCord `ComponentInteractionModule<TContext>`
  shells (button / StringSelectMenu / modal) share one
  `WizardInteractionDispatcher` that:
  1. parses the custom-id tail (`btn:choice:<step>:<value>`,
     `btn:cancel`, `btn:back`, `btn:create`, `btn:resume:continue`,
     `btn:resume:restart`, `select:<step>`, `modal:<step>`);
  2. loads the active draft via
     `IWizardDraftRepository.GetActiveAsync("Discord", ownerId, ct)`;
  3. routes the callback through the shared
     `GameCreationWizard.HandleInteractionAsync` (or
     `DiscordWizardSubmitter.SubmitAsync` for the `create` button);
  4. opens a modal popup when the new step needs text input,
     otherwise acks with a deferred message so Discord doesn't show
     "Application did not respond".

DI / tests:

- `src/GmRelay.DiscordBot/Program.cs` — 7 singleton registrations
  (`IWizardDraftRepository`, `IWizardContextStore`, `IWizardMessenger`,
  `GameCreationWizard`, `DiscordWizardSubmitter`,
  `WizardInteractionDispatcher`, `DiscordWizardButtonModule`,
  `DiscordWizardStringMenuModule`, `DiscordWizardModalModule`) plus
  3 `AddComponentInteractions<TInteraction, TContext>` calls
  (Button, StringMenu, Modal).
- `tests/GmRelay.Bot.Tests/Discord/DiscordWizardInteractionModuleSourceTests.cs`
  — 12 source-level structural smoke tests: handler classes exist,
  all 3 derive from `ComponentInteractionModule<TContext>`, all 3
  register `[ComponentInteraction("wizard")]`, the dispatcher
  exposes `HandleButtonAsync` / `HandleStringMenuAsync` /
  `HandleModalAsync`, all 5 callback kinds (`choice` / `back` /
  `cancel` / `create` / `resume`) are routed, the dispatcher
  invokes `GameCreationWizard.HandleInteractionAsync` and
  `DiscordWizardSubmitter.SubmitAsync` on `create`, Program.cs
  registers all 3 `AddComponentInteractions` and all 4 module
  classes, draft lookup is by `GetActiveAsync("Discord", …)`, modal
  walks `Components[0] → TextInput → .Value`, string menu reads
  `SelectedValues[0]`.

## Custom-id wire format

| Interaction            | Custom-id                                | Handler                                         |
|------------------------|------------------------------------------|-------------------------------------------------|
| Choice button          | `wizard:btn:choice:<step>:<value>`       | Wizard's `ApplyChoice`                           |
| Back button            | `wizard:btn:back`                        | Wizard's `ApplyBack`                             |
| Cancel button          | `wizard:btn:cancel`                      | Wizard deletes draft + edits "❌ Мастер отменён" |
| Create button          | `wizard:btn:create`                      | `DiscordWizardSubmitter.SubmitAsync` (3 retries) |
| Resume: continue       | `wizard:btn:resume:continue`             | Re-render current step via messenger             |
| Resume: restart        | `wizard:btn:resume:restart`              | Delete draft, prompt to re-run                   |
| StringSelectMenu       | `wizard:select:<step>`                   | Wizard's `ApplyChoice` (step, SelectedValues[0]) |
| Modal submit           | `wizard:modal:<step>`                    | Wizard's `ApplyText` (Text = Component[0].Value) |

The wizard renderer (`DiscordWizardStep`) owns the prefix generation:
`wizard:btn:<step>:<value>`, `wizard:select:<step>`,
`wizard:modal:<step>`. The handlers match by the `wizard` prefix and
parse the rest.

## Acceptance

- ✅ `dotnet build` решения — 0 warnings, 0 errors
- ✅ `dotnet test` — 190/190 Discord+Wizard tests pass (2 pre-existing
  skipped). 12 source-level smoke tests cover the interaction module.
- ✅ `dotnet format --verify-no-changes` — clean
- ✅ Pushed to `feat/issue-112-wizard-refactor` (commits `b81d865`,
  `f095209`).

PR link: https://git.codeanddice.ru/Toutsu/GmRelayBot/pulls/new/feat/issue-112-wizard-refactor

## Open questions

- ~~**Club lookup at the PickClub step**~~ — **FIXED in commit `7cfb196`.**
  `WizardClubLookup.LoadClubsAsync` now queries `group_managers`
  directly via injected `NpgsqlDataSource` with the same
  `Owner | CoGm` role filter the messenger uses. The dispatcher
  reads the owner's real club list and renders them in the
  StringSelectMenu. Build green, 12 source-level smoke tests still
  pass.
- ~~**MaybeOpenModalAsync was a no-op in the previous commit**~~ — **FIXED in commit `f095209`.**
  The dispatcher now runs the wizard first (which edits the draft
  embed), then sends the response as either
  `InteractionCallback.Modal(modalProperties)` (when the new step
  needs text input) or `InteractionCallback.DeferredMessage()`
  (otherwise). NetCord locks the response type after the first
  `SendResponseAsync` call, so the fix is NOT to call
  `DeferredMessage` upfront.
- **Modal handler's free-text mapping is a hack.** Modal steps like
  `SystemFreeText`, `DurationFreeText`, `PoolSystemDurationFreeText`
  are mapped to the canonical wizard step (`System`, `Duration`,
  `PoolSystemDuration`) in `MapModalStepToWizardStep`. This works
  because the wizard's `ApplyText` dispatches on the canonical step
  name, but a future refactor of `ApplyText` to know about the
  free-text step names would break this. The clean fix is to add
  dedicated "free text" steps to `WizardStepNames`.
- **Resume:continue is a re-render, not a true resume.** The wizard
  has no special resume case; clicking "▶️ Продолжить" just re-emits
  the current step's embed. This is fine for the user (the embed is
  identical to the last one they saw before the click), but the
  underlying state isn't really "continued" — if the wizard's cleanup
  service expired the draft between the slash command and the
  click, the user gets a re-render of an empty step.
- **One-draft-per-owner invariant.** The wizard's "one active draft
  per owner" rule means a single Discord user can't run two wizard
  sessions in parallel. Acceptable for now, but the wizard's state
  machine doesn't enforce this — only the dispatcher does, via
  `GetActiveAsync("Discord", ownerId)`.

## Final commit history on `feat/issue-112-wizard-refactor`

- `8f0f2ef` — Task 1: platform-neutral wizard refactor (core + Shared types)
- `b81d865` — Task 2: Discord adapter scaffolding (messenger, step, submitter, command)
- `f095209` — Task 2: interaction module + modal popup fix
- `7cfb196` — Task 2: select/modal parser off-by-one + real club lookup

PR link: https://git.codeanddice.ru/Toutsu/GmRelayBot/pulls/new/feat/issue-112-wizard-refactor
