feat(bot): add online/offline wizard locations
PR Checks / test-and-build (pull_request) Successful in 15m52s

Add format and location steps to the Telegram /newsession wizard, persist offline addresses in sessions.location_address, and render online links/offline addresses in schedule messages.

Bump version to 3.10.0.
This commit is contained in:
2026-06-10 11:29:25 +03:00
parent bbd58142db
commit 014b5edd31
36 changed files with 460 additions and 49 deletions
@@ -70,7 +70,7 @@ public sealed class CancelSessionHandler(
// 3. Загружаем весь батч для перерисовки
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink, format as Format, location_address as LocationAddress
FROM sessions
WHERE batch_id = @BatchId
ORDER BY scheduled_at",
@@ -293,15 +293,16 @@ public sealed class CreateSessionHandler
User: user,
Group: group,
Title: p.Title ?? string.Empty,
Link: string.Empty,
Link: p.Format == WizardSessionFormat.Online ? p.JoinLink ?? string.Empty : string.Empty,
ScheduledTimes: scheduledTimes,
MaxPlayers: maxPlayers,
ImageReference: p.ImageFileId ?? p.ImageUrl,
System: ParseSystem(p.System),
Description: p.Description,
Format: null,
Format: p.Format?.ToString(),
DurationMinutes: p.DurationMinutes,
IsOneShot: isOneShot);
IsOneShot: isOneShot,
LocationAddress: p.Format == WizardSessionFormat.Offline ? p.LocationAddress : null);
}
private static GameSystem? ParseSystem(string? code)
@@ -317,6 +318,9 @@ public sealed class CreateSessionHandler
if (string.IsNullOrWhiteSpace(p.Title)) missingFields.Add("название");
if (string.IsNullOrWhiteSpace(p.System)) missingFields.Add("система");
if (!p.DurationMinutes.HasValue) missingFields.Add("длительность");
if (p.Format is null) missingFields.Add("формат");
if (p.Format == WizardSessionFormat.Online && string.IsNullOrWhiteSpace(p.JoinLink)) missingFields.Add("ссылка");
if (p.Format == WizardSessionFormat.Offline && string.IsNullOrWhiteSpace(p.LocationAddress)) missingFields.Add("адрес");
if (p.Visibility is null) missingFields.Add("видимость");
if (p.Type == WizardCreationType.Single)
@@ -139,7 +139,9 @@ public sealed class PromoteWaitlistedPlayerHandler(
scheduled_at AS ScheduledAt,
status AS Status,
max_players AS MaxPlayers,
join_link AS JoinLink
join_link AS JoinLink,
format AS Format,
location_address AS LocationAddress
FROM sessions
WHERE batch_id = @BatchId
ORDER BY scheduled_at
@@ -162,7 +162,7 @@ public sealed class RescheduleVotingDeadlineService(
await using var connection = await dataSource.OpenConnectionAsync(ct);
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { result.BatchId })).ToList();
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
@@ -23,7 +23,14 @@ public static class TelegramSessionBatchRenderer
if (!string.IsNullOrEmpty(session.JoinLink))
{
messageText += $"🔗 <a href=\"{System.Net.WebUtility.HtmlEncode(session.JoinLink)}\">Ссылка на игру</a>\n";
var encodedLink = System.Net.WebUtility.HtmlEncode(session.JoinLink);
messageText += $"🔗 Ссылка на игру: <a href=\"{encodedLink}\">{encodedLink}</a>\n";
}
if (string.Equals(session.Format, "Offline", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(session.LocationAddress))
{
messageText += $"📍 Адрес: {System.Net.WebUtility.HtmlEncode(session.LocationAddress)}\n";
}
if (session.ActivePlayers.Count > 0)
@@ -0,0 +1,2 @@
ALTER TABLE sessions
ADD COLUMN location_address TEXT;
@@ -145,7 +145,7 @@ public sealed class DiscordRescheduleVotingDeadlineService(
return;
var sessions = (await connection.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { result.BatchId })).ToList();
var participants = (await connection.QueryAsync<ParticipantBatchDto>(
@@ -15,4 +15,5 @@ public sealed record CreateSessionCommand(
string? Description = null,
string? Format = null,
int? DurationMinutes = null,
bool IsOneShot = false);
bool IsOneShot = false,
string? LocationAddress = null);
@@ -124,8 +124,8 @@ public sealed class CreateSessionHandler(
{
var sessionId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, system, description, format, duration_minutes, is_one_shot, cover_image_url)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes, @IsOneShot, @CoverImageUrl)
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, system, description, format, duration_minutes, is_one_shot, cover_image_url, location_address)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes, @IsOneShot, @CoverImageUrl, @LocationAddress)
RETURNING id;
""",
new
@@ -142,11 +142,19 @@ public sealed class CreateSessionHandler(
command.Format,
DurationMinutes = command.DurationMinutes,
IsOneShot = command.IsOneShot,
CoverImageUrl = command.ImageReference
CoverImageUrl = command.ImageReference,
command.LocationAddress
},
transaction);
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, command.MaxPlayers, command.Link));
sessions.Add(new SessionBatchDto(
sessionId,
scheduledAt.UtcDateTime,
SessionStatus.Planned,
command.MaxPlayers,
command.Link,
command.Format,
command.LocationAddress));
}
await transaction.CommitAsync(ct);
@@ -135,7 +135,7 @@ public sealed class JoinSessionHandler(
// Загружаем весь батч для перерисовки
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink, format as Format, location_address as LocationAddress
FROM sessions
WHERE batch_id = @BatchId
ORDER BY scheduled_at",
@@ -161,7 +161,9 @@ public sealed class LeaveSessionHandler(
scheduled_at AS ScheduledAt,
status AS Status,
max_players AS MaxPlayers,
join_link AS JoinLink
join_link AS JoinLink,
format AS Format,
location_address AS LocationAddress
FROM sessions
WHERE batch_id = @BatchId
ORDER BY scheduled_at
@@ -226,9 +226,20 @@ public sealed class GameCreationWizard
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)
? (WizardStepNames.Format, SetMaxPlayers(payload, cap), payload)
: (null, "Лимит должен быть 1..50", payload);
case WizardStepNames.Location when payload.Format == WizardSessionFormat.Online:
return Uri.TryCreate(input.Trim(), UriKind.Absolute, out var locationUri) &&
(locationUri.Scheme == Uri.UriSchemeHttp || locationUri.Scheme == Uri.UriSchemeHttps)
? (WizardStepNames.Visibility, SetJoinLink(payload, input.Trim()), payload)
: (null, "Некорректная ссылка", payload);
case WizardStepNames.Location when payload.Format == WizardSessionFormat.Offline:
return ValidateText(input, WizardStepLimits.MaxLocationLength, "Адрес не может быть пустым", "Слишком длинный адрес", out var address)
? (WizardStepNames.Visibility, SetLocationAddress(payload, address), payload)
: (null, address, payload);
case WizardStepNames.PoolSystemDuration when payload.System is null:
return ValidateText(input, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys)
? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys), payload)
@@ -236,7 +247,7 @@ public sealed class GameCreationWizard
case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null:
return TryParseHours(input, out var pdur)
? (WizardStepNames.Visibility, SetDurationMinutes(payload, pdur), payload)
? (WizardStepNames.Format, SetDurationMinutes(payload, pdur), payload)
: (null, "Неверная длительность (1..12 ч)", payload);
case WizardStepNames.PoolSlotDateTime:
@@ -264,6 +275,7 @@ public sealed class GameCreationWizard
WizardStepNames.System => ApplySystemChoice(payload, choice),
WizardStepNames.Duration => ApplyDurationChoice(payload, choice),
WizardStepNames.Capacity => ApplyCapacityChoice(payload, choice),
WizardStepNames.Format => ApplyFormatChoice(payload, choice),
WizardStepNames.Visibility => ApplyVisibilityChoice(payload, choice),
WizardStepNames.PickClub => ApplyPickClubChoice(payload, choice),
WizardStepNames.Publish => ApplyPublishChoice(payload, choice),
@@ -302,7 +314,7 @@ public sealed class GameCreationWizard
{
if (choice is "no_limit")
{
return (WizardStepNames.Visibility, SetMaxPlayers(p, null));
return (WizardStepNames.Format, SetMaxPlayers(p, null));
}
if (choice is "waitlist:on" or "waitlist:off" && p.Single?.MaxPlayers is null)
@@ -312,12 +324,19 @@ public sealed class GameCreationWizard
return choice switch
{
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)),
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)),
"waitlist:on" => (WizardStepNames.Format, SetWaitlist(p, true)),
"waitlist:off" => (WizardStepNames.Format, SetWaitlist(p, false)),
_ => (null, "Неизвестный выбор"),
};
}
private static (string?, string?) ApplyFormatChoice(WizardPayload p, string choice) => choice switch
{
"online" => (WizardStepNames.Location, SetFormat(p, WizardSessionFormat.Online)),
"offline" => (WizardStepNames.Location, SetFormat(p, WizardSessionFormat.Offline)),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplyVisibilityChoice(WizardPayload p, string choice) => choice switch
{
"public" => (NextAfterVisibility(p), SetVisibility(p, WizardVisibility.Public)),
@@ -349,7 +368,7 @@ public sealed class GameCreationWizard
{
"_custom" => (WizardStepNames.PoolSystemDuration, null),
{ } c when c.Contains(':') => SplitSystemDuration(c) is (var sys, var dur)
? (WizardStepNames.Visibility, SetSystem(p, sys) ?? SetDurationMinutes(p, dur))
? (WizardStepNames.Format, SetSystem(p, sys) ?? SetDurationMinutes(p, dur))
: (null, "Неверный выбор"),
_ => (null, "Неизвестный выбор"),
};
@@ -391,13 +410,15 @@ public sealed class GameCreationWizard
WizardStepNames.Duration => WizardStepNames.System,
WizardStepNames.DateTime => WizardStepNames.Duration,
WizardStepNames.Capacity => WizardStepNames.DateTime,
WizardStepNames.Visibility => WizardStepNames.Capacity,
WizardStepNames.Format => p.Type == WizardCreationType.Pool ? WizardStepNames.PoolSystemDuration : WizardStepNames.Capacity,
WizardStepNames.Location => WizardStepNames.Format,
WizardStepNames.Visibility => WizardStepNames.Location,
WizardStepNames.PickClub => WizardStepNames.Visibility,
WizardStepNames.Publish => WizardStepNames.PickClub,
WizardStepNames.Confirm => WizardStepNames.Publish,
WizardStepNames.PoolSystemDuration => null, // first pool step
WizardStepNames.PoolAddSlots => WizardStepNames.PoolSystemDuration,
WizardStepNames.PoolAddSlots => WizardStepNames.Visibility,
WizardStepNames.PoolSlotDateTime => WizardStepNames.PoolAddSlots,
WizardStepNames.PoolSlotCapacity => WizardStepNames.PoolSlotDateTime,
WizardStepNames.PoolConfirm => WizardStepNames.PoolAddSlots,
@@ -442,6 +463,15 @@ public sealed class GameCreationWizard
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? SetFormat(WizardPayload p, WizardSessionFormat v)
{
p.Format = v;
p.JoinLink = null;
p.LocationAddress = null;
return null;
}
private static string? SetJoinLink(WizardPayload p, string v) { p.JoinLink = v; p.LocationAddress = null; return null; }
private static string? SetLocationAddress(WizardPayload p, string v) { p.LocationAddress = v; p.JoinLink = null; return null; }
private static string? SetCurrentSlotDateTime(WizardPayload p, DateTimeOffset v)
{
@@ -488,8 +518,8 @@ public sealed class GameCreationWizard
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;
if (p.Type == WizardCreationType.Pool) return WizardStepNames.Format;
return p.Single?.MaxPlayers is not null ? WizardStepNames.Format : WizardStepNames.DateTime;
}
private static string? NextAfterVisibility(WizardPayload p)
{
@@ -8,6 +8,9 @@ public enum WizardCreationType { Single, Pool }
public enum WizardVisibility { Public, Club, Members }
[JsonConverter(typeof(JsonStringEnumConverter<WizardSessionFormat>))]
public enum WizardSessionFormat { Online, Offline }
public sealed class WizardSlotInput
{
public DateTimeOffset ScheduledAt { get; set; }
@@ -30,6 +33,9 @@ public sealed class WizardPayload
public string? ImageUrl { get; set; }
public string? System { get; set; }
public int? DurationMinutes { get; set; }
public WizardSessionFormat? Format { get; set; }
public string? JoinLink { get; set; }
public string? LocationAddress { get; set; }
public WizardVisibility? Visibility { get; set; }
public Guid? ClubId { get; set; }
public bool? PublishInShowcase { get; set; }
@@ -14,4 +14,5 @@ public static class WizardStepLimits
public const int MinCapacity = 1;
public const int MinDurationHours = 1;
public const int MaxDurationHours = 12;
public const int MaxLocationLength = 500;
}
@@ -16,6 +16,8 @@ public static class WizardStepNames
public const string Duration = "Duration";
public const string DateTime = "DateTime";
public const string Capacity = "Capacity";
public const string Format = "Format";
public const string Location = "Location";
public const string Visibility = "Visibility";
public const string PickClub = "PickClub";
public const string Publish = "Publish";
@@ -30,6 +30,8 @@ public static class WizardStepViewBuilder
WizardStepNames.Duration => BuildDuration(),
WizardStepNames.DateTime => BuildDateTime(),
WizardStepNames.Capacity => BuildCapacity(),
WizardStepNames.Format => BuildFormat(),
WizardStepNames.Location => BuildLocation(payload),
WizardStepNames.Visibility => BuildVisibility(),
WizardStepNames.PickClub => BuildPickClub(clubs ?? Array.Empty<WizardClubOption>()),
WizardStepNames.Publish => BuildPublish(),
@@ -105,6 +107,22 @@ public static class WizardStepViewBuilder
new("♾ Без лимита", WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit"), WizardActionStyle.Primary),
});
private static (string, IReadOnlyList<WizardAction>) BuildFormat() => (
"🧭 Выберите формат игры.",
new List<WizardAction>
{
new("🌐 Online", WizardCallbackData.Choice(WizardStepNames.Format, "online"), WizardActionStyle.Primary),
new("📍 Offline", WizardCallbackData.Choice(WizardStepNames.Format, "offline"), WizardActionStyle.Primary),
new("⬅️ Назад", WizardCallbackData.Back()),
new("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
});
private static (string, IReadOnlyList<WizardAction>) BuildLocation(WizardPayload payload) => payload.Format switch
{
WizardSessionFormat.Offline => ("📍 Введите адрес места проведения.", BackCancel()),
_ => ("🔗 Введите ссылку для подключения к online-игре.", BackCancel()),
};
private static (string, IReadOnlyList<WizardAction>) BuildVisibility() => (
"🔒 Выберите видимость.",
new List<WizardAction>
@@ -150,6 +168,7 @@ public static class WizardStepViewBuilder
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} ч");
AppendFormatLocation(sb, p);
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)}");
@@ -204,6 +223,7 @@ public static class WizardStepViewBuilder
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} ч");
AppendFormatLocation(sb, p);
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
sb.AppendLine();
sb.AppendLine($"Слоты ({p.Pool?.Slots.Count ?? 0}):");
@@ -245,4 +265,19 @@ public static class WizardStepViewBuilder
WizardVisibility.Members => "только для членов клуба",
_ => "не задана",
};
private static void AppendFormatLocation(StringBuilder sb, WizardPayload p)
{
if (p.Format is null) return;
sb.AppendLine($"🧭 Формат: {p.Format}");
if (p.Format == WizardSessionFormat.Online && !string.IsNullOrWhiteSpace(p.JoinLink))
{
sb.AppendLine($"🔗 Ссылка: {p.JoinLink}");
}
else if (p.Format == WizardSessionFormat.Offline && !string.IsNullOrWhiteSpace(p.LocationAddress))
{
sb.AppendLine($"📍 Адрес: {p.LocationAddress}");
}
}
}
@@ -159,7 +159,7 @@ public sealed class HandleRescheduleTimeInputHandler(
await transaction.CommitAsync(ct);
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { proposal.BatchId })).ToList();
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
@@ -1,4 +1,11 @@
namespace GmRelay.Shared.Rendering;
public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers, string JoinLink);
public sealed record SessionBatchDto(
Guid SessionId,
DateTime ScheduledAt,
string Status,
int? MaxPlayers,
string JoinLink,
string? Format = null,
string? LocationAddress = null);
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
@@ -39,6 +39,8 @@ public static class SessionBatchViewBuilder
session.Status,
session.MaxPlayers,
session.JoinLink,
session.Format,
session.LocationAddress,
activePlayers.Count,
activePlayers,
waitlistedPlayers,
@@ -12,6 +12,8 @@ public sealed record SessionViewItem(
string Status,
int? MaxPlayers,
string JoinLink,
string? Format,
string? LocationAddress,
int ActivePlayerCount,
IReadOnlyList<PlayerViewItem> ActivePlayers,
IReadOnlyList<PlayerViewItem> WaitlistedPlayers,
@@ -82,7 +82,7 @@
</button>
</form>
<div class="nav-version">v3.9.9</div>
<div class="nav-version">v3.10.0</div>
</div>
</Authorized>
<NotAuthorized>
+1 -1
View File
@@ -1897,7 +1897,7 @@ public sealed class SessionService(
await using var conn = await dataSource.OpenConnectionAsync();
var sessions = (await conn.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { BatchId = batchId })).ToList();
var participants = (await conn.QueryAsync<ParticipantBatchDto>(
@@ -22,7 +22,14 @@ public static class TelegramSessionBatchRenderer
if (!string.IsNullOrEmpty(session.JoinLink))
{
messageText += $"🔗 <a href=\"{System.Net.WebUtility.HtmlEncode(session.JoinLink)}\">Ссылка на игру</a>\n";
var encodedLink = System.Net.WebUtility.HtmlEncode(session.JoinLink);
messageText += $"🔗 Ссылка на игру: <a href=\"{encodedLink}\">{encodedLink}</a>\n";
}
if (string.Equals(session.Format, "Offline", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(session.LocationAddress))
{
messageText += $"📍 Адрес: {System.Net.WebUtility.HtmlEncode(session.LocationAddress)}\n";
}
if (session.ActivePlayers.Count > 0)