From aa4313fa61dd3b10225fd206a9b215311d3084da Mon Sep 17 00:00:00 2001 From: Arbousier1 Date: Sat, 18 Jul 2026 17:39:08 +0800 Subject: [PATCH] perf(riichi): skip impossible discard reactions --- .../riichi/RiichiPlayerAnalysisState.kt | 14 ++ .../ellan/mahjong/riichi/RiichiPlayerState.kt | 33 ++++ .../RiichiReactionStructuralGateTest.kt | 176 ++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 src/test/kotlin/top/ellan/mahjong/riichi/RiichiReactionStructuralGateTest.kt diff --git a/src/main/kotlin/top/ellan/mahjong/riichi/RiichiPlayerAnalysisState.kt b/src/main/kotlin/top/ellan/mahjong/riichi/RiichiPlayerAnalysisState.kt index ef1a212..27ddf86 100644 --- a/src/main/kotlin/top/ellan/mahjong/riichi/RiichiPlayerAnalysisState.kt +++ b/src/main/kotlin/top/ellan/mahjong/riichi/RiichiPlayerAnalysisState.kt @@ -68,6 +68,7 @@ abstract class RiichiPlayerAnalysisState( protected val cachedFuroReactions: MutableMap, FuroReactionAnalysis?> = mutableMapOf() protected var cachedHandsMahjongTiles: List = emptyList() protected var cachedHandsUtilsTiles: List = emptyList() + protected var cachedHandBaseTileCounts: IntArray = IntArray(MahjongTile.entries.size) protected var cachedFuuroUtils: List = emptyList() protected val cachedCanWinDecisions: MutableMap = mutableMapOf() @@ -287,6 +288,18 @@ abstract class RiichiPlayerAnalysisState( return cachedHandsUtilsTiles } + /** Red fives share their normal five's slot, matching furo eligibility rules. */ + protected fun currentHandBaseTileCounts(): IntArray { + if (!isCacheCurrent(AnalysisCache.HAND_BASE_TILE_COUNTS)) { + cachedHandBaseTileCounts.fill(0) + for (tile in hands) { + cachedHandBaseTileCounts[tile.mahjongTile.baseTile.ordinal]++ + } + markCacheCurrent(AnalysisCache.HAND_BASE_TILE_COUNTS) + } + return cachedHandBaseTileCounts + } + protected fun currentFuuroUtils(): List { if (!isCacheCurrent(AnalysisCache.FUURO_UTILS)) { cachedFuuroUtils = fuuroList.map { it.utilsFuro } @@ -555,6 +568,7 @@ abstract class RiichiPlayerAnalysisState( FURO_REACTION, HANDS_MAHJONG_TILES, HANDS_UTILS_TILES, + HAND_BASE_TILE_COUNTS, FUURO_UTILS, CAN_WIN, } diff --git a/src/main/kotlin/top/ellan/mahjong/riichi/RiichiPlayerState.kt b/src/main/kotlin/top/ellan/mahjong/riichi/RiichiPlayerState.kt index 97c21a8..5372ad7 100644 --- a/src/main/kotlin/top/ellan/mahjong/riichi/RiichiPlayerState.kt +++ b/src/main/kotlin/top/ellan/mahjong/riichi/RiichiPlayerState.kt @@ -251,6 +251,20 @@ open class RiichiPlayerState( } } + if (!hasStructuralFuroCandidate(tile.mahjongTile.baseTile, allowChii)) { + return if (canRon) { + ReactionOptions( + canRon = true, + canPon = false, + canMinkan = false, + chiiPairs = emptyList(), + suggestedResponse = ReactionResponse(ReactionType.RON, null), + ) + } else { + null + } + } + val analysis = analyzeFuroReaction(tile, allowChii) val canPon = analysis?.pon != null val canMinkan = analysis?.minkan != null @@ -274,6 +288,25 @@ open class RiichiPlayerState( ) } + private fun hasStructuralFuroCandidate( + baseTile: MahjongTile, + allowChii: Boolean, + ): Boolean { + val counts = currentHandBaseTileCounts() + val tileIndex = baseTile.ordinal + if (counts[tileIndex] >= 2) { + return true + } + if (!allowChii || tileIndex !in MahjongTile.M1.ordinal..MahjongTile.S9.ordinal) { + return false + } + + val rank = tileIndex % 9 + return (rank >= 2 && counts[tileIndex - 2] > 0 && counts[tileIndex - 1] > 0) || + (rank in 1..7 && counts[tileIndex - 1] > 0 && counts[tileIndex + 1] > 0) || + (rank <= 6 && counts[tileIndex + 1] > 0 && counts[tileIndex + 2] > 0) + } + fun tilePairForPon(tile: TileInstance): Pair { val tiles = tilesForPon(tile) return tiles[0].mahjongTile to tiles[1].mahjongTile diff --git a/src/test/kotlin/top/ellan/mahjong/riichi/RiichiReactionStructuralGateTest.kt b/src/test/kotlin/top/ellan/mahjong/riichi/RiichiReactionStructuralGateTest.kt new file mode 100644 index 0000000..fbd5a4b --- /dev/null +++ b/src/test/kotlin/top/ellan/mahjong/riichi/RiichiReactionStructuralGateTest.kt @@ -0,0 +1,176 @@ +package top.ellan.mahjong.riichi + +import top.ellan.mahjong.riichi.model.MahjongTile +import top.ellan.mahjong.riichi.model.TileInstance +import java.util.UUID +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class RiichiReactionStructuralGateTest { + @Test + fun `reaction fast path skips furo analysis when no structural call exists`() { + val player = RiichiPlayerState("Alice", "alice") + player.hands += + tiles( + MahjongTile.M1, + MahjongTile.M4, + MahjongTile.M7, + MahjongTile.P1, + MahjongTile.P4, + MahjongTile.P7, + MahjongTile.S1, + MahjongTile.S4, + MahjongTile.S7, + MahjongTile.SOUTH, + MahjongTile.WEST, + MahjongTile.WHITE_DRAGON, + MahjongTile.GREEN_DRAGON, + ) + + assertNull(player.reactionOptionsFor(TileInstance(mahjongTile = MahjongTile.EAST), false, false)) + assertEquals(0, cachedFuroReactionCount(player)) + val ronOnly = player.reactionOptionsFor(TileInstance(mahjongTile = MahjongTile.EAST), false, true) + assertEquals(true, ronOnly?.canRon) + assertEquals(false, ronOnly?.canPon) + assertEquals(false, ronOnly?.canMinkan) + assertEquals(ReactionType.RON, ronOnly?.suggestedResponse?.type) + assertEquals(0, cachedFuroReactionCount(player)) + } + + @Test + fun `reaction structural gate normalizes red fives and invalidates with hand version`() { + val player = RiichiPlayerState("Alice", "alice") + player.hands += + tiles( + MahjongTile.M5_RED, + MahjongTile.M5, + MahjongTile.M1, + MahjongTile.M9, + MahjongTile.P1, + MahjongTile.P9, + MahjongTile.S1, + MahjongTile.S9, + MahjongTile.EAST, + MahjongTile.SOUTH, + MahjongTile.WEST, + MahjongTile.WHITE_DRAGON, + MahjongTile.GREEN_DRAGON, + ) + player.reactionOptionsFor(TileInstance(mahjongTile = MahjongTile.M5), false, false) + assertEquals(1, cachedFuroReactionCount(player)) + + val invalidating = RiichiPlayerState("Bob", "bob") + invalidating.hands += + tiles( + MahjongTile.P2, + MahjongTile.M1, + MahjongTile.M4, + MahjongTile.M7, + MahjongTile.P5, + MahjongTile.P8, + MahjongTile.S1, + MahjongTile.S4, + MahjongTile.S7, + MahjongTile.EAST, + MahjongTile.SOUTH, + MahjongTile.WEST, + MahjongTile.WHITE_DRAGON, + ) + assertNull(invalidating.reactionOptionsFor(TileInstance(mahjongTile = MahjongTile.P2), false, false)) + assertEquals(0, cachedFuroReactionCount(invalidating)) + invalidating.drawTile(TileInstance(mahjongTile = MahjongTile.P2)) + invalidating.reactionOptionsFor(TileInstance(mahjongTile = MahjongTile.P2), false, false) + assertEquals(1, cachedFuroReactionCount(invalidating)) + } + + @Test + fun `structural gate matches the original furo path for seeded legal hands`() { + val candidates = MahjongTile.entries.filterNot { it.isFlower || it == MahjongTile.UNKNOWN } + repeat(4) { seed -> + val hand = physicalDeck().shuffled(Random(seed)).take(13) + val optimized = RiichiPlayerState("optimized-$seed", "optimized-$seed") + val reference = ReferenceReactionPlayer("reference-$seed", "reference-$seed") + optimized.hands += tiles(*hand.toTypedArray()) + reference.hands += tiles(*hand.toTypedArray()) + + for (candidate in candidates) { + val discard = TileInstance(UUID(seed.toLong() + 1L, candidate.ordinal.toLong() + 1L), candidate) + for (allowChii in listOf(false, true)) { + for (canRon in listOf(false, true)) { + assertEquals( + reference.reactionOptionsWithoutStructuralGate(discard, allowChii, canRon), + optimized.reactionOptionsFor(discard, allowChii, canRon), + "seed=$seed tile=$candidate allowChii=$allowChii canRon=$canRon hand=$hand", + ) + } + } + } + } + } + + private fun cachedFuroReactionCount(player: RiichiPlayerState): Int { + @Suppress("UNCHECKED_CAST") + val field = RiichiPlayerAnalysisState::class.java.getDeclaredField("cachedFuroReactions") + field.isAccessible = true + val cache = field.get(player) as Map + return cache.size + } + + private fun tiles(vararg tiles: MahjongTile): List = + tiles.mapIndexed { index, tile -> TileInstance(UUID(0L, index.toLong() + 1L), tile) } + + private fun physicalDeck(): List = + buildList { + MahjongTile.entries + .take(MahjongTile.RED_DRAGON.ordinal + 1) + .forEach { tile -> repeat(4) { add(tile) } } + remove(MahjongTile.M5) + remove(MahjongTile.P5) + remove(MahjongTile.S5) + add(MahjongTile.M5_RED) + add(MahjongTile.P5_RED) + add(MahjongTile.S5_RED) + } + + private class ReferenceReactionPlayer( + name: String, + id: String, + ) : RiichiPlayerState(name, id) { + fun reactionOptionsWithoutStructuralGate( + tile: TileInstance, + allowChii: Boolean, + canRon: Boolean, + ): ReactionOptions? { + if (riichi || doubleRiichi) { + return if (canRon) { + ReactionOptions( + canRon = true, + canPon = false, + canMinkan = false, + chiiPairs = emptyList(), + suggestedResponse = ReactionResponse(ReactionType.RON, null), + ) + } else { + null + } + } + + val analysis = analyzeFuroReaction(tile, allowChii) + val canPon = analysis?.pon != null + val canMinkan = analysis?.minkan != null + val chiiPairs = analysis?.chiChoices?.map { it.first } ?: emptyList() + if (!canRon && !canPon && !canMinkan && chiiPairs.isEmpty()) { + return null + } + val suggestion = + when { + canRon -> ReactionResponse(ReactionType.RON, null) + analysis == null -> ReactionResponse(ReactionType.SKIP, null) + else -> suggestedReactionResponse(analysis) + } + return ReactionOptions(canRon, canPon, canMinkan, chiiPairs, suggestion) + } + } +}