青云
青云
发布于 2026-02-13 / 66 阅读
0
0

AI狼人杀

一、说明

本章介绍全部由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);
    }
}

21.效果


评论