This section details the implementation of player state within a round, specifically focusing on how hands and melds (calls) are represented, validated, and transitioned. The system distinguishes between the agent's own perspective (Visible) and other players' perspectives (Invisible) to maintain information symmetry.
The codebase defines a Player interface that combines observation (PlayerViewer) and mutation (PlayerActor) capabilities internal/domain/game/round/player/player.go94-97 This interface is implemented by two primary structs:
VisiblePlayer: Used for the "self" player (usually seat 0 in the agent's view). It tracks specific tile IDs and maintains an accurate set of winning waits (WaitSet) internal/domain/game/round/player/visible_player.go14-19InvisiblePlayer: Used for opponents. It tracks only the number of tiles in the hand and cannot determine furiten state from hand contents, relying instead on discard history and extraSafeTiles internal/domain/game/round/player/invisible_player.go12-15 internal/domain/game/round/player/invisible_player.go44-57Both implementations embed commonPlayerState, which manages shared attributes like the river, discarded tiles, active melds, and Riichi status internal/domain/game/round/player/common_player_state.go11-23
The following diagram maps the natural language concepts of a Mahjong player to the specific Go entities used in the domain layer.
Sources: internal/domain/game/round/player/player.go9-15 internal/domain/game/round/player/player.go25-92 internal/domain/game/round/player/common_player_state.go11-23 internal/domain/game/round/player/visible_player.go31
Hand operations in mjai-manue-go are immutable. Functions like Draw, Discard, and Call return a new hand instance rather than modifying the existing one internal/domain/game/round/player/hand/visible_hand.go10-13 internal/domain/game/round/player/hand/invisible_hand.go10-12
VisibleHand: Stores tile counts in a fixed-size array of 37 types (34 standard + 3 red fives) internal/domain/game/round/player/hand/visible_hand.go14-17 It enforces rules like a maximum of 4 copies per tile and 1 red five per suit internal/domain/game/round/player/hand/visible_hand.go32-37InvisibleHand: Simply tracks a tileCount integer internal/domain/game/round/player/hand/invisible_hand.go13-15 All tiles are represented as the unknown symbol ? when converted to a tile slice internal/domain/game/round/player/hand/invisible_hand.go34-41For shanten and yaku calculations, VisibleHand provides a ToTileCounts34() method. This collapses the 37-tile internal representation (which includes red fives) into a standard 34-tile array by merging red fives into their base numerical counters internal/domain/game/round/player/hand/visible_hand.go65-72 This type is defined as [34]int internal/domain/game/round/player/hand/tilecount.go3
Sources: internal/domain/game/round/player/hand/visible_hand.go14-17 internal/domain/game/round/player/hand/invisible_hand.go13-15 internal/domain/game/round/player/hand/visible_hand.go65-72
Melds are handled via the meld.Meld interface. The system supports five distinct types, each implementing specific logic for which tiles are consumed from the hand and which tile was taken from another player.
| Meld Type | Struct | Consumed from Hand | Origin Tile | Notes |
|---|---|---|---|---|
| Chii | Chii | 2 tiles | 1 tile | Only from left player. |
| Pon | Pon | 2 tiles | 1 tile | From any player. internal/domain/game/round/player/meld/pon.go10-15 |
| Called Kan | CalledKan | 3 tiles | 1 tile | Also known as Daiminkan. |
| Concealed Kan | ConcealedKan | 4 tiles | None | Hand remains "concealed" for most yaku. |
| Promoted Kan | PromotedKan | 1 tile | None | Adds to an existing Pon (Kakan). internal/domain/game/round/player/hand/visible_hand.go133-137 |
Sources: internal/domain/game/round/player/visible_player.go126-254 internal/domain/game/round/player/hand/visible_hand.go119-152 internal/domain/game/round/player/meld/pon.go10-15
The engine strictly enforces Kuikae (forbidden immediate discards after a call).
Chii or Pon is performed, the forbidden tiles are calculated via SwapCallTiles() and stored in the player's swapCallTiles field internal/domain/game/round/player/visible_player.go152 internal/domain/game/round/player/visible_player.go173 internal/domain/game/round/player/meld/pon.go70-76Discard method checks if the tile being discarded is in this forbidden set internal/domain/game/round/player/visible_player.go91-93Chii, the system validates that the player has at least one legal (non-swap) tile to discard; otherwise, the call itself is illegal internal/domain/game/round/player/visible_player.go139-147The following diagram illustrates the sequence of operations when a VisiblePlayer performs a Pon call.
Sources: internal/domain/game/round/player/visible_player.go157-176 internal/domain/game/round/player/hand/visible_hand.go119-152
The VisiblePlayer and InvisiblePlayer actors enforce strict state transitions to ensure game integrity.
Draw if they are already in a discardable state internal/domain/game/round/player/visible_player.go62-64 Conversely, Discard is only allowed if the player has a drawnTile or just performed a call internal/domain/game/round/player/common_player_state.go76-78VisiblePlayer can only perform tsumogiri (discarding the drawn tile) unless they are performing a Kan that doesn't change their waits internal/domain/game/round/player/visible_player.go87-89VisiblePlayer updates its furiten status after every discard by checking if any previously discarded tiles are in the current WaitSet internal/domain/game/round/player/visible_player.go121-122 InvisiblePlayer approximates this by checking if the winningTile matches any symbol in the discardedTiles or extraSafeTiles internal/domain/game/round/player/invisible_player.go44-58Sources: internal/domain/game/round/player/visible_player.go58-124 internal/domain/game/round/player/invisible_player.go44-58 internal/domain/game/round/player/common_player_state.go76-82