一、说明
本章介绍全部由AI生成的玩家,完成狼人杀游戏。做这个目的纯属爱好,自从了解了一些AI相关的技术后,总想着做点有趣的东西,一边练技术,一边打发时间吧~~
采用的前后分离开发模式:
后端技术:Spring AI、Spring AI alibaba、SpringBoot、Mysql、Redis、SSE等等
前端技术:VUE、ElementUI等等
目前后端基本完工,正在开发前端页面中....
准备过年在家写完~~
当然,游戏还有很多小逻辑并不完善,后面会慢慢修改,也欢迎有兴趣的同学们一起探讨~~
二、代码
1.POM文件
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.5</version>
<relativePath/>
</parent>
<groupId>com.zhan</groupId>
<artifactId>game-wolf</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>game-wolf</name>
<description>全AI狼人杀游戏</description>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<java.version>17</java.version>
<spring-ai.version>1.0.0</spring-ai.version>
<spring-ai-alibaba.version>1.0.0.2</spring-ai-alibaba.version>
<spring-boot.version>3.4.5</spring-boot.version>
<mybatis-plus.version>3.5.11</mybatis-plus.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
<version>${spring-ai-alibaba.version}</version>
<type>pom</type>
</dependency>
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-memory-redis</artifactId>
<version>${spring-ai-alibaba.version}</version>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<!-- 添加 Redis 向量数据库依赖 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-redis</artifactId>
<version>1.0.0</version>
</dependency>
<!-- MySQL 驱动(完整正确配置) -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
<version>8.0.33</version> <!-- 固定版本,避免版本适配问题 -->
</dependency>
<!--mybatis-plus 持久层-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- SpringDoc OpenAPI 3.0 -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.7.0</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.34</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.16</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.12.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId>
<version>4.4</version>
</dependency>
<!-- 支持 Java 8 时间类型(LocalDateTime/LocalDate 等)的序列化/反序列化 -->
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<!-- Spring Boot 父工程已管理版本,无需手动指定 -->
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.83</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>17</source>
<target>17</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
</plugin>
</plugins>
</build>
</project>
2.SQL脚本
-- 房间表
CREATE TABLE `room` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '房间ID',
`room_name` varchar(50) NOT NULL COMMENT '房间名称',
`player_count` int NOT NULL COMMENT '玩家数量',
`game_stage` varchar(20) NOT NULL COMMENT '游戏阶段',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
`status` int NOT NULL COMMENT '房间状态:0-未开始 1-进行中 2-已结束',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='狼人杀房间表';
-- AI玩家表
CREATE TABLE `ai_player` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '玩家ID',
`room_id` bigint NOT NULL COMMENT '房间ID',
`player_no` int NOT NULL COMMENT '玩家编号(1-12)',
`role` varchar(20) NOT NULL COMMENT '角色',
`ai_model_code` varchar(50) NOT NULL COMMENT '绑定的AI模型ID',
`status` varchar(20) NOT NULL COMMENT '状态:ALIVE-存活 DEAD-死亡',
`last_speak` text COMMENT '最后发言内容',
PRIMARY KEY (`id`),
KEY `idx_room_id` (`room_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI玩家表';
3.yml主配置文件
server:
port: 8080
application:
name: ai-wolf-kill
spring:
profiles:
active: dev
4.yml开发配置文件
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/gizz?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: xxxxxxxxxxxxxx
# Redis配置(集群)
data:
redis:
host: xxxxxxxxxxxxxx
port: 6379
timeout: 3000
database: 0
password: xxxxxxxxxxxxxx
jedis:
pool:
max-active: 20
max-idle: 10
min-idle: 5
ai:
dashscope:
api-key: sk-09c7b571687b46d5a2e25a03fbddxxxx
client:
connection-timeout: 30000
read-timeout: 180000
call-timeout: 240000
retry:
max-attempts: 10
backoff:
initial-interval: 5000ms
multiplier: 1.5
# MyBatis-Plus配置
mybatis-plus:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
logging:
file:
name: wolf.log
path: logs
logback:
rollingPolicy:
maxFileSize: 100MB
maxHistory: 30
5.config配置类
package com.zhan.wolf.config;
import com.alibaba.cloud.ai.dashscope.api.DashScopeApi;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import java.util.HashMap;
import java.util.Map;
/**
* @author zhanxuewei
*/
@Configuration
public class ChatClientFactory {
@Value("${spring.ai.dashscope.api-key}")
private String apiKey;
// 缓存已创建的 ChatClient 实例
private final Map<String, ChatClient> chatClientCache = new HashMap<>();
/**
* 根据模型代码构建对应的 ChatClient
*
* @param modelCode 模型代码
* @return 对应的 ChatClient 实例
*/
public ChatClient buildChatClient(String modelCode) {
// 检查缓存中是否存在
if (chatClientCache.containsKey(modelCode)) {
return chatClientCache.get(modelCode);
}
// 创建新的 ChatClient 并缓存
ChatClient chatClient = createChatClient(modelCode);
chatClientCache.put(modelCode, chatClient);
return chatClient;
}
/**
* 创建 ChatClient 实例
*
* @param modelCode 模型代码
* @return ChatClient 实例
*/
private ChatClient createChatClient(String modelCode) {
DashScopeChatModel chatModel = DashScopeChatModel.builder()
.dashScopeApi(DashScopeApi.builder().apiKey(apiKey).build())
.defaultOptions(DashScopeChatOptions.builder().withModel(modelCode).build())
.build();
return ChatClient.builder(chatModel)
.defaultOptions(ChatOptions.builder().model(modelCode).build())
.build();
}
}
6.接口文档配置
package com.zhan.game.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class Knife4jConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info()
.title("API接口文档")
.description("API接口文档")
.version("1.0.0")
.contact(new Contact()
.name("wolf")
.email("603085899@qq.com")));
}
}
7.Redis配置
package com.zhan.game.config;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(factory);
// ========== 1. Key序列化(统一为字符串,无任何冲突) ==========
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringRedisSerializer);
redisTemplate.setHashKeySerializer(stringRedisSerializer);
// ========== 2. Value序列化(支持所有类型,自动处理类型ID) ==========
GenericJackson2JsonRedisSerializer jacksonSerializer = new GenericJackson2JsonRedisSerializer(buildObjectMapper() // 自定义ObjectMapper,解决类型ID解析问题
);
redisTemplate.setValueSerializer(jacksonSerializer);
redisTemplate.setHashValueSerializer(jacksonSerializer);
// ========== 3. 初始化模板 ==========
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
/**
* 构建ObjectMapper,解决「Could not resolve type id」问题
*/
private ObjectMapper buildObjectMapper() {
ObjectMapper om = new ObjectMapper();
// 1. 开启类型信息存储(序列化时记录类型,反序列化时识别)
om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, // 放宽类型校验(兼容简单类型如String/Integer)
ObjectMapper.DefaultTyping.NON_FINAL, // 仅为非final类添加类型ID(避免String/Integer等基础类型报错)
JsonTypeInfo.As.PROPERTY // 类型ID作为属性存储(如@class: java.lang.String)
);
// 2. 其他配置(可选,提升兼容性)
om.enableDefaultTyping();
om.findAndRegisterModules(); // 注册所有Jackson模块(如Java8时间类型)
return om;
}
}
8.常量
package com.zhan.game.constants;
public interface ApiConstants {
public static final String API_PREFIX = "/api/v1";
}
9.常量
package com.zhan.game.constants;
public interface GameConstants {
String DEAD = "DEAD";
String ALIVE = "ALIVE";
int DEFAULT_PLAYER_COUNT = 12;
int PLAYER_NO_START_INDEX = 1;
int ROOM_STATUS_ACTIVE = 1;
}
10.统一接口返回封装
package com.zhan.game.common;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.io.Serializable;
/**
* 通用响应对象
*/
public class ApiResult<T> implements Serializable {
private static final long serialVersionUID = 899231L;
private Long code;
private String message;
private T data;
protected ApiResult() {
}
/**
* 构造响应对象
*
* @param code 状态吗
* @param message 返回信息
* @param data 返回数据
*/
public ApiResult(Long code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}
/**
* 响应成功结果
*
* @param <T> 范形对象
* @return 返回范形对象
*/
public static <T> ApiResult<T> success() {
return new ApiResult<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), null);
}
/**
* 响应成功结果
*
* @param code 状态吗
* @param message 返回消息
* @param <T> 范形对象
* @return 范形对象
*/
public static <T> ApiResult<T> success(Long code, String message) {
return new ApiResult<>(code, message, null);
}
/**
* 响应成功结果
*
* @param data
* @param <T>
* @return
*/
public static <T> ApiResult<T> success(T data) {
return new ApiResult<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data);
}
/**
* 响应成功结果
*
* @param code
* @param message
* @param data
* @param <T>
* @return
*/
public static <T> ApiResult<T> success(Long code, String message, T data) {
return new ApiResult<>(code, message, data);
}
/**
* 响应成功结果
*
* @param message
* @param data
* @param <T>
* @return
*/
public static <T> ApiResult<T> success(String message, T data) {
return new ApiResult<>(ResultCode.SUCCESS.getCode(), message, data);
}
/**
* 响应成功结果
*
* @param errorCode
* @param data
* @param <T>
* @return
*/
public static <T> ApiResult<T> success(IErrorCode errorCode, T data) {
return new ApiResult<>(errorCode.getCode(), errorCode.getMessage(), data);
}
/**
* 响应成功结果
*
* @param errorCode
* @param <T>
* @return
*/
public static <T> ApiResult<T> success(IErrorCode errorCode) {
return success(errorCode, null);
}
/**
* 失败返回结果
*
* @param <T>
* @return
*/
public static <T> ApiResult<T> failed() {
return new ApiResult<>(ResultCode.FAILED.getCode(), ResultCode.FAILED.getMessage(), null);
}
/**
* 失败返回结果
*
* @param code
* @param message
* @param <T>
* @return
*/
public static <T> ApiResult<T> failed(Long code, String message) {
return new ApiResult<>(code, message, null);
}
/**
* 失败返回结果
*
* @param message
* @param <T>
* @return
*/
public static <T> ApiResult<T> failed(String message) {
return new ApiResult<>(ResultCode.FAILED.getCode(), message, null);
}
/**
* 失败返回结果
*
* @param errorCode
* @param <T>
* @return
*/
public static <T> ApiResult<T> failed(IErrorCode errorCode) {
return new ApiResult<>(errorCode.getCode(), errorCode.getMessage(), null);
}
/**
* 失败返回结果
*
* @param errorCode
* @param data
* @param <T>
* @return
*/
public static <T> ApiResult<T> failed(IErrorCode errorCode, T data) {
return new ApiResult<>(errorCode.getCode(), errorCode.getMessage(), data);
}
/**
* 失败返回结果
*
* @param message
* @param data
* @param <T>
* @return
*/
public static <T> ApiResult<T> failed(String message, T data) {
return new ApiResult<>(ResultCode.FAILED.getCode(), message, data);
}
public long getCode() {
return code;
}
public void setCode(long code) {
this.code = code;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
@Override
public String toString() {
return "CommonResult{" +
"code=" + code +
", message='" + message + '\'' +
", data=" + data +
'}';
}
}
11.错误模型接口
package com.zhan.game.common;
/**
* 错误模型接口
*/
public interface IErrorCode {
// 状态码
Long getCode();
// 错误消息
String getMessage();
}
12.返回结果枚举
package com.zhan.game.common;
/**
* 枚举了一些常用API操作码
*/
public enum ResultCode implements IErrorCode {
SUCCESS(200L, "操作成功"),
FAILED(500L, "操作失败"),
INVALID_ERROR(10005L, "参数格式校验失败"),
PARAMETER_TYPE_ERROR(10006L, "参数类型错误"),
OTHER_ERROR(10007L, "未知异常"),
FILE_SIZE_TOO_BIG_ERROR(10008L, "上传文件大小超出限制"),
ROOM_NOT_FOUND(2000L, "房间不存在"),
GAME_OVER(2001L, "游戏已结束"),
ADVANCE_GAME_STAGE_ERROR(2002L, "推进游戏阶段失败"),
ROOM_NO_PLAYER(2003L, "房间内无玩家"),
PLAYER_NOT_FOUND(2004L, "玩家编号不存在"),
GET_AI_SPEAK_RECORD_ERROR(2005L, "获取发言失败"),
GET_GAME_STATUS_ERROR(2006L, "获取状态失败"),
PLAYER_DEAD_OR_NOT_FOUND(2007L, "玩家不存在或已死亡"),
TRIGGER_AI_SPEAK_ERROR(2008L, "触发AI发言失败"),
ROLE_NOT_FOUND(2009L, "角色不存在"),
ROOM_EXIST(2010L, "房间已存在"),
ROOM_CREATION_BUSY(2011L, "房间创建繁忙,请稍后再试"),
;
private final Long code;
private final String message;
ResultCode(Long code, String message) {
this.code = code;
this.message = message;
}
@Override
public Long getCode() {
return code;
}
@Override
public String getMessage() {
return message;
}
}
13.控制器
package com.zhan.wolf.controller;
import com.zhan.wolf.common.ApiResult;
import com.zhan.wolf.common.ResultCode;
import com.zhan.wolf.constants.ApiConstants;
import com.zhan.wolf.entity.AiPlayer;
import com.zhan.wolf.entity.Room;
import com.zhan.wolf.service.AiPlayerService;
import com.zhan.wolf.service.GameService;
import jakarta.annotation.Resource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
@Slf4j
@RestController
@RequestMapping(ApiConstants.API_PREFIX + "/game")
@RequiredArgsConstructor
public class WerewolfController {
@Resource
private GameService gameService;
@Resource
private AiPlayerService aiPlayerService;
/**
* 创建狼人杀房间
*
* @param request 请求参数(roomName)
* @return 房间信息
*/
@PostMapping("/room/create")
public ApiResult<Room> createRoom(@RequestBody Map<String, String> request) {
String roomName = request.get("roomName");
Room room = gameService.createRoom(roomName);
return ApiResult.success(room);
}
/**
* 查询房间内所有AI玩家
*
* @param roomId 房间ID
* @return 玩家列表
*/
@GetMapping("/room/players/{roomId}")
public ApiResult<List<AiPlayer>> getRoomPlayers(@PathVariable Long roomId) {
return ApiResult.success(aiPlayerService.getRoomPlayers(roomId));
}
// ========== 新增核心接口 ==========
/**
* 推进游戏阶段(核心接口:触发夜晚/白天流程)
* 调用逻辑:创建房间后,调用此接口推进游戏,自动完成当前阶段的所有AI行动
*
* @param roomId 房间ID
* @return 阶段推进结果(包含当前阶段、AI行动概要)
*/
@PostMapping("/room/advance/{roomId}")
public ApiResult<String> advanceGameStage(@PathVariable Long roomId) throws ExecutionException, InterruptedException {
String gameStage = gameService.advanceGameStage(roomId);
return ApiResult.success(gameStage);
}
/**
* 获取AI玩家的发言/行动记录(核心:查看AI对话内容)
*
* @param roomId 房间ID
* @param playerNo 玩家编号(可选,不传则返回所有AI的发言)
* @return 发言记录
*/
@GetMapping("/room/player/speak/{roomId}")
public ApiResult<List<AiPlayer>> getAiSpeakRecord(@PathVariable Long roomId, @RequestParam(required = false) Integer playerNo) {
List<AiPlayer> record = gameService.getAiSpeakRecord(roomId, playerNo);
return ApiResult.success(record);
}
/**
* 获取房间当前游戏状态(全局视角)
*
* @param roomId 房间ID
* @return 游戏状态(阶段、存活/死亡玩家、胜负结果等)
*/
@GetMapping("/room/status/{roomId}")
public ApiResult<Map<String, Object>> getGameStatus(@PathVariable Long roomId) {
Map<String, Object> result = gameService.getGameStatus(roomId);
return ApiResult.success(result);
}
/**
* 手动触发单个AI玩家发言(测试用,可选)
*
* @param roomId 房间ID
* @param playerNo 玩家编号
* @param prompt 自定义发言提示词(可选,不传则用默认提示词)
* @return 玩家发言内容
*/
@PostMapping("/room/player/speak/{roomId}/{playerNo}")
public ApiResult<Map<String, Object>> triggerAiSpeak(@PathVariable Long roomId,
@PathVariable Integer playerNo,
@RequestParam(required = false) String prompt) {
Map<String, Object> result = gameService.triggerAiSpeak(roomId, playerNo, prompt);
return ApiResult.success(result);
}
/**
* 建立 SSE 连接
*/
@GetMapping("/sse/connect/{roomId}")
public SseEmitter connect(@PathVariable Long roomId) {
return gameService.sseConnect(roomId);
}
}
14.实体类
package com.zhan.game.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
@Data
@TableName("ai_player")
public class AiPlayer implements Serializable {
@TableId(type = IdType.AUTO)
private Long id; // 玩家ID
private Long roomId; // 房间ID
private Integer playerNo; // 玩家编号(1-12)
private String role; // 角色(关联RoleEnum)
private String aiModelCode; // 绑定的AI模型ID
private String status; // 状态:ALIVE-存活 DEAD-死亡
private String lastSpeak; // 最后发言内容
}
package com.zhan.game.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
@Data
@TableName("room")
public class Room implements Serializable {
@TableId(type = IdType.AUTO)
private Long id; // 房间ID
private String roomName; // 房间名称
private Integer playerCount; // 玩家数量(固定12)
private String gameStage; // 游戏阶段(关联GameStageEnum)
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime createTime; // 创建时间
@JsonSerialize(using = LocalDateTimeSerializer.class)
@JsonDeserialize(using = LocalDateTimeDeserializer.class)
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime updateTime; // 更新时间
private Integer status; // 房间状态:0-未开始 1-进行中 2-已结束
}
15.游戏相关的枚举
package com.zhan.game.enums;
public enum GameResult {
CONTINUE,
WOLF_WIN,
GOOD_WIN
}
package com.zhan.game.enums;
import lombok.Getter;
@Getter
public enum GameStageEnum {
NIGHT("夜晚阶段", 1), // 狼人杀人、预言家验人、女巫用药、守卫守护
DAY("白天阶段", 2), // 发言、投票
END("游戏结束", 3);
private final String name;
private final int order;
GameStageEnum(String name, int order) {
this.name = name;
this.order = order;
}
}
package com.zhan.game.enums;
import lombok.Getter;
@Getter
public enum ModelEnum {
WEREWOLF("狼人", "deepseek-v3.2"),
CIVILIAN("平民", "qwen3-max-preview"),
SEER("预言家", "deepseek-v3.2"),
WITCH("女巫", "deepseek-v3.2"),
HUNTER("猎人", "deepseek-v3.2"),
GUARD("守卫", "deepseek-v3.2"),
;
private final String name;
private final String code;
ModelEnum(String name, String code) {
this.name = name;
this.code = code;
}
public static ModelEnum getModeCodeByName(String name) {
for (ModelEnum modelEnum : ModelEnum.values()) {
if (modelEnum.getName().equals(name)) {
return modelEnum;
}
}
return ModelEnum.WEREWOLF;
}
}
package com.zhan.game.enums;
import lombok.Getter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@Getter
public enum RoleEnum {
WEREWOLF("狼人", 4),
CIVILIAN("平民", 4),
SEER("预言家", 1),
WITCH("女巫", 1),
HUNTER("猎人", 1),
GUARD("守卫", 1);
private final String name;
private final int count; // 12人局该角色数量
RoleEnum(String name, int count) {
this.name = name;
this.count = count;
}
// 获取12人局所有角色列表(按数量生成)
public static List<RoleEnum> getFullRoles() {
List<RoleEnum> roles = new ArrayList<>();
for (RoleEnum role : RoleEnum.values()) {
for (int i = 0; i < role.getCount(); i++) {
roles.add(role);
}
}
// 随机打乱顺序
Collections.shuffle(roles);
return roles;
}
public static RoleEnum getRoleByName(String name) {
for (RoleEnum roleEnum : RoleEnum.values()) {
if (roleEnum.getName().equals(name)) {
return roleEnum;
}
}
return null;
}
}
package com.zhan.game.enums;
import lombok.Getter;
/**
* 女巫药水状态枚举
*/
@Getter
public enum WitchStatusEnum {
ANTIDOTE_AVAILABLE("解药可用", 1),
ANTIDOTE_USED("解药已用", 0),
POISON_AVAILABLE("毒药可用", 1),
POISON_USED("毒药已用", 0);
private final String desc;
private final int status;
WitchStatusEnum(String desc, int status) {
this.desc = desc;
this.status = status;
}
}
16.Mapper类
package com.zhan.game.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.zhan.game.entity.AiPlayer;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface AiPlayerMapper extends BaseMapper<AiPlayer> {
}
package com.zhan.game.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.zhan.game.entity.Room;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface RoomMapper extends BaseMapper<Room> {
}
17.Service接口
package com.zhan.game.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.zhan.game.entity.Room;
public interface RoomService extends IService<Room> {
void updateRoomStatus(Long roomId, int status);
void updateRoomStage(Long roomId, String name);
}
package com.zhan.game.service;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
public interface RedisService {
// ========== 基础对象操作 ==========
<T> void setObject(String key, T value);
<T> void setObject(String key, T value, long timeout, TimeUnit unit);
@SuppressWarnings("unchecked")
<T> T getObject(String key, Class<T> clazz);
// ========== List操作 ==========
<T> void listRightPush(String key, T value);
<T> void listRightPushAll(String key, List<T> values);
@SuppressWarnings("unchecked")
<T> List<T> listRange(String key, long start, long end);
// ========== Hash操作 ==========
<T> void hashPut(String key, String hashKey, T value);
<T> void hashPutAll(String key, Map<String, T> map);
@SuppressWarnings("unchecked")
<T> T hashGet(String key, String hashKey, Class<T> clazz);
// ========== 通用操作 ==========
boolean hasKey(String key);
void deleteKey(String key);
/**
* 获取Hash的所有键值对(解决entries读取的序列化问题)
*
* @param key Redis的Hash Key
* @return 键值对Map(自动处理序列化/反序列化)
*/
Map<Object, Object> hashGetAllEntries(String key);
/**
* 设置键值对,仅当键不存在时才设置(分布式锁的基础操作)
* @param key 键
* @param value 值
* @param timeout 过期时间
* @param unit 时间单位
* @return 是否设置成功
*/
Boolean setIfAbsent(String key, Object value, long timeout, TimeUnit unit);
/**
* 删除键值对,仅当键存在且值相等时才删除(用于释放分布式锁)
* @param key 键
* @param value 值
* @return 是否删除成功
*/
Boolean deleteIfEquals(String key, Object value);
}
package com.zhan.game.service;
import com.zhan.game.entity.AiPlayer;
import com.zhan.game.entity.Room;
import com.zhan.game.enums.GameResult;
import java.util.List;
import java.util.Map;
public interface GameService {
Room createRoom(String roomName);
List<AiPlayer> getRoomPlayers(Long roomId);
List<AiPlayer> findDeadLastNight(Long roomId);
void clearNightDeadRecord(Long roomId);
void recordNightDeadPlayers(Long roomId, String killedByWolf, String poisonedByWitch, String guardTarget);
void nightStage(Long roomId);
void dayStage(Long roomId);
GameResult checkWinOrLose(Long roomId);
/**
* 推进游戏阶段(核心接口:触发夜晚/白天流程)
* 调用逻辑:创建房间后,调用此接口推进游戏,自动完成当前阶段的所有AI行动
*
* @param roomId 房间ID
* @return 阶段推进结果(包含当前阶段、AI行动概要)
*/
Map<String, Object> advanceGameStage(Long roomId);
/**
* 获取AI玩家的发言/行动记录(核心:查看AI对话内容)
*
* @param roomId 房间ID
* @param playerNo 玩家编号(可选,不传则返回所有AI的发言)
* @return 发言记录
*/
Map<String, Object> getAiSpeakRecord(Long roomId, Integer playerNo);
/**
* 获取房间当前游戏状态(全局视角)
*
* @param roomId 房间ID
* @return 游戏状态(阶段、存活/死亡玩家、胜负结果等)
*/
Map<String, Object> getGameStatus(Long roomId);
/**
* 手动触发单个AI玩家发言(测试用,可选)
*
* @param roomId 房间ID
* @param playerNo 玩家编号
* @param prompt 自定义发言提示词(可选,不传则用默认提示词)
* @return 玩家发言内容
*/
Map<String, Object> triggerAiSpeak(Long roomId, Integer playerNo, String prompt);
}
package com.zhan.game.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.zhan.game.entity.AiPlayer;
import com.zhan.game.enums.RoleEnum;
import java.util.List;
public interface AiPlayerService extends IService<AiPlayer> {
String aiAction(AiPlayer player, String prompt);
List<AiPlayer> getRoomPlayers(Long roomId);
void initWitchStatus(Long roomId);
AiPlayer findByPlayerNo(List<AiPlayer> players, Integer no);
void usePoison(Long roomId);
boolean validateWitchTarget(List<AiPlayer> players, String poisonTarget, AiPlayer witch);
List<AiPlayer> findAliveAll(Long roomId);
void useAntidote(Long roomId);
List<AiPlayer> findAliveListByRole(Long roomId, String role);
long countAliveCivilian(List<AiPlayer> players);
long countAliveGod(List<AiPlayer> players);
boolean getWitchPoison(Long roomId);
boolean getWitchAntidote(Long roomId);
List<AiPlayer> findDeadLastNight(Long roomId);
}
package com.zhan.game.service;
import com.zhan.game.entity.AiPlayer;
import com.zhan.game.entity.Room;
import com.zhan.game.enums.GameResult;
import com.zhan.game.vo.GameStageAdvanceVO;
import java.util.List;
import java.util.Map;
public interface GameService {
/**
* 创建房间并生成12个AI玩家
*
* @param roomName 房间名称
* @return 房间信息
*/
Room createRoom(String roomName);
/**
* 清空夜间死亡记录(白天阶段结束后调用,避免跨夜混淆)
*
* @param roomId 房间ID
*/
void clearNightDeadRecord(Long roomId);
/**
* 记录夜间死亡玩家(狼人刀杀/女巫毒杀)
*
* @param roomId 房间ID
* @param killedByWolf 狼人刀杀目标(null表示被救/无刀人)
* @param poisonedByWitch 女巫毒杀目标(null表示未毒人)
* @param guardTarget 守卫守护目标(null表示未守护)
*/
void recordNightDeadPlayers(Long roomId, String killedByWolf, String poisonedByWitch, String guardTarget);
/**
* 执行完整夜晚流程:守卫 -> 狼人 -> 预言家 -> 女巫
*/
void nightStage(Long roomId);
/**
* 白天完整流程:
* 1. 公布昨夜死讯
* 2. 按顺序发言
* 3. 公投
* 4. 判胜负
*/
void dayStage(Long roomId);
/**
* 检查胜负结果
*
* @param roomId 房间ID
* @return 胜负结果
*/
GameResult checkWinOrLose(Long roomId);
/**
* 推进游戏阶段(核心接口:触发夜晚/白天流程)
* 调用逻辑:创建房间后,调用此接口推进游戏,自动完成当前阶段的所有AI行动
*
* @param roomId 房间ID
* @return 阶段推进结果(包含当前阶段、AI行动概要)
*/
GameStageAdvanceVO advanceGameStage(Long roomId);
/**
* 获取AI玩家的发言/行动记录(核心:查看AI对话内容)
*
* @param roomId 房间ID
* @param playerNo 玩家编号(可选,不传则返回所有AI的发言)
* @return 发言记录
*/
List<AiPlayer> getAiSpeakRecord(Long roomId, Integer playerNo);
/**
* 获取房间当前游戏状态(全局视角)
*
* @param roomId 房间ID
* @return 游戏状态(阶段、存活/死亡玩家、胜负结果等)
*/
Map<String, Object> getGameStatus(Long roomId);
/**
* 手动触发单个AI玩家发言(测试用,可选)
*
* @param roomId 房间ID
* @param playerNo 玩家编号
* @param prompt 自定义发言提示词(可选,不传则用默认提示词)
* @return 玩家发言内容
*/
Map<String, Object> triggerAiSpeak(Long roomId, Integer playerNo, String prompt);
/**
* AI投票统计
*
* @param list 投票玩家列表
* @param roomId 房间ID
* @return 最终投票目标
*/
String collectVoteFromAi(List<AiPlayer> list, Long roomId);
}
18.Service接口实现
package com.zhan.wolf.service.impl;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.zhan.wolf.entity.Room;
import com.zhan.wolf.mapper.RoomMapper;
import com.zhan.wolf.service.RoomService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class RoomServiceImpl extends ServiceImpl<RoomMapper, Room> implements RoomService {
@Override
public void updateRoomStatus(Long roomId, int status) {
super.update(new LambdaUpdateWrapper<Room>()
.set(Room::getStatus, status)
.eq(Room::getId, roomId)
);
}
@Override
public void updateRoomStage(Long roomId, String name) {
super.update(new LambdaUpdateWrapper<Room>()
.set(Room::getGameStage, name)
.eq(Room::getId, roomId)
);
}
}
package com.zhan.game.service.impl;
import com.zhan.game.service.RedisService;
import jakarta.annotation.Resource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
* Redis通用工具类(统一处理对象/List/Hash存储)
*/
@Service
public class RedisServiceImpl implements RedisService {
@Resource
private RedisTemplate<String, Object> redisTemplate;
// ========== 基础对象操作 ==========
@Override
public <T> void setObject(String key, T value) {
redisTemplate.opsForValue().set(key, value);
}
@Override
public <T> void setObject(String key, T value, long timeout, TimeUnit unit) {
redisTemplate.opsForValue().set(key, value, timeout, unit);
}
@SuppressWarnings("unchecked")
@Override
public <T> T getObject(String key, Class<T> clazz) {
Object value = redisTemplate.opsForValue().get(key);
return value != null ? (T) value : null;
}
// ========== List操作 ==========
@Override
public <T> void listRightPush(String key, T value) {
redisTemplate.opsForList().rightPush(key, value);
}
@Override
public <T> void listRightPushAll(String key, List<T> values) {
if (values != null && !values.isEmpty()) {
redisTemplate.opsForList().rightPushAll(key, values.toArray());
}
}
@SuppressWarnings("unchecked")
@Override
public <T> List<T> listRange(String key, long start, long end) {
return (List<T>) redisTemplate.opsForList().range(key, start, end);
}
// ========== Hash操作 ==========
@Override
public <T> void hashPut(String key, String hashKey, T value) {
redisTemplate.opsForHash().put(key, hashKey, value);
}
@Override
public <T> void hashPutAll(String key, Map<String, T> map) {
if (map != null && !map.isEmpty()) {
redisTemplate.opsForHash().putAll(key, map);
}
}
@SuppressWarnings("unchecked")
@Override
public <T> T hashGet(String key, String hashKey, Class<T> clazz) {
Object value = redisTemplate.opsForHash().get(key, hashKey);
return value != null ? (T) value : null;
}
// ========== 通用操作 ==========
@Override
public boolean hasKey(String key) {
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
@Override
public void deleteKey(String key) {
redisTemplate.delete(key);
}
/**
* 获取Hash的所有键值对(解决entries读取的序列化问题)
*
* @param key Redis的Hash Key
* @return 键值对Map(自动处理序列化/反序列化)
*/
@Override
public Map<Object, Object> hashGetAllEntries(String key) {
if (!hasKey(key)) {
// 若key不存在,返回空Map而非null,避免业务代码空指针
return new HashMap<>();
}
return redisTemplate.opsForHash().entries(key);
}
@Override
public Boolean setIfAbsent(String key, Object value, long timeout, TimeUnit unit) {
return redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit);
}
@Override
public Boolean deleteIfEquals(String key, Object value) {
return Objects.equals(redisTemplate.opsForValue().getAndDelete(key), value);
}
}
package com.zhan.wolf.service.impl;
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.zhan.wolf.config.ChatClientFactory;
import com.zhan.wolf.constants.GameConstants;
import com.zhan.wolf.entity.AiPlayer;
import com.zhan.wolf.enums.WitchStatusEnum;
import com.zhan.wolf.mapper.AiPlayerMapper;
import com.zhan.wolf.service.AiPlayerService;
import com.zhan.wolf.service.RedisService;
import jakarta.annotation.Resource;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class AiPlayerServiceImpl extends ServiceImpl<AiPlayerMapper, AiPlayer> implements AiPlayerService {
@Resource
private ChatClientFactory chatClientFactory;
@Resource
private RedisService redisService;
/**
* 调用AI模型生成玩家发言/行动
*
* @param player AI玩家
* @param prompt 提示词(根据游戏阶段/角色生成)
* @return AI回复内容
*/
@Override
public String aiAction(AiPlayer player, String prompt) {
log.info("{}号玩家({}), 提示词:{}", player.getPlayerNo(), player.getRole(), prompt);
// 调用对应角色的AI模型
ChatClient chatClient = chatClientFactory.buildChatClient(player.getAiModelCode());
String reply = chatClient
.prompt(prompt)
.call()
.content();
log.info("{}号玩家({}), 回复内容:{}", player.getPlayerNo(), player.getRole(), reply);
// 更新玩家最后发言
player.setLastSpeak(reply);
this.updateById(player);
return reply;
}
@Override
public List<AiPlayer> findAliveAll(Long roomId) {
return super.list(new LambdaQueryWrapper<AiPlayer>()
.eq(AiPlayer::getRoomId, roomId)
.eq(AiPlayer::getStatus, GameConstants.ALIVE)
);
}
// 查找存活角色列表
@Override
public List<AiPlayer> findAliveListByRole(Long roomId, String role) {
return super.list(new LambdaQueryWrapper<AiPlayer>()
.eq(AiPlayer::getRoomId, roomId)
.eq(AiPlayer::getRole, role)
);
}
// 根据玩家编号查找
@Override
public AiPlayer findByPlayerNo(List<AiPlayer> players, Integer no) {
return players.stream()
.filter(p -> p.getPlayerNo().equals(no))
.findFirst()
.orElse(null);
}
// 统计存活神民
@Override
public long countAliveGod(List<AiPlayer> players) {
return players.stream()
.filter(p -> List.of("SEER", "WITCH", "HUNTER", "GUARD").contains(p.getRole()))
.filter(p -> GameConstants.ALIVE.equals(p.getStatus()))
.count();
}
// 统计存活平民
@Override
public long countAliveCivilian(List<AiPlayer> players) {
return players.stream()
.filter(p -> p.getRole().equals("CIVILIAN"))
.filter(p -> GameConstants.ALIVE.equals(p.getStatus()))
.count();
}
@Override
public List<AiPlayer> getRoomPlayers(Long roomId) {
return super.list(new LambdaQueryWrapper<AiPlayer>()
.eq(AiPlayer::getRoomId, roomId)
);
}
/**
* 初始化女巫药水状态(房间创建时调用)
*
* @param roomId 房间ID
*/
@Override
public void initWitchStatus(Long roomId) {
// Redis键格式:room:witch:状态:房间ID
redisService.setObject("room:witch:antidote:" + roomId, WitchStatusEnum.ANTIDOTE_AVAILABLE.getStatus(), 1, TimeUnit.HOURS);
redisService.setObject("room:witch:poison:" + roomId, WitchStatusEnum.POISON_AVAILABLE.getStatus(), 1, TimeUnit.HOURS);
}
/**
* 获取女巫解药状态
*
* @param roomId 房间ID
* @return true-可用 false-已用
*/
@Override
public boolean getWitchAntidote(Long roomId) {
Integer status = redisService.getObject("room:witch:antidote:" + roomId, Integer.class);
return status != null && status == WitchStatusEnum.ANTIDOTE_AVAILABLE.getStatus();
}
/**
* 获取女巫毒药状态
*
* @param roomId 房间ID
* @return true-可用 false-已用
*/
@Override
public boolean getWitchPoison(Long roomId) {
Integer status = redisService.getObject("room:witch:poison:" + roomId, Integer.class);
return status != null && status == WitchStatusEnum.POISON_AVAILABLE.getStatus();
}
/**
* 使用解药(更新状态)
*
* @param roomId 房间ID
*/
@Override
public void useAntidote(Long roomId) {
redisService.setObject("room:witch:antidote:" + roomId, WitchStatusEnum.ANTIDOTE_USED.getStatus(), 1, TimeUnit.HOURS);
}
/**
* 使用毒药(更新状态)
*
* @param roomId 房间ID
*/
@Override
public void usePoison(Long roomId) {
redisService.setObject("room:witch:poison:" + roomId, WitchStatusEnum.POISON_USED.getStatus(), 1, TimeUnit.HOURS);
}
/**
* 校验女巫目标合法性(不能毒自己、不能毒已死玩家)
*
* @param players 所有玩家
* @param poisonTarget 毒药目标编号
* @param witch 女巫本人
* @return true-合法 false-不合法
*/
@Override
public boolean validateWitchTarget(List<AiPlayer> players, String poisonTarget, AiPlayer witch) {
// 1. 校验参数有效性
if (players == null || poisonTarget == null || witch == null) {
return false;
}
// 2. 校验是否为数字且在合理范围内
if (!poisonTarget.matches("\\d+")) {
return false;
}
int targetNo;
try {
targetNo = Integer.parseInt(poisonTarget);
} catch (NumberFormatException e) {
return false;
}
// 3. 校验目标编号是否在合理范围内
if (targetNo <= 0) { // 玩家编号通常从1开始
return false;
}
// 4. 校验女巫编号是否有效
Integer witchNo = witch.getPlayerNo();
if (witchNo == null) {
return false;
}
// 5. 不能毒自己
if (targetNo == witchNo) {
return false;
}
// 6. 不能毒已死亡玩家
AiPlayer targetPlayer = findByPlayerNo(players, targetNo);
return targetPlayer != null && GameConstants.ALIVE.equals(targetPlayer.getStatus());
}
/**
* 核心方法:查找上一夜死亡的玩家
*
* @param roomId 房间ID
* @return 上一夜死亡的AI玩家列表(空列表表示平安夜)
*/
@Override
public List<AiPlayer> findDeadLastNight(Long roomId) {
// 验证roomId参数
if (roomId == null || roomId <= 0) {
return new ArrayList<>();
}
try {
// 1. 从Redis获取上一夜死亡的玩家编号
String json = redisService.getObject("room:night:dead:" + roomId,String.class);
if (StringUtils.isBlank(json)) {
return new ArrayList<>();
}
// 将死玩家编号转换为Set以提高查找效率
List<Integer> deadNos = JSON.parseArray(json, Integer.class);
Set<Integer> deadNoSet = new HashSet<>(deadNos);
// 2. 获取房间所有玩家
List<AiPlayer> allPlayers = getRoomPlayers(roomId);
if (allPlayers == null || allPlayers.isEmpty()) {
return new ArrayList<>();
}
// 3. 筛选出死亡玩家(编号匹配 + 状态为DEAD)
return allPlayers.stream()
.filter(player -> player != null
&& player.getPlayerNo() != null
&& deadNoSet.contains(player.getPlayerNo())
&& GameConstants.DEAD.equals(player.getStatus()))
.collect(Collectors.toList());
} catch (Exception e) {
// 记录异常日志,返回空列表作为安全默认值
log.error("查询房间 {} 上一夜死亡玩家时发生异常", roomId, e);
return new ArrayList<>();
}
}
}
package com.zhan.wolf.service.impl;
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.zhan.wolf.baen.*;
import com.zhan.wolf.common.ResultCode;
import com.zhan.wolf.constants.GameConstants;
import com.zhan.wolf.entity.AiPlayer;
import com.zhan.wolf.entity.Room;
import com.zhan.wolf.enums.GameResultConstant;
import com.zhan.wolf.enums.GameStageEnum;
import com.zhan.wolf.enums.ModelEnum;
import com.zhan.wolf.enums.RoleEnum;
import com.zhan.wolf.exception.BizException;
import com.zhan.wolf.service.AiPlayerService;
import com.zhan.wolf.service.GameService;
import com.zhan.wolf.service.RedisService;
import com.zhan.wolf.service.RoomService;
import com.zhan.wolf.vo.GameResultVO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import static com.zhan.wolf.enums.RoleEnum.WEREWOLF;
@Slf4j
@Service
public class GameServiceImpl implements GameService {
@Resource
private RoomService roomService;
@Resource
private AiPlayerService aiPlayerService;
@Resource
private RedisService redisService;
private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();
/**
* 创建房间并生成12个AI玩家
*
* @param roomName 房间名称
* @return 房间信息
*/
@Transactional(rollbackFor = Exception.class)
@Override
public Room createRoom(String roomName) {
String lockKey = "room:create:lock:" + roomName;
String lockValue = UUID.randomUUID().toString();
try {
// 使用分布式锁防止并发创建同名房间
Boolean lockAcquired = redisService.setIfAbsent(lockKey, lockValue, 30, TimeUnit.SECONDS);
if (!lockAcquired) {
throw new BizException(ResultCode.ROOM_CREATION_BUSY);
}
// 检查房间是否已存在
Room existingRoom = roomService.getOne(new LambdaQueryWrapper<Room>()
.eq(Room::getRoomName, roomName)
.last("limit 1"));
if (Objects.nonNull(existingRoom)) {
throw new BizException(ResultCode.ROOM_EXIST);
}
// 1. 创建房间
Room room = new Room();
room.setRoomName(roomName);
room.setPlayerCount(GameConstants.DEFAULT_PLAYER_COUNT);
room.setGameStage(GameStageEnum.NIGHT.getName());
room.setStatus(GameConstants.ROOM_STATUS_ACTIVE); // 1-进行中
room.setCreateTime(LocalDateTime.now());
room.setUpdateTime(LocalDateTime.now());
roomService.save(room);
// 生成AI玩家并分配身份
// 2. 生成角色列表(随机打乱)
List<RoleEnum> roles = RoleEnum.getFullRoles();
// 3. 生成AI玩家,分配编号,绑定角色和AI模型
List<AiPlayer> aiPlayers = new ArrayList<>();
for (int i = 0; i < roles.size(); i++) {
RoleEnum roleEnum = roles.get(i);
AiPlayer player = new AiPlayer();
player.setRoomId(room.getId());
player.setPlayerNo(i + GameConstants.PLAYER_NO_START_INDEX); // 玩家编号1-12
player.setRole(roleEnum.getName());
player.setAiModelCode(ModelEnum.getModeCodeByName(roleEnum.getName()).getCode());
player.setStatus(GameConstants.ALIVE); // 初始存活
aiPlayers.add(player);
}
// 4. 保存AI玩家到数据库
aiPlayerService.saveBatch(aiPlayers);
// 5. 缓存房间和玩家信息到Redis(方便快速查询)
try {
redisService.setObject("room:" + room.getId(), room, 1, TimeUnit.HOURS);
redisService.listRightPushAll("room:players:" + room.getId(), aiPlayers);
} catch (Exception e) {
log.warn("Redis缓存设置失败,不影响主要业务流程", e);
}
// 6. 初始化女巫药水状态
aiPlayerService.initWitchStatus(room.getId());
// 初始化全局游戏信息
initGameGlobalInfo(room.getId());
return room;
} finally {
// 释放分布式锁
try {
redisService.deleteIfEquals(lockKey, lockValue);
} catch (Exception e) {
log.warn("释放分布式锁失败", e);
}
}
}
/**
* 清空夜间死亡记录(白天阶段结束后调用,避免跨夜混淆)
*
* @param roomId 房间ID
*/
@Override
public void clearNightDeadRecord(Long roomId) {
redisService.deleteKey("room:night:dead:" + roomId);
}
/**
* 记录夜间死亡玩家(狼人刀杀/女巫毒杀)
*
* @param roomId 房间ID
* @param killedByWolf 狼人刀杀目标(null表示被救/无刀人)
* @param poisonedByWitch 女巫毒杀目标(null表示未毒人)
* @param guardTarget 守卫守护目标(null表示未守护)
*/
@Override
public List<Integer> recordNightDeadPlayers(Long roomId, String killedByWolf, String poisonedByWitch, String guardTarget) {
List<Integer> deadNos = new ArrayList<>();
// 1. 处理狼人刀杀:未被守卫守护 + 未被解药拯救
if (StringUtils.isNotBlank(killedByWolf) && !Objects.equals(killedByWolf, guardTarget)) {
deadNos.add(Integer.parseInt(killedByWolf));
}
// 2. 处理女巫毒杀:有效目标
if (StringUtils.isNotBlank(poisonedByWitch) && !Objects.equals("0", poisonedByWitch)) {
deadNos.add(Integer.parseInt(poisonedByWitch));
}
// 3. 存入Redis:键格式 room:night:dead:房间ID,值为死亡玩家编号列表
redisService.setObject("room:night:dead:" + roomId, JSON.toJSONString(deadNos), 1, TimeUnit.HOURS);
log.info("房间{}:夜间死亡玩家编号列表 -> {}", roomId, deadNos);
sendSystemMessage(roomId, new SseSystemMessage("房间" + roomId + ":夜间死亡玩家编号列表 -> " + deadNos));
return deadNos;
}
/**
* 执行完整夜晚流程:守卫 -> 狼人 -> 预言家 -> 女巫
*/
@Override
public void nightStage(Long roomId) {
// 推送执行完整夜晚流程提示
sendSystemMessage(roomId, new SseSystemMessage("开始执行完整夜晚流程..."));
// 所有玩家
List<AiPlayer> roomPlayers = aiPlayerService.getRoomPlayers(roomId);
// 所有存活玩家
List<AiPlayer> alivePlayers = aiPlayerService.findAliveAll(roomId);
// 所有存活玩家编号
List<String> alivePlayerNos = alivePlayers.stream().map(AiPlayer::getPlayerNo).map(String::valueOf).toList();
// ====================== 1. 守卫守护 ======================
AiPlayer guard = roomPlayers.stream()
.filter(p -> p.getRole().equals(RoleEnum.GUARD.getName()) && GameConstants.ALIVE.equals(p.getStatus()))
.findFirst()
.orElse(null);
String guardTarget = null;
if (Objects.nonNull(guard)) {
String prompt = String.format("""
你是本剧狼人杀游戏中的%d号玩家,你的角色是守卫。
历史发言:
%s
场上存活玩家:%s
规则:
1.根据历史游戏信息和自己的判断,今晚请选择一名玩家守护(不能连续两晚守同一个人)。
2.只回复数字,不要多余内容。
""", guard.getPlayerNo(), getHistorySpeakText(roomId), String.join(",", alivePlayerNos));
guardTarget = aiPlayerService.aiAction(guard, prompt);
// 推送系统消息,守卫守护
sendSystemMessage(roomId, new SseSystemMessage(guard.getPlayerNo() + "号守护" + guardTarget + "号"));
redisService.setObject("room:guard:last:" + roomId, guardTarget, 1, TimeUnit.HOURS);
// 记录守卫行动
recordNightAction(roomId, "guardProtect", guardTarget);
}
// ====================== 2. 狼人刀人 ======================
List<AiPlayer> werewolves = aiPlayerService.findAliveListByRole(roomId, RoleEnum.WEREWOLF.getName());
String killTarget = null;
if (CollectionUtils.isNotEmpty(werewolves)) {
// 狼人刀人
killTarget = collectVoteFromAi(werewolves, roomId);
// 记录狼人刀人行动
recordNightAction(roomId, "wolfKill", killTarget);
// 推送系统消息,狼人刀人
sendSystemMessage(roomId, new SseSystemMessage("狼人集体刀人: " + killTarget + "号"));
}
// ====================== 3. 预言家查验 ======================
AiPlayer seer = roomPlayers.stream()
.filter(p -> p.getRole().equals(RoleEnum.SEER.getName()) && GameConstants.ALIVE.equals(p.getStatus()))
.findFirst()
.orElse(null);
String checkResult = null;
if (Objects.nonNull(seer)) {
String prompt = String.format("""
你是本剧狼人杀游戏中的%s号玩家,你的角色是预言家,晚上选择一名玩家查验身份。
历史发言:
%s
场上存活玩家:%s
规则:
1.只能回复1-12之间的数字(排除自己%s),不要任何多余内容(如文字、符号、空格)。
2.根据你的判断,优先查验你认为可能是狼人的其他玩家。
根据历史游戏信息和自己的判断,请直接回复目标玩家编号:
""", seer.getPlayerNo(), getHistorySpeakText(roomId), String.join(",", alivePlayerNos), seer.getPlayerNo());
String checkTarget = aiPlayerService.aiAction(seer, prompt);
AiPlayer target = aiPlayerService.findByPlayerNo(roomPlayers, Integer.parseInt(checkTarget));
checkResult = target.getRole().equals(WEREWOLF.getName()) ? "狼人" : "好人";
redisService.setObject("room:seer:result:" + roomId + ":" + seer.getPlayerNo(), checkResult, 1, TimeUnit.HOURS);
// 推送系统消息,预言家查验
sendSystemMessage(roomId, new SseSystemMessage("预言家" + seer.getPlayerNo() + "号查验" + checkTarget + "号,结果为:" + checkResult));
// 记录预言家查验行动
recordNightAction(roomId, "seerCheck", checkTarget);
}
// ====================== 4. 女巫用药 ======================
AiPlayer witch = roomPlayers.stream()
.filter(p -> p.getRole().equals(RoleEnum.WITCH.getName()) && GameConstants.ALIVE.equals(p.getStatus()))
.findFirst()
.orElse(null);
aiPlayerService.initWitchStatus(roomId);
boolean hasPoison = aiPlayerService.getWitchPoison(roomId);
boolean hasAntidote = aiPlayerService.getWitchAntidote(roomId);
boolean usedAntidote = false;
String poisonTarget = null;
String witchUse = "未用药";
if (Objects.nonNull(witch)) {
// 解药逻辑
if (hasAntidote && StringUtils.isNotBlank(killTarget)) {
String savePrompt = String.format("""
你是本剧狼人杀游戏中的%d号玩家,你的角色是女巫,当前解药可用、毒药%s。
历史发言:
%s
场上存活玩家:%s
今夜%d号玩家被狼人刀杀,你是否使用解药拯救他?
注意:解药整局只能用1次,且不能和毒药同晚使用。
根据历史游戏信息和自己的判断,仅回复:是 或 否,不要多余内容。
""", witch.getPlayerNo(), hasPoison ? "可用" : "已用", getHistorySpeakText(roomId), String.join(",", alivePlayerNos), Integer.parseInt(killTarget));
String saveAns = aiPlayerService.aiAction(witch, savePrompt).trim();
if (Objects.equals(saveAns, "是")) {
aiPlayerService.useAntidote(roomId);
log.info("女巫{}号使用解药拯救了{}号玩家", witch.getPlayerNo(), killTarget);
witchUse = "使用解药拯救" + killTarget + "号玩家";
sendSystemMessage(roomId, new SseSystemMessage("女巫" + witch.getPlayerNo() + "号使用解药拯救" + killTarget + "号玩家"));
usedAntidote = true;
killTarget = null;
}
}
// 毒药逻辑
if (hasPoison && !usedAntidote) {
String poisonPrompt = String.format("""
你是本剧狼人杀游戏中的%d号玩家,你的角色是女巫,当前毒药可用、解药%s。
历史发言:
%s
场上存活玩家:%s
你可以选择毒杀一名玩家(整局仅1次机会),也可以选择不毒。
根据历史游戏信息和自己的判断,给出你的回复。
规则:不能毒自己、不能毒已死玩家、不能和解药同晚使用。
不毒回复:0;毒杀回复对应玩家编号,仅回复数字,不要多余内容。
""", witch.getPlayerNo(), hasAntidote ? "可用" : "已用", getHistorySpeakText(roomId), String.join(",", alivePlayerNos));
poisonTarget = aiPlayerService.aiAction(witch, poisonPrompt).trim();
if (!Objects.equals("0", poisonTarget) && aiPlayerService.validateWitchTarget(roomPlayers, poisonTarget, witch)) {
aiPlayerService.usePoison(roomId);
AiPlayer poisoned = aiPlayerService.findByPlayerNo(roomPlayers, Integer.parseInt(poisonTarget));
poisoned.setStatus(GameConstants.DEAD);
aiPlayerService.updateById(poisoned);
// 有玩家死亡,重新推送游戏玩家列表
sendPlayerList(roomId, roomPlayers);
witchUse = "使用毒药毒杀" + poisonTarget + "号玩家";
log.info("女巫{}号使用毒药毒杀了{}号玩家", witch.getPlayerNo(), poisonTarget);
sendSystemMessage(roomId, new SseSystemMessage("女巫" + witch.getPlayerNo() + "号使用毒药毒杀了" + poisonTarget + "号玩家"));
}
}
// 记录女巫用药行动
recordNightAction(roomId, "witchUse", witchUse);
}
// ====================== 最终死亡判定 ======================
// 记录夜间死亡玩家(狼人刀杀/女巫毒杀)
List<Integer> deadPlayerNos = recordNightDeadPlayers(roomId, killTarget, poisonTarget, guardTarget);
if(CollectionUtils.isNotEmpty(deadPlayerNos)){
List<AiPlayer> list = aiPlayerService.list(new LambdaQueryWrapper<AiPlayer>()
.eq(AiPlayer::getRoomId, roomId)
.in(AiPlayer::getPlayerNo, deadPlayerNos)
.eq(AiPlayer::getStatus, GameConstants.ALIVE)
);
list.forEach(p -> p.setStatus(GameConstants.DEAD));
aiPlayerService.updateBatchById(list);
// 有玩家死亡,重新推送游戏玩家列表
sendPlayerList(roomId, roomPlayers);
}
// 阶段切换
roomService.updateRoomStage(roomId, GameStageEnum.DAY.getName());
}
/**
* 白天完整流程:
* 1. 公布昨夜死讯
* 2. 按顺序发言
* 3. 公投
* 4. 判胜负
*/
@Override
public void dayStage(Long roomId) {
sendSystemMessage(roomId, new SseSystemMessage("开始白天阶段"));
// 房间内所有玩家列表
List<AiPlayer> roomPlayers = aiPlayerService.getRoomPlayers(roomId);
// 昨晚死亡玩家雷彪
List<AiPlayer> deadLastNight = aiPlayerService.findDeadLastNight(roomId);
// ====================== 1. 公布死讯 ======================
if (CollectionUtils.isNotEmpty(deadLastNight)) {
String deadNos = deadLastNight.stream().map(p -> p.getPlayerNo().toString()).reduce((a, b) -> a + "、" + b).orElse("");
log.info("昨夜死亡玩家:{}", deadNos);
sendSystemMessage(roomId, new SseSystemMessage("昨夜死亡玩家:" + deadNos));
} else {
log.info("昨夜平安夜");
sendSystemMessage(roomId, new SseSystemMessage("昨夜平安夜"));
}
// ====================== 2. 存活玩家依次发言 ======================
List<AiPlayer> alive = aiPlayerService.findAliveAll(roomId);
for (AiPlayer p : alive) {
// 拼接完整提示词:全局信息 + 历史发言 + 角色专属信息 + 发言要求
String nightActionInfo = getNightActionInfo(roomId, p);
String historySpeak = getHistorySpeakText(roomId);
String roleName = p.getRole();
String prompt = String.format("""
【狼人杀游戏信息】
你是本剧狼人杀游戏中的%d号玩家,你的角色是%s
场上局势:
%s
请根据场上局势、其他人发言和你的身份进行逻辑发言。
历史发言:
%s
发言要求:
1. 请根据场上局势、其他人发言和你的身份进行逻辑发言
2. 狼人要隐藏身份,好人要找出狼人
3. 发言字数50-120字,只回复发言内容,不要多余文字
4. 不要说自己是AI,不要解释规则
5. 不要加任何前缀,比如“以XX视角”,直接开始说内容
6. 语言像真人玩家一样自然、简短、有逻辑
7. 可以分析、站队、怀疑、保人、点狼坑
""", p.getPlayerNo(), roleName, nightActionInfo, historySpeak);
// 调用AI发言
String speakContent = aiPlayerService.aiAction(p, prompt).trim();
// 推送玩家发言
sendPlayerSpeech(roomId, new PlayerSpeech(p.getPlayerNo(), p.getRole(), speakContent, System.currentTimeMillis()));
// 记录当前玩家发言(供后续玩家查看)
recordPlayerSpeak(roomId, p.getPlayerNo(), speakContent);
// 更新玩家最后发言
p.setLastSpeak(speakContent);
aiPlayerService.updateById(p);
log.info("{}号玩家({})发言:{}", p.getPlayerNo(), roleName, speakContent);
}
// ====================== 3. 公投放逐 ======================
String voteOutTarget = collectVoteFromAi(alive, roomId);
if (StringUtils.isNotBlank(voteOutTarget)) {
AiPlayer voted = aiPlayerService.findByPlayerNo(roomPlayers, Integer.valueOf(voteOutTarget));
voted.setStatus(GameConstants.DEAD);
aiPlayerService.updateById(voted);
// 有玩家死亡,重新推送游戏玩家列表
sendPlayerList(roomId, roomPlayers);
sendSystemMessage(roomId, new SseSystemMessage("公投结果:" + voteOutTarget + "号玩家被放逐,身份" + voted.getRole()));
log.info("公投结果:{}号玩家被放逐,身份{}", voteOutTarget, voted.getRole());
// 猎人被放逐触发开枪
if (Objects.equals(voted.getRole(), RoleEnum.HUNTER.getName())) {
hunterShoot(roomId, voted);
}
}
// ====================== 4. 屠边判胜负 ======================
GameResultVO gameResultVO = checkWinOrLose(roomId);
GameResultConstant result = gameResultVO.getResult();
String reason = gameResultVO.getReason();
if (result != GameResultConstant.CONTINUE) {
roomService.updateRoomStatus(roomId, 2);
roomService.updateRoomStage(roomId, GameStageEnum.END.getName());
log.info("游戏结束,胜利者:{}", result);
sendSystemMessage(roomId, new SseSystemMessage("游戏结束,胜利者:" + result));
sendGameEnd(roomId, new GameResult(result.name(), alive.stream().map(AiPlayer::getPlayerNo).collect(Collectors.toList()), reason));
} else {
roomService.updateRoomStage(roomId, GameStageEnum.NIGHT.getName());
}
clearNightDeadRecord(roomId);
clearSpeakRecord(roomId);
}
/**
* 检查胜负结果
*
* @param roomId 房间ID
* @return 胜负结果
*/
@Override
public GameResultVO checkWinOrLose(Long roomId) {
Map<String, Object> map = new HashMap<>();
List<AiPlayer> players = aiPlayerService.getRoomPlayers(roomId);
// 存活数量统计
long wolfAlive = aiPlayerService.findAliveListByRole(roomId, WEREWOLF.getName()).size();
long godAlive = players.stream()
.filter(p -> List.of(RoleEnum.SEER.getName(), RoleEnum.WITCH.getName(), RoleEnum.HUNTER.getName(), RoleEnum.GUARD.getName()).contains(p.getRole()))
.filter(p -> GameConstants.ALIVE.equals(p.getStatus()))
.count();
long civilianAlive = aiPlayerService.findAliveListByRole(roomId, RoleEnum.CIVILIAN.getName()).size();
// 狼人胜利条件:屠神(神全死)或 屠民(民全死),且至少1狼存活
if (wolfAlive > 0 && (godAlive == 0 || civilianAlive == 0)) {
return new GameResultVO(GameResultConstant.WOLF_WIN, "屠神(神全死)或 屠民(民全死),且至少1狼存活");
}
// 好人胜利条件:狼全死
if (wolfAlive == 0) {
log.info("好人胜利:狼全死");
return new GameResultVO(GameResultConstant.GOOD_WIN, "狼全死");
}
// 游戏继续
return new GameResultVO(GameResultConstant.CONTINUE, null);
}
/**
* 推进游戏阶段(核心接口:触发夜晚/白天流程)
* 调用逻辑:创建房间后,调用此接口推进游戏,自动完成当前阶段的所有AI行动
*
* @param roomId 房间ID
* @return 阶段推进结果(包含当前阶段、AI行动概要)
*/
@Override
public synchronized String advanceGameStage(Long roomId) {
log.info("开始推进游戏阶段");
Room room = roomService.getById(roomId);
if (Objects.isNull(room)) {
throw new BizException(ResultCode.ROOM_NOT_FOUND);
}
List<AiPlayer> aliveAll = aiPlayerService.findAliveAll(roomId);
List<Integer> aliveNos = aliveAll.stream().map(AiPlayer::getPlayerNo).toList();
String currentStage = room.getGameStage();
try {
if (Objects.equals(GameStageEnum.NIGHT.getName(), currentStage)) {
// 执行夜晚流程:守卫→狼人→预言家→女巫→死亡判定
nightStage(roomId);
GameStatus gameStatus = new GameStatus();
gameStatus.setPhase(GameStageEnum.NIGHT.getName());
gameStatus.setRound(null);
gameStatus.setAliveCount(aliveAll.size());
gameStatus.setGameState("进行中");
gameStatus.setRemainingTime(null);
sendGameStatus(roomId, gameStatus);
} else if (Objects.equals(GameStageEnum.DAY.getName(), currentStage)) {
// 执行白天流程:公布死讯→发言→公投→判胜负
dayStage(roomId);
GameStatus gameStatus = new GameStatus();
gameStatus.setPhase(GameStageEnum.DAY.getName());
gameStatus.setRound(null);
gameStatus.setAliveCount(aliveAll.size());
gameStatus.setGameState("进行中");
gameStatus.setRemainingTime(null);
sendGameStatus(roomId, gameStatus);
sendGameStatus(roomId, gameStatus);
} else {
sendGameEnd(roomId, new GameResult(GameResultConstant.CONTINUE.name(), aliveNos, "游戏结束"));
}
} catch (Exception e) {
log.error("推进游戏阶段失败,房间id = {}", roomId, e);
}
return room.getGameStage();
}
/**
* 获取AI玩家的发言/行动记录(核心:查看AI对话内容)
*
* @param roomId 房间ID
* @param playerNo 玩家编号(可选,不传则返回所有AI的发言)
* @return 发言记录
*/
@Override
public List<AiPlayer> getAiSpeakRecord(Long roomId, Integer playerNo) {
try {
List<AiPlayer> players = aiPlayerService.getRoomPlayers(roomId);
if (CollectionUtils.isEmpty(players)) {
throw new BizException(ResultCode.ROOM_NO_PLAYER);
}
// 1. 查询指定玩家的发言
if (Objects.nonNull(playerNo)) {
AiPlayer targetPlayer = players.stream()
.filter(p -> p.getPlayerNo().equals(playerNo))
.findFirst()
.orElse(null);
if (Objects.isNull(targetPlayer)) {
throw new BizException(ResultCode.PLAYER_NOT_FOUND);
}
return List.of(targetPlayer);
} else {
// 2. 查询所有玩家的发言
return players;
}
} catch (Exception e) {
log.error("获取AI发言记录失败", e);
throw new BizException(ResultCode.GET_AI_SPEAK_RECORD_ERROR);
}
}
/**
* 获取房间当前游戏状态(全局视角)
*
* @param roomId 房间ID
* @return 游戏状态(阶段、存活/死亡玩家、胜负结果等)
*/
@Override
public Map<String, Object> getGameStatus(Long roomId) {
Map<String, Object> result = new HashMap<>();
try {
Room room = roomService.getById(roomId);
if (Objects.isNull(room)) {
throw new BizException(ResultCode.ROOM_NOT_FOUND);
}
List<AiPlayer> allPlayers = aiPlayerService.getRoomPlayers(roomId);
// 存活/死亡玩家分类
List<AiPlayer> alivePlayers = allPlayers.stream().filter(p -> GameConstants.ALIVE.equals(p.getStatus())).toList();
List<AiPlayer> deadPlayers = allPlayers.stream().filter(p -> GameConstants.DEAD.equals(p.getStatus())).toList();
// 组装返回结果
result.put("code", 200);
result.put("roomInfo", Map.of(
"roomId", room.getId(),
"roomName", room.getRoomName(),
"gameStage", room.getGameStage(),
"gameStageName", GameStageEnum.valueOf(room.getGameStage()).getName(),
"status", room.getStatus()));
result.put("alivePlayers", alivePlayers.stream()
.map(p -> Map.of(
"playerNo", p.getPlayerNo(),
"role", p.getRole()))
.toList());
result.put("deadPlayers", deadPlayers.stream()
.map(p -> Map.of(
"playerNo", p.getPlayerNo(),
"role", p.getRole()))
.toList());
// 游戏结束则返回胜负结果
if (Objects.equals(GameStageEnum.END.getName(), room.getGameStage())) {
result.put("gameResult", checkWinOrLose(roomId));
}
return result;
} catch (Exception e) {
log.error("获取游戏状态失败", e);
throw new BizException(ResultCode.GET_GAME_STATUS_ERROR);
}
}
/**
* 手动触发单个AI玩家发言(测试用,可选)
*
* @param roomId 房间ID
* @param playerNo 玩家编号
* @param prompt 自定义发言提示词(可选,不传则用默认提示词)
* @return 玩家发言内容
*/
@Override
public Map<String, Object> triggerAiSpeak(Long roomId, Integer playerNo, String prompt) {
Map<String, Object> result = new HashMap<>();
try {
List<AiPlayer> players = aiPlayerService.getRoomPlayers(roomId);
AiPlayer targetPlayer = players.stream()
.filter(p -> p.getPlayerNo().equals(playerNo) && GameConstants.ALIVE.equals(p.getStatus()))
.findFirst()
.orElse(null);
if (Objects.isNull(targetPlayer)) {
throw new BizException(ResultCode.PLAYER_DEAD_OR_NOT_FOUND);
}
// 使用自定义提示词或默认提示词
String format = String.format("你是%d号%s,现在是游戏发言阶段,请根据局势发表你的观点,50-100字。", targetPlayer.getPlayerNo(), RoleEnum.valueOf(targetPlayer.getRole()).getName());
String finalPrompt = StringUtils.isNotBlank(prompt) ? prompt : format;
// 调用AI生成发言
String speakContent = aiPlayerService.aiAction(targetPlayer, finalPrompt);
result.put("code", 200);
result.put("playerNo", playerNo);
result.put("role", targetPlayer.getRole());
result.put("speakContent", speakContent);
return result;
} catch (Exception e) {
log.error("触发AI发言失败", e);
throw new BizException(ResultCode.TRIGGER_AI_SPEAK_ERROR);
}
}
/**
* 猎人被放逐/刀杀触发开枪
*/
private void hunterShoot(Long roomId, AiPlayer hunter) {
List<AiPlayer> roomPlayers = aiPlayerService.getRoomPlayers(roomId);
List<AiPlayer> alivePlayers = aiPlayerService.findAliveAll(roomId);
String prompt = String.format("""
你是%d号猎人,被放逐/刀杀,可开枪带走一名玩家。
存活玩家:%s
请选择带走的玩家编号,只回复数字。
""", hunter.getPlayerNo(), alivePlayers.stream().map(p -> p.getPlayerNo().toString()).collect(Collectors.joining("、")));
String shootTarget = aiPlayerService.aiAction(hunter, prompt).trim();
if (shootTarget.matches("\\d+")) {
AiPlayer target = aiPlayerService.findByPlayerNo(alivePlayers, Integer.valueOf(shootTarget));
if (Objects.nonNull(target)) {
target.setStatus(GameConstants.DEAD);
aiPlayerService.updateById(target);
// 有玩家死亡,重新推送游戏玩家列表
sendPlayerList(roomId, roomPlayers);
log.info("猎人{}号开枪带走了{}号玩家", hunter.getPlayerNo(), shootTarget);
}
}
}
/**
* 初始化游戏全局信息(房间创建时调用)
* 修复:移除空集合的rightPushAll,避免非空校验报错
*/
public void initGameGlobalInfo(Long roomId) {
// 1. 夜间行动记录:初始化空Hash(用String类型占位)
redisService.hashPut("room:global:night:action:" + roomId, "init", "ok");
// 2. 狼人队友列表:仅当有狼人时才存储(确保存入String类型)
List<AiPlayer> wolves = aiPlayerService.findAliveListByRole(roomId, WEREWOLF.getName());
if (CollectionUtils.isNotEmpty(wolves)) {
List<String> wolfNos = wolves.stream().map(p -> String.valueOf(p.getPlayerNo())) // 明确转为String
.filter(str -> !str.isEmpty()) // 过滤空值
.toList();
if (CollectionUtils.isNotEmpty(wolfNos)) { // 二次校验,避免空列表
redisService.listRightPushAll("room:global:wolf:teammate:" + roomId, wolfNos);
}
}
}
/**
* 记录玩家发言(用于后续玩家查看)
*/
public void recordPlayerSpeak(Long roomId, Integer playerNo, String speakContent) {
if (playerNo == null || speakContent == null || speakContent.trim().isEmpty()) {
log.warn("发言记录失败:玩家编号/内容为空,roomId={}, playerNo={}", roomId, playerNo);
return;
}
String speakKey = "room:global:speak:" + roomId;
String speakRecord = playerNo + ":" + speakContent.trim();
// 首次调用时,Redis会自动创建空列表并添加元素(无需提前初始化)
redisService.listRightPush(speakKey, speakRecord);
}
/**
* 获取历史发言记录(拼接成文本)
*/
public String getHistorySpeakText(Long roomId) {
String speakKey = "room:global:speak:" + roomId;
// 先判断key是否存在,避免返回null
if (!redisService.hasKey(speakKey)) {
return "暂无玩家发言";
}
// 获取全部发言记录(0到-1表示全量)
List<Object> speakList = redisService.listRange(speakKey, 0, -1);
if (CollectionUtils.isEmpty(speakList)) {
return "暂无玩家发言";
}
List<String> list = speakList.stream().map(Object::toString).toList();
StringBuilder sb = new StringBuilder();
for (String speak : list) {
String[] split = speak.split(":", 2); // 按第一个":"分割(避免发言含":")
if (split.length == 2) { // 校验格式,避免数组越界
sb.append(split[0]).append("号:").append(split[1]).append("\n");
}
}
return sb.toString().trim(); // 去除末尾换行
}
/**
* 记录夜间行动(如狼人刀人、预言家查验、女巫用药、守卫守护)
*/
public void recordNightAction(Long roomId, String actionType, String value) {
redisService.hashPut("room:global:night:action:" + roomId, actionType, value);
}
/**
* 获取夜间行动信息
*/
public String getNightActionInfo(Long roomId, AiPlayer player) {
Map<Object, Object> actionMap = redisService.hashGetAllEntries("room:global:night:action:" + roomId);
StringBuilder sb = new StringBuilder();
// 通用信息:昨夜死亡玩家
String json = redisService.getObject("room:night:dead:" + roomId, String.class);
if (StringUtils.isNotBlank(json)) {
List<String> deadLastNight = JSON.parseArray(json, String.class);
List<String> list = deadLastNight.stream().map(Object::toString).toList();
sb.append("昨夜死亡玩家:").append(String.join("、", list)).append("\n");
} else {
sb.append("昨夜平安夜\n");
}
// 角色专属信息
String role = player.getRole();
RoleEnum roleEnum = RoleEnum.getRoleByName(role);
if (Objects.isNull(roleEnum)) {
throw new BizException(ResultCode.ROLE_NOT_FOUND);
}
switch (roleEnum) {
// 原狼人专属信息部分修改
case WEREWOLF:
// 狼人:知道队友+昨夜刀人目标(补充非空校验)
String wolfTeammateKey = "room:global:wolf:teammate:" + roomId;
List<String> wolfTeammates = new ArrayList<>();
if (Boolean.TRUE.equals(redisService.hasKey(wolfTeammateKey))) {
// 关键修复:强制转换为String类型(避免Object解析失败)
List<Object> tempList = redisService.listRange(wolfTeammateKey, 0, -1);
if (tempList != null && !tempList.isEmpty()) {
wolfTeammates = tempList.stream().map(obj -> obj != null ? obj.toString() : "") // 转为字符串
.filter(str -> !str.isEmpty()) // 过滤空值
.toList();
}
}
sb.append("你的狼人队友:").append(wolfTeammates.isEmpty() ? "无" : String.join("、", wolfTeammates)).append("\n");
sb.append("昨夜狼人刀人目标:").append(actionMap.getOrDefault("wolfKill", "无")).append("\n");
break;
case SEER:
// 预言家:知道自己的查验结果
String checkResult = redisService.getObject("room:seer:result:" + roomId + ":" + player.getPlayerNo(), String.class);
String checkTarget = (String) actionMap.getOrDefault("seerCheck", "无");
sb.append("你昨夜查验").append(checkTarget).append("号玩家,身份是:").append(checkResult).append("\n");
break;
case WITCH:
// 女巫:知道药水状态+昨夜被刀玩家+自己用药情况
boolean hasAntidote = aiPlayerService.getWitchAntidote(roomId);
boolean hasPoison = aiPlayerService.getWitchPoison(roomId);
sb.append("你的解药状态:").append(hasAntidote ? "可用" : "已用").append("\n");
sb.append("你的毒药状态:").append(hasPoison ? "可用" : "已用").append("\n");
sb.append("昨夜被刀玩家:").append(actionMap.getOrDefault("wolfKill", "无")).append("\n");
sb.append("你昨夜用药情况:").append(actionMap.getOrDefault("witchUse", "未用药")).append("\n");
break;
case GUARD:
// 守卫:知道自己昨夜守护目标
sb.append("你昨夜守护的玩家:").append(actionMap.getOrDefault("guardProtect", "无")).append("\n");
break;
default:
// 平民/猎人:只有通用信息
break;
}
return sb.toString();
}
/**
* 清空本轮发言记录(白天阶段结束后调用)
*/
public void clearSpeakRecord(Long roomId) {
redisService.deleteKey("room:global:speak:" + roomId);
}
/**
* 生成放逐投票提示词(按角色定制,禁止自投)
*
* @param playerNo 玩家编号
* @param role 玩家身份
* @param suspectNos 怀疑的狼人编号列表
* @return 精准提示词
*/
public String generateVotePrompt(String playerNo, String role, List<String> suspectNos) {
// 好人(预言家/女巫/守卫/平民)提示词
if (!WEREWOLF.getName().equals(role)) {
return String.format("""
你是%d号玩家,身份是%s(好人),正在参与放逐投票。
规则:
1. 绝对不能投票给自己(%d号);
2. 优先投票你怀疑的狼人(怀疑列表:%s);
3. 只回复1-12之间的数字,不要加任何文字、符号、括号;
4. 你的目标是投出狼人,不要投好人。
请直接回复你要投票的玩家编号:
""", Integer.parseInt(playerNo), role, Integer.parseInt(playerNo),
suspectNos.isEmpty() ? "无,优先投发言异常的玩家" : String.join("、", suspectNos));
}
// 狼人提示词(也禁止自投,优先投好人)
else {
return String.format("""
你是%d号玩家,身份是狼人,正在参与放逐投票。
规则:
1. 绝对不能投票给自己(%d号);
2. 优先投票你识别的好人/神牌;
3. 只回复1-12之间的数字,不要加任何文字、符号、括号;
4. 你的目标是投出好人,保护狼人队友。
请直接回复你要投票的玩家编号:
""", Integer.parseInt(playerNo), Integer.parseInt(playerNo));
}
}
/**
* AI投票统计
*
* @param list 投票玩家列表
* @param roomId 房间ID
* @return 最终投票目标
*/
@Override
public String collectVoteFromAi(List<AiPlayer> list, Long roomId) {
Map<String, Integer> cnt = new HashMap<>();
// 所有存活玩家
List<AiPlayer> allAlivePlayers = aiPlayerService.findAliveAll(roomId);
// 所有存活玩家编号列表
List<String> alivePlayersNos = allAlivePlayers.stream().map(AiPlayer::getPlayerNo).map(String::valueOf).toList();
for (AiPlayer p : list) {
if (GameConstants.ALIVE.equals(p.getStatus())) {
String prompt = "";
if (Objects.equals(p.getRole(), WEREWOLF.getName())) {
prompt = String.format("""
你是本剧狼人杀游戏中的%d号玩家,你的身份是狼人,正在参与放逐投票。
历史发言:
%s
场上存活玩家:%s
规则:
1.优先投票你识别的好人/神牌
2.只能回复1-12之间的数字,不要任何多余内容(如文字、符号、空格)。
3.优先投票你识别的好人/神牌。
4.你的目标是投出好人,保护狼人队友。
请根据历史游戏信息和自己的判断,直接回复你要投票的玩家编号:
""", p.getPlayerNo(), getHistorySpeakText(roomId), String.join("、", alivePlayersNos));
} else {
prompt = String.format("""
你是%d号玩家,身份是%s(好人),正在参与放逐投票。
历史发言:
%s
场上存活玩家:%s
规则:
1. 绝对不能投票给自己(%d号);
2. 优先投票你怀疑的狼人;
3. 只回复1-12之间的数字,不要加任何文字、符号、括号;
4. 你的目标是投出狼人,不要投好人。
请请根据历史游戏信息和自己的判断,投票放逐你怀疑的狼人,只回复玩家编号(数字),不要多余内容。
""", p.getPlayerNo(), p.getRole(), getHistorySpeakText(roomId), String.join("、", alivePlayersNos), p.getPlayerNo());
}
String vote = aiPlayerService.aiAction(p, prompt).trim();
// 校验投票合法性:必须是数字、存活玩家、狼人不能投自己
if (vote.matches("\\d+") && alivePlayersNos.contains(vote)) {
cnt.put(vote, cnt.getOrDefault(vote, 0) + 1);
}
}
}
// 取票数最多的目标(无有效投票则随机选一个非狼人存活玩家)
if (cnt.isEmpty()) {
List<String> targetNos = allAlivePlayers.stream()
.filter(p -> !p.getRole().equals(WEREWOLF.name()))
.map(p -> p.getPlayerNo().toString())
.toList();
return targetNos.get(new Random().nextInt(targetNos.size()));
}
return cnt.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse(null);
}
@Override
public SseEmitter sseConnect(Long roomId) {
SseEmitter emitter = new SseEmitter(0L); // 0L 表示永不超时
emitters.put(roomId, emitter);
emitter.onCompletion(() -> emitters.remove(roomId));
emitter.onTimeout(() -> emitters.remove(roomId));
emitter.onError(((e) -> emitters.remove(roomId)));
// 发送连接成功消息
sendEvent(roomId, "CONNECT", new SseConnectMessage("连接成功"));
// 连接成功后,推送玩家雷彪数据
List<AiPlayer> roomPlayers = aiPlayerService.getRoomPlayers(roomId);
sendPlayerList(roomId, roomPlayers);
return emitter;
}
/**
* 推送游戏状态事件
*/
public void sendGameStatus(Long roomId, GameStatus status) {
sendEvent(roomId, "GAME_STATUS", status);
}
public void sendGameStart(Long roomId, SseGameStartMessage message) {
sendEvent(roomId, "GAME_START", message);
}
/**
* 推送玩家发言事件
*/
public void sendPlayerSpeech(Long roomId, PlayerSpeech speech) {
sendEvent(roomId, "PLAYER_SPEECH", speech);
}
public void sendPlayerList(Long roomId, List<AiPlayer> aiPlayers) {
sendEvent(roomId, "PLAYER_LIST", aiPlayers);
}
/**
* 推送系统消息事件
*/
public SseEmitter sendSystemMessage(Long roomId, SseSystemMessage systemMessage) {
return sendEvent(roomId, "SYSTEM_MESSAGE", JSON.toJSONString(systemMessage));
}
/**
* 推送玩家状态变化事件
*/
public void sendPlayerStatus(Long roomId, PlayerStatus status) {
sendEvent(roomId, "PLAYER_STATUS", status);
}
/**
* 推送游戏日志事件
*/
public void sendGameLog(Long roomId, GameLog log) {
sendEvent(roomId, "GAME_LOG", log);
}
/**
* 推送游戏结束事件
*/
public void sendGameEnd(Long roomId, GameResult result) {
sendEvent(roomId, "GAME_END", result);
}
private SseEmitter sendEvent(Long roomId, String eventType, Object data) {
SseEmitter emitter = emitters.get(roomId);
if (emitter != null) {
try {
emitter.send(SseEmitter.event()
.name(eventType)
.data(data));
} catch (IOException e) {
log.error("sendEvent - 发送失败", e);
emitters.remove(roomId);
}
} else {
log.warn("sendEvent - emitter不存在,roomId: {}", roomId);
}
return emitter;
}
}
19.返回结果对象VO
package com.zhan.game.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class GameStageAdvancePlayerVO {
private Integer playerNo;
private String role;
}
package com.zhan.game.vo;
import com.zhan.game.enums.GameResult;
import lombok.Data;
import java.util.List;
@Data
public class GameStageAdvanceVO {
private Long roomId;
private String stage;
private String stageMsg;
private GameResult gameResult;
private List<GameStageAdvancePlayerVO> votedDead;
private List<GameStageAdvancePlayerVO> deadLastNight;
}
20.工具类
package com.zhan.game.utils;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
public class GameUtil {
public static String generateRoomName() {
// 获取当前系统时间
LocalDateTime now = LocalDateTime.now();
// 定义日期时间格式:yyyyMMddhhmmss
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
// 格式化时间并拼接前缀 "AI狼人杀房间"
return "AI狼人杀房间" + now.format(formatter);
}
}