validateResponseAction(GameSession, GameTable, GameSeat, Integer, String, String)

validateResponseAction(GameSession, GameTable, GameSeat, Integer, String, String, boolean)

validateCandidateAction(ResponseActionWindow, GameSeat, String, String)

applySettlement(GameTable, SettlementType, ActionType, int, int, String, Map<Integer, Integer>)

GameSessionService(RoomService, GameEngine, GameActionProcessor, ResponseActionWindowBuilder, ResponseActionResolver, SettlementService, BloodBattleScoringService, HuEvaluator, StrategyService, PlayerVisibilityService, TeachingService, GameMessagePublisher)

handlePostActionEffects(GameSession, ActionType, GameActionRequest, List<GameEvent>)

handleDiscardPostAction(GameSession, GameActionRequest, List<GameEvent>)

handleResponseDeclarationPostAction(GameSession, ActionType, GameActionRequest)

closeResponseWindowWithResolution(GameSession, ResponseActionWindow, ActionType)

executeHu(GameSession, ResponseActionWindow, List<ResponseActionResolution>)

applyChaJiao(GameSession, Map<Integer, ReadyHandOption>, Set<Integer>)

GameSessionService(RoomService, GameEngine, GameActionProcessor, ResponseActionWindowBuilder, ResponseActionResolver, SettlementService, HuEvaluator, StrategyService, PlayerVisibilityService, TeachingService, GameMessagePublisher)

executeHu(GameSession, ResponseActionWindow, ResponseActionResolution)

responseActionDeclared(String, GameEventType, int, Integer, String)

private final BloodBattleScoringService bloodBattleScoringService = new BloodBattleScoringService(huEvaluator);

private final GameSessionService gameSessionService = new GameSessionService(
            roomService,
            new GameEngine(new DeckFactory()),
            new GameActionProcessor(huEvaluator),
            new ResponseActionWindowBuilder(huEvaluator),
            new ResponseActionResolver(),
            new SettlementService(bloodBattleScoringService),
            bloodBattleScoringService,
            huEvaluator,
            new StrategyService(),
            new PlayerVisibilityService(),
            new TeachingService(),
            new GameMessagePublisher(mock(SimpMessagingTemplate.class))
    );

@Test
    void shouldStartGameAndEnterPlayingAfterHumanSelectsLackSuit() {
        RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
        roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));

        GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
        assertThat(started.phase()).isEqualTo("LACK_SELECTION");
        assertThat(started.seats()).hasSize(4);
        assertThat(started.selfSeat().handTiles()).hasSize(14);

        GameStateResponse afterLack = gameSessionService.selectLackSuit(
                started.gameId(),
                "host-1",
                TileSuit.WAN.name()
        );

        assertThat(afterLack.phase()).isEqualTo("PLAYING");
        assertThat(afterLack.selfSeat().lackSuit()).isEqualTo(TileSuit.WAN.name());
    }

@Test
    void shouldDiscardAndLoopBackToHumanAfterBotsAutoPlay() {

        GameStateResponse playing = gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
        String discardTile = playing.selfSeat().handTiles().get(0);
        GameSession session = getSession(playing.gameId());
        removeMatchingTilesFromOtherSeats(session, discardTile, 0);

        GameStateResponse afterDiscard = gameSessionService.discardTile(playing.gameId(), "host-1", discardTile);

        assertThat(afterDiscard.phase()).isEqualTo("PLAYING");
        assertThat(afterDiscard.currentSeatNo()).isEqualTo(0);
        assertThat(afterDiscard.selfSeat().handTiles()).hasSize(14);
        assertThat(afterDiscard.remainingWallCount()).isEqualTo(51);
    }

@Test
    void shouldRouteNewActionTypesThroughUnifiedActionEntry() {

        gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());

        assertThatThrownBy(() -> gameSessionService.performAction(
                new GameActionRequest("host-1", "PENG", "三万", 1)
        ))
                .isInstanceOf(BusinessException.class)
                .extracting(throwable -> ((BusinessException) throwable).getCode())
                .isEqualTo("GAME_ACTION_WINDOW_NOT_FOUND");
    }

@Test
    void shouldRejectResponseActionWithoutSourceSeatNo() {

                new GameActionRequest("host-1", "PENG", "三万", null)
        ))
                .isEqualTo("GAME_ACTION_PARAM_INVALID");
    }

@Test
    void shouldRejectResponseActionOutsidePlayingPhase() {

                new GameActionRequest("host-1", "PASS", null, 1)
        ))
                .isEqualTo("GAME_PHASE_INVALID");
    }

@Test
    void shouldRejectResponseActionFromSelfSeat() {

                new GameActionRequest("host-1", "PASS", null, 0)
        ))
    }

@Test
    void shouldRejectUnknownActionTypeFromUnifiedActionEntry() {

                new GameActionRequest("host-1", "UNKNOWN_ACTION", null, null)
        ))
                .isEqualTo("GAME_ACTION_INVALID");
    }

@Test
    void shouldPauseDiscardFlowUntilHumanPassesResponseWindow() {
        roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-2", "玩家二"));
        roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-2", true));

        gameSessionService.selectLackSuit(started.gameId(), "player-2", TileSuit.TONG.name());
        GameStateResponse playing = gameSessionService.getState(started.gameId(), "host-1");
        ensureSeatHasMatchingTiles(session.getTable().getSeats().get(1), discardTile, 2);
        removeMatchingTilesFromOtherSeats(session, discardTile, 0, 1);

        assertThat(session.getPendingResponseActionWindow()).isNotNull();

        GameStateResponse afterPass = gameSessionService.performAction(
                playing.gameId(),
                new GameActionRequest("player-2", "PASS", null, 0)
        );

        assertThat(session.getPendingResponseActionWindow()).isNull();
        assertThat(afterPass.currentSeatNo()).isEqualTo(1);
        assertThat(afterPass.phase()).isEqualTo("PLAYING");
    }

@Test
    void shouldResolvePengAndTransferTurnToWinner() {

        GameStateResponse hostView = gameSessionService.getState(started.gameId(), "host-1");
        String discardTile = hostView.selfSeat().handTiles().get(0);
        GameSession session = getSession(hostView.gameId());

        gameSessionService.discardTile(hostView.gameId(), "host-1", discardTile);
        GameStateResponse afterPeng = gameSessionService.performAction(
                hostView.gameId(),
                new GameActionRequest("player-2", "PENG", discardTile, 0)
        );

        assertThat(afterPeng.currentSeatNo()).isEqualTo(1);
        assertThat(afterPeng.seats().get(0).discardTiles()).doesNotContain(discardTile);
        assertThat(afterPeng.selfSeat().handTiles()).hasSize(13);
        assertThat(afterPeng.selfSeat().melds()).containsExactly("碰:" + discardTile);
    }

@Test
    void shouldResolveGangAndApplySettlementScore() {

        ensureSeatHasMatchingTiles(session.getTable().getSeats().get(1), discardTile, 3);

        GameStateResponse afterGang = gameSessionService.performAction(
                new GameActionRequest("player-2", "GANG", discardTile, 0)
        );

        assertThat(afterGang.currentSeatNo()).isEqualTo(1);
        assertThat(afterGang.selfSeat().score()).isEqualTo(2);
        assertThat(afterGang.seats().get(0).score()).isEqualTo(-2);
        assertThat(afterGang.selfSeat().melds()).containsExactly("明杠:" + discardTile);
        assertThat(session.getEvents())
                .extracting(event -> event.eventType())
                .contains(GameEventType.GANG_DECLARED, GameEventType.SETTLEMENT_APPLIED, GameEventType.SCORE_CHANGED);
    }

@Test
    void shouldResolveHuAndContinueBloodBattleWhenMultipleActiveSeatsRemain() {
        roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-3", "玩家三"));
        roomService.joinRoom(room.roomId(), new com.xuezhanmaster.room.dto.JoinRoomRequest("player-4", "玩家四"));
        roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-3", true));
        roomService.toggleReady(room.roomId(), new ToggleReadyRequest("player-4", true));

        gameSessionService.selectLackSuit(started.gameId(), "player-3", TileSuit.TIAO.name());
        gameSessionService.selectLackSuit(started.gameId(), "player-4", TileSuit.WAN.name());

        GameSession session = getSession(started.gameId());
        prepareWinningHand(session.getTable().getSeats().get(1));
        String discardTile = "9筒";
        ensureSeatHasMatchingTiles(session.getTable().getSeats().get(0), discardTile, 1);

        gameSessionService.discardTile(started.gameId(), "host-1", discardTile);
        GameStateResponse afterHu = gameSessionService.performAction(
                new GameActionRequest("player-2", "HU", discardTile, 0)
        );

        assertThat(afterHu.phase()).isEqualTo("PLAYING");
        assertThat(afterHu.currentSeatNo()).isEqualTo(2);
        assertThat(afterHu.selfSeat().won()).isTrue();
        assertThat(afterHu.selfSeat().score()).isEqualTo(1);
        assertThat(afterHu.seats().get(0).score()).isEqualTo(-1);
                .contains(GameEventType.HU_DECLARED, GameEventType.SETTLEMENT_APPLIED, GameEventType.SCORE_CHANGED);
    }

@Test
    void shouldFinishBloodBattleWhenOnlyOneActiveSeatRemainsAfterHu() {

        session.getTable().getSeats().get(2).declareHu();
        session.getTable().getSeats().get(3).declareHu();

        );

        assertThat(afterHu.phase()).isEqualTo("FINISHED");
    }

@Test
    void shouldSkipWonSeatWhenAdvancingToNextTurn() {

        session.getTable().getSeats().get(1).declareHu();
        String discardTile = started.selfSeat().handTiles().get(0);

        GameStateResponse afterDiscard = gameSessionService.discardTile(started.gameId(), "host-1", discardTile);

        assertThat(afterDiscard.currentSeatNo()).isEqualTo(2);
    }

@Test
    void shouldResolveSelfDrawHuAndContinueToNextActiveSeat() {

        prepareSelfDrawWinningHand(session.getTable().getSeats().get(0));
                new GameActionRequest("host-1", "HU", null, null)
        );

        assertThat(afterHu.selfSeat().score()).isEqualTo(3);
        assertThat(afterHu.currentSeatNo()).isEqualTo(1);
        assertThat(afterHu.seats()).extracting(seat -> seat.score()).contains(3, -1, -1, -1);
    }

@Test
    void shouldResolveSelfConcealedGangAndKeepTurnAfterReplacementDraw() {

        prepareConcealedGangHand(session.getTable().getSeats().get(0), "3万");
        int beforeWallCount = session.getTable().getWallTiles().size();

                new GameActionRequest("host-1", "GANG", "3万", null)
        );

        assertThat(afterGang.phase()).isEqualTo("PLAYING");
        assertThat(afterGang.currentSeatNo()).isEqualTo(0);
        assertThat(afterGang.remainingWallCount()).isEqualTo(beforeWallCount - 1);
        assertThat(afterGang.selfSeat().melds()).containsExactly("暗杠:3万");
        assertThat(afterGang.selfSeat().score()).isEqualTo(6);
        assertThat(afterGang.seats()).extracting(seat -> seat.score()).contains(6, -2, -2, -2);
        assertThat(afterGang.selfSeat().handTiles()).hasSize(11);
    }

@Test
    void shouldScoreGangShangHuaAfterReplacementDrawHu() {

        prepareGangShangHuaHand(session.getTable().getSeats().get(0), "3万");
        setNextWallTile(session, "9筒");

        );
        );

        assertThat(afterHu.selfSeat().score()).isEqualTo(12);
        assertThat(afterHu.seats()).extracting(seat -> seat.score()).contains(12, -4, -4, -4);
        assertThat(latestSettlementFanCodes(session)).contains("GANG_SHANG_HUA");
    }

@Test
    void shouldRejectSelfConcealedGangWithoutFourMatchingTiles() {

        GameStateResponse afterLack = gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
        String invalidGangTile = findTileWithLessThanFourCopies(session.getTable().getSeats().get(0));

                afterLack.gameId(),
                new GameActionRequest("host-1", "GANG", invalidGangTile, null)
        ))
                .isEqualTo("GAME_ACTION_WINDOW_INVALID");
    }

@Test
    void shouldResolveSupplementalGangAndKeepMeldState() {

        GameSeat seat = session.getTable().getSeats().get(0);
        prepareSupplementalGangHand(seat, "3万");

        );

        assertThat(afterGang.selfSeat().melds()).containsExactly("补杠:3万");
        assertThat(afterGang.selfSeat().score()).isEqualTo(3);
        assertThat(afterGang.seats()).extracting(seatView -> seatView.score()).contains(3, -1, -1, -1);
    }

@Test
    void shouldResolveRobbingGangHuBeforeSupplementalGangCompletes() {

        prepareSupplementalGangHand(session.getTable().getSeats().get(0), "9筒");

        GameStateResponse afterGangDeclare = gameSessionService.performAction(
                new GameActionRequest("host-1", "GANG", "9筒", null)
        );

        assertThat(afterGangDeclare.currentSeatNo()).isEqualTo(0);

                new GameActionRequest("player-2", "HU", "9筒", 0)
        );

        assertThat(afterHu.selfSeat().score()).isEqualTo(2);
        assertThat(afterHu.seats().get(0).score()).isEqualTo(-2);
        assertThat(afterHu.seats().get(0).melds()).containsExactly("碰:9筒");
    }

@Test
    void shouldScoreGangShangPaoOnDiscardAfterReplacementDraw() {

        prepareGangShangPaoHand(session.getTable().getSeats().get(0), "3万");
        removeMatchingTilesFromOtherSeats(session, "9筒", 0, 1);
        setNextWallTile(session, "8条");

        );
        GameStateResponse afterHu = gameSessionService.discardTile(started.gameId(), "host-1", "9筒");
        afterHu = gameSessionService.performAction(
        );

        assertThat(afterHu.selfSeat().score()).isEqualTo(0);
        assertThat(afterHu.seats().get(0).score()).isEqualTo(4);
        assertThat(latestSettlementFanCodes(session)).contains("GANG_SHANG_PAO");
    }

@Test
    void shouldScoreHaiDiLaoYueOnLastTileSelfDraw() {

        session.getTable().getWallTiles().clear();

        );

        assertThat(afterHu.selfSeat().score()).isEqualTo(6);
        assertThat(afterHu.seats()).extracting(seat -> seat.score()).contains(6, -2, -2, -2);
        assertThat(latestSettlementFanCodes(session)).contains("HAI_DI_LAO_YUE");
    }

@Test
    void shouldScoreHaiDiPaoOnLastTileDiscardHu() {

        ensureSeatHasMatchingTiles(session.getTable().getSeats().get(0), "9筒", 1);

        gameSessionService.discardTile(started.gameId(), "host-1", "9筒");
        );

        assertThat(latestSettlementFanCodes(session)).contains("HAI_DI_PAO");
    }

@Test
    void shouldRefundGangScoresForNotReadySeatWhenWallExhausted() {

        prepareTaxRefundGangHand(session.getTable().getSeats().get(0), "3万");
        setNextWallTile(session, "9万");

        );
        removeMatchingTilesFromOtherSeats(session, "9万", 0);

        GameStateResponse afterDiscard = gameSessionService.discardTile(started.gameId(), "host-1", "9万");

        assertThat(afterDiscard.phase()).isEqualTo("FINISHED");
        assertThat(settlementResultsOfType(session, SettlementType.TUI_SHUI)).hasSize(3);
        assertThat(afterDiscard.seats()).extracting(seat -> seat.score()).contains(0, 0, 0, 0);
    }

@Test
    void shouldApplyChaJiaoFromNotReadySeatToReadySeatWhenWallExhausted() {

        prepareNotReadyHand(session.getTable().getSeats().get(0));
        ensureSeatHasMatchingTiles(session.getTable().getSeats().get(0), "1万", 1);
        removeMatchingTilesFromOtherSeats(session, "1万", 0);

        GameStateResponse afterDiscard = gameSessionService.discardTile(started.gameId(), "host-1", "1万");

        assertThat(afterDiscard.seats().get(0).score()).isEqualTo(-1);
        assertThat(afterDiscard.seats().get(1).score()).isEqualTo(1);
        assertThat(settlementResultsOfType(session, SettlementType.CHA_JIAO)).hasSize(1);
        assertThat(latestSettlementFanCodes(session)).isEmpty();
    }

@Test
    void shouldAllowMultipleHuWinnersOnSingleDiscard() {

        prepareWinningHand(session.getTable().getSeats().get(2));
        removeMatchingTilesFromOtherSeats(session, "9筒", 0, 1, 2);

        );
                new GameActionRequest("player-3", "HU", "9筒", 0)
        );

        assertThat(afterHu.currentSeatNo()).isEqualTo(3);
        assertThat(afterHu.seats().get(1).won()).isTrue();
        assertThat(afterHu.seats().get(2).won()).isTrue();
        assertThat(afterHu.seats().get(1).score()).isEqualTo(1);
        assertThat(settlementResultsOfType(session, SettlementType.DIAN_PAO_HU)).hasSize(2);
    }

@Test
    void shouldBlockResponseHuBeforeSeatDrawsAgainAfterPassingWinningWindow() {

        GameSeat winnerSeat = session.getTable().getSeats().get(1);
        prepareWinningHand(winnerSeat);
        session.getTable().setCurrentSeatNo(2);
        ensureSeatHasMatchingTiles(session.getTable().getSeats().get(2), "9筒", 1);
        ensureSeatHasMatchingTiles(session.getTable().getSeats().get(3), "9筒", 1);
        removeMatchingTilesFromOtherSeats(session, "9筒", 1, 2, 3);

        gameSessionService.performAction(
                new GameActionRequest("player-3", "DISCARD", "9筒", null)
        );
                new GameActionRequest("player-2", "PASS", null, 2)
        );

        assertThat(winnerSeat.isPassedHuBlocked()).isTrue();
        assertThat(afterPass.currentSeatNo()).isEqualTo(3);

        GameStateResponse afterSecondDiscard = gameSessionService.performAction(
                new GameActionRequest("player-4", "DISCARD", "9筒", null)
        );

        assertThat(afterSecondDiscard.currentSeatNo()).isEqualTo(0);
    }

@Test
    void shouldClearPassedHuRestrictionAfterSeatDrawsAgain() {

        ensureSeatHasMatchingTiles(session.getTable().getSeats().get(2), "9筒", 2);
        removeMatchingTilesFromOtherSeats(session, "9筒", 1, 2);

        );
        );

        String seatThreeSafeDiscard = session.getTable().getSeats().get(3).getHandTiles().get(0).getDisplayName();
        removeMatchingTilesFromOtherSeats(session, seatThreeSafeDiscard, 3);
                new GameActionRequest("player-4", "DISCARD", seatThreeSafeDiscard, null)
        );

        String seatZeroSafeDiscard = session.getTable().getSeats().get(0).getHandTiles().get(0).getDisplayName();
        removeMatchingTilesFromOtherSeats(session, seatZeroSafeDiscard, 0);
        setNextWallTile(session, "8万");
        GameStateResponse afterSeatZeroDiscard = gameSessionService.performAction(
                new GameActionRequest("host-1", "DISCARD", seatZeroSafeDiscard, null)
        );

        assertThat(afterSeatZeroDiscard.currentSeatNo()).isEqualTo(1);
        assertThat(winnerSeat.isPassedHuBlocked()).isFalse();
        removeMatchingTilesFromOtherSeats(session, "8万", 1);

                new GameActionRequest("player-2", "DISCARD", "8万", null)
        );
        );
                new GameActionRequest("player-2", "HU", "9筒", 2)
        );

    }

@Test
    void shouldRejectSelfDrawHuWhenHandIsNotWinning() {

        ))
    }

@SuppressWarnings("unchecked")
    private GameSession getSession(String gameId) {
        try {
            Field field = GameSessionService.class.getDeclaredField("sessions");
            field.setAccessible(true);
            Map<String, GameSession> sessions = (Map<String, GameSession>) field.get(gameSessionService);
            return sessions.get(gameId);
        } catch (ReflectiveOperationException exception) {
            throw new IllegalStateException("无法读取测试用对局会话", exception);
        }
    }

private void ensureSeatHasMatchingTiles(GameSeat seat, String tileDisplayName, int count) {
        Tile tile = parseTile(tileDisplayName);
        for (int i = 0; i < count; i++) {
            seat.receiveTile(tile);
        }
    }

private void removeMatchingTilesFromOtherSeats(GameSession session, String tileDisplayName, int... keepSeatNos) {
        for (GameSeat seat : session.getTable().getSeats()) {
            if (shouldKeepSeat(seat.getSeatNo(), keepSeatNos)) {
                continue;
            }
            seat.getHandTiles().removeIf(tile -> tile.getDisplayName().equals(tileDisplayName));
        }
    }

private boolean shouldKeepSeat(int seatNo, int... keepSeatNos) {
        for (int keepSeatNo : keepSeatNos) {
            if (seatNo == keepSeatNo) {
                return true;
            }
        }
        return false;
    }

private Tile parseTile(String tileDisplayName) {
        String rankPart = tileDisplayName.substring(0, tileDisplayName.length() - 1);
        String suitLabel = tileDisplayName.substring(tileDisplayName.length() - 1);
        int rank = Integer.parseInt(rankPart);
        return switch (suitLabel) {
            case "万" -> new Tile(TileSuit.WAN, rank);
            case "筒" -> new Tile(TileSuit.TONG, rank);
            case "条" -> new Tile(TileSuit.TIAO, rank);
            default -> throw new IllegalArgumentException("未知花色");
        };
    }

private String findTileWithLessThanFourCopies(GameSeat seat) {
        for (Tile tile : seat.getHandTiles()) {
            int matchCount = 0;
            for (Tile handTile : seat.getHandTiles()) {
                if (handTile.equals(tile)) {
                    matchCount++;
                }
            }
            if (matchCount < 4) {
                return tile.getDisplayName();
            }
        }
        throw new IllegalStateException("测试手牌意外全部为四张同牌");
    }

private void prepareWinningHand(GameSeat seat) {
        seat.getHandTiles().clear();
        seat.receiveTile(new Tile(TileSuit.WAN, 1));
        seat.receiveTile(new Tile(TileSuit.WAN, 2));
        seat.receiveTile(new Tile(TileSuit.WAN, 3));
        seat.receiveTile(new Tile(TileSuit.WAN, 4));
        seat.receiveTile(new Tile(TileSuit.TONG, 2));
        seat.receiveTile(new Tile(TileSuit.TONG, 3));
        seat.receiveTile(new Tile(TileSuit.TONG, 4));
        seat.receiveTile(new Tile(TileSuit.TIAO, 5));
        seat.receiveTile(new Tile(TileSuit.TIAO, 6));
        seat.receiveTile(new Tile(TileSuit.TIAO, 7));
        seat.receiveTile(new Tile(TileSuit.TONG, 9));
    }

private void prepareSelfDrawWinningHand(GameSeat seat) {
        seat.receiveTile(new Tile(TileSuit.TIAO, 9));
    }

private void prepareConcealedGangHand(GameSeat seat, String gangTileDisplayName) {
        Tile gangTile = parseTile(gangTileDisplayName);
        for (int i = 0; i < 4; i++) {
            seat.receiveTile(gangTile);
        }
        seat.receiveTile(new Tile(TileSuit.TONG, 5));
        seat.receiveTile(new Tile(TileSuit.TONG, 6));
        seat.receiveTile(new Tile(TileSuit.TIAO, 8));
        seat.receiveTile(new Tile(TileSuit.WAN, 9));
    }

private void prepareGangShangHuaHand(GameSeat seat, String gangTileDisplayName) {
        }
    }

private void prepareGangShangPaoHand(GameSeat seat, String gangTileDisplayName) {
        }
    }

private void prepareTaxRefundGangHand(GameSeat seat, String gangTileDisplayName) {
        }
        seat.receiveTile(new Tile(TileSuit.WAN, 7));
        seat.receiveTile(new Tile(TileSuit.TONG, 8));
        seat.receiveTile(new Tile(TileSuit.TIAO, 1));
        seat.receiveTile(new Tile(TileSuit.TIAO, 4));
        seat.receiveTile(new Tile(TileSuit.WAN, 8));
    }

private void prepareNotReadyHand(GameSeat seat) {
        seat.receiveTile(new Tile(TileSuit.WAN, 5));
        seat.receiveTile(new Tile(TileSuit.TONG, 1));
        seat.receiveTile(new Tile(TileSuit.TONG, 7));
        seat.receiveTile(new Tile(TileSuit.TIAO, 2));
    }

private void prepareSupplementalGangHand(GameSeat seat, String gangTileDisplayName) {
        seat.getMeldGroups().clear();
        seat.addPengMeld(gangTile, 1);
        seat.receiveTile(gangTile);
    }

private void setNextWallTile(GameSession session, String tileDisplayName) {
        session.getTable().getWallTiles().set(0, parseTile(tileDisplayName));
    }

private java.util.List<SettlementType> settlementResultsOfType(GameSession session, SettlementType settlementType) {
        return session.getSettlementHistory().stream()
                .map(settlementResult -> settlementResult.settlementType())
                .filter(type -> type == settlementType)
                .toList();
    }

    private java.util.List<String> latestSettlementFanCodes(GameSession session) {
        return session.getEvents().stream()
                .filter(event -> event.eventType() == GameEventType.SETTLEMENT_APPLIED)
                .reduce((first, second) -> second)
                .map(event -> (Map<String, Object>) event.payload().get("settlementDetail"))
                .map(detail -> (java.util.List<Map<String, Object>>) detail.get("fans"))
                .map(fans -> fans.stream()
                        .map(item -> String.valueOf(item.get("code")))
                        .toList())
                .orElseGet(java.util.List::of);
    }

class GameSessionServiceTest {

    private final RoomService roomService = new RoomService();
    private final HuEvaluator huEvaluator = new HuEvaluator();
    private final BloodBattleScoringService bloodBattleScoringService = new BloodBattleScoringService(huEvaluator);
    private final GameSessionService gameSessionService = new GameSessionService(
    );

    @Test

                "host-1",
        );

    }

    @Test

    }

    @Test

        ))
    }

    @Test

        ))
    }

    @Test

        ))
    }

    @Test

        ))
    }

    @Test

        ))
    }

    @Test

        );

    }

    @Test

        );

    }

    @Test

        );

    }

    @Test

        );

    }

    @Test

        );

    }

    @Test

    }

    @Test

        );

    }

    @Test

        );

    }

    @Test

        );
        );

    }

    @Test

        ))
    }

    @Test

        );

    }

    @Test

        );

        );

    }

    @Test

        );
        );

    }

    @Test

        );

    }

    @Test

        );

    }

    @Test

        );

    }

    @Test

    }

    @Test

        );
        );

    }

    @Test

        );
        );

        );

    }

    @Test

        );
        );

        );

        );

        );
        );
        );

    }

    @Test

        ))
    }

    @SuppressWarnings("unchecked")
        try {
        }
    }

    private void ensureSeatHasMatchingTiles(GameSeat seat, String tileDisplayName, int count) {
        }
    }

    private void removeMatchingTilesFromOtherSeats(GameSession session, String tileDisplayName, int... keepSeatNos) {
                continue;
            }
        }
    }

    private boolean shouldKeepSeat(int seatNo, int... keepSeatNos) {
            }
        }
    }

    private Tile parseTile(String tileDisplayName) {
        };
    }

    private String findTileWithLessThanFourCopies(GameSeat seat) {
                }
            }
            }
        }
    }

    private void prepareWinningHand(GameSeat seat) {
    }

    private void prepareSelfDrawWinningHand(GameSeat seat) {
    }

    private void prepareConcealedGangHand(GameSeat seat, String gangTileDisplayName) {
        }
    }

    private void prepareGangShangHuaHand(GameSeat seat, String gangTileDisplayName) {
        }
    }

    private void prepareGangShangPaoHand(GameSeat seat, String gangTileDisplayName) {
        }
    }

    private void prepareTaxRefundGangHand(GameSeat seat, String gangTileDisplayName) {
        }
    }

    private void prepareNotReadyHand(GameSeat seat) {
    }

    private void prepareSupplementalGangHand(GameSeat seat, String gangTileDisplayName) {
    }

    private void setNextWallTile(GameSession session, String tileDisplayName) {
    }

    private java.util.List<SettlementType> settlementResultsOfType(GameSession session, SettlementType settlementType) {
    }

    }
}

private final ResponseActionWindowBuilder responseActionWindowBuilder;

private final Map<String, GameSession> sessions = new ConcurrentHashMap<>();

public GameSessionService(
            RoomService roomService,
            GameEngine gameEngine,
            GameActionProcessor gameActionProcessor,
            ResponseActionWindowBuilder responseActionWindowBuilder,
            ResponseActionResolver responseActionResolver,
            SettlementService settlementService,
            BloodBattleScoringService scoringService,
            HuEvaluator huEvaluator,
            StrategyService strategyService,
            PlayerVisibilityService playerVisibilityService,
            TeachingService teachingService,
            GameMessagePublisher gameMessagePublisher
    ) {
        this.roomService = roomService;
        this.gameEngine = gameEngine;
        this.gameActionProcessor = gameActionProcessor;
        this.responseActionWindowBuilder = responseActionWindowBuilder;
        this.responseActionResolver = responseActionResolver;
        this.settlementService = settlementService;
        this.scoringService = scoringService;
        this.huEvaluator = huEvaluator;
        this.strategyService = strategyService;
        this.playerVisibilityService = playerVisibilityService;
        this.teachingService = teachingService;
        this.gameMessagePublisher = gameMessagePublisher;
    }

public GameStateResponse startGame(String roomId, String operatorUserId) {
        Room room = roomService.prepareRoomForGame(roomId, operatorUserId);
        GameTable table = gameEngine.createTableFromRoom(room);
        GameSession session = new GameSession(room.getRoomId(), table);
        sessions.put(session.getGameId(), session);

        appendAndPublish(session, GameEvent.gameStarted(session.getGameId(), roomId));
        notifyActionIfHumanTurn(session);
        return toStateResponse(session, operatorUserId);
    }

public GameStateResponse getState(String gameId, String userId) {
        GameSession session = getRequiredSession(gameId);
        return toStateResponse(session, userId);
    }

public GameStateResponse performAction(String gameId, GameActionRequest request) {
        ActionType actionType = parseActionType(request.actionType());
        List<GameEvent> events = gameActionProcessor.process(session, request);
        appendAndPublish(session, events);
        handlePostActionEffects(session, actionType, request, events);
        return toStateResponse(session, request.userId());
    }

public GameStateResponse selectLackSuit(String gameId, String userId, String lackSuitValue) {
        return performAction(gameId, new GameActionRequest(userId, "SELECT_LACK_SUIT", lackSuitValue, null));
    }

public GameStateResponse discardTile(String gameId, String userId, String tileDisplayName) {
        return performAction(gameId, new GameActionRequest(userId, "DISCARD", tileDisplayName, null));
    }

private GameSession getRequiredSession(String gameId) {
        GameSession session = sessions.get(gameId);
        if (session == null) {
            throw new BusinessException("GAME_NOT_FOUND", "对局不存在");
        }
        return session;
    }

private GameSeat findSeatByUserId(GameTable table, String userId) {
        return table.getSeats().stream()
                .filter(seat -> seat.getPlayerId().equals(userId))
                .findFirst()
                .orElseThrow(() -> new BusinessException("GAME_SEAT_NOT_FOUND", "当前玩家不在对局中"));
    }

private GameStateResponse toStateResponse(GameSession session, String userId) {
        GameTable table = session.getTable();
        GameSeat self = findSeatByUserId(table, userId);

        return new GameStateResponse(
                session.getGameId(),
                table.getPhase().name(),
                table.getDealerSeatNo(),
                table.getCurrentSeatNo(),
                table.getWallTiles().size(),
                new SelfSeatView(
                        self.getSeatNo(),
                        self.getPlayerId(),
                        self.getNickname(),
                        self.isWon(),
                        self.getLackSuit() == null ? null : self.getLackSuit().name(),
                        self.getScore(),
                        self.getHandTiles().stream().map(Tile::getDisplayName).toList(),
                        self.getDiscardTiles().stream().map(Tile::getDisplayName).toList(),
                        toMeldLabels(self)
                ),
                buildPublicSeats(table)
        );
    }

private void moveToNextSeat(GameSession session) {
        if (finishTableIfNeeded(session)) {
            return;
        }

        Optional<GameSeat> nextSeatOptional = findNextActiveSeat(table, table.getCurrentSeatNo());
        if (nextSeatOptional.isEmpty()) {
            table.setPhase(GamePhase.FINISHED);
            appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
            return;
        }

        GameSeat nextSeat = nextSeatOptional.get();
        Tile drawnTile = table.getWallTiles().remove(0);
        nextSeat.receiveTile(drawnTile);
        // 玩家真正完成下一次摸牌后，才解除此前由“过水不胡”带来的响应胡限制。
        nextSeat.clearPassedHuBlocked();
        table.setCurrentSeatNo(nextSeat.getSeatNo());
        gameMessagePublisher.publishPublicEvent(GameEvent.tileDrawn(session.getGameId(), nextSeat.getSeatNo(), table.getWallTiles().size()));
        gameMessagePublisher.publishPublicEvent(GameEvent.turnSwitched(session.getGameId(), nextSeat.getSeatNo()));
    }

private void autoPlayBots(GameSession session) {
        while (table.getPhase() == GamePhase.PLAYING) {
            GameSeat currentSeat = table.getSeats().get(table.getCurrentSeatNo());
            if (currentSeat.isWon()) {
                moveToNextSeat(session);
                continue;
            }
            if (!currentSeat.isAi()) {
                return;
            }

            if (canSelfDrawHu(currentSeat)) {
                List<GameEvent> huEvents = gameActionProcessor.process(
                        session,
                        new GameActionRequest(
                                currentSeat.getPlayerId(),
                                "HU",
                                null,
                                null
                        )
                );
                appendAndPublish(session, huEvents);
                handlePostActionEffects(
                        session,
                        ActionType.HU,
                        new GameActionRequest(currentSeat.getPlayerId(), "HU", null, null),
                        huEvents
                );
                if (table.getPhase() == GamePhase.FINISHED) {
                    return;
                }
                continue;
            }

            Optional<String> selfGangCandidate = findSelfGangCandidate(currentSeat);
            if (selfGangCandidate.isPresent()) {
                List<GameEvent> gangEvents = gameActionProcessor.process(
                        session,
                                "GANG",
                                selfGangCandidate.get(),
                                null
                        )
                );
                appendAndPublish(session, gangEvents);
                        session,
                        ActionType.GANG,
                        new GameActionRequest(currentSeat.getPlayerId(), "GANG", selfGangCandidate.get(), null),
                        gangEvents
                );
                    return;
                }
                continue;
            }

            PlayerVisibleGameState visibleState = playerVisibilityService.buildVisibleState(table, currentSeat);
            StrategyDecision decision = strategyService.evaluateDiscard(visibleState);
            List<GameEvent> events = gameActionProcessor.process(
                    session,
                    new GameActionRequest(
                            currentSeat.getPlayerId(),
                            "DISCARD",
                            decision.recommendedAction().getTile().getDisplayName(),
                            null
                    )
            );
            appendAndPublish(session, events);
            moveToNextSeat(session);
            if (table.getPhase() == GamePhase.FINISHED) {
                return;
            }
        }
    }

private void appendAndPublish(GameSession session, GameEvent event) {
        session.getEvents().add(event);
        gameMessagePublisher.publishPublicEvent(event);
    }

private void appendAndPublish(GameSession session, List<GameEvent> events) {
        for (GameEvent event : events) {
            appendAndPublish(session, event);
        }
    }

private void notifyActionIfHumanTurn(GameSession session) {
        if (session.getTable().getPhase() != GamePhase.PLAYING) {
            return;
        }

        GameSeat currentSeat = session.getTable().getSeats().get(session.getTable().getCurrentSeatNo());
        if (currentSeat.isAi() || currentSeat.isWon()) {
            return;
        }

        gameMessagePublisher.publishPrivateTurnActionCandidates(
                currentSeat.getPlayerId(),
                buildTurnActionCandidates(currentSeat),
                currentSeat.getSeatNo()
        );

        PlayerVisibleGameState visibleState = playerVisibilityService.buildVisibleState(session.getTable(), currentSeat);
        StrategyDecision decision = strategyService.evaluateDiscard(visibleState);
        TeachingAdviceResponse advice = teachingService.buildAdvice(decision, TeachingMode.BRIEF);
        gameMessagePublisher.publishPrivateTeaching(
                advice.teachingMode(),
                advice.recommendedAction(),
                advice.explanation(),
                advice.candidates()
        );
    }

private void handlePostActionEffects(
            GameSession session,
            ActionType actionType,
            GameActionRequest request,
            List<GameEvent> events
    ) {
        switch (actionType) {
            case SELECT_LACK_SUIT -> {
                if (session.getTable().getPhase() == GamePhase.PLAYING) {
                    notifyActionIfHumanTurn(session);
                }
            }
            case DISCARD -> handleDiscardPostAction(session, request, events);
            case PASS -> handlePassPostAction(session, request);
            case PENG -> handleResponseDeclarationPostAction(session, actionType, request);
            case GANG -> handleGangPostAction(session, request);
            case HU -> handleHuPostAction(session, request);
            default -> {
                // 其余动作在后续 Sprint 中补充对应副作用
            }
        }
    }

private void notifyResponseCandidates(GameSession session, ResponseActionWindow responseActionWindow) {
        for (ResponseActionSeatCandidate seatCandidate : responseActionWindow.seatCandidates()) {
            GameSeat seat = session.getTable().getSeats().get(seatCandidate.seatNo());
            if (seat.isAi()) {
                continue;
            }
            gameMessagePublisher.publishPrivateResponseActionRequired(
                    session.getGameId(),
                    seatCandidate.userId(),
                    session.getTable().getCurrentSeatNo(),
                    responseActionWindow,
                    seatCandidate
            );
        }
    }

private void handleDiscardPostAction(GameSession session, GameActionRequest request, List<GameEvent> events) {
        GameSeat sourceSeat = findSeatByUserId(session.getTable(), request.userId());
        Tile discardedTile = resolveDiscardedTile(events, request.tile());
        Optional<ResponseActionWindow> window = responseActionWindowBuilder.buildForDiscard(
                session,
                sourceSeat.getSeatNo(),
                discardedTile
        );

        if (window.isPresent()) {
            openResponseWindow(session, window.get());
            autoPassAiCandidates(session);
            if (session.getPendingResponseActionWindow() == null) {
                continueAfterDiscardWithoutResponse(session);
            }
            return;
        }

        continueAfterDiscardWithoutResponse(session);
    }

private void handlePassPostAction(GameSession session, GameActionRequest request) {
        ResponseActionWindow responseActionWindow = requirePendingResponseWindow(session);
        GameSeat actorSeat = findSeatByUserId(session.getTable(), request.userId());
        markPassedHuBlockedIfNeeded(responseActionWindow, actorSeat);
        session.getResponseActionSelections().put(actorSeat.getSeatNo(), ActionType.PASS);
        tryResolveResponseWindow(session, responseActionWindow);
    }

private void handleResponseDeclarationPostAction(GameSession session, ActionType actionType, GameActionRequest request) {
        session.getResponseActionSelections().put(actorSeat.getSeatNo(), actionType);
    }

private void handleGangPostAction(GameSession session, GameActionRequest request) {
        if (request.sourceSeatNo() == null) {
            handleSelfGangPostAction(session, request.userId(), request.tile());
            return;
        }
        handleResponseDeclarationPostAction(session, ActionType.GANG, request);
    }

private void handleHuPostAction(GameSession session, GameActionRequest request) {
            executeSelfDrawHu(session, request.userId());
            return;
        }
        handleResponseDeclarationPostAction(session, ActionType.HU, request);
    }

private void openResponseWindow(GameSession session, ResponseActionWindow responseActionWindow) {
        session.setPendingResponseActionWindow(responseActionWindow);
        session.clearResponseActionSelections();
        appendAndPublish(session, GameEvent.responseWindowOpened(
                responseActionWindow.sourceSeatNo(),
                responseActionWindow.triggerTile()
        ));
        notifyResponseCandidates(session, responseActionWindow);
    }

private void autoPassAiCandidates(GameSession session) {
        ResponseActionWindow responseActionWindow = session.getPendingResponseActionWindow();
        if (responseActionWindow == null) {
            return;
        }
                markPassedHuBlockedIfNeeded(responseActionWindow, seat);
                session.getResponseActionSelections().put(seatCandidate.seatNo(), ActionType.PASS);
            }
        }
        if (allResponseCandidatesResolved(session, responseActionWindow)) {
            closeResponseWindowAsPass(session, responseActionWindow);
        }
    }

private boolean allResponseCandidatesResolved(GameSession session, ResponseActionWindow responseActionWindow) {
        return responseActionWindow.seatCandidates().stream()
                .allMatch(candidate -> session.getResponseActionSelections().containsKey(candidate.seatNo()));
    }

private void closeResponseWindowAsPass(GameSession session, ResponseActionWindow responseActionWindow) {
        appendAndPublish(session, GameEvent.responseWindowClosed(
                null,
                ActionType.PASS.name()
        ));
        session.setPendingResponseActionWindow(null);
    }

private void tryResolveResponseWindow(GameSession session, ResponseActionWindow responseActionWindow) {
        if (!allResponseCandidatesResolved(session, responseActionWindow)) {
            return;
        }

        Optional<ResponseActionResolutionBatch> resolutionBatch = responseActionResolver.resolve(
                responseActionWindow,
                session.getResponseActionSelections(),
                session.getTable().getSeats().size()
        );

        if (resolutionBatch.isPresent()) {
            executeResolvedResponseAction(session, responseActionWindow, resolutionBatch.get());
            return;
        }

        closeResponseWindowAsPass(session, responseActionWindow);
        if (isSupplementalGangWindow(responseActionWindow)) {
            executeSupplementalGang(session, responseActionWindow.sourceSeatNo(), responseActionWindow.triggerTile());
            return;
        }
    }

private ResponseActionWindow requirePendingResponseWindow(GameSession session) {
            throw new BusinessException("GAME_ACTION_WINDOW_NOT_FOUND", "当前没有待处理的响应窗口");
        }
        return responseActionWindow;
    }

private Tile resolveDiscardedTile(List<GameEvent> events, String fallbackTileDisplayName) {
        return events.stream()
                .filter(event -> event.eventType() == GameEventType.TILE_DISCARDED)
                .map(event -> event.payload().get("tile"))
                .filter(String.class::isInstance)
                .map(String.class::cast)
                .map(this::parseTile)
                .orElseGet(() -> parseTile(fallbackTileDisplayName));
    }

        if (tileDisplayName == null || tileDisplayName.isBlank()) {
            throw new BusinessException("GAME_TILE_INVALID", "无法解析当前动作对应的牌");
        }

        int rank;
        try {
            rank = Integer.parseInt(rankPart);
        } catch (NumberFormatException exception) {
            throw new BusinessException("GAME_TILE_INVALID", "牌面点数不合法");
        }

        Tile tile = switch (suitLabel) {
            default -> throw new BusinessException("GAME_TILE_INVALID", "牌面花色不合法");
        };
        return tile;
    }

private void continueAfterDiscardWithoutResponse(GameSession session) {
        session.clearPostGangContext();
        moveToNextSeat(session);
        autoPlayBots(session);
    }

private void executeResolvedResponseAction(
            ResponseActionWindow responseActionWindow,
            ResponseActionResolutionBatch resolutionBatch
    ) {
        closeResponseWindowWithResolution(session, responseActionWindow, resolutionBatch.actionType());
        switch (resolutionBatch.actionType()) {
            case PENG -> executePeng(session, resolutionBatch.resolutions().get(0));
            case GANG -> executeGang(session, resolutionBatch.resolutions().get(0));
            case HU -> executeHu(session, responseActionWindow, resolutionBatch.resolutions());
            default -> throw new BusinessException("GAME_ACTION_INVALID", "未知的响应裁决结果");
        }
    }

private void closeResponseWindowWithResolution(GameSession session, ResponseActionWindow responseActionWindow, ActionType actionType) {
                null,
                actionType.name()
        ));
    }

private void executePeng(GameSession session, ResponseActionResolution resolution) {
        Tile claimedTile = parseTile(resolution.triggerTile());
        GameSeat winnerSeat = table.getSeats().get(resolution.winnerSeatNo());
        GameSeat sourceSeat = table.getSeats().get(resolution.sourceSeatNo());

        winnerSeat.removeMatchingHandTiles(claimedTile, 2);
        sourceSeat.removeMatchingDiscardTile(claimedTile);
        winnerSeat.addPengMeld(claimedTile, sourceSeat.getSeatNo());
        table.setCurrentSeatNo(winnerSeat.getSeatNo());

        appendAndPublish(session, GameEvent.responseActionDeclared(
                GameEventType.PENG_DECLARED,
                winnerSeat.getSeatNo(),
                claimedTile.getDisplayName()
        ));
        appendAndPublish(session, GameEvent.turnSwitched(session.getGameId(), winnerSeat.getSeatNo()));
        continueFromResolvedActionTurn(session, winnerSeat);
    }

private void executeGang(GameSession session, ResponseActionResolution resolution) {

        winnerSeat.removeMatchingHandTiles(claimedTile, 3);
        winnerSeat.addMingGangMeld(claimedTile, sourceSeat.getSeatNo());

                GameEventType.GANG_DECLARED,
        ));
        appendSettlementEvents(session, settlementService.settleExposedGang(
                table,
        ));

            return;
        }

        winnerSeat.receiveTile(drawnTile);
        winnerSeat.clearPassedHuBlocked();
        markPostGangContext(session, winnerSeat.getSeatNo(), claimedTile.getDisplayName());
        appendAndPublish(session, GameEvent.tileDrawn(session.getGameId(), winnerSeat.getSeatNo(), table.getWallTiles().size()));
    }

private void continueFromResolvedActionTurn(GameSession session, GameSeat currentSeat) {
        if (currentSeat.isAi()) {
            autoPlayBots(session);
            notifyActionIfHumanTurn(session);
            return;
        }
    }

private void executeHu(
            List<ResponseActionResolution> resolutions
    ) {
        ResponseActionResolution primaryResolution = resolutions.get(0);
        Tile claimedTile = parseTile(primaryResolution.triggerTile());
        GameSeat sourceSeat = table.getSeats().get(primaryResolution.sourceSeatNo());

            sourceSeat.removeMatchingHandTiles(claimedTile, 1);
        } else {
            sourceSeat.removeMatchingDiscardTile(claimedTile);
        }
        // 一炮多响时多个赢家共用同一张弃牌或补杠牌，牌源只移除一次，赢家逐个补牌并独立结算。
        for (ResponseActionResolution resolution : resolutions) {
            GameSeat winnerSeat = table.getSeats().get(resolution.winnerSeatNo());
            winnerSeat.receiveTile(claimedTile);
            winnerSeat.declareHu();

            appendAndPublish(session, GameEvent.responseActionDeclared(
                    GameEventType.HU_DECLARED,
                    winnerSeat.getSeatNo(),
                    sourceSeat.getSeatNo(),
                    claimedTile.getDisplayName()
            ));
            // 牌墙已空时，这次点炮对应“最后一张牌后的弃牌胡”，按海底炮加番。
            boolean gangShangPao = !isSupplementalGangWindow(responseActionWindow)
                    && isPostGangDiscard(session, sourceSeat.getSeatNo());
            boolean haiDiPao = !isSupplementalGangWindow(responseActionWindow)
                    && table.getWallTiles().isEmpty();
            if (isSupplementalGangWindow(responseActionWindow)) {
                appendSettlementEvents(session, settlementService.settleRobbingGangHu(
                        table,
                        winnerSeat.getSeatNo(),
                        sourceSeat.getSeatNo(),
                        claimedTile.getDisplayName()
                ));
                continue;
            }
            appendSettlementEvents(session, settlementService.settleDiscardHu(
                    table,
                    claimedTile.getDisplayName(),
                    gangShangPao,
                    haiDiPao
            ));
        }

            return;
        }

    }

private void executeSelfDrawHu(GameSession session, String userId) {
        GameSeat winnerSeat = findSeatByUserId(table, userId);
        // 自摸胡时牌墙已空，说明当前这张就是最后一张牌，按海底捞月加番。
        boolean gangShangHua = isPostGangDraw(session, winnerSeat.getSeatNo());
        boolean haiDiLaoYue = table.getWallTiles().isEmpty();
        winnerSeat.declareHu();

                GameEventType.HU_DECLARED,
                null,
                null
        ));
        appendSettlementEvents(session, settlementService.settleSelfDrawHu(
                table,
                null,
                gangShangHua,
                haiDiLaoYue
        ));

            return;
        }

    }

private void executeSelfGang(GameSession session, String userId, String tileDisplayName) {
        Tile gangTile = parseTile(tileDisplayName);

        winnerSeat.removeMatchingHandTiles(gangTile, 4);
        winnerSeat.addAnGangMeld(gangTile);

                null,
                gangTile.getDisplayName()
        ));
        appendSettlementEvents(session, settlementService.settleConcealedGang(
                table,
        ));

            return;
        }

        markPostGangContext(session, winnerSeat.getSeatNo(), gangTile.getDisplayName());
    }

private void handleSelfGangPostAction(GameSession session, String userId, String tileDisplayName) {
        GameSeat actorSeat = findSeatByUserId(table, userId);

        if (actorSeat.hasPengMeld(gangTile)) {
            Optional<ResponseActionWindow> window = responseActionWindowBuilder.buildForSupplementalGang(
                    session,
                    actorSeat.getSeatNo(),
                    gangTile
            );
            if (window.isPresent()) {
                openResponseWindow(session, window.get());
                autoPassAiCandidates(session);
                if (session.getPendingResponseActionWindow() == null) {
                    executeSupplementalGang(session, actorSeat.getSeatNo(), tileDisplayName);
                }
                return;
            }
            executeSupplementalGang(session, actorSeat.getSeatNo(), tileDisplayName);
            return;
        }

        executeSelfGang(session, userId, tileDisplayName);
    }

private void executeSupplementalGang(GameSession session, int seatNo, String tileDisplayName) {
        GameSeat winnerSeat = table.getSeats().get(seatNo);

        winnerSeat.removeMatchingHandTiles(gangTile, 1);
        winnerSeat.upgradePengToBuGang(gangTile);

                null,
        ));
        appendSettlementEvents(session, settlementService.settleSupplementalGang(
                table,
        ));

            return;
        }

    }

private void markPassedHuBlockedIfNeeded(ResponseActionWindow responseActionWindow, GameSeat seat) {
        responseActionWindow.seatCandidates().stream()
                .filter(candidate -> candidate.seatNo() == seat.getSeatNo())
                .filter(candidate -> candidate.options().stream().anyMatch(option -> option.actionType() == ActionType.HU))
                .ifPresent(candidate -> seat.markPassedHuBlocked());
    }

private ActionType parseActionType(String actionType) {
        try {
            return ActionType.valueOf(actionType.toUpperCase(Locale.ROOT));
        } catch (IllegalArgumentException exception) {
            throw new BusinessException("GAME_ACTION_INVALID", "动作类型不合法");
        }
    }

private List<PublicSeatView> buildPublicSeats(GameTable table) {
                .map(seat -> new PublicSeatView(
                        seat.getSeatNo(),
                        seat.getPlayerId(),
                        seat.getNickname(),
                        seat.isAi(),
                        seat.isWon(),
                        seat.getLackSuit() == null ? null : seat.getLackSuit().name(),
                        seat.getScore(),
                        seat.getHandTiles().size(),
                        seat.getDiscardTiles().stream().map(Tile::getDisplayName).toList(),
                        toMeldLabels(seat)
                ))
    }

private void appendSettlementEvents(GameSession session, SettlementResult settlementResult) {
        session.getSettlementHistory().add(settlementResult);
        appendAndPublish(session, GameEvent.settlementApplied(session.getGameId(), settlementResult));
        for (ScoreChange scoreChange : settlementResult.scoreChanges()) {
            appendAndPublish(session, GameEvent.scoreChanged(
                    scoreChange.seatNo(),
                    settlementResult.settlementType().name(),
                    scoreChange.delta(),
                    scoreChange.afterScore()
            ));
        }
    }

private boolean finishTableIfNeeded(GameSession session) {
        if (!shouldFinishTable(table)) {
            return false;
        }
        // 血战流局场景下，牌墙耗尽但仍有多人未胡，需要先做退税和查叫，再真正结束牌局。
        if (table.getWallTiles().isEmpty() && countActiveSeats(table) > 1) {
            applyDrawEndSettlements(session);
        }
        table.setPhase(GamePhase.FINISHED);
        appendAndPublish(session, GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
        return true;
    }

private void applyDrawEndSettlements(GameSession session) {
        Map<Integer, ReadyHandOption> readyHandOptions = buildReadyHandOptions(session.getTable());
        Set<Integer> notReadySeatNos = session.getTable().getSeats().stream()
                .filter(seat -> !seat.isWon())
                .map(GameSeat::getSeatNo)
                .filter(seatNo -> !readyHandOptions.containsKey(seatNo))
                .collect(java.util.stream.Collectors.toSet());

        applyTaxRefunds(session, notReadySeatNos);
        applyChaJiao(session, readyHandOptions, notReadySeatNos);
    }

private Map<Integer, ReadyHandOption> buildReadyHandOptions(GameTable table) {
        Map<Integer, ReadyHandOption> readyHandOptions = new LinkedHashMap<>();
        for (GameSeat seat : table.getSeats()) {
            if (seat.isWon()) {
                continue;
            }
            scoringService.findBestReadyHandOption(seat)
                    .ifPresent(option -> readyHandOptions.put(seat.getSeatNo(), option));
        }
        return readyHandOptions;
    }

private void applyTaxRefunds(GameSession session, Set<Integer> notReadySeatNos) {
        if (notReadySeatNos.isEmpty()) {
            return;
        }
        // 退税只针对未听牌玩家此前“已经拿到手”的杠分收入，按原支付关系逐笔退回。
        for (SettlementResult settlementResult : List.copyOf(session.getSettlementHistory())) {
            if (!isGangSettlement(settlementResult) || !notReadySeatNos.contains(settlementResult.actorSeatNo())) {
                continue;
            }
            for (ScoreChange scoreChange : settlementResult.scoreChanges()) {
                if (scoreChange.delta() >= 0) {
                    continue;
                }
                appendSettlementEvents(session, settlementService.settleTaxRefund(
                        session.getTable(),
                        scoreChange.seatNo(),
                        settlementResult.actorSeatNo(),
                        -scoreChange.delta(),
                        settlementResult.triggerTile()
                ));
            }
        }
    }

private void applyChaJiao(
            Map<Integer, ReadyHandOption> readyHandOptions,
            Set<Integer> notReadySeatNos
    ) {
            return;
        }
        // 查叫按“未听牌向所有听牌玩家分别赔付其最大理论点炮值”处理，先不做花猪等额外分支。
        for (Integer notReadySeatNo : notReadySeatNos) {
            for (Map.Entry<Integer, ReadyHandOption> entry : readyHandOptions.entrySet()) {
                ReadyHandOption readyHandOption = entry.getValue();
                SettlementDetail settlementDetail = readyHandOption.settlementDetail();
                appendSettlementEvents(session, settlementService.settleChaJiao(
                        entry.getKey(),
                        notReadySeatNo,
                        readyHandOption.triggerTile(),
                        settlementDetail
                ));
            }
        }
    }

private boolean isGangSettlement(SettlementResult settlementResult) {
        return settlementResult.settlementType() == SettlementType.MING_GANG
                || settlementResult.settlementType() == SettlementType.BU_GANG
                || settlementResult.settlementType() == SettlementType.AN_GANG;
    }

private boolean shouldFinishTable(GameTable table) {
        return table.getWallTiles().isEmpty() || countActiveSeats(table) <= 1;
    }

private boolean canSelfDrawHu(GameSeat seat) {
        return huEvaluator.canHu(seat.getHandTiles());
    }

private void markPostGangContext(GameSession session, int seatNo, String tileDisplayName) {
        // 杠后补摸只对当前这名玩家的下一次自摸/弃牌裁决有效。
        session.setPostGangContext(new PostGangContext(seatNo, tileDisplayName));
    }

private boolean isPostGangDraw(GameSession session, int seatNo) {
        PostGangContext postGangContext = session.getPostGangContext();
        return postGangContext != null && postGangContext.seatNo() == seatNo;
    }

private boolean isPostGangDiscard(GameSession session, int sourceSeatNo) {
        return postGangContext != null && postGangContext.seatNo() == sourceSeatNo;
    }

private boolean isSupplementalGangWindow(ResponseActionWindow responseActionWindow) {
        return "SUPPLEMENTAL_GANG_DECLARED".equals(responseActionWindow.triggerEventType());
    }

private long countActiveSeats(GameTable table) {
                .count();
    }

private Optional<GameSeat> findNextActiveSeat(GameTable table, int fromSeatNo) {
        int seatCount = table.getSeats().size();
        for (int offset = 1; offset <= seatCount; offset++) {
            GameSeat candidate = table.getSeats().get((fromSeatNo + offset) % seatCount);
            if (!candidate.isWon()) {
                return Optional.of(candidate);
            }
        }
        return Optional.empty();
    }

private List<PrivateActionCandidate> buildTurnActionCandidates(GameSeat currentSeat) {
        List<PrivateActionCandidate> candidates = new java.util.ArrayList<>();
        if (canSelfDrawHu(currentSeat)) {
            candidates.add(new PrivateActionCandidate(ActionType.HU.name(), null));
        }
        for (String gangTile : findSelfGangCandidates(currentSeat)) {
            candidates.add(new PrivateActionCandidate(ActionType.GANG.name(), gangTile));
        }
        candidates.add(new PrivateActionCandidate(ActionType.DISCARD.name(), null));
        return List.copyOf(candidates);
    }

private Optional<String> findSelfGangCandidate(GameSeat seat) {
        List<String> candidates = findSelfGangCandidates(seat);
        if (candidates.isEmpty()) {
            return Optional.empty();
        }
        return Optional.of(candidates.get(0));
    }

private List<String> findSelfGangCandidates(GameSeat seat) {
        return seat.getHandTiles().stream()
                .collect(java.util.stream.Collectors.groupingBy(Tile::getDisplayName, java.util.stream.Collectors.counting()))
                .entrySet().stream()
                .filter(entry -> entry.getValue() >= 4 || seat.hasPengMeld(parseTile(entry.getKey())))
                .map(java.util.Map.Entry::getKey)
                .sorted()
    }

private List<String> toMeldLabels(GameSeat seat) {
        return seat.getMeldGroups().stream()
                .map(meld -> switch (meld.type()) {
                    case PENG -> "碰:" + meld.tile().getDisplayName();
                    case MING_GANG -> "明杠:" + meld.tile().getDisplayName();
                    case BU_GANG -> "补杠:" + meld.tile().getDisplayName();
                    case AN_GANG -> "暗杠:" + meld.tile().getDisplayName();
                })
    }

@Service
public class GameSessionService {

    private final RoomService roomService;
    private final GameEngine gameEngine;
    private final GameActionProcessor gameActionProcessor;
    private final ResponseActionWindowBuilder responseActionWindowBuilder;
    private final ResponseActionResolver responseActionResolver;
    private final SettlementService settlementService;
    private final BloodBattleScoringService scoringService;
    private final HuEvaluator huEvaluator;
    private final StrategyService strategyService;
    private final PlayerVisibilityService playerVisibilityService;
    private final TeachingService teachingService;
    private final GameMessagePublisher gameMessagePublisher;
    private final Map<String, GameSession> sessions = new ConcurrentHashMap<>();

    public GameSessionService(
    ) {
    }

    public GameStateResponse startGame(String roomId, String operatorUserId) {

    }

    public GameStateResponse getState(String gameId, String userId) {
    }

    public GameStateResponse performAction(String gameId, GameActionRequest request) {
    }

    public GameStateResponse selectLackSuit(String gameId, String userId, String lackSuitValue) {
    }

    public GameStateResponse discardTile(String gameId, String userId, String tileDisplayName) {
    }

    private GameSession getRequiredSession(String gameId) {
        }
    }

    private GameSeat findSeatByUserId(GameTable table, String userId) {
    }

    private GameStateResponse toStateResponse(GameSession session, String userId) {

                ),
        );
    }

    private void moveToNextSeat(GameSession session) {
            return;
        }

            return;
        }

    }

    private void autoPlayBots(GameSession session) {
                continue;
            }
                return;
            }

                        session,
                                "HU",
                                null,
                                null
                        )
                );
                        session,
                        huEvents
                );
                    return;
                }
                continue;
            }

                        session,
                                "GANG",
                                null
                        )
                );
                        session,
                );
                    return;
                }
                continue;
            }

                    session,
                            null
                    )
            );
                return;
            }
        }
    }

    private void appendAndPublish(GameSession session, GameEvent event) {
    }

    private void appendAndPublish(GameSession session, List<GameEvent> events) {
        }
    }

    private void notifyActionIfHumanTurn(GameSession session) {
            return;
        }

            return;
        }

        );

        );
    }

    private void handlePostActionEffects(
    ) {
                }
            }
            }
        }
    }

    private void notifyResponseCandidates(GameSession session, ResponseActionWindow responseActionWindow) {
                continue;
            }
            );
        }
    }

    private void handleDiscardPostAction(GameSession session, GameActionRequest request, List<GameEvent> events) {
                session,
        );

            }
            return;
        }

    }

    private void handlePassPostAction(GameSession session, GameActionRequest request) {
    }

    private void handleResponseDeclarationPostAction(GameSession session, ActionType actionType, GameActionRequest request) {
    }

    private void handleGangPostAction(GameSession session, GameActionRequest request) {
            return;
        }
    }

    private void handleHuPostAction(GameSession session, GameActionRequest request) {
            return;
        }
    }

    private void openResponseWindow(GameSession session, ResponseActionWindow responseActionWindow) {
        ));
    }

    private void autoPassAiCandidates(GameSession session) {
            return;
        }
            }
        }
        }
    }

    private boolean allResponseCandidatesResolved(GameSession session, ResponseActionWindow responseActionWindow) {
    }

    private void closeResponseWindowAsPass(GameSession session, ResponseActionWindow responseActionWindow) {
                null,
        ));
    }

    private void tryResolveResponseWindow(GameSession session, ResponseActionWindow responseActionWindow) {
            return;
        }

        );

            return;
        }

            return;
        }
    }

    private ResponseActionWindow requirePendingResponseWindow(GameSession session) {
        }
    }

    private Tile resolveDiscardedTile(List<GameEvent> events, String fallbackTileDisplayName) {
    }

        }

        int rank;
        try {
        }

        };
    }

    private void continueAfterDiscardWithoutResponse(GameSession session) {
    }

    private void executeResolvedResponseAction(
    ) {
        }
    }

    private void closeResponseWindowWithResolution(GameSession session, ResponseActionWindow responseActionWindow, ActionType actionType) {
                null,
        ));
    }

    private void executePeng(GameSession session, ResponseActionResolution resolution) {

        ));
    }

    private void executeGang(GameSession session, ResponseActionResolution resolution) {

        ));
                table,
        ));

            return;
        }

    }

    private void continueFromResolvedActionTurn(GameSession session, GameSeat currentSeat) {
            return;
        }
    }

    private void executeHu(
    ) {

        } else {
        }

            ));
                        table,
                ));
                continue;
            }
                    table,
                    haiDiPao
            ));
        }

            return;
        }

    }

    private void executeSelfDrawHu(GameSession session, String userId) {

                null,
                null
        ));
                table,
                null,
        ));

            return;
        }

    }

    private void executeSelfGang(GameSession session, String userId, String tileDisplayName) {

                null,
        ));
                table,
        ));

            return;
        }

    }

    private void handleSelfGangPostAction(GameSession session, String userId, String tileDisplayName) {

                    session,
                    gangTile
            );
                }
                return;
            }
            return;
        }

    }

    private void executeSupplementalGang(GameSession session, int seatNo, String tileDisplayName) {

                null,
        ));
                table,
        ));

            return;
        }

    }

    private void markPassedHuBlockedIfNeeded(ResponseActionWindow responseActionWindow, GameSeat seat) {
    }

    private ActionType parseActionType(String actionType) {
        try {
        }
    }

    private List<PublicSeatView> buildPublicSeats(GameTable table) {
                ))
    }

    private void appendSettlementEvents(GameSession session, SettlementResult settlementResult) {
            ));
        }
    }

    private boolean finishTableIfNeeded(GameSession session) {
        }
        }
    }

    private void applyDrawEndSettlements(GameSession session) {

    }

    private Map<Integer, ReadyHandOption> buildReadyHandOptions(GameTable table) {
                continue;
            }
        }
    }

    private void applyTaxRefunds(GameSession session, Set<Integer> notReadySeatNos) {
            return;
        }
                continue;
            }
                    continue;
                }
                ));
            }
        }
    }

    private void applyChaJiao(
    ) {
            return;
        }
                ));
            }
        }
    }

    private boolean isGangSettlement(SettlementResult settlementResult) {
    }

    private boolean shouldFinishTable(GameTable table) {
    }

    private boolean canSelfDrawHu(GameSeat seat) {
    }

    private void markPostGangContext(GameSession session, int seatNo, String tileDisplayName) {
    }

    private boolean isPostGangDraw(GameSession session, int seatNo) {
    }

    private boolean isPostGangDiscard(GameSession session, int sourceSeatNo) {
    }

    private boolean isSupplementalGangWindow(ResponseActionWindow responseActionWindow) {
    }

    private long countActiveSeats(GameTable table) {
                .count();
    }

    private Optional<GameSeat> findNextActiveSeat(GameTable table, int fromSeatNo) {
            }
        }
    }

    private List<PrivateActionCandidate> buildTurnActionCandidates(GameSeat currentSeat) {
        }
        }
    }

    private Optional<String> findSelfGangCandidate(GameSeat seat) {
        }
    }

    private List<String> findSelfGangCandidates(GameSeat seat) {
                .sorted()
    }

    private List<String> toMeldLabels(GameSeat seat) {
                })
    }
}