riichi_elements/
tile.rs

1//! [`Tile`] (牌) and utils.
2//!
3//! ## Ref
4//!
5//! - <https://ja.wikipedia.org/wiki/麻雀牌>
6//! - <https://en.wikipedia.org/wiki/Mahjong_tiles>
7//! - <https://riichi.wiki/Mahjong_equipment>
8
9use core::{
10    cmp::Ordering,
11    fmt::{Display, Formatter},
12    str::FromStr,
13};
14
15use crate::typedefs::*;
16
17/// Represents one tile (牌).
18///
19/// Encoded as a 6-bit integer:
20///
21/// | Encoding   |  Shorthand  | Category (EN) | Category (JP) |
22/// |------------|-------------|---------------|---------------|
23/// | 0  ..= 8   |  1m ..= 9m  | characters    | 萬子          |
24/// | 9  ..= 17  |  1p ..= 9p  | dots          | 筒子          |
25/// | 18 ..= 26  |  1s ..= 9s  | bamboos       | 索子          |
26/// | 27 ..= 30  |  1z ..= 4z  | winds         | 風牌          |
27/// | 31, 32, 33 |  5z, 6z, 7z | dragons       | 三元牌        |
28/// | 34, 35, 36 |  0m, 0p, 0s | reds          | 赤牌          |
29///
30/// Note that only red 5's can be represented (not other numbers or honors).
31///
32/// Details of this encoding is significant and implicitly assumed across the crate.
33/// It should never be changed.
34///
35///
36/// ## Optional `serde` support
37///
38/// The common string shorthand (e.g. `"1m"`, `"0p"`, `"7z"`) is used as the serialization format.
39/// This ensures readability and interoperability.
40///
41#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
42#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
43#[cfg_attr(feature = "serde", serde(into = "&str"))]
44#[cfg_attr(all(feature = "serde", feature = "std"), serde(try_from = "String"))]
45pub struct Tile(u8);
46
47impl Tile {
48    pub const MIN_ENCODING: u8 = 0;
49    pub const MAX_ENCODING: u8 = 36;
50    pub const MIN: Self = Self(Self::MIN_ENCODING);
51    pub const MAX: Self = Self(Self::MAX_ENCODING);
52
53    pub const fn from_encoding(encoding: u8) -> Option<Self> {
54        if encoding <= Self::MAX_ENCODING { Some(Self(encoding)) } else { None }
55    }
56
57    pub const fn from_num_suit(num: u8, suit: u8) -> Option<Self> {
58        if !(num <= 9 && suit <= 3) { return None; }
59        if suit == 3 && !(1 <= num && num <= 7) { return None; }
60        if num == 0 {
61            Some(Self(34 + suit))
62        } else {
63            Some(Self(suit * 9 + num - 1))
64        }
65    }
66
67    pub fn from_wind(wind: Wind) -> Self { Self(27 + wind.to_u8()) }
68
69    pub const fn is_valid(self) -> bool { self.0 <= 36 }
70
71    /// Not red 5
72    pub const fn is_normal(self) -> bool { self.0 <= 33 }
73    /// Red 5 (赤牌)
74    pub const fn is_red(self) -> bool { 34 <= self.0 && self.0 <= 36 }
75
76    /// Either red or normal 5
77    pub const fn has_red(self) -> bool {
78        self.0 == 4 || self.0 == 13 || self.0 == 22 || self.is_red()
79    }
80
81    /// Numerals (数牌) := Characters (萬子) + Dots (筒子) + Bamboos (索子)
82    pub const fn is_numeral(self) -> bool {
83        (self.0 <= 26) || (34 <= self.0 && self.0 <= 36)
84    }
85    /// Pure terminals (老頭牌) := {1,9}{m,p,s}
86    pub const fn is_pure_terminal(self) -> bool {
87        matches!(self.0, 0 | 8 | 9 | 17 | 18 | 26)
88    }
89    /// Middle numerals (中張牌) := {2..=8}{m,p,s}
90    pub const fn is_middle(self) -> bool { self.is_numeral() && !self.is_pure_terminal() }
91
92    /// Winds (風牌) := {1,2,3,4}z (correspond to {E,S,W,N})
93    pub const fn is_wind(self) -> bool { 27 <= self.0 && self.0 <= 30 }
94    /// Dragons (三元牌) := {5,6,7}z (correspond to {blue, green, red} dragons).
95    pub const fn is_dragon(self) -> bool { 31 <= self.0 && self.0 <= 33 }
96    /// Honors (字牌) := Winds (風牌) + Dragons (三元牌)
97    pub const fn is_honor(self) -> bool { 27 <= self.0 && self.0 <= 33 }
98
99    /// Terminals (么九牌) := Pure terminals (老頭牌) + Honors (字牌)
100    pub const fn is_terminal(self) -> bool {
101        self.is_pure_terminal() || self.is_honor()
102    }
103
104    pub const fn encoding(self) -> u8 {
105        debug_assert!(self.is_valid());
106        self.0
107    }
108    /// Encoding of this tile, except red 5 is converted to normal 5
109    pub const fn normal_encoding(self) -> u8 {
110        debug_assert!(self.is_valid());
111        match self.0 {
112            34 => 4,
113            35 => 13,
114            36 => 22,
115            x => x,
116        }
117    }
118    /// Encoding of this tile, except normal 5 is converted to red 5
119    pub const fn red_encoding(self) -> u8 {
120        debug_assert!(self.is_valid());
121        match self.0 {
122            4 => 34,
123            13 => 35,
124            22 => 36,
125            x => x,
126        }
127    }
128
129    /// Converts a red 5 to normal 5; otherwise no-op.
130    pub const fn to_normal(self) -> Self { Self(self.normal_encoding()) }
131
132    /// Converts normal 5 to red 5; otherwise no-op.
133    pub const fn to_red(self) -> Self { Self(self.red_encoding()) }
134
135    /// Converts to the corresponding wind (ESWN) if this is a wind tile.
136    pub const fn wind(self) -> Option<Wind> {
137        // Not using `Option::then` because it cannot be used in `const fn` (yet).
138        if self.is_wind() { Some(Wind::new(self.0 - 27)) } else { None }
139    }
140
141    /// Converts tile to an internal ordering key where:
142    /// 1m < ... < 4m < 0m < 5m < ... < 9m < 1p < ... < 9p < 1s < ... < 9s < 1z < ... < 7z
143    ///
144    /// This is implemented by doubling the encoding space and inserting the reds
145    /// between 4 and 5 tiles.
146    const fn to_ordering_key(self) -> u8 {
147        debug_assert!(self.is_valid());
148        if self.0 <= 33 { self.0 * 2 } else { 7 + (self.0 - 34) * 18 }
149    }
150
151    /// Returns the "number" part of the shorthand
152    pub const fn num(self) -> u8 {
153        debug_assert!(self.is_valid());
154        if self.0 <= 33 { self.0 % 9 + 1 } else { 0 }
155    }
156    /// Returns the "number" part of the shorthand, with reds converted to non-red (i.e. 0 => 5).
157    pub const fn normal_num(self) -> u8 {
158        debug_assert!(self.is_valid());
159        if self.0 <= 33 { self.0 % 9 + 1 } else { 5 }
160    }
161    /// Returns the "suit" part of the shorthand (0, 1, 2, 3 for m, p, s, z respectively)
162    pub const fn suit(self) -> u8 {
163        debug_assert!(self.is_valid());
164        if self.0 <= 33 { self.0 / 9 } else { self.0 - 34 }
165    }
166
167    /// For numerals 1 to 8, returns 2 to 9 respectively. Otherwise None.
168    pub const fn succ(self) -> Option<Self> {
169        if self.is_numeral() && self.normal_num() <= 8 {
170            Some(Self(self.normal_encoding() + 1))
171        } else { None }
172    }
173    /// For numerals 1 to 7, returns 3 to 9 respectively. Otherwise None.
174    pub const fn succ2(self) -> Option<Self> {
175        if self.is_numeral() && self.normal_num() <= 7 {
176            Some(Self(self.normal_encoding() + 2))
177        } else { None }
178    }
179    /// For numerals 2 to 9, returns 1 to 8 respectively. Otherwise None.
180    pub const fn pred(self) -> Option<Self> {
181        if self.is_numeral() && self.normal_num() >= 2 {
182            Some(Self(self.normal_encoding() - 1))
183        } else { None }
184    }
185    /// For numerals 3 to 9, returns 1 to 7 respectively. Otherwise None.
186    pub const fn pred2(self) -> Option<Self> {
187        if self.is_numeral() && self.normal_num() >= 3 {
188            Some(Self(self.normal_encoding() - 2))
189        } else { None }
190    }
191
192    /// Given this tile as the dora-indicator (ドラ表示牌), returns the indicated dora tile (ドラ).
193    ///
194    /// Ref:
195    /// - <https://ja.wikipedia.org/wiki/%E3%83%89%E3%83%A9_(%E9%BA%BB%E9%9B%80)>
196    pub const fn indicated_dora(self) -> Self {
197        debug_assert!(self.is_valid());
198        Self([
199            1, 2, 3, 4, 5, 6, 7, 8, 0, // m
200            10, 11, 12, 13, 14, 15, 16, 17, 9, // p
201            19, 20, 21, 22, 23, 24, 25, 26, 18, // s
202            28, 29, 30, 27, // winds
203            32, 33, 31, // dragons
204            5, 14, 23u8, // reds indicate 6
205        ][self.0 as usize])
206    }
207}
208
209impl PartialOrd<Self> for Tile {
210    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
211        Some(self.cmp(other))
212    }
213}
214
215impl Ord for Tile {
216    fn cmp(&self, other: &Self) -> Ordering {
217        self.to_ordering_key().cmp(&other.to_ordering_key())
218    }
219}
220
221// String/Char Conversions
222
223/// Returns the tile suit represented by the shorthand suit char.
224pub(crate) const fn suit_from_char(c: char) -> Option<u8> {
225    match c {
226        'm' => Some(0),
227        'p' => Some(1),
228        's' => Some(2),
229        'z' => Some(3),
230        _ => None
231    }
232}
233
234/// Returns the shorthand char for tile suit.
235pub(crate) const fn char_from_suit(suit: u8) -> Option<char> {
236    match suit {
237        0 => Some('m'),
238        1 => Some('p'),
239        2 => Some('s'),
240        3 => Some('z'),
241        _ => None
242    }
243}
244
245// Concrete impls for conversion to/from strings.
246
247impl Tile {
248    /// Returns the "suit" part of the shorthand (0, 1, 2, 3 for m, p, s, z respectively)
249    pub fn suit_char(self) -> char {
250        debug_assert!(self.is_valid());
251        char_from_suit(self.suit()).unwrap()
252    }
253
254    /// Returns the standard shorthand string of this tile.
255    pub const fn as_str(self) -> &'static str {
256        debug_assert!(self.is_valid());
257        [
258            "1m", "2m", "3m", "4m", "5m", "6m", "7m", "8m", "9m", //
259            "1p", "2p", "3p", "4p", "5p", "6p", "7p", "8p", "9p", //
260            "1s", "2s", "3s", "4s", "5s", "6s", "7s", "8s", "9s", //
261            "1z", "2z", "3z", "4z", "5z", "6z", "7z", //
262            "0m", "0p", "0s", //
263        ][self.encoding() as usize]
264    }
265
266    /// Returns the corresponding codepoint in the Unicode Mahjong Tiles section (1F000 ~ 1F02F)
267    ///
268    /// NOTE: The ordering in Unicode is differerent from Japanese Riichi Mahjong conventions.
269    pub const fn unicode(self) -> char {
270        debug_assert!(self.is_valid());
271        [
272            '\u{1f007}', '\u{1f008}', '\u{1f009}', '\u{1f00a}', '\u{1f00b}', '\u{1f00c}', '\u{1f00d}', '\u{1f00e}', '\u{1f00f}',  // 1-9m
273            '\u{1f019}', '\u{1f01a}', '\u{1f01b}', '\u{1f01c}', '\u{1f01d}', '\u{1f01e}', '\u{1f01f}', '\u{1f020}', '\u{1f021}',  // 1-9p
274            '\u{1f010}', '\u{1f011}', '\u{1f012}', '\u{1f013}', '\u{1f014}', '\u{1f015}', '\u{1f016}', '\u{1f017}', '\u{1f018}',  // 1-9s
275            '\u{1f000}', '\u{1f001}', '\u{1f002}', '\u{1f003}',  // 1-4z
276            '\u{1f006}', '\u{1f005}', '\u{1f004}',  // 5-7z (this is the correct order!)
277            '\u{1f00b}', '\u{1f01d}', '\u{1f014}',  // 0mps (char has no color, same as 5mps)
278        ][self.encoding() as usize]
279    }
280}
281
282pub const UNICODE_TILE_BACK: char = '\u{1f02B}';
283
284impl FromStr for Tile {
285    type Err = UnspecifiedError;
286    fn from_str(pai_str: &str) -> Result<Self, Self::Err> {
287        if pai_str.len() != 2 { return Err(UnspecifiedError); }
288        let mut chars = pai_str.chars();
289        if let (Some(num_char), Some(suit_char)) = (chars.next(), chars.next()) {
290            let num = num_char.to_digit(10).ok_or(UnspecifiedError)? as u8;
291            let suit = suit_from_char(suit_char).ok_or(UnspecifiedError)?;
292            Self::from_num_suit(num, suit).ok_or(UnspecifiedError)
293        } else { Err(UnspecifiedError) }
294    }
295}
296
297// Blanket adaptors for various ways of converting to/from strings.
298
299impl TryFrom<&str> for Tile {
300    type Error = UnspecifiedError;
301    fn try_from(value: &str) -> Result<Self, Self::Error> { value.parse() }
302}
303
304#[cfg(feature = "std")]
305impl TryFrom<String> for Tile {
306    type Error = UnspecifiedError;
307    fn try_from(value: String) -> Result<Self, Self::Error> { value.parse() }
308}
309
310impl Into<&str> for Tile {
311    fn into(self) -> &'static str { self.as_str() }
312}
313
314impl Display for Tile {
315    fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
316        write!(f, "{}", self.as_str())
317    }
318}
319
320/// Represents `Some(tile)` as [`Tile::unicode()`] and `None` as [`UNICODE_TILE_BACK`]
321pub const fn maybe_tile_unicode(tile: Option<Tile>) -> char {
322    if let Some(tile) = tile { tile.unicode() } else { UNICODE_TILE_BACK }
323}
324
325/// Parses a shorthand string of tiles following the format `(\d*[mpsz])*`.
326///
327/// Example:
328/// ```
329/// use riichi_elements::tile::*;
330/// use itertools::assert_equal;
331/// assert_equal(tiles_from_str(""), []);
332/// assert_equal(tiles_from_str("11123m8p8ps777z"), [
333///     t!("1m"), t!("1m"), t!("1m"), t!("2m"), t!("3m"),
334///     t!("8p"), t!("8p"),
335///     t!("7z"), t!("7z"), t!("7z"),
336/// ]);
337/// ```
338pub fn tiles_from_str(s: &str) -> impl Iterator<Item = Tile> + '_ {
339    let mut iter = TilesFromStr {
340        iter_n: s.chars().peekable(),
341        iter_s: s.chars().peekable(),
342        suit_c: None,
343        suit: None,
344    };
345    iter.find_next_suit();
346    iter
347}
348
349// A `no_std` impl means we cannot cheat by buffering the numbers. Instead, we must find the next
350// suit char in advance (two-pointer approach).
351
352struct TilesFromStr<'a> {
353    iter_n: core::iter::Peekable<core::str::Chars<'a>>,
354    iter_s: core::iter::Peekable<core::str::Chars<'a>>,
355    suit_c: Option<char>,
356    suit: Option<u8>,
357}
358
359impl<'a> TilesFromStr<'a> {
360    fn find_next_suit(&mut self) {
361        while let Some(c) = self.iter_s.next() {
362            if let Some(suit) = suit_from_char(c) {
363                self.suit_c = Some(c);
364                self.suit = Some(suit);
365                return;
366            }
367        }
368        self.suit_c = None;
369        self.suit = None;
370    }
371}
372
373impl<'a> Iterator for TilesFromStr<'a> {
374    type Item = Tile;
375    fn next(&mut self) -> Option<Self::Item> {
376        while let Some(c) = self.iter_n.peek() {
377            if Some(*c) == self.suit_c {
378                self.iter_n.next();
379                self.find_next_suit();
380            } else {
381                break;
382            }
383        }
384        self.suit.and_then(|suit|
385            self.iter_n.next()
386                .and_then(|num_char| num_char.to_digit(10))
387                .and_then(|num| Tile::from_num_suit(num as u8, suit)))
388    }
389}
390
391/// Shortcut for creating a tile literal through its string shorthand.
392///
393/// Example:
394/// ```
395/// use riichi_elements::tile::*;
396/// assert_eq!(t!("5p"), Tile::from_encoding(1 * 9 + (5 - 1)).unwrap());
397/// ```
398#[macro_export]
399macro_rules! t {
400    ($s:expr) => {{
401        use core::str::FromStr;
402        $crate::tile::Tile::from_str($s).unwrap()
403    }};
404}
405pub use t;
406
407#[cfg(test)]
408mod tests {
409    extern crate std;
410    use std::{
411        string::*,
412        print,
413        println,
414    };
415
416    use itertools::{assert_equal, Itertools};
417
418    use super::*;
419
420    #[test]
421    fn tile_str_is_num_and_suite() {
422        for encoding in Tile::MIN_ENCODING..=Tile::MAX_ENCODING {
423            let tile = Tile::from_encoding(encoding).unwrap();
424            let tile_str = tile.as_str();
425            assert_eq!(tile_str.len(), 2);
426            assert_eq!(tile_str[0..=0], tile.num().to_string());
427            assert_eq!(tile_str[1..=1], tile.suit_char().to_string());
428        }
429    }
430
431    #[test]
432    fn tile_str_roundtrip() {
433        for encoding in Tile::MIN_ENCODING..=Tile::MAX_ENCODING {
434            let tile = Tile::from_encoding(encoding).unwrap();
435            let tile_str = tile.as_str();
436            let roundtrip: Tile = tile_str.parse().unwrap();
437            assert_eq!(tile, roundtrip);
438        }
439    }
440
441    #[test]
442    fn tiles_from_str_examples() {
443        assert_equal(tiles_from_str(""), []);
444        assert_equal(tiles_from_str("1m2p3s4z"), [
445            t!("1m"), t!("2p"), t!("3s"), t!("4z"),
446        ]);
447        assert_equal(tiles_from_str("1m3sps4z"), [
448            t!("1m"), t!("3s"), t!("4z"),
449        ]);
450        assert_equal(tiles_from_str("m3sps4z"), [
451            t!("3s"), t!("4z"),
452        ]);
453        assert_equal(tiles_from_str("mmmm3smmmmmps4zmmmmm"), [
454            t!("3s"), t!("4z"),
455        ]);
456    }
457
458    #[test]
459    fn tile_num_suite_roundtrip() {
460        for encoding in Tile::MIN_ENCODING..=Tile::MAX_ENCODING {
461            let tile = Tile::from_encoding(encoding).unwrap();
462            let roundtrip: Tile = Tile::from_num_suit(tile.num(), tile.suit()).unwrap();
463            assert_eq!(tile, roundtrip);
464        }
465    }
466
467    #[test]
468    fn tile_has_total_order() {
469        use core::str::FromStr;
470        let correct_order = [
471            "1m", "2m", "3m", "4m", "0m", "5m", "6m", "7m", "8m", "9m", //
472            "1p", "2p", "3p", "4p", "0p", "5p", "6p", "7p", "8p", "9p", //
473            "1s", "2s", "3s", "4s", "0s", "5s", "6s", "7s", "8s", "9s", //
474            "1z", "2z", "3z", "4z", "5z", "6z", "7z", //
475        ];
476        for (a, b) in correct_order.iter().tuple_windows() {
477            assert!(Tile::from_str(a).unwrap() < Tile::from_str(b).unwrap());
478        }
479    }
480
481    #[test]
482    fn tile_indicates_correct_dora() {
483        // non-red numerals => wrapping successor in the same suit
484        for num_indicator in 1..=9 {
485            let num_dora = num_indicator % 9 + 1;
486            for suit in 0..=2 {
487                let indicator = Tile::from_num_suit(num_indicator, suit).unwrap();
488                let dora = Tile::from_num_suit(num_dora, suit).unwrap();
489                let indicated_dora = indicator.indicated_dora();
490                assert_eq!(dora, indicated_dora);
491            }
492        }
493        // red 5 => 6 in the same suit
494        {
495            let num_indicator = 0;
496            let num_dora = 6;
497            for suit in 0..=2 {
498                let indicator = Tile::from_num_suit(num_indicator, suit).unwrap();
499                let dora = Tile::from_num_suit(num_dora, suit).unwrap();
500                let indicated_dora = indicator.indicated_dora();
501                assert_eq!(dora, indicated_dora);
502            }
503        }
504        // winds => wrapping successor among winds
505        for num_indicator in 1..=4 {
506            let num_dora = num_indicator % 4 + 1;
507            let indicator = Tile::from_num_suit(num_indicator, 3).unwrap();
508            let dora = Tile::from_num_suit(num_dora, 3).unwrap();
509            let indicated_dora = indicator.indicated_dora();
510            assert_eq!(dora, indicated_dora);
511        }
512        // dragons => wrapping successor among dragons
513        for num_indicator in 5..=7 {
514            let num_dora = (num_indicator - 4) % 3 + 5;
515            let indicator = Tile::from_num_suit(num_indicator, 3).unwrap();
516            let dora = Tile::from_num_suit(num_dora, 3).unwrap();
517            let indicated_dora = indicator.indicated_dora();
518            assert_eq!(dora, indicated_dora);
519        }
520    }
521
522    #[test]
523    fn wind_tile_indicates_correct_wind() {
524        assert_eq!(t!("1z").wind(), Some(Wind::new(0)));
525        assert_eq!(t!("2z").wind(), Some(Wind::new(1)));
526        assert_eq!(t!("3z").wind(), Some(Wind::new(2)));
527        assert_eq!(t!("4z").wind(), Some(Wind::new(3)));
528        for enc in 0..27 {
529            assert_eq!(Tile::from_encoding(enc).unwrap().wind(), None);
530        }
531        for enc in 31..37 {
532            assert_eq!(Tile::from_encoding(enc).unwrap().wind(), None);
533        }
534    }
535
536    #[test]
537    fn print_tile_unicode() {
538        for r in [0..9, 9..18, 18..27, 27..34, 34..37] {
539            for enc in r {
540                print!("{}", Tile::from_encoding(enc).unwrap().unicode());
541            }
542            println!();
543        }
544        println!("{}", UNICODE_TILE_BACK);
545    }
546}