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,12 @@
package com.xuezhanmaster;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class XueZhanMasterApplication {
public static void main(String[] args) {
SpringApplication.run(XueZhanMasterApplication.class, args);
}
}

View File

@@ -0,0 +1,18 @@
package com.xuezhanmaster.common.api;
public record ApiResponse<T>(
boolean success,
String code,
String message,
T data
) {
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, "OK", "success", data);
}
public static <T> ApiResponse<T> failure(String code, String message) {
return new ApiResponse<>(false, code, message, null);
}
}

View File

@@ -0,0 +1,20 @@
package com.xuezhanmaster.common.api;
import com.xuezhanmaster.common.exception.BusinessException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ApiResponse<Void> handleBusinessException(BusinessException exception) {
return ApiResponse.failure(exception.getCode(), exception.getMessage());
}
@ExceptionHandler(Exception.class)
public ApiResponse<Void> handleGenericException(Exception exception) {
return ApiResponse.failure("INTERNAL_ERROR", exception.getMessage());
}
}

View File

@@ -0,0 +1,21 @@
package com.xuezhanmaster.common.api;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/health")
public class HealthController {
@GetMapping
public ApiResponse<Map<String, Object>> health() {
return ApiResponse.success(Map.of(
"status", "UP",
"service", "XueZhanMaster",
"variant", "sichuan-blood-battle"
));
}
}

View File

@@ -0,0 +1,16 @@
package com.xuezhanmaster.common.exception;
public class BusinessException extends RuntimeException {
private final String code;
public BusinessException(String code, String message) {
super(message);
this.code = code;
}
public String getCode() {
return code;
}
}

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();
}
}

View File

@@ -0,0 +1,57 @@
package com.xuezhanmaster.room.controller;
import com.xuezhanmaster.common.api.ApiResponse;
import com.xuezhanmaster.room.dto.CreateRoomRequest;
import com.xuezhanmaster.room.dto.JoinRoomRequest;
import com.xuezhanmaster.room.dto.RoomSummaryResponse;
import com.xuezhanmaster.room.dto.ToggleReadyRequest;
import com.xuezhanmaster.room.service.RoomService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
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.RestController;
import org.springframework.web.bind.annotation.PathVariable;
@RestController
@RequestMapping("/api/rooms")
public class RoomController {
private final RoomService roomService;
public RoomController(RoomService roomService) {
this.roomService = roomService;
}
@PostMapping
public ApiResponse<RoomSummaryResponse> create(@Valid @RequestBody CreateRoomRequest request) {
return ApiResponse.success(roomService.createRoom(request));
}
@GetMapping("/{roomId}")
public ApiResponse<RoomSummaryResponse> detail(@PathVariable String roomId) {
return ApiResponse.success(roomService.getRoomSummary(roomId));
}
@PostMapping("/{roomId}/join")
public ApiResponse<RoomSummaryResponse> join(
@PathVariable String roomId,
@Valid @RequestBody JoinRoomRequest request
) {
return ApiResponse.success(roomService.joinRoom(roomId, request));
}
@PostMapping("/{roomId}/ready")
public ApiResponse<RoomSummaryResponse> ready(
@PathVariable String roomId,
@Valid @RequestBody ToggleReadyRequest request
) {
return ApiResponse.success(roomService.toggleReady(roomId, request));
}
@GetMapping("/demo")
public ApiResponse<RoomSummaryResponse> demo() {
return ApiResponse.success(roomService.createDemoRoom());
}
}

View File

@@ -0,0 +1,8 @@
package com.xuezhanmaster.room.domain;
public enum BotLevel {
BEGINNER,
STANDARD,
EXPERT
}

View File

@@ -0,0 +1,7 @@
package com.xuezhanmaster.room.domain;
public enum ParticipantType {
HUMAN,
BOT
}

View File

@@ -0,0 +1,7 @@
package com.xuezhanmaster.room.domain;
public enum ReadyStatus {
NOT_READY,
READY
}

View File

@@ -0,0 +1,69 @@
package com.xuezhanmaster.room.domain;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class Room {
private final String roomId;
private final String ownerUserId;
private final String inviteCode;
private final int maxPlayers;
private final Instant createdAt;
private final List<RoomSeat> seats = new ArrayList<>();
private RoomStatus status;
private boolean allowBotFill;
public Room(String ownerUserId, boolean allowBotFill) {
this.roomId = UUID.randomUUID().toString();
this.ownerUserId = ownerUserId;
this.inviteCode = UUID.randomUUID().toString().substring(0, 6).toUpperCase();
this.maxPlayers = 4;
this.createdAt = Instant.now();
this.status = RoomStatus.WAITING;
this.allowBotFill = allowBotFill;
}
public String getRoomId() {
return roomId;
}
public String getOwnerUserId() {
return ownerUserId;
}
public String getInviteCode() {
return inviteCode;
}
public int getMaxPlayers() {
return maxPlayers;
}
public Instant getCreatedAt() {
return createdAt;
}
public List<RoomSeat> getSeats() {
return seats;
}
public RoomStatus getStatus() {
return status;
}
public void setStatus(RoomStatus status) {
this.status = status;
}
public boolean isAllowBotFill() {
return allowBotFill;
}
public void setAllowBotFill(boolean allowBotFill) {
this.allowBotFill = allowBotFill;
}
}

View File

@@ -0,0 +1,65 @@
package com.xuezhanmaster.room.domain;
public class RoomSeat {
private final int seatNo;
private final ParticipantType participantType;
private final String displayName;
private final String userId;
private final BotLevel botLevel;
private ReadyStatus readyStatus;
private boolean teachingEnabled;
public RoomSeat(
int seatNo,
ParticipantType participantType,
String displayName,
String userId,
BotLevel botLevel,
boolean teachingEnabled
) {
this.seatNo = seatNo;
this.participantType = participantType;
this.displayName = displayName;
this.userId = userId;
this.botLevel = botLevel;
this.readyStatus = ReadyStatus.NOT_READY;
this.teachingEnabled = teachingEnabled;
}
public int getSeatNo() {
return seatNo;
}
public ParticipantType getParticipantType() {
return participantType;
}
public String getDisplayName() {
return displayName;
}
public String getUserId() {
return userId;
}
public BotLevel getBotLevel() {
return botLevel;
}
public ReadyStatus getReadyStatus() {
return readyStatus;
}
public void setReadyStatus(ReadyStatus readyStatus) {
this.readyStatus = readyStatus;
}
public boolean isTeachingEnabled() {
return teachingEnabled;
}
public void setTeachingEnabled(boolean teachingEnabled) {
this.teachingEnabled = teachingEnabled;
}
}

View File

@@ -0,0 +1,10 @@
package com.xuezhanmaster.room.domain;
public enum RoomStatus {
WAITING,
READY,
PLAYING,
FINISHED,
DISMISSED
}

View File

@@ -0,0 +1,10 @@
package com.xuezhanmaster.room.dto;
import jakarta.validation.constraints.NotBlank;
public record CreateRoomRequest(
@NotBlank String ownerUserId,
boolean allowBotFill
) {
}

View File

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

View File

@@ -0,0 +1,12 @@
package com.xuezhanmaster.room.dto;
public record RoomSeatView(
int seatNo,
String participantType,
String displayName,
String botLevel,
String readyStatus,
boolean teachingEnabled
) {
}

View File

@@ -0,0 +1,13 @@
package com.xuezhanmaster.room.dto;
import java.util.List;
public record RoomSummaryResponse(
String roomId,
String inviteCode,
String status,
boolean allowBotFill,
List<RoomSeatView> seats
) {
}

View File

@@ -0,0 +1,10 @@
package com.xuezhanmaster.room.dto;
import jakarta.validation.constraints.NotBlank;
public record ToggleReadyRequest(
@NotBlank String userId,
boolean ready
) {
}

View File

@@ -0,0 +1,160 @@
package com.xuezhanmaster.room.service;
import com.xuezhanmaster.common.exception.BusinessException;
import com.xuezhanmaster.room.domain.BotLevel;
import com.xuezhanmaster.room.domain.ParticipantType;
import com.xuezhanmaster.room.domain.ReadyStatus;
import com.xuezhanmaster.room.domain.Room;
import com.xuezhanmaster.room.domain.RoomSeat;
import com.xuezhanmaster.room.domain.RoomStatus;
import com.xuezhanmaster.room.dto.CreateRoomRequest;
import com.xuezhanmaster.room.dto.JoinRoomRequest;
import com.xuezhanmaster.room.dto.RoomSeatView;
import com.xuezhanmaster.room.dto.RoomSummaryResponse;
import com.xuezhanmaster.room.dto.ToggleReadyRequest;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class RoomService {
private final Map<String, Room> rooms = new ConcurrentHashMap<>();
public RoomSummaryResponse createRoom(CreateRoomRequest request) {
Room room = new Room(request.ownerUserId(), request.allowBotFill());
room.getSeats().add(new RoomSeat(0, ParticipantType.HUMAN, "Host", request.ownerUserId(), null, true));
rooms.put(room.getRoomId(), room);
return toSummary(room);
}
public Room getRequiredRoom(String roomId) {
Room room = rooms.get(roomId);
if (room == null) {
throw new BusinessException("ROOM_NOT_FOUND", "房间不存在");
}
return room;
}
public RoomSummaryResponse getRoomSummary(String roomId) {
return toSummary(getRequiredRoom(roomId));
}
public RoomSummaryResponse joinRoom(String roomId, JoinRoomRequest request) {
Room room = getRequiredRoom(roomId);
if (room.getStatus() != RoomStatus.WAITING && room.getStatus() != RoomStatus.READY) {
throw new BusinessException("ROOM_JOIN_FORBIDDEN", "当前房间不允许加入");
}
if (room.getSeats().stream().anyMatch(seat -> request.userId().equals(seat.getUserId()))) {
throw new BusinessException("ROOM_USER_EXISTS", "玩家已经在房间中");
}
if (room.getSeats().size() >= room.getMaxPlayers()) {
throw new BusinessException("ROOM_FULL", "房间人数已满");
}
room.getSeats().add(new RoomSeat(
room.getSeats().size(),
ParticipantType.HUMAN,
request.displayName(),
request.userId(),
null,
false
));
room.setStatus(RoomStatus.WAITING);
return toSummary(room);
}
public RoomSummaryResponse toggleReady(String roomId, ToggleReadyRequest request) {
Room room = getRequiredRoom(roomId);
RoomSeat seat = room.getSeats().stream()
.filter(item -> request.userId().equals(item.getUserId()))
.findFirst()
.orElseThrow(() -> new BusinessException("ROOM_SEAT_NOT_FOUND", "玩家不在房间中"));
seat.setReadyStatus(request.ready() ? ReadyStatus.READY : ReadyStatus.NOT_READY);
boolean allHumanReady = room.getSeats().stream()
.filter(item -> item.getParticipantType() == ParticipantType.HUMAN)
.allMatch(item -> item.getReadyStatus() == ReadyStatus.READY);
room.setStatus(allHumanReady ? RoomStatus.READY : RoomStatus.WAITING);
return toSummary(room);
}
public Room prepareRoomForGame(String roomId, String operatorUserId) {
Room room = getRequiredRoom(roomId);
if (!room.getOwnerUserId().equals(operatorUserId)) {
throw new BusinessException("ROOM_FORBIDDEN", "只有房主可以开局");
}
if (room.getStatus() == RoomStatus.PLAYING) {
throw new BusinessException("ROOM_ALREADY_PLAYING", "房间已经在对局中");
}
boolean allHumanReady = room.getSeats().stream()
.filter(seat -> seat.getParticipantType() == ParticipantType.HUMAN)
.allMatch(seat -> seat.getReadyStatus() == ReadyStatus.READY);
if (!allHumanReady) {
throw new BusinessException("ROOM_NOT_READY", "还有真人玩家未准备");
}
fillBotsIfNeeded(room);
room.setStatus(RoomStatus.PLAYING);
room.getSeats().forEach(seat -> seat.setReadyStatus(ReadyStatus.READY));
return room;
}
public RoomSummaryResponse createDemoRoom() {
Room room = new Room("demo-host", true);
room.getSeats().add(new RoomSeat(0, ParticipantType.HUMAN, "Host", "demo-host", null, true));
room.getSeats().add(new RoomSeat(1, ParticipantType.HUMAN, "Player-2", "player-2", null, false));
room.getSeats().add(new RoomSeat(2, ParticipantType.BOT, "Bot-Standard", null, BotLevel.STANDARD, false));
room.getSeats().add(new RoomSeat(3, ParticipantType.BOT, "Bot-Expert", null, BotLevel.EXPERT, false));
room.getSeats().forEach(seat -> seat.setReadyStatus(ReadyStatus.READY));
return toSummary(room);
}
private RoomSummaryResponse toSummary(Room room) {
List<RoomSeatView> seatViews = new ArrayList<>();
for (RoomSeat seat : room.getSeats()) {
seatViews.add(new RoomSeatView(
seat.getSeatNo(),
seat.getParticipantType().name(),
seat.getDisplayName(),
seat.getBotLevel() == null ? null : seat.getBotLevel().name(),
seat.getReadyStatus().name(),
seat.isTeachingEnabled()
));
}
return new RoomSummaryResponse(
room.getRoomId(),
room.getInviteCode(),
room.getStatus().name(),
room.isAllowBotFill(),
seatViews
);
}
private void fillBotsIfNeeded(Room room) {
if (!room.isAllowBotFill()) {
return;
}
int nextSeatNo = room.getSeats().size();
BotLevel[] levels = {BotLevel.BEGINNER, BotLevel.STANDARD, BotLevel.EXPERT};
int botIndex = 0;
while (room.getSeats().size() < room.getMaxPlayers()) {
BotLevel botLevel = levels[botIndex % levels.length];
room.getSeats().add(new RoomSeat(
nextSeatNo,
ParticipantType.BOT,
"Bot-" + botLevel.name(),
null,
botLevel,
false
));
nextSeatNo++;
botIndex++;
}
}
}

View File

@@ -0,0 +1,38 @@
package com.xuezhanmaster.strategy.domain;
import com.xuezhanmaster.game.domain.ActionType;
import com.xuezhanmaster.game.domain.Tile;
import java.util.List;
public class CandidateAction {
private final ActionType actionType;
private final Tile tile;
private final int score;
private final List<ReasonTag> reasonTags;
public CandidateAction(ActionType actionType, Tile tile, int score, List<ReasonTag> reasonTags) {
this.actionType = actionType;
this.tile = tile;
this.score = score;
this.reasonTags = reasonTags;
}
public ActionType getActionType() {
return actionType;
}
public Tile getTile() {
return tile;
}
public int getScore() {
return score;
}
public List<ReasonTag> getReasonTags() {
return reasonTags;
}
}

View File

@@ -0,0 +1,10 @@
package com.xuezhanmaster.strategy.domain;
public enum ReasonTag {
LACK_SUIT_PRIORITY,
ISOLATED_TILE,
KEEP_PAIR,
EDGE_TILE,
KEEP_SEQUENCE_POTENTIAL
}

View File

@@ -0,0 +1,10 @@
package com.xuezhanmaster.strategy.domain;
import java.util.List;
public record StrategyDecision(
CandidateAction recommendedAction,
List<CandidateAction> candidates
) {
}

View File

@@ -0,0 +1,73 @@
package com.xuezhanmaster.strategy.service;
import com.xuezhanmaster.game.domain.ActionType;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.strategy.domain.CandidateAction;
import com.xuezhanmaster.strategy.domain.ReasonTag;
import com.xuezhanmaster.strategy.domain.StrategyDecision;
import com.xuezhanmaster.teaching.domain.PlayerVisibleGameState;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
public class StrategyService {
public StrategyDecision evaluateDiscard(PlayerVisibleGameState visibleState) {
Map<Tile, Long> counts = visibleState.handTiles().stream()
.collect(Collectors.groupingBy(tile -> tile, Collectors.counting()));
List<CandidateAction> candidates = visibleState.handTiles().stream()
.distinct()
.map(tile -> scoreTile(visibleState, tile, counts))
.sorted(Comparator.comparingInt(CandidateAction::getScore).reversed())
.limit(3)
.toList();
return new StrategyDecision(candidates.get(0), candidates);
}
private CandidateAction scoreTile(PlayerVisibleGameState visibleState, Tile tile, Map<Tile, Long> counts) {
int score = 50;
List<ReasonTag> reasonTags = new ArrayList<>();
if (tile.getSuit() == visibleState.lackSuit()) {
score += 30;
reasonTags.add(ReasonTag.LACK_SUIT_PRIORITY);
}
long sameCount = counts.getOrDefault(tile, 0L);
if (sameCount == 1) {
score += 10;
reasonTags.add(ReasonTag.ISOLATED_TILE);
} else if (sameCount >= 2) {
score -= 15;
reasonTags.add(ReasonTag.KEEP_PAIR);
}
if (tile.getRank() == 1 || tile.getRank() == 9) {
score += 8;
reasonTags.add(ReasonTag.EDGE_TILE);
}
if (hasNeighbor(visibleState.handTiles(), tile, -1) || hasNeighbor(visibleState.handTiles(), tile, 1)) {
score -= 12;
reasonTags.add(ReasonTag.KEEP_SEQUENCE_POTENTIAL);
}
return new CandidateAction(ActionType.DISCARD, tile, score, reasonTags);
}
private boolean hasNeighbor(List<Tile> handTiles, Tile tile, int offset) {
int targetRank = tile.getRank() + offset;
if (targetRank < 1 || targetRank > 9) {
return false;
}
return handTiles.stream()
.anyMatch(item -> item.getSuit() == tile.getSuit() && item.getRank() == targetRank);
}
}

View File

@@ -0,0 +1,17 @@
package com.xuezhanmaster.teaching.domain;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.game.domain.TileSuit;
import java.util.List;
public record PlayerVisibleGameState(
String gameId,
int seatNo,
TileSuit lackSuit,
List<Tile> handTiles,
List<String> publicDiscards,
List<String> publicMelds
) {
}

View File

@@ -0,0 +1,9 @@
package com.xuezhanmaster.teaching.domain;
public enum TeachingMode {
OFF,
BRIEF,
STANDARD,
EXPERT
}

View File

@@ -0,0 +1,11 @@
package com.xuezhanmaster.teaching.dto;
import java.util.List;
public record CandidateAdviceItem(
String tile,
int score,
List<String> reasonTags
) {
}

View File

@@ -0,0 +1,13 @@
package com.xuezhanmaster.teaching.dto;
import java.util.List;
public record TeachingAdviceResponse(
boolean teachingEnabled,
String teachingMode,
String recommendedAction,
String explanation,
List<CandidateAdviceItem> candidates
) {
}

View File

@@ -0,0 +1,33 @@
package com.xuezhanmaster.teaching.service;
import com.xuezhanmaster.game.domain.GameSeat;
import com.xuezhanmaster.game.domain.GameTable;
import com.xuezhanmaster.game.domain.Tile;
import com.xuezhanmaster.teaching.domain.PlayerVisibleGameState;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class PlayerVisibilityService {
public PlayerVisibleGameState buildVisibleState(GameTable table, GameSeat seat) {
List<String> publicDiscards = new ArrayList<>();
for (GameSeat gameSeat : table.getSeats()) {
for (Tile tile : gameSeat.getDiscardTiles()) {
publicDiscards.add(gameSeat.getSeatNo() + ":" + tile.getDisplayName());
}
}
return new PlayerVisibleGameState(
table.getTableId(),
seat.getSeatNo(),
seat.getLackSuit(),
List.copyOf(seat.getHandTiles()),
publicDiscards,
List.of()
);
}
}

View File

@@ -0,0 +1,41 @@
package com.xuezhanmaster.teaching.service;
import com.xuezhanmaster.strategy.domain.CandidateAction;
import com.xuezhanmaster.strategy.domain.ReasonTag;
import com.xuezhanmaster.strategy.domain.StrategyDecision;
import com.xuezhanmaster.teaching.domain.TeachingMode;
import com.xuezhanmaster.teaching.dto.CandidateAdviceItem;
import com.xuezhanmaster.teaching.dto.TeachingAdviceResponse;
import org.springframework.stereotype.Service;
@Service
public class TeachingService {
public TeachingAdviceResponse buildAdvice(StrategyDecision decision, TeachingMode teachingMode) {
CandidateAction best = decision.recommendedAction();
return new TeachingAdviceResponse(
teachingMode != TeachingMode.OFF,
teachingMode.name(),
best.getTile().getDisplayName(),
buildExplanation(best),
decision.candidates().stream()
.map(candidate -> new CandidateAdviceItem(
candidate.getTile().getDisplayName(),
candidate.getScore(),
candidate.getReasonTags().stream().map(Enum::name).toList()
))
.toList()
);
}
private String buildExplanation(CandidateAction candidate) {
if (candidate.getReasonTags().contains(ReasonTag.LACK_SUIT_PRIORITY)) {
return "建议优先处理定缺花色,先降低缺门压力,再保留其余两门的成型效率。";
}
if (candidate.getReasonTags().contains(ReasonTag.ISOLATED_TILE)) {
return "这张牌当前是孤张,对现有搭子帮助较小,先打掉能保留更多后续进张空间。";
}
return "这张牌综合效率最低,先打出更有利于保留连张与对子。";
}
}

View File

@@ -0,0 +1,24 @@
package com.xuezhanmaster.ws.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic");
registry.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").setAllowedOriginPatterns("*");
}
}

View File

@@ -0,0 +1,12 @@
package com.xuezhanmaster.ws.dto;
import java.util.List;
public record PrivateActionMessage(
String gameId,
String userId,
List<String> availableActions,
int currentSeatNo
) {
}

View File

@@ -0,0 +1,11 @@
package com.xuezhanmaster.ws.dto;
public record PrivateTeachingMessage(
String gameId,
String userId,
String teachingMode,
String recommendedAction,
String explanation
) {
}

View File

@@ -0,0 +1,14 @@
package com.xuezhanmaster.ws.dto;
import java.time.Instant;
import java.util.Map;
public record PublicGameMessage(
String gameId,
String eventType,
Integer seatNo,
Map<String, Object> payload,
Instant createdAt
) {
}

View File

@@ -0,0 +1,47 @@
package com.xuezhanmaster.ws.service;
import com.xuezhanmaster.game.event.GameEvent;
import com.xuezhanmaster.ws.dto.PrivateActionMessage;
import com.xuezhanmaster.ws.dto.PrivateTeachingMessage;
import com.xuezhanmaster.ws.dto.PublicGameMessage;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class GameMessagePublisher {
private final SimpMessagingTemplate messagingTemplate;
public GameMessagePublisher(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}
public void publishPublicEvent(GameEvent event) {
messagingTemplate.convertAndSend(
"/topic/games/" + event.gameId() + "/events",
new PublicGameMessage(
event.gameId(),
event.eventType().name(),
event.seatNo(),
event.payload(),
event.createdAt()
)
);
}
public void publishPrivateActionRequired(String gameId, String userId, List<String> availableActions, int currentSeatNo) {
messagingTemplate.convertAndSend(
"/topic/users/" + userId + "/actions",
new PrivateActionMessage(gameId, userId, availableActions, currentSeatNo)
);
}
public void publishPrivateTeaching(String gameId, String userId, String teachingMode, String recommendedAction, String explanation) {
messagingTemplate.convertAndSend(
"/topic/users/" + userId + "/teaching",
new PrivateTeachingMessage(gameId, userId, teachingMode, recommendedAction, explanation)
);
}
}