The Round State Machine is the core logic engine that manages the lifecycle of a single Mahjong round. It is primarily implemented via the round.State struct and the EventApplier interface. It ensures that every transition (drawing a tile, discarding, calling a meld, etc.) follows the legal rules of the game and correctly updates the internal representation of the board and players.
The round.State struct encapsulates the complete status of a round, including the wall size, dora indicators, scores, and the status of all four players.
| Field | Description |
|---|---|
roundWind, roundNumber | Current round identifiers (e.g., East 1). |
numLeftTiles | Number of tiles remaining in the wall. |
doraIndicators | Collection of revealed dora indicator tiles. |
kanProgress | Enum tracking the multi-step sequence of Kan actions. |
pendingDiscard | Pointer to the seat expected to perform the next discard. |
players | Array of player.Player objects (Visible or Invisible). |
legalActionsCache | Map of seat to valid actions to avoid redundant calculations. |
Sources: internal/domain/game/round/state.go37-66
State transitions are driven by the Apply(ev event.Event) method. This function acts as a dispatcher, validating the incoming event against the current state before delegating to specific application logic.
The Apply method follows a strict sequence:
Win events in double/triple Ron scenarios internal/domain/game/round/event_applier.go20-24applyXXX method (e.g., applyDraw, applyDiscard, applyChii, applyPon, applyWin) internal/domain/game/round/event_applier.go30-57legalActionsCache. It also sets legalActionsSuppressed for intermediate states like event.Dora internal/domain/game/round/event_applier.go63-68The following diagram illustrates how the EventApplier bridges the gap between high-level game events and the internal state modifications.
Diagram: Event to State Mapping
Sources: internal/domain/game/round/event_applier.go19-57 internal/domain/game/round/state.go37-66
Managing Kans is complex due to varying rules regarding when the replacement tile is drawn and when the Dora is revealed. The state machine uses kanProgress to enforce these sequences.
noKanProgress: Normal play.waitingReplacementBeforeDora: Used after CalledKan (Daiminkan) or PromotedKan (Kakan). The actor must draw a replacement tile before the next Dora is revealed internal/domain/game/round/state.go32waitingReplacementAfterDora: Used after ConcealedKan (Ankan). The Dora indicator is revealed before the replacement tile is drawn internal/domain/game/round/state.go34The implementation supports various protocol orders (Tenhou vs Mahjong Soul) by tracking pendingDoraReveals separately from kanProgress internal/domain/game/round/event_applier.go81-83 internal/domain/game/round/event_applier_consecutive_kan_test.go15-31
Riichi is a multi-event process:
event.Riichi: The player declares Riichi. The state updates the player's internal status to RiichiDeclared internal/domain/game/round/event_applier.go273-277event.Discard: The player discards a tile. If their status is RiichiDeclared, the seat is stored in pendingRiichiAcceptance internal/domain/game/round/event_applier.go124-126event.RiichiAccepted: The Riichi is finalized. The deposit is added to the table (riichiDeposit), and the player's state moves to RiichiAccepted internal/domain/game/round/event_applier.go279-301The engine supports multiple winners. When a Win (Ron) occurs, roundEnded is set to true, but the state machine allows subsequent Win events for the same target tile to handle double or triple Ron internal/domain/game/round/event_applier.go20-24
Additionally, if a player declines a Ron (Pass), the tile becomes "extra safe" for that player. The pendingExtraSafeDiscard struct tracks this until the next non-win event occurs internal/domain/game/round/event_applier.go28-29 internal/domain/game/round/event_applier.go63-65
The state machine handles both fully known and partially known hands:
VisiblePlayer: Used for the "self" player or when hands are revealed. Contains full TileCounts34 internal/domain/game/round/state.go135-139InvisiblePlayer: Used for opponents whose hands are hidden. Only tracks their river and melds internal/domain/game/round/state.go131-133Diagram: Player State Implementation
Sources: internal/domain/game/round/state.go64 internal/domain/game/round/state.go130-140
The EventApplier performs rigorous checks during every transition:
applyDraw verifies that ev.Actor() == s.nextDraw internal/domain/game/round/event_applier.go84-86applyDraw and Kan methods check s.numLeftTiles > 0 internal/domain/game/round/event_applier.go88-90 internal/domain/game/round/event_applier.go202-204maxNumKan) internal/domain/game/round/event_applier.go174-176canKyushukyuhai) and disabled once any player makes a call internal/domain/game/round/event_applier.go150 internal/domain/game/round/state.go109-111applyPromotedKan sets pendingRobbedKanTile to allow other players to declare Ron on the added tile internal/domain/game/round/event_applier.go250-252Sources: internal/domain/game/round/event_applier.go1-350 internal/domain/game/round/state.go1-147