riichi/engine/
utils.rs

1use itertools::Itertools;
2use log::log_enabled;
3
4use riichi_decomp::{Decomposer, RegularWait, WaitSet};
5use riichi_elements::prelude::*;
6
7use crate::{
8    model::*,
9    rules::Ruleset,
10};
11
12// TODO(summivox): Consider porting these directly to `impl TileSet37`.
13
14pub fn terminal_kinds(h: &TileSet37) -> u8 {
15    pure_terminal_kinds(h) + honor_kinds(h)
16}
17
18pub fn terminal_count(h: &TileSet37) -> u8 {
19    pure_terminal_count(h) + honor_count(h)
20}
21
22#[rustfmt::skip]
23pub fn pure_terminal_kinds(h: &TileSet37) -> u8 {
24    0u8 + (h[0] > 0) as u8 + (h[8] > 0) as u8
25        + (h[9] > 0) as u8 + (h[17] > 0) as u8
26        + (h[18] > 0) as u8 + (h[26] > 0) as u8
27}
28
29pub fn pure_terminal_count(h: &TileSet37) -> u8 {
30    h[0] + h[8] + h[9] + h[17] + h[18] + h[26]
31}
32
33#[rustfmt::skip]
34pub fn honor_kinds(h: &TileSet37) -> u8 {
35    0u8 + (h[27] > 0) as u8 + (h[28] > 0) as u8
36        + (h[29] > 0) as u8 + (h[30] > 0) as u8
37        + (h[31] > 0) as u8 + (h[32] > 0) as u8
38        + (h[33] > 0) as u8
39}
40
41pub fn honor_count(h: &TileSet37) -> u8 {
42    h[27] + h[28] + h[29] + h[30] + h[31] + h[32] + h[33]
43}
44
45pub fn green_count(h: &TileSet37) -> u8 {
46    h[19] + h[20] + h[21] + h[23] + h[25] + h[32]
47}
48
49pub fn m_count(h: &TileSet37) -> u8 {
50    (&h.0[0..9]).iter().sum::<u8>() + h[34]
51}
52pub fn p_count(h: &TileSet37) -> u8 {
53    (&h.0[9..18]).iter().sum::<u8>() + h[35]
54}
55pub fn s_count(h: &TileSet37) -> u8 {
56    (&h.0[18..27]).iter().sum::<u8>() + h[36]
57}
58/// Alias of `honor_count`.
59pub fn z_count(h: &TileSet37) -> u8 { honor_count(h) }
60
61
62// TODO(summivox): We don't actually need the pack --- convert this to use normal bins
63
64/// Determine whether a packed suit (3N+2) satisfies the [Chuurenpoutou] form, i.e.
65/// `311111113` + any. If it does, then returns the _position_ of the winning tile (0..=8).
66///
67/// [Chuurenpoutou]: crate::yaku::Yaku::Chuurenpoutou
68pub fn chuuren_agari(x: u32) -> Option<u8> {
69    // check x is at least 0o311111113 (each bin must individually apply, without overflow)
70    if (x + 0o133333331) & 0o444444444 != 0o444444444 { return None; }
71    // subtract our target, and now only 1 shall remain (full closed hand, n == 14, target n = 13)
72    let r = x - 0o311111113;
73    // sanity check (what if we started with more than 14?)
74    if !r.is_power_of_two() { return None; }
75    Some(r.trailing_zeros() as u8 / 3)
76}
77
78/// Determines whether a _non-packed_ suit (3N+1) is 1 tile away from the [Chuurenpoutou]
79/// form, i.e. `311111113` - some + other. If it does, then returns the _position_ of:
80///
81/// - the lacking tile
82/// - the over tile
83///
84/// Special case: `311111113` (pure chuuren) => `Some(0, 0)`
85///
86/// [Chuurenpoutou]: crate::yaku::Yaku::Chuurenpoutou
87pub fn chuuren_wait(h: &[u8]) -> Option<(u8, u8)> {
88    const TARGET: [i8; 9] = [3, 1, 1, 1, 1, 1, 1, 1, 3];
89    let mut lack = 100;
90    let mut over = 100;
91    for (i, (a, b)) in itertools::zip_eq(h, TARGET).enumerate() {
92        let x = *a as i8 - b;
93        match x {
94            -1 => {
95                if lack < 9 { return None; }
96                lack = i;
97            }
98            0 => {}
99            1 => {
100                if over < 9 { return None; }
101                over = i;
102            }
103            _ => return None,
104        }
105    }
106    if lack > 9 && over > 9 {
107        Some((0, 0))
108    } else if lack < 9 && over < 9 {
109        Some((lack as u8, over as u8))
110    } else {
111        None
112    }
113}
114
115/// Returns if this discard immediately after calling Chii/Pon constitutes a swap call (喰い替え),
116/// i.e. the discarded tile can form a similar group as the meld. This is usually forbidden.
117///
118/// Example:
119/// - Hand 678m; if 78m is used to call 9m, then 6m cannot be discarded.
120/// - Hand 456m; if 46m is used to call (red) 0m, then the (normal) 5m in hand cannot be discarded.
121///
122/// <https://riichi.wiki/Kuikae>
123pub fn is_forbidden_swap_call(ruleset: &Ruleset, meld: Meld, discard: Tile) -> bool {
124    let discard = discard.to_normal();
125    let (allow_same, allow_other) = (ruleset.swap_call_allow_same, ruleset.swap_call_allow_other);
126    match meld {
127        Meld::Chii(chii) => {
128            (!allow_same && chii.called.to_normal() == discard) ||
129                (!allow_other && chii.dir() == 0 && Some(discard) == chii.own[1].succ()) ||
130                (!allow_other && chii.dir() == 2 && Some(discard) == chii.min.pred())
131        }
132        Meld::Pon(pon) => {
133            !allow_same && pon.called.to_normal() == discard
134        }
135        _ => false,
136    }
137}
138
139/// <https://riichi.wiki/Kan#Kan_during_riichi>
140pub fn is_ankan_ok_under_riichi(
141    ruleset: &Ruleset,
142    decomposer: &mut Decomposer,
143    hand: &TileSet37,
144    wait_set: &WaitSet,
145    draw: Tile,
146    ankan: Tile,
147) -> bool {
148    let draw = draw.to_normal();
149    let ankan = ankan.to_normal();
150    if ruleset.riichi_ankan_strict_mode {
151        is_ankan_ok_under_riichi_strict(hand, &wait_set.regular, draw, ankan)
152    } else {
153        is_ankan_ok_under_riichi_relaxed(hand, decomposer, wait_set, ankan)
154    }
155}
156
157pub fn is_ankan_ok_under_riichi_strict(
158    hand: &TileSet37,
159    regulars: &[RegularWait],
160    draw: Tile,
161    ankan: Tile,
162) -> bool {
163    // Okuri-Kan (送り槓) is not allowed under strict mode.
164    if draw != ankan { return false; }
165
166    // Every way of normal decomposition must include `ankan` as a Koutsu
167    if !regulars.iter().all(|regular|
168        regular.groups().any(|group| group == HandGroup::Koutsu(ankan))) {
169        return false;
170    }
171
172    // Must not destroy Chuuren form
173    if ankan.suit() == 3 { return true }
174    let i = (ankan.suit() * 9) as usize;
175    let mut hand = TileSet34::from(hand);
176    hand[ankan] -= 1;
177    !chuuren_wait(&hand.0[i..(i + 9)]).is_some()
178}
179
180pub fn is_ankan_ok_under_riichi_relaxed(
181    hand: &TileSet37,
182    decomposer: &mut Decomposer,
183    wait_set: &WaitSet,
184    ankan: Tile,
185) -> bool {
186    let mut hand = hand.clone();
187    hand[ankan] -= 1;
188    let new_wait_set = WaitSet::from_keys(decomposer, &hand.packed_34());
189    wait_set.waiting_tiles == new_wait_set.waiting_tiles
190}
191
192/********/
193
194pub fn num_active_riichi(state: &State) -> usize {
195    state.core.riichi.into_iter().flatten().count()
196}
197
198pub fn num_draws(state: &State) -> u8 {
199    state.core.num_drawn_head + state.core.num_drawn_tail
200}
201
202/// The prerequisite of Haitei and Houtei: no more draws available.
203pub fn is_last_draw(state: &State) -> bool {
204    debug_assert!(num_draws(state) <= wall::MAX_NUM_DRAWS);
205    num_draws(state) == wall::MAX_NUM_DRAWS
206}
207
208/// First 4 turns of the game without being interrupted by any meld.
209/// Affects:
210/// - [`AbortReason::NineKinds`] (active), [`AbortReason::FourWind`] (passive)
211/// - [`Riichi::is_double`]
212/// - [`crate::yaku::Yaku`]: Tenhou, Chiihou, Renhou (first-chance win)
213pub fn is_first_chance(state: &State) -> bool {
214    state.core.seq <= 3 && state.melds.iter().all(|melds| melds.is_empty())
215}
216
217/// Checks if [`AbortReason::NagashiMangan`] applies (during end-of-turn resolution) for the
218/// specified player.
219/// Assuming [`is_last_draw`].
220pub fn is_nagashi_mangan(state: &State, player: Player) -> bool {
221    state.discards[player.to_usize()].iter().all(|discard|
222        discard.tile.is_terminal() && discard.called_by == player)
223}
224
225/// Checks if [`AbortReason::NagashiMangan`] applies (during end-of-turn resolution) for all
226/// players.
227/// Assuming [`is_last_draw`].
228pub fn is_any_player_nagashi_mangan(state: &State) -> bool {
229    ALL_PLAYERS.into_iter().any(|player| is_nagashi_mangan(state, player))
230}
231
232/// Checks if [`AbortReason::FourWind`] applies (during end-of-turn resolution).
233pub fn is_aborted_four_wind(state: &State, action: Action) -> bool {
234    if let Action::Discard(discard) = action {
235        return is_first_chance(state) &&
236            state.core.seq == 3 &&
237            discard.tile.is_wind() &&
238            other_players_after(state.core.actor).iter()
239                .map(|actor| &state.discards[actor.to_usize()])
240                .all(|discards|
241                    discards.len() == 1 && discards[0].tile == discard.tile)
242    }
243    false
244}
245
246/// Checks if [`AbortReason::FourKan`] applies (during end-of-turn resolution).
247pub fn is_aborted_four_kan(state: &State, action: Action, reaction: Option<Reaction>) -> bool {
248    let actor_i = state.core.actor.to_usize();
249
250    if matches!(action, Action::Kakan(_)) ||
251        matches!(action, Action::Ankan(_)) ||
252        matches!(reaction, Some(Reaction::Daiminkan)) {
253        // Gather the owner of each kan on the table into one list.
254        let kan_players =
255            state.melds.iter().enumerate().flat_map(|(player, melds_p)|
256                melds_p.iter().filter_map(move |meld|
257                    if meld.is_kan() { Some(player) } else { None })).collect_vec();
258        // - 3 existing kans + this one => ok if all 4 are from the same player.
259        // - 4 existing kans + this one => not ok (max number of kans on the table is 4).
260        if kan_players.len() == 4 ||
261            kan_players.len() == 3 && !kan_players.iter().all(|&player| player == actor_i) {
262            return true;
263        }
264    }
265    false
266}
267
268/// Checks if [`AbortReason::FourRiichi`] applies (during end-of-turn resolution).
269pub fn is_aborted_four_riichi(state: &State, action: Action) -> bool {
270    matches!(action, Action::Discard(Discard{declares_riichi: true, ..})) &&
271        num_active_riichi(state) == 3  // not a typo --- the last player only declared => not active yet
272}
273
274/// When the wall has been exhausted and no player has achieved
275/// [`AbortReason::NagashiMangan`], given whether each player is waiting (1) or not (0),
276/// returns the points delta for each player.
277pub fn calc_wall_exhausted_delta(waiting: [u8; 4]) -> [GamePoints; 4] {
278    // TODO(summivox): rules (ten-no-ten points)
279    const NO_WAIT_PENALTY_TOTAL: GamePoints = 3000;
280    let no_wait = NO_WAIT_PENALTY_TOTAL;
281
282    let num_waiting = waiting.into_iter().sum();
283    let (down, up) = match num_waiting {
284        1 => (-no_wait / 3, no_wait / 1),
285        2 => (-no_wait / 2, no_wait / 2),
286        3 => (-no_wait / 1, no_wait / 3),
287        _ => (0, 0),
288    };
289    waiting.map(|w| if w > 0 { up } else { down })
290}
291
292/// When the wall has been exhausted and some player has achieved
293/// [`AbortReason::NagashiMangan`], returns the points delta for each player.
294pub fn calc_nagashi_mangan_delta(state: &State, button: Player) -> [GamePoints; 4] {
295    // TODO(summivox): rules (nagashi-mangan-points)
296
297    let mut delta = [0; 4];
298    for player in ALL_PLAYERS {
299        if is_nagashi_mangan(state, player) {
300            if player == button {
301                delta[player.to_usize()] += 12000 + 4000;
302                for qq in 0..4 { delta[qq] -= 4000; }
303            } else {
304                delta[player.to_usize()] += 8000 + 2000;
305                delta[button.to_usize()] -= 2000;
306                for qq in 0..4 { delta[qq] -= 2000; }
307            }
308        }
309    }
310    delta
311}
312
313/// Each player with active riichi must pay into the pot.
314pub fn calc_pot_delta(riichi: &[Option<Riichi>; 4]) -> [GamePoints; 4] {
315    riichi.map(|r| if r.is_some() { -super::RIICHI_POT } else { 0 })
316}
317
318/// All tiles at win condition = closed hand + the winning tile + all tiles in melds .
319/// A fully closed hand win will be 14 tiles.
320/// Chii/Pon will not change this number, while each Kan introduces 1 more tile.
321/// At the extreme, 4 Kan's will result in 18 tiles (4x4 for each Kan + 2 for the pair).
322pub fn get_all_tiles(
323    closed_hand: &TileSet37,
324    winning_tile: Tile,
325    melds: &[Meld],
326) -> TileSet37 {
327    let mut all_tiles = closed_hand.clone();
328    log::debug!("closed_hand: {}", all_tiles);
329    all_tiles[winning_tile] += 1;
330    log::debug!("+winning   : {}", all_tiles);
331    for meld in melds {
332        match meld {
333            Meld::Chii(chii) => {
334                for own in chii.own { all_tiles[own] += 1 }
335                all_tiles[chii.called] += 1;
336            }
337            Meld::Pon(pon) => {
338                for own in pon.own { all_tiles[own] += 1 }
339                all_tiles[pon.called] += 1;
340            }
341            Meld::Kakan(kakan) => {
342                for own in kakan.pon.own { all_tiles[own] += 1 }
343                all_tiles[kakan.pon.called] += 1;
344                all_tiles[kakan.added] += 1;
345            }
346            Meld::Daiminkan(daiminkan) => {
347                for own in daiminkan.own { all_tiles[own] += 1 }
348                all_tiles[daiminkan.called] += 1;
349            }
350            Meld::Ankan(ankan) => {
351                for own in ankan.own { all_tiles[own] += 1; }
352            }
353        }
354    }
355    log::debug!("+meld      : {}", all_tiles);
356    all_tiles
357}
358
359pub fn count_doras(
360    ruleset: &Ruleset,
361    all_tiles: &TileSet37,
362    num_dora_indicators: u8,
363    wall: &Wall,
364    is_riichi: bool,
365) -> DoraHits {
366    let all_tiles_normal = TileSet34::from(all_tiles);
367
368    let n = if ruleset.dora_allow_kan { num_dora_indicators as usize } else { 1 };
369    let n_ura = if ruleset.dora_allow_kan_ura { n } else { 1 };
370
371    if log_enabled!(log::Level::Debug) {
372        log::debug!("count doras: n={} n_ura={} di={} udi={}, all_tiles={}",
373            n,
374            n_ura,
375            wall::dora_indicators(wall).iter().map(|t| t.as_str()).join(","),
376            wall::ura_dora_indicators(wall).iter().map(|t| t.as_str()).join(","),
377            all_tiles,
378        );
379    }
380
381    DoraHits {
382        dora:
383        (&wall::dora_indicators(wall)[0..n])
384            .iter()
385            .map(|t| all_tiles_normal[t.indicated_dora()])
386            .sum(),
387
388        ura_dora:
389        if is_riichi && ruleset.dora_allow_ura {
390            (&wall::ura_dora_indicators(wall)[0..n_ura])
391                .iter()
392                .map(|t| all_tiles_normal[t.indicated_dora()])
393                .sum()
394        } else { 0 },
395
396        aka_dora: all_tiles[34] + all_tiles[35] + all_tiles[36],
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use rustc_hash::FxHashSet as HashSet;
403    use super::*;
404
405    #[test]
406    fn chuuren_wait_exhaustive() {
407        let mut s: HashSet<[u8; 9]> = HashSet::default();
408
409        let target = [3, 1, 1, 1, 1, 1, 1, 1, 3];
410        s.insert(target);
411        assert_eq!(chuuren_wait(&target[..]), Some((0, 0)));
412
413        for lack in 0..9 {
414            for over in 0..9 {
415                if lack == over { continue; }
416                let mut x = target;
417                x[lack] -= 1;
418                x[over] += 1;
419                s.insert(x);
420                assert_eq!(chuuren_wait(&x[..]), Some((lack as u8, over as u8)));
421            }
422        }
423
424        for x in itertools::repeat_n(0..4, 9).multi_cartesian_product() {
425            let x: [u8; 9] = x.try_into().unwrap();
426            if !s.contains(&x) {
427                assert_eq!(chuuren_wait(&x), None);
428            }
429        }
430    }
431}