mahjong_core/ai/
mod.rs

1use crate::game::{DrawError, DrawTileResult};
2use crate::meld::PossibleMeld;
3use crate::{Game, GamePhase, PlayerId, TileId, Wind, WINDS_ROUND_ORDER};
4use rand::seq::SliceRandom;
5use rand::thread_rng;
6use rustc_hash::FxHashSet;
7use strum_macros::EnumIter;
8
9mod best_drops;
10
11// Naive AI as a placeholder which can be extended later
12pub struct StandardAI<'a> {
13    ai_players: FxHashSet<PlayerId>,
14    pub auto_stop_claim_meld: FxHashSet<PlayerId>,
15    pub can_draw_round: bool,
16    pub can_pass_turn: bool,
17    pub dealer_order_deterministic: Option<bool>,
18    pub draw_tile_for_real_player: bool,
19    pub game: &'a mut Game,
20    pub shuffle_players: bool,
21    pub sort_on_draw: bool,
22    pub sort_on_initial_draw: bool,
23    pub with_dead_wall: bool,
24}
25
26#[derive(Debug, Eq, PartialEq, EnumIter, Clone)]
27pub enum PlayExitLocation {
28    AIPlayerTileDrawn,
29    AIPlayerTurnPassed,
30    AlreadyEnd,
31    AutoStoppedDrawMahjong,
32    AutoStoppedDrawNormal,
33    ClaimedTile,
34    CompletedPlayers,
35    CouldNotClaimTile,
36    DecidedDealer,
37    FinishedCharleston,
38    InitialDraw,
39    InitialDrawError(DrawError),
40    InitialShuffle,
41    MeldCreated,
42    NewRoundFromMeld,
43    NoAction,
44    NoAutoDrawTile,
45    RoundPassed,
46    StartGame,
47    SuccessMahjong,
48    TileDiscarded,
49    TileDrawn,
50    TurnPassed,
51    WaitingDealerOrder,
52    WaitingPlayers,
53}
54
55// This is used for debugging unexpected "NoAction" results
56#[derive(Debug, PartialEq, Eq, Clone)]
57pub struct Metadata {}
58
59#[derive(Debug, Eq, PartialEq, Clone)]
60pub struct PlayActionResult {
61    pub changed: bool,
62    pub exit_location: PlayExitLocation,
63    pub metadata: Option<Metadata>,
64}
65
66pub fn sort_by_is_mahjong(a: &PossibleMeld, b: &PossibleMeld) -> std::cmp::Ordering {
67    if a.is_mahjong && !b.is_mahjong {
68        std::cmp::Ordering::Less
69    } else if !a.is_mahjong && b.is_mahjong {
70        std::cmp::Ordering::Greater
71    } else {
72        std::cmp::Ordering::Equal
73    }
74}
75
76impl StandardAI<'_> {
77    pub fn get_is_after_discard(&self) -> bool {
78        let current_player = self.game.get_current_player();
79        if current_player.is_none() {
80            return false;
81        }
82        let current_hand = self.game.table.hands.get(&current_player.unwrap());
83
84        current_hand.unwrap().len() < self.game.style.tiles_after_claim()
85            && self.game.round.tile_claimed.is_some()
86    }
87}
88
89impl<'a> StandardAI<'a> {
90    pub fn new(
91        game: &'a mut Game,
92        ai_players: FxHashSet<PlayerId>,
93        auto_stop_claim_meld: FxHashSet<PlayerId>,
94    ) -> Self {
95        Self {
96            ai_players,
97            auto_stop_claim_meld,
98            can_draw_round: false,
99            can_pass_turn: true,
100            dealer_order_deterministic: None,
101            draw_tile_for_real_player: true,
102            game,
103            shuffle_players: false,
104            sort_on_draw: false,
105            sort_on_initial_draw: false,
106            with_dead_wall: false,
107        }
108    }
109
110    pub fn play_action(&mut self, with_metadata: bool) -> PlayActionResult {
111        let mut metadata: Option<Metadata> = None;
112
113        if with_metadata {
114            metadata = Some(Metadata {});
115        }
116
117        match self.game.phase {
118            GamePhase::Charleston => {
119                let finished_charleston = self.game.move_charleston();
120
121                if finished_charleston.is_ok() {
122                    return PlayActionResult {
123                        changed: true,
124                        exit_location: PlayExitLocation::FinishedCharleston,
125                        metadata,
126                    };
127                }
128
129                return PlayActionResult {
130                    changed: false,
131                    exit_location: PlayExitLocation::NoAction,
132                    metadata,
133                };
134            }
135            GamePhase::WaitingPlayers => {
136                return match self.game.complete_players(self.shuffle_players) {
137                    Ok(_) => PlayActionResult {
138                        changed: true,
139                        exit_location: PlayExitLocation::CompletedPlayers,
140                        metadata,
141                    },
142                    Err(_) => PlayActionResult {
143                        changed: false,
144                        exit_location: PlayExitLocation::WaitingPlayers,
145                        metadata,
146                    },
147                };
148            }
149            GamePhase::InitialShuffle => {
150                self.game.prepare_table(self.with_dead_wall);
151
152                return PlayActionResult {
153                    changed: true,
154                    exit_location: PlayExitLocation::InitialShuffle,
155                    metadata,
156                };
157            }
158            GamePhase::DecidingDealer => {
159                if self.dealer_order_deterministic.is_some() {
160                    let dealer_order_deterministic = self.dealer_order_deterministic.unwrap();
161
162                    self.game
163                        .round
164                        .set_initial_winds(Some(if dealer_order_deterministic {
165                            WINDS_ROUND_ORDER.clone()
166                        } else {
167                            let mut winds: [Wind; 4] = WINDS_ROUND_ORDER.clone();
168                            winds.shuffle(&mut thread_rng());
169                            winds
170                        }))
171                        .unwrap();
172                } else if self.game.round.initial_winds.is_none() {
173                    return PlayActionResult {
174                        metadata,
175                        changed: false,
176                        exit_location: PlayExitLocation::WaitingDealerOrder,
177                    };
178                }
179
180                self.game.decide_dealer().unwrap();
181
182                return PlayActionResult {
183                    changed: true,
184                    metadata,
185                    exit_location: PlayExitLocation::DecidedDealer,
186                };
187            }
188            GamePhase::Beginning => {
189                self.game.start(self.shuffle_players);
190
191                return PlayActionResult {
192                    changed: true,
193                    metadata,
194                    exit_location: PlayExitLocation::StartGame,
195                };
196            }
197            GamePhase::InitialDraw => match self.game.initial_draw() {
198                Ok(_) => {
199                    if self.sort_on_initial_draw {
200                        for player in self.game.table.hands.0.clone().keys() {
201                            self.game.table.hands.sort_player_hand(player);
202                        }
203                    }
204
205                    return PlayActionResult {
206                        changed: true,
207                        exit_location: PlayExitLocation::InitialDraw,
208                        metadata,
209                    };
210                }
211                Err(e) => {
212                    return PlayActionResult {
213                        metadata,
214                        changed: false,
215                        exit_location: PlayExitLocation::InitialDrawError(e),
216                    };
217                }
218            },
219            GamePhase::End => {
220                return PlayActionResult {
221                    changed: false,
222                    exit_location: PlayExitLocation::AlreadyEnd,
223                    metadata,
224                };
225            }
226            GamePhase::Playing => {}
227        }
228
229        // Check if any meld can be created with existing cards
230        let mut melds = self.game.get_possible_melds(true);
231
232        // Suffle melds
233        let mut rng = thread_rng();
234        melds.shuffle(&mut rng);
235        melds.sort_by(sort_by_is_mahjong);
236
237        for meld in melds {
238            if self.ai_players.contains(&meld.player_id) {
239                if meld.is_mahjong {
240                    let mahjong_success = self.game.say_mahjong(&meld.player_id);
241
242                    if mahjong_success.is_ok() {
243                        return PlayActionResult {
244                            changed: true,
245                            exit_location: PlayExitLocation::SuccessMahjong,
246                            metadata,
247                        };
248                    }
249                }
250
251                let player_hand = self.game.table.hands.0.get(&meld.player_id).unwrap();
252                let missing_tile = meld
253                    .tiles
254                    .iter()
255                    .find(|tile| !player_hand.get_has_tile(tile));
256
257                if let Some(missing_tile) = missing_tile {
258                    if let Some(claimable_type) =
259                        self.game.round.get_claimable_tile(&meld.player_id)
260                    {
261                        if claimable_type == *missing_tile {
262                            let was_tile_claimed = self.game.claim_tile(&meld.player_id);
263
264                            if was_tile_claimed {
265                                return PlayActionResult {
266                                    changed: true,
267                                    exit_location: PlayExitLocation::ClaimedTile,
268                                    metadata,
269                                };
270                            } else {
271                                // Unexpected state
272                                return PlayActionResult {
273                                    changed: false,
274                                    exit_location: PlayExitLocation::CouldNotClaimTile,
275                                    metadata,
276                                };
277                            }
278                        }
279                    }
280                }
281
282                if meld.discard_tile.is_some() {
283                    continue;
284                }
285
286                let phase_before = self.game.phase;
287
288                let meld_created = self.game.create_meld(
289                    &meld.player_id,
290                    &meld.tiles,
291                    meld.is_upgrade,
292                    meld.is_concealed,
293                );
294
295                if phase_before == GamePhase::Playing && self.game.phase != GamePhase::Playing {
296                    return PlayActionResult {
297                        changed: true,
298                        exit_location: PlayExitLocation::NewRoundFromMeld,
299                        metadata,
300                    };
301                }
302
303                if meld_created.is_ok() {
304                    return PlayActionResult {
305                        changed: true,
306                        exit_location: PlayExitLocation::MeldCreated,
307                        metadata,
308                    };
309                }
310            }
311        }
312
313        let current_player = self.game.get_current_player().unwrap();
314
315        if self.ai_players.contains(&current_player) {
316            let is_tile_claimed = self.game.round.tile_claimed.is_some();
317
318            if !is_tile_claimed {
319                let tile_drawn = self.game.draw_tile_from_wall();
320
321                match tile_drawn {
322                    DrawTileResult::Bonus(_) | DrawTileResult::Normal(_) => {
323                        if let DrawTileResult::Normal(_) = tile_drawn {
324                            if self.sort_on_initial_draw {
325                                self.game.table.hands.sort_player_hand(&current_player);
326                            }
327                        }
328
329                        return PlayActionResult {
330                            changed: true,
331                            exit_location: PlayExitLocation::AIPlayerTileDrawn,
332                            metadata,
333                        };
334                    }
335                    DrawTileResult::AlreadyDrawn | DrawTileResult::WallExhausted => {}
336                };
337            }
338
339            let player_hand = self.game.table.hands.0.get(&current_player).unwrap();
340            if player_hand.len() == self.game.style.tiles_after_claim() {
341                let mut tiles_without_meld = player_hand
342                    .list
343                    .iter()
344                    .filter(|tile| tile.set_id.is_none())
345                    .map(|tile| tile.id)
346                    .collect::<Vec<TileId>>();
347
348                if !tiles_without_meld.is_empty() {
349                    let tile_to_discard = 'a: {
350                        if let Some(tile_claimed) = self.game.round.tile_claimed.clone() {
351                            for tile in tiles_without_meld.iter() {
352                                if tile_claimed.id == *tile {
353                                    break 'a tile_claimed.id;
354                                }
355                            }
356                        }
357
358                        tiles_without_meld.shuffle(&mut thread_rng());
359                        tiles_without_meld[0]
360                    };
361
362                    let discarded = self.game.discard_tile_to_board(&tile_to_discard);
363
364                    if discarded.is_ok() {
365                        return PlayActionResult {
366                            changed: true,
367                            exit_location: PlayExitLocation::TileDiscarded,
368                            metadata,
369                        };
370                    }
371                }
372            } else if self.can_pass_turn {
373                let auto_stop_claim_meld = self.auto_stop_claim_meld.clone();
374                if !auto_stop_claim_meld.is_empty() {
375                    for player in auto_stop_claim_meld {
376                        if player.is_empty() {
377                            continue;
378                        }
379                        let (can_claim_tile, tile_claimed, _) =
380                            self.game.get_can_claim_tile(&player);
381
382                        if !can_claim_tile {
383                            continue;
384                        }
385
386                        let tile_claimed = tile_claimed.unwrap();
387
388                        let melds_mahjong = self.game.get_possible_melds_for_player(&player, true);
389                        let melds_with_draw_mahjong = melds_mahjong
390                            .iter()
391                            .filter(|meld| meld.tiles.contains(&tile_claimed))
392                            .collect::<Vec<&PossibleMeld>>();
393
394                        if !melds_with_draw_mahjong.is_empty() {
395                            return PlayActionResult {
396                                changed: false,
397                                exit_location: PlayExitLocation::AutoStoppedDrawMahjong,
398                                metadata,
399                            };
400                        }
401
402                        let melds_normal = self.game.get_possible_melds_for_player(&player, false);
403                        let melds_with_draw_normal = melds_normal
404                            .iter()
405                            .filter(|meld| meld.tiles.contains(&tile_claimed))
406                            .collect::<Vec<&PossibleMeld>>();
407
408                        if !melds_with_draw_normal.is_empty() {
409                            return PlayActionResult {
410                                changed: false,
411                                exit_location: PlayExitLocation::AutoStoppedDrawNormal,
412                                metadata,
413                            };
414                        }
415                    }
416                }
417                let success = self.game.round.next_turn(&self.game.table.hands);
418
419                if success.is_ok() {
420                    return PlayActionResult {
421                        changed: true,
422                        exit_location: PlayExitLocation::AIPlayerTurnPassed,
423                        metadata,
424                    };
425                }
426            };
427        } else {
428            let is_tile_claimed = self.game.round.tile_claimed.is_some();
429
430            if !is_tile_claimed {
431                if !self.draw_tile_for_real_player {
432                    return PlayActionResult {
433                        changed: false,
434                        exit_location: PlayExitLocation::NoAutoDrawTile,
435                        metadata,
436                    };
437                }
438
439                let tile_drawn = self.game.draw_tile_from_wall();
440
441                match tile_drawn {
442                    DrawTileResult::Bonus(_) | DrawTileResult::Normal(_) => {
443                        if let DrawTileResult::Normal(_) = tile_drawn {
444                            if self.sort_on_draw {
445                                self.game.table.hands.sort_player_hand(&current_player);
446                            }
447                        }
448
449                        return PlayActionResult {
450                            changed: true,
451                            exit_location: PlayExitLocation::TileDrawn,
452                            metadata,
453                        };
454                    }
455                    DrawTileResult::AlreadyDrawn | DrawTileResult::WallExhausted => {}
456                };
457            } else if self.can_pass_turn {
458                let player_hand = self.game.table.hands.0.get(&current_player).unwrap();
459                if player_hand.len() < self.game.style.tiles_after_claim() {
460                    let success = self.game.round.next_turn(&self.game.table.hands);
461
462                    if success.is_ok() {
463                        return PlayActionResult {
464                            changed: true,
465                            exit_location: PlayExitLocation::TurnPassed,
466                            metadata,
467                        };
468                    }
469                }
470            }
471        }
472
473        if self.game.table.draw_wall.is_empty() && self.can_draw_round {
474            let round_passed = self.game.pass_null_round();
475
476            if round_passed.is_ok() {
477                return PlayActionResult {
478                    changed: true,
479                    exit_location: PlayExitLocation::RoundPassed,
480                    metadata,
481                };
482            }
483        }
484
485        PlayActionResult {
486            changed: false,
487            exit_location: PlayExitLocation::NoAction,
488            metadata,
489        }
490    }
491}