Skip to main content

Overview

AutoRefQualifiersStage handles the automated refereeing logic for Qualifier Lobbies. It manages a linear flow of maps with no picks/bans, cycling through the entire map pool sequentially. Namespace: ss.Internal.Management.Server.AutoRef

Class Definition

public partial class AutoRefQualifiersStage : IAutoRef

State Machine

State Enum

public enum MatchState
{
    Idle,
    WaitingForStart,
    Playing,
    MatchFinished,
    MatchOnHold
}

State Descriptions

StateDescription
IdleCooldown/loading state between maps
WaitingForStartMap loaded, timer active, waiting for players to ready up
PlayingMap in progress, waiting for completion
MatchFinishedAll maps in the pool have been played
MatchOnHoldPanic mode - automation paused

State Transition Diagram

Qualifiers State Machine

Key Methods

StartAsync()

Initializes the qualifier room and connects to Bancho.
public async Task StartAsync()
Behavior:
  1. Loads QualifierRoom from database by matchId
  2. Validates referee credentials
  3. Loads round configuration and map pool
  4. Retrieves list of players assigned to this room
  5. Connects to Bancho IRC
  6. Creates tournament lobby

StopAsync()

Saves match state and closes the lobby.
public async Task StopAsync()
Persisted Data:
  • MpLinkId: osu! match ID for score tracking

HandleIrcMessage()

Core event loop that drives the state machine.
internal async Task HandleIrcMessage(IIrcMessage msg)
Responsibilities:
  1. Parse IRC messages from BanchoBot
  2. Detect lobby creation and extract MP link ID
  3. Handle !panic emergency protocol
  4. Detect match completion
  5. Process admin commands (prefix: >)
  6. Drive state transitions via TryStateChange()

ExecuteAdminCommand()

Processes referee commands.
private async Task ExecuteAdminCommand(string sender, string[] args)
Available Commands:
CommandSyntaxDescription
>finish>finishCloses the lobby
>invite>inviteInvites all assigned players to the lobby
>setmap>setmap [slot]Manually sets a map (requires Idle state)
>start>startEngages automation and starts qualifier flow
>stop>stopStops automation

StartQualifiersFlow()

Initializes the qualifier lifecycle.
private async Task StartQualifiersFlow()
Behavior:
  1. Resets currentMapIndex to 0
  2. Sets state to Idle
  3. Calls PrepareNextQualifierMap() to load first map

PrepareNextQualifierMap()

Iterates to the next map in the pool and prepares it for play.
private async Task PrepareNextQualifierMap()
Logic:
if (currentMapIndex >= currentMatch.Round.MapPool.Count)
{
    await SendMessageBothWays(Strings.QualifiersOver);
    currentState = MatchState.MatchFinished;
    return;
}

var beatmap = currentMatch.Round.MapPool[currentMapIndex];
await SendMessageBothWays($"!mp map {beatmap.BeatmapID}");
await SendMessageBothWays($"!mp mods {beatmap.Slot[..2]} NF");
await SendMessageBothWays("!mp timer 120");

currentState = MatchState.WaitingForStart;
Flow:
  1. Checks if all maps have been played
  2. If finished, transitions to MatchFinished state
  3. Otherwise, loads next map by BeatmapID
  4. Applies mods based on slot prefix (e.g., NM, HD, HR)
  5. Adds NF (No Fail) mod
  6. Starts 120-second ready timer
  7. Transitions to WaitingForStart

TryStateChange()

The state machine brain - evaluates Bancho messages and transitions states.
private async Task TryStateChange(string banchoMsg)
State Logic:

Idle State

case MatchState.Idle:
    return; // No transitions from idle except via admin commands

WaitingForStart State

case MatchState.WaitingForStart:
{
    if (banchoMsg.Contains("All players are ready") || 
        banchoMsg.Contains("Countdown finished"))
    {
        await SendMessageBothWays("!mp start 10");
        currentState = MatchState.Playing;
    }
    break;
}

Playing State

case MatchState.Playing:
{
    if (banchoMsg.Contains("The match has finished"))
    {
        currentMapIndex++;
        currentState = MatchState.Idle;
        
        _ = Task.Run(async () =>
        {
            await Task.Delay(10000); // 10-second cooldown
            await PrepareNextQualifierMap();
        });
    }
    break;
}

Map Pool Iteration

The qualifier flow is fully linear:
  1. Start: >start command sets currentMapIndex = 0
  2. Load Map: PrepareNextQualifierMap() loads map at current index
  3. Play Map: Players ready up, timer expires, or all ready → map starts
  4. Complete Map: Match finishes → increment currentMapIndex
  5. Cooldown: 10-second delay before loading next map
  6. Repeat: Go to step 2 until all maps are played
  7. Finish: When currentMapIndex >= MapPool.Count, transitions to MatchFinished

Timer Management

Qualifiers use different timers than elimination matches:
EventTimer DurationCommand
Map ready phase120 seconds!mp timer 120
Match start delay10 seconds!mp start 10
Map cooldown10 secondsTask.Delay(10000)
Panic recovery10 seconds!mp timer 10

Panic Protocol

Identical to elimination matches: Trigger: Anyone types !panic
if (content.Contains("!panic"))
{
    currentState = MatchState.MatchOnHold;
    await SendMessageBothWays("!mp aborttimer");
    await SendMessageBothWays($"<@&{REFEREE_ROLE_ID}> PANIC by {senderNick}");
}
Recovery: Referee types >panic_over
if (content.Contains(">panic_over") && senderNick == referee)
{
    await SendMessageBothWays(Strings.BackToAuto);
    currentState = MatchState.WaitingForStart;
    await SendMessageBothWays("!mp timer 10");
}

Player Invitations

The >invite command iterates through all players assigned to the room:
case "invite":
    foreach (var osuId in usersInRoom)
    {
        await SendMessageBothWays($"!mp invite #{osuId}");
        await Task.Delay(500);
    }
    break;
Players List:
  • Loaded from database during StartAsync()
  • Query: Players.Where(p => p.QualifierRoomId == matchId).Select(p => p.User.OsuID)
  • Stored in List<int> usersInRoom

Lobby Settings

Qualifiers use Head-to-Head mode:
private async Task InitializeLobbySettings()
{
    // TeamMode=0 (Head2Head), WinCondition=3 (ScoreV2), Slots=16
    await client.SendPrivateMessageAsync(lobbyChannelName, "!mp set 0 3 16");
    await client.SendPrivateMessageAsync(lobbyChannelName, 
        "!mp invite " + currentMatch.Referee.DisplayName.Replace(' ', '_'));
    await SendMessageBothWays($"Join this match via an IRC app with this command: \n- `/join {lobbyChannelName}`");
}
Settings:
  • Team Mode: 0 (Head-to-Head)
  • Win Condition: 3 (ScoreV2)
  • Slots: 16 (allows multiple players simultaneously)

Cooldown System

After each map, a 10-second cooldown prevents immediate transitions:
_ = Task.Run(async () =>
{
    await Task.Delay(10000);
    await PrepareNextQualifierMap();
});
Purpose:
  • Gives players time to review scores
  • Prevents spamming Bancho with rapid map changes
  • Allows for brief discussions between maps

Internal Fields

internal Models.QualifierRoom? currentMatch;
private readonly string matchId;
private readonly string refDisplayName;
internal IBanchoClient? client;
internal string? lobbyChannelName;
internal bool joined;
private bool stoppedPreviously;
private int currentMapIndex;
internal MatchState currentState;
internal MatchState previousState;
private int mpLinkId;
private List<int> usersInRoom = new();
private TaskCompletionSource<string>? chatResponseTcs;
private readonly Action<string, string> msgCallback;

Differences from Elimination Automaton

FeatureQualifiersElimination
Team ModeHead-to-Head (0)TeamVs (2)
Map SelectionLinear (full pool)Pick/Ban phases
Player CountMultiple (1-16)Two teams
State ComplexitySimple (4 states)Complex (13 states)
Timeout SystemNonePer-team tactical timeouts
Win ConditionComplete all mapsBest-of-N
Map OrderSequentialPlayer-chosen
Timer Duration120 seconds90 seconds

Build docs developers (and LLMs) love