first commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.xuezhanmaster.game.domain;
|
||||
|
||||
public enum ActionType {
|
||||
SELECT_LACK_SUIT,
|
||||
DRAW,
|
||||
DISCARD,
|
||||
PENG,
|
||||
GANG,
|
||||
HU,
|
||||
PASS
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.xuezhanmaster.game.domain;
|
||||
|
||||
public enum GamePhase {
|
||||
WAITING,
|
||||
LACK_SELECTION,
|
||||
PLAYING,
|
||||
FINISHED
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.xuezhanmaster.game.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record DiscardTileRequest(
|
||||
@NotBlank String userId,
|
||||
@NotBlank String tile
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.xuezhanmaster.game.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record SelectLackSuitRequest(
|
||||
@NotBlank String userId,
|
||||
@NotBlank String lackSuit
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.xuezhanmaster.game.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record StartGameRequest(
|
||||
@NotBlank String operatorUserId
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.xuezhanmaster.room.domain;
|
||||
|
||||
public enum BotLevel {
|
||||
BEGINNER,
|
||||
STANDARD,
|
||||
EXPERT
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.xuezhanmaster.room.domain;
|
||||
|
||||
public enum ParticipantType {
|
||||
HUMAN,
|
||||
BOT
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.xuezhanmaster.room.domain;
|
||||
|
||||
public enum ReadyStatus {
|
||||
NOT_READY,
|
||||
READY
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.xuezhanmaster.room.domain;
|
||||
|
||||
public enum RoomStatus {
|
||||
WAITING,
|
||||
READY,
|
||||
PLAYING,
|
||||
FINISHED,
|
||||
DISMISSED
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.xuezhanmaster.room.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record CreateRoomRequest(
|
||||
@NotBlank String ownerUserId,
|
||||
boolean allowBotFill
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.xuezhanmaster.room.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record JoinRoomRequest(
|
||||
@NotBlank String userId,
|
||||
@NotBlank String displayName
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.xuezhanmaster.room.dto;
|
||||
|
||||
public record RoomSeatView(
|
||||
int seatNo,
|
||||
String participantType,
|
||||
String displayName,
|
||||
String botLevel,
|
||||
String readyStatus,
|
||||
boolean teachingEnabled
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.xuezhanmaster.room.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record ToggleReadyRequest(
|
||||
@NotBlank String userId,
|
||||
boolean ready
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.xuezhanmaster.strategy.domain;
|
||||
|
||||
public enum ReasonTag {
|
||||
LACK_SUIT_PRIORITY,
|
||||
ISOLATED_TILE,
|
||||
KEEP_PAIR,
|
||||
EDGE_TILE,
|
||||
KEEP_SEQUENCE_POTENTIAL
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.xuezhanmaster.strategy.domain;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record StrategyDecision(
|
||||
CandidateAction recommendedAction,
|
||||
List<CandidateAction> candidates
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.xuezhanmaster.teaching.domain;
|
||||
|
||||
public enum TeachingMode {
|
||||
OFF,
|
||||
BRIEF,
|
||||
STANDARD,
|
||||
EXPERT
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.xuezhanmaster.teaching.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record CandidateAdviceItem(
|
||||
String tile,
|
||||
int score,
|
||||
List<String> reasonTags
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 "这张牌综合效率最低,先打出更有利于保留连张与对子。";
|
||||
}
|
||||
}
|
||||
@@ -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("*");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.xuezhanmaster.ws.dto;
|
||||
|
||||
public record PrivateTeachingMessage(
|
||||
String gameId,
|
||||
String userId,
|
||||
String teachingMode,
|
||||
String recommendedAction,
|
||||
String explanation
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
12
backend/src/main/resources/application.yml
Normal file
12
backend/src/main/resources/application.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
spring:
|
||||
application:
|
||||
name: xzmaster-backend
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
xzmaster:
|
||||
variant: sichuan-blood-battle
|
||||
ai:
|
||||
provider: mock
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.xuezhanmaster.game.event;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class GameEventTest {
|
||||
|
||||
@Test
|
||||
void shouldBuildResponseActionPayloadForFutureResponseEvents() {
|
||||
GameEvent event = GameEvent.responseActionDeclared(
|
||||
"game-1",
|
||||
GameEventType.PENG_DECLARED,
|
||||
2,
|
||||
1,
|
||||
"三万"
|
||||
);
|
||||
|
||||
assertThat(event.eventType()).isEqualTo(GameEventType.PENG_DECLARED);
|
||||
assertThat(event.seatNo()).isEqualTo(2);
|
||||
assertThat(event.payload())
|
||||
.containsEntry("actionType", "PENG")
|
||||
.containsEntry("sourceSeatNo", 1)
|
||||
.containsEntry("tile", "三万");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldBuildStandardPublicTableEvents() {
|
||||
GameEvent started = GameEvent.gameStarted("game-1", "room-1");
|
||||
GameEvent phaseChanged = GameEvent.phaseChanged("game-1", "PLAYING");
|
||||
GameEvent switched = GameEvent.turnSwitched("game-1", 3);
|
||||
|
||||
assertThat(started.eventType()).isEqualTo(GameEventType.GAME_STARTED);
|
||||
assertThat(started.payload()).containsEntry("roomId", "room-1");
|
||||
|
||||
assertThat(phaseChanged.eventType()).isEqualTo(GameEventType.GAME_PHASE_CHANGED);
|
||||
assertThat(phaseChanged.payload()).containsEntry("phase", "PLAYING");
|
||||
|
||||
assertThat(switched.eventType()).isEqualTo(GameEventType.TURN_SWITCHED);
|
||||
assertThat(switched.payload()).containsEntry("currentSeatNo", 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.xuezhanmaster.game.service;
|
||||
|
||||
import com.xuezhanmaster.game.domain.Tile;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DeckFactoryTest {
|
||||
|
||||
private final DeckFactory deckFactory = new DeckFactory();
|
||||
|
||||
@Test
|
||||
void shouldCreate108TilesForSichuanVariant() {
|
||||
List<Tile> wall = deckFactory.createShuffledWall();
|
||||
|
||||
assertThat(wall).hasSize(108);
|
||||
Map<Tile, Long> counts = wall.stream()
|
||||
.collect(Collectors.groupingBy(tile -> tile, Collectors.counting()));
|
||||
assertThat(counts.values()).allMatch(count -> count == 4L);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.xuezhanmaster.game.service;
|
||||
|
||||
import com.xuezhanmaster.game.dto.GameTableSnapshot;
|
||||
import com.xuezhanmaster.game.engine.GameEngine;
|
||||
import com.xuezhanmaster.strategy.service.StrategyService;
|
||||
import com.xuezhanmaster.teaching.dto.TeachingAdviceResponse;
|
||||
import com.xuezhanmaster.teaching.service.PlayerVisibilityService;
|
||||
import com.xuezhanmaster.teaching.service.TeachingService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DemoGameServiceTest {
|
||||
|
||||
private final DemoGameService demoGameService = new DemoGameService(
|
||||
new GameEngine(new DeckFactory()),
|
||||
new StrategyService(),
|
||||
new PlayerVisibilityService(),
|
||||
new TeachingService()
|
||||
);
|
||||
|
||||
@Test
|
||||
void shouldCreateDemoTableSnapshot() {
|
||||
GameTableSnapshot snapshot = demoGameService.createDemoTableSnapshot();
|
||||
|
||||
assertThat(snapshot.seats()).hasSize(4);
|
||||
assertThat(snapshot.remainingWallCount()).isEqualTo(55);
|
||||
assertThat(snapshot.seats().get(0).handTiles()).hasSize(14);
|
||||
assertThat(snapshot.seats().get(1).handTiles()).hasSize(13);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnAdviceCandidates() {
|
||||
TeachingAdviceResponse advice = demoGameService.createDemoTeachingAdvice();
|
||||
|
||||
assertThat(advice.recommendedAction()).isNotBlank();
|
||||
assertThat(advice.candidates()).hasSizeLessThanOrEqualTo(3);
|
||||
assertThat(advice.explanation()).isNotBlank();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.xuezhanmaster.game.service;
|
||||
|
||||
import com.xuezhanmaster.common.exception.BusinessException;
|
||||
import com.xuezhanmaster.game.dto.GameActionRequest;
|
||||
import com.xuezhanmaster.game.domain.TileSuit;
|
||||
import com.xuezhanmaster.game.dto.GameStateResponse;
|
||||
import com.xuezhanmaster.game.service.GameActionProcessor;
|
||||
import com.xuezhanmaster.strategy.service.StrategyService;
|
||||
import com.xuezhanmaster.teaching.service.PlayerVisibilityService;
|
||||
import com.xuezhanmaster.teaching.service.TeachingService;
|
||||
import com.xuezhanmaster.room.dto.CreateRoomRequest;
|
||||
import com.xuezhanmaster.room.dto.RoomSummaryResponse;
|
||||
import com.xuezhanmaster.room.dto.ToggleReadyRequest;
|
||||
import com.xuezhanmaster.room.service.RoomService;
|
||||
import com.xuezhanmaster.game.engine.GameEngine;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
|
||||
import com.xuezhanmaster.ws.service.GameMessagePublisher;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class GameSessionServiceTest {
|
||||
|
||||
private final RoomService roomService = new RoomService();
|
||||
private final GameSessionService gameSessionService = new GameSessionService(
|
||||
roomService,
|
||||
new GameEngine(new DeckFactory()),
|
||||
new GameActionProcessor(),
|
||||
new StrategyService(),
|
||||
new PlayerVisibilityService(),
|
||||
new TeachingService(),
|
||||
new GameMessagePublisher(mock(SimpMessagingTemplate.class))
|
||||
);
|
||||
|
||||
@Test
|
||||
void shouldStartGameAndEnterPlayingAfterHumanSelectsLackSuit() {
|
||||
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
|
||||
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
|
||||
|
||||
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
|
||||
assertThat(started.phase()).isEqualTo("LACK_SELECTION");
|
||||
assertThat(started.seats()).hasSize(4);
|
||||
assertThat(started.selfSeat().handTiles()).hasSize(14);
|
||||
|
||||
GameStateResponse afterLack = gameSessionService.selectLackSuit(
|
||||
started.gameId(),
|
||||
"host-1",
|
||||
TileSuit.WAN.name()
|
||||
);
|
||||
|
||||
assertThat(afterLack.phase()).isEqualTo("PLAYING");
|
||||
assertThat(afterLack.selfSeat().lackSuit()).isEqualTo(TileSuit.WAN.name());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDiscardAndLoopBackToHumanAfterBotsAutoPlay() {
|
||||
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
|
||||
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
|
||||
|
||||
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
|
||||
GameStateResponse playing = gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
|
||||
String discardTile = playing.selfSeat().handTiles().get(0);
|
||||
|
||||
GameStateResponse afterDiscard = gameSessionService.discardTile(playing.gameId(), "host-1", discardTile);
|
||||
|
||||
assertThat(afterDiscard.phase()).isEqualTo("PLAYING");
|
||||
assertThat(afterDiscard.currentSeatNo()).isEqualTo(0);
|
||||
assertThat(afterDiscard.selfSeat().handTiles()).hasSize(14);
|
||||
assertThat(afterDiscard.remainingWallCount()).isEqualTo(51);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRouteNewActionTypesThroughUnifiedActionEntry() {
|
||||
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
|
||||
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
|
||||
|
||||
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
|
||||
gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
|
||||
|
||||
assertThatThrownBy(() -> gameSessionService.performAction(
|
||||
started.gameId(),
|
||||
new GameActionRequest("host-1", "PENG", "三万", 1)
|
||||
))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.extracting(throwable -> ((BusinessException) throwable).getCode())
|
||||
.isEqualTo("GAME_ACTION_UNSUPPORTED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectResponseActionWithoutSourceSeatNo() {
|
||||
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
|
||||
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
|
||||
|
||||
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
|
||||
gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
|
||||
|
||||
assertThatThrownBy(() -> gameSessionService.performAction(
|
||||
started.gameId(),
|
||||
new GameActionRequest("host-1", "PENG", "三万", null)
|
||||
))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.extracting(throwable -> ((BusinessException) throwable).getCode())
|
||||
.isEqualTo("GAME_ACTION_PARAM_INVALID");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectResponseActionOutsidePlayingPhase() {
|
||||
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
|
||||
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
|
||||
|
||||
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
|
||||
|
||||
assertThatThrownBy(() -> gameSessionService.performAction(
|
||||
started.gameId(),
|
||||
new GameActionRequest("host-1", "PASS", null, 1)
|
||||
))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.extracting(throwable -> ((BusinessException) throwable).getCode())
|
||||
.isEqualTo("GAME_PHASE_INVALID");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectResponseActionFromSelfSeat() {
|
||||
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
|
||||
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
|
||||
|
||||
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
|
||||
gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
|
||||
|
||||
assertThatThrownBy(() -> gameSessionService.performAction(
|
||||
started.gameId(),
|
||||
new GameActionRequest("host-1", "PASS", null, 0)
|
||||
))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.extracting(throwable -> ((BusinessException) throwable).getCode())
|
||||
.isEqualTo("GAME_ACTION_PARAM_INVALID");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectUnknownActionTypeFromUnifiedActionEntry() {
|
||||
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
|
||||
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
|
||||
|
||||
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
|
||||
|
||||
assertThatThrownBy(() -> gameSessionService.performAction(
|
||||
started.gameId(),
|
||||
new GameActionRequest("host-1", "UNKNOWN_ACTION", null, null)
|
||||
))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.extracting(throwable -> ((BusinessException) throwable).getCode())
|
||||
.isEqualTo("GAME_ACTION_INVALID");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.xuezhanmaster.room.service;
|
||||
|
||||
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 org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class RoomServiceTest {
|
||||
|
||||
private final RoomService roomService = new RoomService();
|
||||
|
||||
@Test
|
||||
void shouldCreateAndQueryRoomFromMemory() {
|
||||
RoomSummaryResponse created = roomService.createRoom(new CreateRoomRequest("host-1", true));
|
||||
RoomSummaryResponse queried = roomService.getRoomSummary(created.roomId());
|
||||
|
||||
assertThat(queried.roomId()).isEqualTo(created.roomId());
|
||||
assertThat(queried.seats()).hasSize(1);
|
||||
assertThat(queried.allowBotFill()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldJoinRoomAndBecomeReady() {
|
||||
RoomSummaryResponse created = roomService.createRoom(new CreateRoomRequest("host-1", true));
|
||||
roomService.toggleReady(created.roomId(), new ToggleReadyRequest("host-1", true));
|
||||
RoomSummaryResponse joined = roomService.joinRoom(created.roomId(), new JoinRoomRequest("player-2", "Player-2"));
|
||||
RoomSummaryResponse ready = roomService.toggleReady(created.roomId(), new ToggleReadyRequest("player-2", true));
|
||||
|
||||
assertThat(joined.seats()).hasSize(2);
|
||||
assertThat(ready.status()).isEqualTo("READY");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user