diff --git a/src/main/java/top/ellan/mahjong/table/core/round/GbBotDecisionService.java b/src/main/java/top/ellan/mahjong/table/core/round/GbBotDecisionService.java index 7045301..cd3298e 100644 --- a/src/main/java/top/ellan/mahjong/table/core/round/GbBotDecisionService.java +++ b/src/main/java/top/ellan/mahjong/table/core/round/GbBotDecisionService.java @@ -30,8 +30,7 @@ int suggestedDiscardIndex( if (hand == null || hand.isEmpty()) { return -1; } - DiscardChoice best = this.bestDiscardChoice(hand, melds, tingEvaluator); - return best == null ? hand.size() - 1 : best.index(); + return this.bestDiscardIndex(hand, melds, tingEvaluator); } ReactionResponse suggestedReaction( @@ -178,6 +177,54 @@ private long readyScoreForBestDiscard( return best == null ? 0 : best.readyScore(); } + /** + * Index-only variant of {@link #bestDiscardChoice} for the + * {@link #suggestedDiscardIndex} hot path. Avoids allocating a + * {@link DiscardChoice} record per call when the caller only needs the + * discard index. The loop body is identical; only the return shape + * differs (primitive {@code int} vs record). + */ + private int bestDiscardIndex( + List hand, + List melds, + TingEvaluator tingEvaluator + ) { + int bestIndex = -1; + long bestReadyScore = 0; + int bestDiscardPreference = 0; + GbTingResponse[] tingMemo = new GbTingResponse[TILE_KIND_COUNT]; + MahjongTile previousDiscarded = null; + int previousDiscardPreference = 0; + for (int i = 0; i < hand.size(); i++) { + MahjongTile discarded = hand.get(i); + int discardedOrdinal = discarded.ordinal(); + GbTingResponse ting = tingMemo[discardedOrdinal]; + boolean tingMemoHit = ting != null; + if (ting == null) { + List remaining = new ArrayList<>(hand); + remaining.remove(i); + ting = tingEvaluator.evaluate(remaining, melds); + if (ting != null) { + tingMemo[discardedOrdinal] = ting; + } + } + long candidateReadyScore = readyScore(ting); + int candidateDiscardPreference = tingMemoHit && discarded == previousDiscarded + ? previousDiscardPreference + : discardPreference(hand, discarded); + previousDiscarded = discarded; + previousDiscardPreference = candidateDiscardPreference; + if (bestIndex < 0 + || candidateReadyScore > bestReadyScore + || (candidateReadyScore == bestReadyScore && candidateDiscardPreference > bestDiscardPreference)) { + bestIndex = i; + bestReadyScore = candidateReadyScore; + bestDiscardPreference = candidateDiscardPreference; + } + } + return bestIndex < 0 ? hand.size() - 1 : bestIndex; + } + private DiscardChoice bestDiscardChoice( List hand, List melds, diff --git a/src/main/java/top/ellan/mahjong/table/core/round/GbRoundSupport.java b/src/main/java/top/ellan/mahjong/table/core/round/GbRoundSupport.java index 3dbbaf6..e7a688d 100644 --- a/src/main/java/top/ellan/mahjong/table/core/round/GbRoundSupport.java +++ b/src/main/java/top/ellan/mahjong/table/core/round/GbRoundSupport.java @@ -118,15 +118,55 @@ static boolean sameKind(MahjongTile left, MahjongTile right) { return leftBase == rightBase; } + // Precomputed lookup tables indexed by MahjongTile.ordinal(). Eliminates the + // per-call String allocation (name().substring(1,2)) and Integer.parseInt + // parsing inside hot loops such as GbBotDecisionService.discardPreference. + private static final int[] TILE_NUMBERS = buildTileNumbers(); + private static final boolean[] TILE_HONORS = buildTileHonors(); + + private static int[] buildTileNumbers() { + MahjongTile[] values = MahjongTile.values(); + int[] numbers = new int[values.length]; + int east = MahjongTile.EAST.ordinal(); + int redDragon = MahjongTile.RED_DRAGON.ordinal(); + for (MahjongTile tile : values) { + int ord = tile.ordinal(); + if (tile.isFlower() || (ord >= east && ord <= redDragon)) { + numbers[ord] = 0; + continue; + } + // UNKNOWN and any other non-suited tile would fail parseInt; leave 0. + // Callers never pass such tiles to tileNumber() (they guard with + // isFlower()/isHonor() or only pass suited tiles from hands/walls). + try { + numbers[ord] = Integer.parseInt(tile.name().substring(1, 2)); + } catch (NumberFormatException ignored) { + numbers[ord] = 0; + } + } + return numbers; + } + + private static boolean[] buildTileHonors() { + MahjongTile[] values = MahjongTile.values(); + boolean[] honors = new boolean[values.length]; + int east = MahjongTile.EAST.ordinal(); + int redDragon = MahjongTile.RED_DRAGON.ordinal(); + for (int i = 0; i < values.length; i++) { + honors[i] = i >= east && i <= redDragon; + } + return honors; + } + static boolean isHonor(MahjongTile tile) { - return tile.ordinal() >= MahjongTile.EAST.ordinal() && tile.ordinal() <= MahjongTile.RED_DRAGON.ordinal(); + return TILE_HONORS[tile.ordinal()]; } static int tileNumber(MahjongTile tile) { if (tile == null || tile.isFlower() || isHonor(tile)) { return 0; } - return Integer.parseInt(tile.name().substring(1, 2)); + return TILE_NUMBERS[tile.ordinal()]; } static MahjongTile offsetTile(MahjongTile tile, int delta) {