first commit

This commit is contained in:
hujun
2026-03-20 12:50:41 +08:00
commit 24fce055fd
88 changed files with 7655 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
package com.xuezhanmaster.game.controller;
import com.xuezhanmaster.common.api.ApiResponse;
import com.xuezhanmaster.game.dto.GameTableSnapshot;
import com.xuezhanmaster.game.service.DemoGameService;
import com.xuezhanmaster.teaching.dto.TeachingAdviceResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/demo")
public class DemoGameController {
private final DemoGameService demoGameService;
public DemoGameController(DemoGameService demoGameService) {
this.demoGameService = demoGameService;
}
@GetMapping("/table")
public ApiResponse<GameTableSnapshot> table() {
return ApiResponse.success(demoGameService.createDemoTableSnapshot());
}
@GetMapping("/advice")
public ApiResponse<TeachingAdviceResponse> advice() {
return ApiResponse.success(demoGameService.createDemoTeachingAdvice());
}
}

View File

@@ -0,0 +1,68 @@
package com.xuezhanmaster.game.controller;
import com.xuezhanmaster.common.api.ApiResponse;
import com.xuezhanmaster.game.dto.DiscardTileRequest;
import com.xuezhanmaster.game.dto.GameActionRequest;
import com.xuezhanmaster.game.dto.GameStateResponse;
import com.xuezhanmaster.game.dto.SelectLackSuitRequest;
import com.xuezhanmaster.game.dto.StartGameRequest;
import com.xuezhanmaster.game.service.GameSessionService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api")
public class GameSessionController {
private final GameSessionService gameSessionService;
public GameSessionController(GameSessionService gameSessionService) {
this.gameSessionService = gameSessionService;
}
@PostMapping("/rooms/{roomId}/start")
public ApiResponse<GameStateResponse> start(
@PathVariable String roomId,
@Valid @RequestBody StartGameRequest request
) {
return ApiResponse.success(gameSessionService.startGame(roomId, request.operatorUserId()));
}
@GetMapping("/games/{gameId}/state")
public ApiResponse<GameStateResponse> state(
@PathVariable String gameId,
@RequestParam String userId
) {
return ApiResponse.success(gameSessionService.getState(gameId, userId));
}
@PostMapping("/games/{gameId}/actions")
public ApiResponse<GameStateResponse> action(
@PathVariable String gameId,
@Valid @RequestBody GameActionRequest request
) {
return ApiResponse.success(gameSessionService.performAction(gameId, request));
}
@PostMapping("/games/{gameId}/lack")
public ApiResponse<GameStateResponse> selectLackSuit(
@PathVariable String gameId,
@Valid @RequestBody SelectLackSuitRequest request
) {
return ApiResponse.success(gameSessionService.selectLackSuit(gameId, request.userId(), request.lackSuit()));
}
@PostMapping("/games/{gameId}/discard")
public ApiResponse<GameStateResponse> discard(
@PathVariable String gameId,
@Valid @RequestBody DiscardTileRequest request
) {
return ApiResponse.success(gameSessionService.discardTile(gameId, request.userId(), request.tile()));
}
}

View File

@@ -0,0 +1,12 @@
package com.xuezhanmaster.game.domain;
public enum ActionType {
SELECT_LACK_SUIT,
DRAW,
DISCARD,
PENG,
GANG,
HU,
PASS
}

View File

@@ -0,0 +1,9 @@
package com.xuezhanmaster.game.domain;
public enum GamePhase {
WAITING,
LACK_SELECTION,
PLAYING,
FINISHED
}

View File

@@ -0,0 +1,68 @@
package com.xuezhanmaster.game.domain;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class GameSeat {
private final int seatNo;
private final boolean ai;
private final String playerId;
private final String nickname;
private final List<Tile> handTiles = new ArrayList<>();
private final List<Tile> discardTiles = new ArrayList<>();
private TileSuit lackSuit;
public GameSeat(int seatNo, boolean ai, String nickname) {
this(seatNo, ai, UUID.randomUUID().toString(), nickname);
}
public GameSeat(int seatNo, boolean ai, String playerId, String nickname) {
this.seatNo = seatNo;
this.ai = ai;
this.playerId = playerId;
this.nickname = nickname;
}
public int getSeatNo() {
return seatNo;
}
public boolean isAi() {
return ai;
}
public String getPlayerId() {
return playerId;
}
public String getNickname() {
return nickname;
}
public List<Tile> getHandTiles() {
return handTiles;
}
public List<Tile> getDiscardTiles() {
return discardTiles;
}
public TileSuit getLackSuit() {
return lackSuit;
}
public void setLackSuit(TileSuit lackSuit) {
this.lackSuit = lackSuit;
}
public void receiveTile(Tile tile) {
handTiles.add(tile);
}
public void discard(Tile tile) {
handTiles.remove(tile);
discardTiles.add(tile);
}
}

View File

@@ -0,0 +1,45 @@
package com.xuezhanmaster.game.domain;
import com.xuezhanmaster.game.event.GameEvent;
import java.util.ArrayList;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public class GameSession {
private final String gameId;
private final String roomId;
private final Instant createdAt;
private final GameTable table;
private final List<GameEvent> events;
public GameSession(String roomId, GameTable table) {
this.gameId = UUID.randomUUID().toString();
this.roomId = roomId;
this.createdAt = Instant.now();
this.table = table;
this.events = new ArrayList<>();
}
public String getGameId() {
return gameId;
}
public String getRoomId() {
return roomId;
}
public Instant getCreatedAt() {
return createdAt;
}
public GameTable getTable() {
return table;
}
public List<GameEvent> getEvents() {
return events;
}
}

View File

@@ -0,0 +1,60 @@
package com.xuezhanmaster.game.domain;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class GameTable {
private final String tableId;
private final List<GameSeat> seats;
private final List<Tile> wallTiles;
private GamePhase phase;
private int dealerSeatNo;
private int currentSeatNo;
public GameTable(List<GameSeat> seats, List<Tile> wallTiles) {
this.tableId = UUID.randomUUID().toString();
this.seats = new ArrayList<>(seats);
this.wallTiles = new ArrayList<>(wallTiles);
this.phase = GamePhase.WAITING;
this.dealerSeatNo = 0;
this.currentSeatNo = 0;
}
public String getTableId() {
return tableId;
}
public List<GameSeat> getSeats() {
return seats;
}
public List<Tile> getWallTiles() {
return wallTiles;
}
public GamePhase getPhase() {
return phase;
}
public void setPhase(GamePhase phase) {
this.phase = phase;
}
public int getDealerSeatNo() {
return dealerSeatNo;
}
public void setDealerSeatNo(int dealerSeatNo) {
this.dealerSeatNo = dealerSeatNo;
}
public int getCurrentSeatNo() {
return currentSeatNo;
}
public void setCurrentSeatNo(int currentSeatNo) {
this.currentSeatNo = currentSeatNo;
}
}

View File

@@ -0,0 +1,51 @@
package com.xuezhanmaster.game.domain;
import java.util.Objects;
public final class Tile {
private final TileSuit suit;
private final int rank;
public Tile(TileSuit suit, int rank) {
if (rank < 1 || rank > 9) {
throw new IllegalArgumentException("牌面点数必须在 1 到 9 之间");
}
this.suit = Objects.requireNonNull(suit, "花色不能为空");
this.rank = rank;
}
public TileSuit getSuit() {
return suit;
}
public int getRank() {
return rank;
}
public String getDisplayName() {
return rank + suit.getLabel();
}
@Override
public String toString() {
return getDisplayName();
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof Tile tile)) {
return false;
}
return rank == tile.rank && suit == tile.suit;
}
@Override
public int hashCode() {
return Objects.hash(suit, rank);
}
}

View File

@@ -0,0 +1,18 @@
package com.xuezhanmaster.game.domain;
public enum TileSuit {
WAN(""),
TONG(""),
TIAO("");
private final String label;
TileSuit(String label) {
this.label = label;
}
public String getLabel() {
return label;
}
}

View File

@@ -0,0 +1,9 @@
package com.xuezhanmaster.game.dto;
import jakarta.validation.constraints.NotBlank;
public record DiscardTileRequest(
@NotBlank String userId,
@NotBlank String tile
) {
}

View File

@@ -0,0 +1,11 @@
package com.xuezhanmaster.game.dto;
import jakarta.validation.constraints.NotBlank;
public record GameActionRequest(
@NotBlank String userId,
@NotBlank String actionType,
String tile,
Integer sourceSeatNo
) {
}

View File

@@ -0,0 +1,14 @@
package com.xuezhanmaster.game.dto;
import java.util.List;
public record GameSeatView(
int seatNo,
String nickname,
boolean ai,
String lackSuit,
List<String> handTiles,
List<String> discardTiles
) {
}

View File

@@ -0,0 +1,14 @@
package com.xuezhanmaster.game.dto;
import java.util.List;
public record GameStateResponse(
String gameId,
String phase,
int dealerSeatNo,
int currentSeatNo,
int remainingWallCount,
SelfSeatView selfSeat,
List<PublicSeatView> seats
) {
}

View File

@@ -0,0 +1,14 @@
package com.xuezhanmaster.game.dto;
import java.util.List;
public record GameTableSnapshot(
String tableId,
String phase,
int dealerSeatNo,
int currentSeatNo,
int remainingWallCount,
List<GameSeatView> seats
) {
}

View File

@@ -0,0 +1,15 @@
package com.xuezhanmaster.game.dto;
import java.util.List;
public record PublicSeatView(
int seatNo,
String playerId,
String nickname,
boolean ai,
String lackSuit,
int handCount,
List<String> discardTiles
) {
}

View File

@@ -0,0 +1,10 @@
package com.xuezhanmaster.game.dto;
import jakarta.validation.constraints.NotBlank;
public record SelectLackSuitRequest(
@NotBlank String userId,
@NotBlank String lackSuit
) {
}

View File

@@ -0,0 +1,14 @@
package com.xuezhanmaster.game.dto;
import java.util.List;
public record SelfSeatView(
int seatNo,
String playerId,
String nickname,
String lackSuit,
List<String> handTiles,
List<String> discardTiles
) {
}

View File

@@ -0,0 +1,9 @@
package com.xuezhanmaster.game.dto;
import jakarta.validation.constraints.NotBlank;
public record StartGameRequest(
@NotBlank String operatorUserId
) {
}

View File

@@ -0,0 +1,122 @@
package com.xuezhanmaster.game.engine;
import com.xuezhanmaster.game.domain.GamePhase;
import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.GameTable;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.domain.TileSuit;
import com.xuezhanmaster.game.service.DeckFactory;
import com.xuezhanmaster.room.domain.Room;
import com.xuezhanmaster.room.domain.RoomSeat;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Component
public class GameEngine {
private final DeckFactory deckFactory;
public GameEngine(DeckFactory deckFactory) {
this.deckFactory = deckFactory;
}
public GameTable createDemoTable() {
List<GameSeat> seats = List.of(
new GameSeat(0, false, "Player"),
new GameSeat(1, true, "AI-Jifeng"),
new GameSeat(2, true, "AI-Duanyao"),
new GameSeat(3, true, "AI-Shouzhuo")
);
List<Tile> wallTiles = new ArrayList<>(deckFactory.createShuffledWall());
GameTable table = new GameTable(seats, wallTiles);
table.setPhase(GamePhase.LACK_SELECTION);
table.setDealerSeatNo(0);
table.setCurrentSeatNo(0);
dealInitialHands(table);
assignDemoLackSuits(table);
return table;
}
public GameTable createTableFromRoom(Room room) {
List<GameSeat> seats = room.getSeats().stream()
.map(this::toGameSeat)
.toList();
List<Tile> wallTiles = new ArrayList<>(deckFactory.createShuffledWall());
GameTable table = new GameTable(seats, wallTiles);
table.setPhase(GamePhase.LACK_SELECTION);
table.setDealerSeatNo(0);
table.setCurrentSeatNo(0);
dealInitialHands(table);
autoAssignBotLackSuits(table);
return table;
}
private void dealInitialHands(GameTable table) {
for (int round = 0; round < 13; round++) {
for (GameSeat seat : table.getSeats()) {
seat.receiveTile(drawOne(table));
}
}
table.getSeats().get(table.getDealerSeatNo()).receiveTile(drawOne(table));
}
private Tile drawOne(GameTable table) {
return table.getWallTiles().remove(0);
}
private void assignDemoLackSuits(GameTable table) {
TileSuit[] lackOrder = {TileSuit.TIAO, TileSuit.WAN, TileSuit.TONG, TileSuit.TIAO};
for (int i = 0; i < table.getSeats().size(); i++) {
table.getSeats().get(i).setLackSuit(lackOrder[i]);
}
}
private GameSeat toGameSeat(RoomSeat roomSeat) {
return new GameSeat(
roomSeat.getSeatNo(),
roomSeat.getParticipantType().name().equals("BOT"),
roomSeat.getUserId() == null ? "bot-" + roomSeat.getSeatNo() : roomSeat.getUserId(),
roomSeat.getDisplayName()
);
}
private void autoAssignBotLackSuits(GameTable table) {
for (GameSeat seat : table.getSeats()) {
if (seat.isAi()) {
seat.setLackSuit(chooseLackSuit(seat));
}
}
}
private TileSuit chooseLackSuit(GameSeat seat) {
int wanCount = countSuit(seat, TileSuit.WAN);
int tongCount = countSuit(seat, TileSuit.TONG);
int tiaoCount = countSuit(seat, TileSuit.TIAO);
TileSuit lackSuit = TileSuit.WAN;
int minCount = wanCount;
if (tongCount < minCount) {
lackSuit = TileSuit.TONG;
minCount = tongCount;
}
if (tiaoCount < minCount) {
lackSuit = TileSuit.TIAO;
}
return lackSuit;
}
private int countSuit(GameSeat seat, TileSuit suit) {
int count = 0;
for (Tile tile : seat.getHandTiles()) {
if (tile.getSuit() == suit) {
count++;
}
}
return count;
}
}

View File

@@ -0,0 +1,102 @@
package com.xuezhanmaster.game.event;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
public record GameEvent(
String gameId,
GameEventType eventType,
Integer seatNo,
Map<String, Object> payload,
Instant createdAt
) {
public static GameEvent of(String gameId, GameEventType eventType, Integer seatNo, Map<String, Object> payload) {
return new GameEvent(gameId, eventType, seatNo, payload, Instant.now());
}
public static GameEvent gameStarted(String gameId, String roomId) {
return of(gameId, GameEventType.GAME_STARTED, null, Map.of(
"roomId", roomId
));
}
public static GameEvent lackSelected(String gameId, int seatNo, String lackSuit) {
return of(gameId, GameEventType.LACK_SELECTED, seatNo, Map.of(
"lackSuit", lackSuit
));
}
public static GameEvent phaseChanged(String gameId, String phase) {
return of(gameId, GameEventType.GAME_PHASE_CHANGED, null, Map.of(
"phase", phase
));
}
public static GameEvent tileDiscarded(String gameId, int seatNo, String tile) {
return of(gameId, GameEventType.TILE_DISCARDED, seatNo, Map.of(
"tile", tile
));
}
public static GameEvent tileDrawn(String gameId, int seatNo, int remainingWallCount) {
return of(gameId, GameEventType.TILE_DRAWN, seatNo, Map.of(
"remainingWallCount", remainingWallCount
));
}
public static GameEvent turnSwitched(String gameId, int seatNo) {
return of(gameId, GameEventType.TURN_SWITCHED, seatNo, Map.of(
"currentSeatNo", seatNo
));
}
public static GameEvent responseWindowOpened(String gameId, int seatNo, int sourceSeatNo, String triggerTile) {
return of(gameId, GameEventType.RESPONSE_WINDOW_OPENED, seatNo, buildActionPayload(
"RESPONSE_WINDOW_OPENED",
sourceSeatNo,
triggerTile
));
}
public static GameEvent responseWindowClosed(String gameId, Integer seatNo, int sourceSeatNo, String resolvedActionType) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("sourceSeatNo", sourceSeatNo);
payload.put("resolvedActionType", resolvedActionType);
return of(gameId, GameEventType.RESPONSE_WINDOW_CLOSED, seatNo, payload);
}
public static GameEvent responseActionDeclared(
String gameId,
GameEventType eventType,
int seatNo,
int sourceSeatNo,
String tile
) {
return of(gameId, eventType, seatNo, buildActionPayload(
toActionType(eventType),
sourceSeatNo,
tile
));
}
private static Map<String, Object> buildActionPayload(String actionType, int sourceSeatNo, String tile) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("actionType", actionType);
payload.put("sourceSeatNo", sourceSeatNo);
if (tile != null && !tile.isBlank()) {
payload.put("tile", tile);
}
return payload;
}
private static String toActionType(GameEventType eventType) {
return switch (eventType) {
case PENG_DECLARED -> "PENG";
case GANG_DECLARED -> "GANG";
case HU_DECLARED -> "HU";
case PASS_DECLARED -> "PASS";
default -> eventType.name();
};
}
}

View File

@@ -0,0 +1,17 @@
package com.xuezhanmaster.game.event;
public enum GameEventType {
GAME_STARTED,
LACK_SELECTED,
GAME_PHASE_CHANGED,
TILE_DISCARDED,
TILE_DRAWN,
TURN_SWITCHED,
RESPONSE_WINDOW_OPENED,
RESPONSE_WINDOW_CLOSED,
PENG_DECLARED,
GANG_DECLARED,
HU_DECLARED,
PASS_DECLARED,
ACTION_REQUIRED
}

View File

@@ -0,0 +1,28 @@
package com.xuezhanmaster.game.service;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.domain.TileSuit;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
@Component
public class DeckFactory {
public List<Tile> createShuffledWall() {
List<Tile> wall = new ArrayList<>(108);
for (TileSuit suit : TileSuit.values()) {
for (int rank = 1; rank <= 9; rank++) {
for (int copy = 0; copy < 4; copy++) {
wall.add(new Tile(suit, rank));
}
}
}
Collections.shuffle(wall, new Random());
return wall;
}
}

View File

@@ -0,0 +1,64 @@
package com.xuezhanmaster.game.service;
import com.xuezhanmaster.game.domain.GameTable;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.dto.GameSeatView;
import com.xuezhanmaster.game.dto.GameTableSnapshot;
import com.xuezhanmaster.game.engine.GameEngine;
import com.xuezhanmaster.strategy.domain.StrategyDecision;
import com.xuezhanmaster.strategy.service.StrategyService;
import com.xuezhanmaster.teaching.domain.PlayerVisibleGameState;
import com.xuezhanmaster.teaching.domain.TeachingMode;
import com.xuezhanmaster.teaching.dto.TeachingAdviceResponse;
import com.xuezhanmaster.teaching.service.PlayerVisibilityService;
import com.xuezhanmaster.teaching.service.TeachingService;
import org.springframework.stereotype.Service;
@Service
public class DemoGameService {
private final GameEngine gameEngine;
private final StrategyService strategyService;
private final PlayerVisibilityService playerVisibilityService;
private final TeachingService teachingService;
public DemoGameService(
GameEngine gameEngine,
StrategyService strategyService,
PlayerVisibilityService playerVisibilityService,
TeachingService teachingService
) {
this.gameEngine = gameEngine;
this.strategyService = strategyService;
this.playerVisibilityService = playerVisibilityService;
this.teachingService = teachingService;
}
public GameTableSnapshot createDemoTableSnapshot() {
GameTable table = gameEngine.createDemoTable();
return new GameTableSnapshot(
table.getTableId(),
table.getPhase().name(),
table.getDealerSeatNo(),
table.getCurrentSeatNo(),
table.getWallTiles().size(),
table.getSeats().stream()
.map(seat -> new GameSeatView(
seat.getSeatNo(),
seat.getNickname(),
seat.isAi(),
seat.getLackSuit() == null ? null : seat.getLackSuit().name(),
seat.getHandTiles().stream().map(Tile::getDisplayName).toList(),
seat.getDiscardTiles().stream().map(Tile::getDisplayName).toList()
))
.toList()
);
}
public TeachingAdviceResponse createDemoTeachingAdvice() {
GameTable table = gameEngine.createDemoTable();
PlayerVisibleGameState visibleState = playerVisibilityService.buildVisibleState(table, table.getSeats().get(0));
StrategyDecision decision = strategyService.evaluateDiscard(visibleState);
return teachingService.buildAdvice(decision, TeachingMode.STANDARD);
}
}

View File

@@ -0,0 +1,183 @@
package com.xuezhanmaster.game.service;
import com.xuezhanmaster.common.exception.BusinessException;
import com.xuezhanmaster.game.domain.ActionType;
import com.xuezhanmaster.game.domain.GamePhase;
import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.GameSession;
import com.xuezhanmaster.game.domain.GameTable;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.domain.TileSuit;
import com.xuezhanmaster.game.dto.GameActionRequest;
import com.xuezhanmaster.game.event.GameEvent;
import com.xuezhanmaster.game.event.GameEventType;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Component
public class GameActionProcessor {
public List<GameEvent> process(GameSession session, GameActionRequest request) {
ActionType actionType = parseActionType(request.actionType());
return switch (actionType) {
case SELECT_LACK_SUIT -> selectLackSuit(session, request.userId(), request.tile());
case DISCARD -> discard(session, request.userId(), request.tile());
case PENG -> peng(session, request.userId(), request.tile(), request.sourceSeatNo());
case GANG -> gang(session, request.userId(), request.tile(), request.sourceSeatNo());
case HU -> hu(session, request.userId(), request.tile(), request.sourceSeatNo());
case PASS -> pass(session, request.userId(), request.sourceSeatNo());
default -> throw new BusinessException("GAME_ACTION_UNSUPPORTED", "当前动作尚未实现");
};
}
private List<GameEvent> peng(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) {
GameTable table = session.getTable();
GameSeat seat = findSeatByUserId(table, userId);
validateResponseAction(table, seat, sourceSeatNo, tileDisplayName, "");
return unsupportedAction(ActionType.PENG);
}
private List<GameEvent> gang(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) {
GameTable table = session.getTable();
GameSeat seat = findSeatByUserId(table, userId);
validateResponseAction(table, seat, sourceSeatNo, tileDisplayName, "");
return unsupportedAction(ActionType.GANG);
}
private List<GameEvent> hu(GameSession session, String userId, String tileDisplayName, Integer sourceSeatNo) {
GameTable table = session.getTable();
GameSeat seat = findSeatByUserId(table, userId);
validateResponseAction(table, seat, sourceSeatNo, tileDisplayName, "");
return unsupportedAction(ActionType.HU);
}
private List<GameEvent> pass(GameSession session, String userId, Integer sourceSeatNo) {
GameTable table = session.getTable();
GameSeat seat = findSeatByUserId(table, userId);
validateResponseAction(table, seat, sourceSeatNo, null, "", false);
return unsupportedAction(ActionType.PASS);
}
private List<GameEvent> unsupportedAction(ActionType actionType) {
throw new BusinessException("GAME_ACTION_UNSUPPORTED", "当前动作尚未实现: " + actionType.name());
}
private List<GameEvent> selectLackSuit(GameSession session, String userId, String lackSuitValue) {
GameTable table = session.getTable();
if (table.getPhase() != GamePhase.LACK_SELECTION) {
throw new BusinessException("GAME_PHASE_INVALID", "当前阶段不允许选择定缺");
}
GameSeat seat = findSeatByUserId(table, userId);
TileSuit lackSuit = parseSuit(lackSuitValue);
seat.setLackSuit(lackSuit);
List<GameEvent> events = new ArrayList<>();
events.add(GameEvent.lackSelected(session.getGameId(), seat.getSeatNo(), lackSuit.name()));
if (allSeatsSelectedLack(table)) {
table.setPhase(GamePhase.PLAYING);
events.add(GameEvent.phaseChanged(session.getGameId(), table.getPhase().name()));
}
return events;
}
private List<GameEvent> discard(GameSession session, String userId, String tileDisplayName) {
GameTable table = session.getTable();
if (table.getPhase() != GamePhase.PLAYING) {
throw new BusinessException("GAME_PHASE_INVALID", "当前阶段不允许出牌");
}
GameSeat seat = findSeatByUserId(table, userId);
if (seat.getSeatNo() != table.getCurrentSeatNo()) {
throw new BusinessException("GAME_TURN_INVALID", "当前不是该玩家的出牌回合");
}
Tile tile = findTileInHand(seat, tileDisplayName);
seat.discard(tile);
List<GameEvent> events = new ArrayList<>();
events.add(GameEvent.tileDiscarded(session.getGameId(), seat.getSeatNo(), tile.getDisplayName()));
return events;
}
private ActionType parseActionType(String value) {
try {
return ActionType.valueOf(value.toUpperCase());
} catch (IllegalArgumentException exception) {
throw new BusinessException("GAME_ACTION_INVALID", "动作类型不合法");
}
}
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 TileSuit parseSuit(String value) {
try {
return TileSuit.valueOf(value.toUpperCase());
} catch (IllegalArgumentException exception) {
throw new BusinessException("LACK_SUIT_INVALID", "定缺花色不合法");
}
}
private boolean allSeatsSelectedLack(GameTable table) {
for (GameSeat seat : table.getSeats()) {
if (seat.getLackSuit() == null) {
return false;
}
}
return true;
}
private Tile findTileInHand(GameSeat seat, String tileDisplayName) {
return seat.getHandTiles().stream()
.filter(tile -> tile.getDisplayName().equals(tileDisplayName))
.findFirst()
.orElseThrow(() -> new BusinessException("GAME_TILE_NOT_FOUND", "指定牌不在当前手牌中"));
}
private void validateResponseAction(
GameTable table,
GameSeat actorSeat,
Integer sourceSeatNo,
String tileDisplayName,
String actionName
) {
validateResponseAction(table, actorSeat, sourceSeatNo, tileDisplayName, actionName, true);
}
private void validateResponseAction(
GameTable table,
GameSeat actorSeat,
Integer sourceSeatNo,
String tileDisplayName,
String actionName,
boolean requiresTile
) {
if (table.getPhase() != GamePhase.PLAYING) {
throw new BusinessException("GAME_PHASE_INVALID", "当前阶段不允许" + actionName);
}
if (sourceSeatNo == null) {
throw new BusinessException("GAME_ACTION_PARAM_INVALID", actionName + "动作缺少来源座位");
}
if (sourceSeatNo < 0 || sourceSeatNo >= table.getSeats().size()) {
throw new BusinessException("GAME_ACTION_PARAM_INVALID", actionName + "动作来源座位不合法");
}
if (sourceSeatNo == actorSeat.getSeatNo()) {
throw new BusinessException("GAME_ACTION_PARAM_INVALID", actionName + "动作来源座位不能是自己");
}
if (requiresTile && isBlank(tileDisplayName)) {
throw new BusinessException("GAME_ACTION_PARAM_INVALID", actionName + "动作缺少目标牌");
}
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
}

View File

@@ -0,0 +1,256 @@
package com.xuezhanmaster.game.service;
import com.xuezhanmaster.common.exception.BusinessException;
import com.xuezhanmaster.game.domain.ActionType;
import com.xuezhanmaster.game.domain.GamePhase;
import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.GameSession;
import com.xuezhanmaster.game.domain.GameTable;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.dto.GameActionRequest;
import com.xuezhanmaster.game.dto.GameStateResponse;
import com.xuezhanmaster.game.dto.PublicSeatView;
import com.xuezhanmaster.game.dto.SelfSeatView;
import com.xuezhanmaster.game.engine.GameEngine;
import com.xuezhanmaster.game.event.GameEvent;
import com.xuezhanmaster.game.event.GameEventType;
import com.xuezhanmaster.room.domain.Room;
import com.xuezhanmaster.room.service.RoomService;
import com.xuezhanmaster.strategy.domain.StrategyDecision;
import com.xuezhanmaster.strategy.service.StrategyService;
import com.xuezhanmaster.teaching.domain.PlayerVisibleGameState;
import com.xuezhanmaster.teaching.domain.TeachingMode;
import com.xuezhanmaster.teaching.dto.TeachingAdviceResponse;
import com.xuezhanmaster.teaching.service.PlayerVisibilityService;
import com.xuezhanmaster.teaching.service.TeachingService;
import com.xuezhanmaster.ws.service.GameMessagePublisher;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class GameSessionService {
private final RoomService roomService;
private final GameEngine gameEngine;
private final GameActionProcessor gameActionProcessor;
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(
RoomService roomService,
GameEngine gameEngine,
GameActionProcessor gameActionProcessor,
StrategyService strategyService,
PlayerVisibilityService playerVisibilityService,
TeachingService teachingService,
GameMessagePublisher gameMessagePublisher
) {
this.roomService = roomService;
this.gameEngine = gameEngine;
this.gameActionProcessor = gameActionProcessor;
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) {
GameSession session = getRequiredSession(gameId);
ActionType actionType = parseActionType(request.actionType());
List<GameEvent> events = gameActionProcessor.process(session, request);
appendAndPublish(session, events);
handlePostActionEffects(session, actionType);
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.getLackSuit() == null ? null : self.getLackSuit().name(),
self.getHandTiles().stream().map(Tile::getDisplayName).toList(),
self.getDiscardTiles().stream().map(Tile::getDisplayName).toList()
),
buildPublicSeats(table)
);
}
private void moveToNextSeat(GameTable table, String gameId) {
int nextSeatNo = (table.getCurrentSeatNo() + 1) % table.getSeats().size();
GameSeat nextSeat = table.getSeats().get(nextSeatNo);
if (table.getWallTiles().isEmpty()) {
table.setPhase(GamePhase.FINISHED);
gameMessagePublisher.publishPublicEvent(GameEvent.phaseChanged(gameId, table.getPhase().name()));
return;
}
Tile drawnTile = table.getWallTiles().remove(0);
nextSeat.receiveTile(drawnTile);
table.setCurrentSeatNo(nextSeatNo);
gameMessagePublisher.publishPublicEvent(GameEvent.tileDrawn(gameId, nextSeatNo, table.getWallTiles().size()));
gameMessagePublisher.publishPublicEvent(GameEvent.turnSwitched(gameId, nextSeatNo));
}
private void autoPlayBots(GameSession session) {
GameTable table = session.getTable();
while (table.getPhase() == GamePhase.PLAYING) {
GameSeat currentSeat = table.getSeats().get(table.getCurrentSeatNo());
if (!currentSeat.isAi()) {
return;
}
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(table, session.getGameId());
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()) {
return;
}
gameMessagePublisher.publishPrivateActionRequired(
session.getGameId(),
currentSeat.getPlayerId(),
List.of("DISCARD"),
currentSeat.getSeatNo()
);
PlayerVisibleGameState visibleState = playerVisibilityService.buildVisibleState(session.getTable(), currentSeat);
StrategyDecision decision = strategyService.evaluateDiscard(visibleState);
TeachingAdviceResponse advice = teachingService.buildAdvice(decision, TeachingMode.BRIEF);
gameMessagePublisher.publishPrivateTeaching(
session.getGameId(),
currentSeat.getPlayerId(),
advice.teachingMode(),
advice.recommendedAction(),
advice.explanation()
);
}
private void handlePostActionEffects(GameSession session, ActionType actionType) {
switch (actionType) {
case SELECT_LACK_SUIT -> {
if (session.getTable().getPhase() == GamePhase.PLAYING) {
notifyActionIfHumanTurn(session);
}
}
case DISCARD -> {
moveToNextSeat(session.getTable(), session.getGameId());
autoPlayBots(session);
notifyActionIfHumanTurn(session);
}
default -> {
// 其余动作在后续 Sprint 中补充对应副作用
}
}
}
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) {
return table.getSeats().stream()
.map(seat -> new PublicSeatView(
seat.getSeatNo(),
seat.getPlayerId(),
seat.getNickname(),
seat.isAi(),
seat.getLackSuit() == null ? null : seat.getLackSuit().name(),
seat.getHandTiles().size(),
seat.getDiscardTiles().stream().map(Tile::getDisplayName).toList()
))
.toList();
}
}