diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..160384b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,53 @@ +name: Build & Verify + +on: + push: + branches: [ main, gpu-accel ] + pull_request: + branches: [ main ] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Setup JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Setup Gradle cache + uses: gradle/actions/setup-gradle@v4 + + - name: Check whitespace + run: | + if git rev-parse HEAD^ >/dev/null 2>&1; then + git diff --check HEAD^..HEAD + else + git show --check --oneline HEAD + fi + + - name: Compile and test + run: bash ./gradlew compileJava test --no-daemon --stacktrace + + - name: Package jar + run: bash ./gradlew jar --no-daemon + + - name: Verify jar contents + shell: bash + run: | + test -n "$(find build/libs -maxdepth 1 -type f -name '*.jar' -print -quit)" + ! jar tf build/libs/*.jar | grep -E '(^|/)(jna|jna-platform)-[^/]+\.jar$|^com/sun/jna/' + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: GameConsole-jar + path: build/libs/*.jar + retention-days: 14 diff --git a/.gitignore b/.gitignore index 812d5d0..f64c143 100644 --- a/.gitignore +++ b/.gitignore @@ -1,40 +1,10 @@ -# MacOS DS_Store files -.DS_Store - -# Gradle cache folder -.gradle - -# Gradle build folder -build - -# IntelliJ -out/ -.idea -*.iml -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# VS Code +/.zcode/ +.gradle/ +.idea/ .vscode/ - -# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml -hs_err_pid* - -# Common working directory -run - -# Decompiled sources -decompiled/ - -# Build logs -build_*.txt - -# Tools -cfr.jar -*.ps1 - -# Root data folder (duplicate) -/data/ - -# 但保留 src/main/resources 下的真实资源(如合成配方),避免被上述规则误忽略 -!src/main/resources/data/ +build/ +run/ +logs/ +data/ +cuda/ +/.bench/ diff --git a/README.md b/README.md index 6a38b2c..c0c1271 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ ## 🚀 使用方法 1. 确保已安装 [NeoForge](https://neoforged.net/) 1.21.1 -2. 将 `game_console-1.0.0.jar` 放入 `.minecraft/mods` 文件夹 +2. 将 `Game Console-1.0.0-NeoForge-1.21.1-beta5.jar` 放入 `.minecraft/mods` 文件夹 3. 启动游戏,在创造模式物品栏「游戏机」分类中获取游戏机 4. 右键使用游戏机即可打开游戏选择界面 @@ -95,7 +95,7 @@ cd game_console # 构建 ./gradlew build -# 产物位于 build/libs/game_console-1.0.0.jar +# 产物位于 build/libs/Game Console-1.0.0-NeoForge-1.21.1-beta5.jar ``` ## 📄 许可证 diff --git a/build.gradle b/build.gradle index 7c0d2db..9adc6fa 100644 --- a/build.gradle +++ b/build.gradle @@ -10,17 +10,18 @@ group = mod_group_id repositories { mavenLocal() - mavenCentral() // 供测试依赖(如 JUnit)下载 + mavenCentral() // 供测试依赖(如 JUnit)与 JNA 下载 + // 注:JogAmp 仓库已移除——项目从未使用 JOCL 绑定(OpenCL 走 JNA 直调,见 OpenCLBackend) } base { archivesName = mod_id } -// 构建产物命名:Game Console----beta2.jar +// 构建产物命名:Game Console----beta6.jar var loaderName = "NeoForge" tasks.named('jar') { - archiveBaseName = "${mod_name}-${mod_version}-${loaderName}-${minecraft_version}-beta2" + archiveBaseName = "${mod_name}-${mod_version}-${loaderName}-${minecraft_version}-beta6" archiveVersion = '' archiveClassifier = '' } @@ -36,6 +37,8 @@ tasks.withType(Javadoc).configureEach { } tasks.withType(ProcessResources).configureEach { filteringCharset = 'UTF-8' + // ★ 优化:排除任何残留的空目录(如 data/game_console/recipes/)和占位文件 + exclude '**/recipes/**', '**/.gitignore', '**/.gitkeep' } neoForge { @@ -135,12 +138,55 @@ dependencies { // ─── 单元测试:JUnit 5 ─── testImplementation platform('org.junit:junit-bom:5.10.2') testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.slf4j:slf4j-api:2.0.9' + + // ─── OpenCL GPU 加速 ─── + // JNA 通过 compileOnly 引入(编译期可见),运行时由玩家环境的 classloader 解析 + // —— 不再 jarJar 嵌入。原因:Java 22 模块系统下,自嵌入的 jna jar 会形成 + // "com.sun.jna" 自动模块,与其他 mod(如 Iris/Sodium 或 NeoForge 间接带) + // 提供的 jna 形成两个同名模块冲突,触发 ResolutionException。 + // 缺 JNA 时由 OpenCLBackend.canExecute() 检测并 catch(Throwable) 回退到 CPU。 + compileOnly 'net.java.dev.jna:jna:5.14.0' } test { useJUnitPlatform() } +// Pure-Java self-play training entry point. Pass arguments with -PtrainArgs="--weights path ...". +tasks.register('trainGoAI', JavaExec) { + group = 'training' + description = 'Train the Go MCTS neural evaluator with self-play' + mainClass = 'com.wzz.game_console.client.screens.games.gogame.GoTrainingMain' + args((project.findProperty('trainArgs') ?: '').toString().trim().split("\\s+").findAll { !it.isEmpty() }) + classpath = sourceSets.main.runtimeClasspath + maxHeapSize = '4g' +} + +tasks.register('runChessSim', JavaExec) { + group = 'training' + description = '自对弈模拟:内置中国象棋引擎自我对弈,统计胜率/步数/耗时' + mainClass = 'com.wzz.game_console.client.screens.games.chess.ChessSimulationMain' + args((project.findProperty('chessSimArgs') ?: '').toString().trim().split("\\s+").findAll { !it.isEmpty() }) + classpath = sourceSets.main.runtimeClasspath +} + +tasks.register('adversarialTrain', JavaExec) { + group = 'training' + description = '对抗训练:己方 MCTS AI vs 外部 KataGo 引擎' + mainClass = 'com.wzz.game_console.client.screens.games.gogame.GoAdversarialMain' + args((project.findProperty('advArgs') ?: '').toString().trim().split("\\s+").findAll { !it.isEmpty() }) + classpath = sourceSets.main.runtimeClasspath + maxHeapSize = '4g' +} + +tasks.register('gpuProbe', JavaExec) { + group = 'training' + description = '探测 OpenCL GPU 设备是否可用' + mainClass = 'com.wzz.game_console.client.screens.games.gogame.GoGpuProbe' + classpath = sourceSets.main.runtimeClasspath +} + // This block of code expands all declared replace properties in the specified resource targets. // A missing property will result in an error. Properties are expanded using ${} Groovy notation. var generateModMetadata = tasks.register("generateModMetadata", ProcessResources) { diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9355b41..ca025c8 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/src/main/java/com/wzz/game_console/ModMain.java b/src/main/java/com/wzz/game_console/ModMain.java index c4d79ae..3438b4a 100644 --- a/src/main/java/com/wzz/game_console/ModMain.java +++ b/src/main/java/com/wzz/game_console/ModMain.java @@ -3,11 +3,13 @@ import com.wzz.game_console.init.ModItems; import com.wzz.game_console.init.ModNetworks; import com.wzz.game_console.init.ModTabs; +import com.wzz.game_console.network.ServerDisconnectWatcher; import com.wzz.game_console.util.ExternalFileManager; import net.neoforged.bus.api.IEventBus; import net.neoforged.fml.common.Mod; import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent; import net.neoforged.fml.ModContainer; +import net.neoforged.neoforge.common.NeoForge; @Mod(ModMain.MODID) public class ModMain { @@ -19,6 +21,8 @@ public ModMain(IEventBus modEventBus, ModContainer modContainer) { modEventBus.addListener(ModNetworks::register); ModItems.REGISTRY.register(modEventBus); ModTabs.REGISTRY.register(modEventBus); + // 服务端断线看门狗:玩家退出服务器时广播 PLAYER_QUIT(客户端据此关闭死等的对局界面) + NeoForge.EVENT_BUS.addListener(ServerDisconnectWatcher::onPlayerLoggedOut); } private void commonSetup(final FMLCommonSetupEvent event) { diff --git a/src/main/java/com/wzz/game_console/client/screens/GameSelectorScreen.java b/src/main/java/com/wzz/game_console/client/screens/GameSelectorScreen.java index 48a58ca..576d37d 100644 --- a/src/main/java/com/wzz/game_console/client/screens/GameSelectorScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/GameSelectorScreen.java @@ -266,30 +266,56 @@ private int getCategoryColor(String category) { /** 打开文件对话框导入外部游戏设置 JSON */ private void importSettingsFromFile() { - try { - Frame frame = new Frame(); - frame.setAlwaysOnTop(true); - FileDialog dialog = new FileDialog(frame, "选择游戏设置文件 (.json)", FileDialog.LOAD); - dialog.setFile("*.json"); - dialog.setVisible(true); - String filePath = dialog.getFile(); - String dirPath = dialog.getDirectory(); - frame.dispose(); - - if (filePath == null || dirPath == null) return; - - File srcFile = new File(dirPath, filePath); - if (!srcFile.exists() || !srcFile.getName().endsWith(".json")) return; - - Path srcPath = srcFile.toPath(); - boolean success = GameSettings.importFromFile(srcPath); - - importMessage = success ? "设置导入成功!" : "导入失败:文件格式不正确"; - importMessageTime = System.currentTimeMillis(); - } catch (Exception e) { - importMessage = "导入失败:" + e.getMessage(); - importMessageTime = System.currentTimeMillis(); - } + // ★ Bug修复:FileDialog.setVisible(true) 是模态阻塞调用,在 MC 主线程直接打开 + // 会冻结整个游戏(停帧、无响应)。改为在后台 daemon 线程弹窗, + // 用户选完后 mc.execute 回主线程执行实际导入与提示(importMessage 由 render 读取) + Thread picker = new Thread(() -> { + Frame frame = null; + try { + frame = new Frame(); + frame.setAlwaysOnTop(true); + // ★ Bug修复:原版不设位置,Windows 多显示器/扩展屏(主屏 x<0 或 y<0) + // 时 FileDialog 会落在不可见区域,玩家看不见但模态阻塞,只能 Alt+F4。 + // setLocationRelativeTo(null) 强制居中到主屏可视区 + frame.setLocationRelativeTo(null); + FileDialog dialog = new FileDialog(frame, "选择游戏设置文件 (.json)", FileDialog.LOAD); + dialog.setFile("*.json"); + dialog.setLocationRelativeTo(frame); + dialog.setVisible(true); // 只阻塞本后台线程,不再冻结游戏 + String filePath = dialog.getFile(); + String dirPath = dialog.getDirectory(); + + if (filePath == null || dirPath == null) return; // 用户取消 + + File srcFile = new File(dirPath, filePath); + if (!srcFile.isFile() || !srcFile.getName().toLowerCase(java.util.Locale.ROOT).endsWith(".json")) { + Minecraft.getInstance().execute(() -> { + importMessage = "导入失败:请选择 JSON 文件"; + importMessageTime = System.currentTimeMillis(); + }); + return; + } + + Path srcPath = srcFile.toPath(); + // 回到 MC 主线程执行导入与界面提示 + Minecraft.getInstance().execute(() -> { + boolean success = GameSettings.importFromFile(srcPath); + importMessage = success ? "设置导入成功!" : "导入失败:文件格式不正确"; + importMessageTime = System.currentTimeMillis(); + }); + } catch (Exception e) { + String msg = "导入失败:" + e.getMessage(); + Minecraft.getInstance().execute(() -> { + importMessage = msg; + importMessageTime = System.currentTimeMillis(); + }); + } finally { + // ★ 修复 AWT Frame 泄漏:dispose 移入 finally,异常/用户取消路径也会释放 + if (frame != null) frame.dispose(); + } + }, "GameConsole-SettingsImport"); + picker.setDaemon(true); + picker.start(); } @Override diff --git a/src/main/java/com/wzz/game_console/client/screens/MultiplayerLobbyScreen.java b/src/main/java/com/wzz/game_console/client/screens/MultiplayerLobbyScreen.java index c0fd6ec..d5b8253 100644 --- a/src/main/java/com/wzz/game_console/client/screens/MultiplayerLobbyScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/MultiplayerLobbyScreen.java @@ -3,6 +3,7 @@ import com.wzz.game_console.client.screens.games.LanMultiplayerScreen; import com.wzz.game_console.init.ModNetworks; import com.wzz.game_console.network.MultiplayerGamePacket; +import com.wzz.game_console.network.MultiplayerInviteAttempt; import com.wzz.game_console.util.GameRenderHelper; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.Font; @@ -37,7 +38,7 @@ public record MultiplayerGame(String id, String name, String icon, boolean suppo new MultiplayerGame("chess", "中国象棋", "♚", true, true, true), new MultiplayerGame("icefire", "森林冰火人","❄", false, true, true), new MultiplayerGame("colorchase","颜色追逐", "🎨", true, true, true), - new MultiplayerGame("landlord", "斗地主", "🃏\uFE0F", true, false, true), + new MultiplayerGame("landlord", "斗地主", "🃏\uFE0F", true, true, true), new MultiplayerGame("breakout", "打砖块", "🧱", false, true, false), new MultiplayerGame("maze", "迷宫", "🌀", false, true, false), new MultiplayerGame("snake", "贪吃蛇", "🐍", false, true, false), @@ -62,6 +63,8 @@ public record PlayerInfo(UUID uuid, String name) {} // ─── 等待状态 ─── private String waitingMessage = ""; private UUID invitedPlayer = null; + private String invitedGameId = null; + private UUID invitedAttemptNonce = null; // ─── 斗地主三人联机:需要选两个玩家 ─── private final Set selectedLanPeers = Collections.synchronizedSet(new LinkedHashSet<>()); // 最多2个,线程安全 @@ -71,23 +74,54 @@ public record PlayerInfo(UUID uuid, String name) {} private UUID lanHostUuid = null; // 发起邀请时记录主机自身UUID // ─── 等待超时机制 ─── - private long waitingStartTick = 0; + /** -1 means the lobby is not currently waiting for invite responses. */ + private long waitingStartTick = -1L; private static final long WAIT_TIMEOUT_TICKS = 600; // 30秒超时 // ─── 分页 ─── private int playerListPage = 0; private static final int PLAYERS_PER_PAGE = 8; + private int gamesPage = 0; // 游戏选择列表当前页 + private static final int GAMES_PER_PAGE = 7; // 1080p 自动缩放下一页最多画 7 张卡 // ─── 收到的邀请 ─── private static MultiplayerGamePacket pendingInvite = null; private static String inviterName = null; private static long pendingInviteArrivalMs = 0; // 邀请到达时间戳 private static final long INVITE_TIMEOUT_MS = 60_000; // 邀请 60 秒过期 + // 已接受邀请的主机与时间戳:候选者接受邀请后会切到对局界面等待开局(pendingInvite 已清空), + // 主机此后发来的 INVITE_CANCELLED 需要靠它做兜底通知(见 handleIncomingPacket) + private static UUID acceptedInviteHostUuid = null; + private static String acceptedInviteGameId = null; + private static UUID acceptedInviteNonce = null; + private static long acceptedInviteMs = 0; + /** 兜底通知有效窗口:主机等待上限 30 秒,60 秒足以覆盖其流产通知,又防过期报文误伤 */ + private static final long ACCEPTED_INVITE_VALID_MS = 60_000; + /** 等待动画 "." 帧表(预计算,避免 renderWaiting 每帧 repeat 分配) */ + private static final String[] WAIT_DOTS = { "", ".", "..", "..." }; // 说明:主机不在大厅时到达的 ACCEPT_INVITE 不再缓存回放。 // 原因:回放发生在新建的大厅实例上,邀请上下文(invitedPlayer、selectedLanPeers 等) // 无法随包可靠恢复,回放会被来源校验全部拒绝(形同虚设),且误恢复上下文反而 // 可能被伪造包利用。故降级为明确的 LOGGER.warn + 丢弃,见 ACCEPT_INVITE 分支。 + private static MultiplayerGame findGame(String gameId) { + if (gameId == null || gameId.isBlank()) return null; + for (MultiplayerGame game : MP_GAMES) { + if (game.id().equals(gameId) && game.supportsLAN()) return game; + } + return null; + } + + private static void clearAcceptedInvite(UUID sender, String gameId) { + if (Objects.equals(acceptedInviteHostUuid, sender) + && Objects.equals(acceptedInviteGameId, gameId)) { + acceptedInviteHostUuid = null; + acceptedInviteGameId = null; + acceptedInviteNonce = null; + acceptedInviteMs = 0; + } + } + public MultiplayerLobbyScreen() { super(Component.literal("联机大厅")); // 进入大厅时清理已过期的邀请 @@ -102,10 +136,27 @@ public static void handleIncomingPacket(MultiplayerGamePacket packet) { Minecraft mc = Minecraft.getInstance(); switch (packet.getType()) { case INVITE -> { + UUID sender = packet.getSenderUuid(); + UUID nonce = MultiplayerInviteAttempt.parse(packet.getData()); + UUID self = mc.player != null ? mc.player.getUUID() : null; + if (sender == null || sender.equals(self) || nonce == null || findGame(packet.getGameId()) == null) { + LOGGER.warn("[游戏机联机] 忽略非法邀请 sender={} gameId={}", sender, packet.getGameId()); + break; + } + if (pendingInvite != null + && (!Objects.equals(pendingInvite.getSenderUuid(), sender) + || !Objects.equals(pendingInvite.getGameId(), packet.getGameId()) + || !Objects.equals(MultiplayerInviteAttempt.parse(pendingInvite.getData()), nonce))) { + LOGGER.warn("[游戏机联机] 已有待处理邀请,拒绝覆盖 sender={} gameId={}", sender, packet.getGameId()); + ModNetworks.PACKET_HANDLER.sendToServer(new MultiplayerGamePacket( + MultiplayerGamePacket.PacketType.DECLINE_INVITE, + sender, packet.getGameId(), MultiplayerInviteAttempt.encode(nonce))); + break; + } pendingInvite = packet; - // 邀请者名字优先用服务端盖章的 senderName,data 只作兜底 - inviterName = (packet.getSenderName() != null && !packet.getSenderName().isEmpty()) - ? packet.getSenderName() : packet.getData(); + // 邀请者名字只使用服务端盖章字段;data 保留给邀请尝试 nonce。 + inviterName = packet.getSenderName() != null && !packet.getSenderName().isEmpty() + ? packet.getSenderName() : "对方"; pendingInviteArrivalMs = System.currentTimeMillis(); // 如果当前不在大厅,显示通知 if (!(mc.screen instanceof MultiplayerLobbyScreen)) { @@ -135,24 +186,72 @@ public static void handleIncomingPacket(MultiplayerGamePacket packet) { case INVITE_CANCELLED -> { // 主机取消/超时/离开大厅:被邀者清除待处理邀请并提示 mc.execute(() -> { - // 仅当取消方确实是当前待处理邀请的发起者时才清除,防止伪造包顶掉正常邀请 + // 仅当取消方确实是当前待处理邀请的发起者时才清除并提示, + // 防止伪造包顶掉正常邀请、或无关报文打扰玩家 if (pendingInvite != null - && Objects.equals(pendingInvite.getSenderUuid(), packet.getSenderUuid())) { + && Objects.equals(pendingInvite.getSenderUuid(), packet.getSenderUuid()) + && Objects.equals(pendingInvite.getGameId(), packet.getGameId()) + && Objects.equals(MultiplayerInviteAttempt.parse(pendingInvite.getData()), + MultiplayerInviteAttempt.parse(packet.getData()))) { pendingInvite = null; inviterName = null; + if (mc.player != null) { + mc.player.displayClientMessage( + Component.literal("[游戏机] 邀请已取消/超时"), false); + } + return; } - if (mc.player != null) { - mc.player.displayClientMessage( - Component.literal("[游戏机] 邀请已取消/超时"), false); + // ★ Bug修复:兜底——候选者已接受邀请并切到对局界面等待开局时, + // pendingInvite 已在接受时清空,上面的分支不会命中,主机流产 + // 会让玩家永远停在"等待游戏开始"界面。此处校验取消方是当初 + // 接受邀请的主机且仍在有效窗口内,防止伪造/过期报文误伤; + // 回到大厅并用 chat 提示(沿用"邀请已取消/超时"的提示机制) + if (acceptedInviteHostUuid != null + && Objects.equals(acceptedInviteHostUuid, packet.getSenderUuid()) + && Objects.equals(acceptedInviteGameId, packet.getGameId()) + && MultiplayerInviteAttempt.matches(packet.getData(), acceptedInviteNonce) + && System.currentTimeMillis() - acceptedInviteMs <= ACCEPTED_INVITE_VALID_MS + && !(mc.screen instanceof MultiplayerLobbyScreen)) { + acceptedInviteHostUuid = null; + acceptedInviteGameId = null; + acceptedInviteNonce = null; + acceptedInviteMs = 0; + mc.setScreen(new MultiplayerLobbyScreen()); + if (mc.player != null) { + mc.player.displayClientMessage( + Component.literal("[游戏机] 主机已取消对局"), false); + } } }); } case DECLINE_INVITE -> { mc.execute(() -> { if (mc.screen instanceof MultiplayerLobbyScreen lobby) { - // 安全:仅接受被邀请者本人发来的拒绝,防止第三方伪造包误取消邀请 - if (lobby.invitedPlayer == null || !lobby.invitedPlayer.equals(packet.getSenderUuid())) { - LOGGER.warn("[游戏机联机] 忽略非受邀玩家的拒绝消息 sender={}", packet.getSenderUuid()); + UUID from = packet.getSenderUuid(); + if (lobby.state != LobbyState.WAITING || lobby.invitedGameId == null + || !lobby.invitedGameId.equals(packet.getGameId()) + || !MultiplayerInviteAttempt.matches(packet.getData(), lobby.invitedAttemptNonce)) { + LOGGER.warn("[游戏机联机] 忽略与当前邀请不匹配的拒绝消息 sender={} gameId={}", + from, packet.getGameId()); + return; + } + // 斗地主批量邀请路径:候选人拒绝则移出名单,凑不齐两人时终止等待 + if (from != null && lobby.selectedLanPeers.contains(from)) { + lobby.selectedLanPeers.remove(from); + String nm = packet.getSenderName() == null || packet.getSenderName().isEmpty() + ? "一位玩家" : packet.getSenderName(); + if (lobby.state == LobbyState.WAITING && lobby.selectedLanPeers.size() < 2) { + // 剩余候选不足两人,永远等不到 2 个接受,直接终止 + lobby.terminateWaiting(nm + " 拒绝了斗地主邀请"); + } else if (mc.player != null) { + mc.player.displayClientMessage( + Component.literal("[游戏机] " + nm + " 拒绝了斗地主邀请"), false); + } + return; + } + // 普通单人邀请路径:仅接受被邀请者本人发来的拒绝,防止第三方伪造包误取消邀请 + if (lobby.invitedPlayer == null || !lobby.invitedPlayer.equals(from)) { + LOGGER.warn("[游戏机联机] 忽略非受邀玩家的拒绝消息 sender={}", from); return; } lobby.state = LobbyState.MODE_SELECT; @@ -172,23 +271,44 @@ public static void handleIncomingPacket(MultiplayerGamePacket packet) { // ─── 游戏内网络包路由(通用,任何实现 LanMultiplayerScreen 的 Screen 均可接收) case GAME_MOVE -> { mc.execute(() -> { - if (mc.screen instanceof LanMultiplayerScreen s) - // 传入服务端盖章的发送者 UUID,供需要按来源校验座位的游戏使用 - s.onRemoteMove(packet.getSenderUuid(), packet.getData()); + if (mc.screen instanceof LanMultiplayerScreen s + && s.getLanGameId() != null + && packet.getGameId() != null + && Objects.equals(s.getLanGameId(), packet.getGameId())) + { + MultiplayerGamePacket.DataEnvelope data = s.acceptLanEnvelope(packet.getType(), packet.getSenderUuid(), packet.getData()); + if (data != null) { + clearAcceptedInvite(packet.getSenderUuid(), packet.getGameId()); + s.onRemoteMove(packet.getSenderUuid(), data.body()); + } + } }); } case GAME_STATE_SYNC -> { mc.execute(() -> { - if (mc.screen instanceof LanMultiplayerScreen s) - // 传入服务端盖章的发送者 UUID,供需要按来源校验的游戏使用 - s.onRemoteState(packet.getSenderUuid(), packet.getData()); + if (mc.screen instanceof LanMultiplayerScreen s + && s.getLanGameId() != null + && packet.getGameId() != null + && Objects.equals(s.getLanGameId(), packet.getGameId())) + { + MultiplayerGamePacket.DataEnvelope data = s.acceptLanEnvelope(packet.getType(), packet.getSenderUuid(), packet.getData()); + if (data != null) { + clearAcceptedInvite(packet.getSenderUuid(), packet.getGameId()); + s.onRemoteState(packet.getSenderUuid(), data.body()); + } + } }); } case GAME_OVER -> { mc.execute(() -> { - if (mc.screen instanceof LanMultiplayerScreen s) - // 传入服务端盖章的发送者 UUID,供需要按来源校验的游戏使用 - s.onRemoteGameOver(packet.getSenderUuid(), packet.getData()); + if (mc.screen instanceof LanMultiplayerScreen s + && s.getLanGameId() != null + && packet.getGameId() != null + && Objects.equals(s.getLanGameId(), packet.getGameId())) + { + MultiplayerGamePacket.DataEnvelope data = s.acceptLanEnvelope(packet.getType(), packet.getSenderUuid(), packet.getData()); + if (data != null) s.onRemoteGameOver(packet.getSenderUuid(), data.body()); + } }); } case LEAVE_GAME -> { @@ -204,6 +324,13 @@ public static void handleIncomingPacket(MultiplayerGamePacket packet) { currentGameId, packet.getGameId()); return; } + // 来源校验:仅对端本人(多方对局由各 Screen 重写 isLeaveFromPeer 判定), + // 非对局参与者的 LEAVE_GAME 一律忽略 + if (!s.isLeaveFromPeer(packet.getSenderUuid())) { + LOGGER.warn("[游戏机联机] 忽略非对端来源的 LEAVE_GAME(sender={})", + packet.getSenderUuid()); + return; + } String name = packet.getSenderName() == null || packet.getSenderName().isEmpty() ? "对方" : packet.getSenderName(); s.onRemoteLeave(name); @@ -212,6 +339,34 @@ public static void handleIncomingPacket(MultiplayerGamePacket packet) { Component.literal("[游戏机] " + name + " 已退出对局"), false); } mc.setScreen(null); + } else if (mc.screen instanceof MultiplayerLobbyScreen lobby) { + lobby.onWaitingPeerLeave(packet); + } + }); + } + case PLAYER_QUIT -> { + // 服务端断线看门狗(ServerDisconnectWatcher)广播:某玩家退出服务器。 + // 若退出者正是当前对端,同样按"对方退出对局"处理,避免对端干等 + mc.execute(() -> { + UUID quitter; + try { + quitter = UUID.fromString(packet.getData().trim()); + } catch (Exception e) { + return; + } + if (mc.screen instanceof LanMultiplayerScreen s && s.isLeaveFromPeer(quitter)) { + String name = packet.getSenderName() == null || packet.getSenderName().isEmpty() + ? "对方" : packet.getSenderName(); + s.onRemoteLeave(name); + if (mc.player != null) { + mc.player.displayClientMessage( + Component.literal("[游戏机] " + name + " 已断线,对局结束"), false); + } + mc.setScreen(null); + } else if (mc.screen instanceof MultiplayerLobbyScreen lobby) { + // ★ Bug修复:大厅侧同样要处理——退出者是候选/被邀玩家时 + // 移出名单并提前终止等待,避免主机干等到 30 秒超时 + lobby.onPeerQuit(quitter); } }); } @@ -239,29 +394,36 @@ private void requestPlayerList() { )); } - private void sendInvite(UUID target) { + private void sendInvite(UUID target, UUID nonce) { MultiplayerGame game = MP_GAMES.get(selectedGameIndex); - String senderName = Minecraft.getInstance().player != null ? - Minecraft.getInstance().player.getGameProfile().getName() : "???"; ModNetworks.PACKET_HANDLER.sendToServer(new MultiplayerGamePacket( - MultiplayerGamePacket.PacketType.INVITE, target, game.id, senderName + MultiplayerGamePacket.PacketType.INVITE, target, game.id, + MultiplayerInviteAttempt.encode(nonce) )); } + /** 按玩家点击选择的先后顺序生成快照;不要依赖 ConcurrentHashMap 的遍历顺序分配座位。 */ + private List selectedLanPeersInOrder() { + synchronized (selectedLanPeers) { + return new ArrayList<>(selectedLanPeers); + } + } + /** 斗地主:选好2个玩家后批量发邀请 */ private void sendLandlordInvites() { MultiplayerGame game = MP_GAMES.get(selectedGameIndex); Minecraft mc = Minecraft.getInstance(); - String senderName = mc.player != null ? - mc.player.getGameProfile().getName() : "???"; // 记录主机UUID,启动游戏时传入三个UUID(主机+两个接受者) lanHostUuid = mc.player != null ? mc.player.getGameProfile().getId() : null; - for (UUID uuid : selectedLanPeers) { + invitedAttemptNonce = UUID.randomUUID(); + for (UUID uuid : selectedLanPeersInOrder()) { ModNetworks.PACKET_HANDLER.sendToServer(new MultiplayerGamePacket( - MultiplayerGamePacket.PacketType.INVITE, uuid, game.id, senderName + MultiplayerGamePacket.PacketType.INVITE, uuid, game.id, + MultiplayerInviteAttempt.encode(invitedAttemptNonce) )); } invitedPlayer = null; // 斗地主用 selectedLanPeers 代替 + invitedGameId = game.id(); expectedAccepts = 2; pendingAccepts = 0; acceptedPeers.clear(); @@ -279,10 +441,13 @@ private void notifyInviteCancelled() { if (invitedPlayer != null) targets.add(invitedPlayer); targets.addAll(selectedLanPeers); // 斗地主:批量邀请的所有候选 if (targets.isEmpty()) return; - String gameId = MP_GAMES.get(selectedGameIndex).id; + String gameId = invitedGameId; + UUID nonce = invitedAttemptNonce; + if (gameId == null || nonce == null) return; for (UUID target : targets) { ModNetworks.PACKET_HANDLER.sendToServer(new MultiplayerGamePacket( - MultiplayerGamePacket.PacketType.INVITE_CANCELLED, target, gameId, "" + MultiplayerGamePacket.PacketType.INVITE_CANCELLED, target, gameId, + MultiplayerInviteAttempt.encode(nonce) )); } } @@ -290,13 +455,57 @@ private void notifyInviteCancelled() { /** 统一清理联机等待状态(无论成功或失败都调用) */ private void resetLanWaitState() { invitedPlayer = null; + invitedGameId = null; + invitedAttemptNonce = null; lanHostUuid = null; pendingAccepts = 0; expectedAccepts = 1; acceptedPeers.clear(); selectedLanPeers.clear(); waitingMessage = ""; - waitingStartTick = 0; + waitingStartTick = -1L; + } + + /** + * 终止当前等待(等待超时/对端断线共用的终止路径): + * 先通知其余被邀者邀请作废,再回到模式选择并给出原因提示。 + */ + private void terminateWaiting(String reason) { + notifyInviteCancelled(); // 先通知被邀者,避免对方无限等待 + state = LobbyState.MODE_SELECT; + resetLanWaitState(); + waitingMessage = reason; // resetLanWaitState会清空,重新设置提示 + } + + /** + * PLAYER_QUIT(服务端断线看门狗广播)的大厅侧处理。 + * (a) 将退出者移出斗地主候选名单;(b) 若正处于等待其接受的 WAITING 态 + * (单邀的 invitedPlayer 或批量邀的候选含该 UUID),复用超时终止路径 + * 提前结束等待,避免主机干等到 30 秒超时。 + */ + private void onPeerQuit(UUID quitter) { + if (quitter == null) return; + boolean wasCandidate = selectedLanPeers.contains(quitter); + boolean wasInvitee = quitter.equals(invitedPlayer); + if (!wasCandidate && !wasInvitee) return; // 与当前邀请无关的退出者,忽略 + selectedLanPeers.remove(quitter); + if (state == LobbyState.WAITING) { + terminateWaiting("对方已断线"); + } + } + + private void onWaitingPeerLeave(MultiplayerGamePacket packet) { + UUID sender = packet.getSenderUuid(); + if (state != LobbyState.WAITING || !"landlord".equals(invitedGameId) + || !Objects.equals(invitedGameId, packet.getGameId()) + || !MultiplayerInviteAttempt.matches(packet.getData(), invitedAttemptNonce) + || sender == null || !acceptedPeers.containsKey(sender) + || !selectedLanPeers.contains(sender)) { + return; + } + String name = acceptedPeers.remove(sender); + selectedLanPeers.remove(sender); + terminateWaiting((name == null || name.isEmpty() ? "一位玩家" : name) + " 已退出等待"); } private void onInviteAccepted(MultiplayerGamePacket packet) { @@ -305,11 +514,18 @@ private void onInviteAccepted(MultiplayerGamePacket packet) { String gameId = packet.getGameId(); UUID accepterUuid = packet.getSenderUuid(); String accepterName = packet.getSenderName() != null && !packet.getSenderName().isEmpty() - ? packet.getSenderName() : packet.getData(); + ? packet.getSenderName() : "一位玩家"; if (accepterUuid == null) { LOGGER.warn("[游戏机联机] 收到缺少发送者身份的 ACCEPT_INVITE,已忽略"); return; } + if (state != LobbyState.WAITING || invitedGameId == null + || !invitedGameId.equals(gameId) || findGame(gameId) == null + || !MultiplayerInviteAttempt.matches(packet.getData(), invitedAttemptNonce)) { + LOGGER.warn("[游戏机联机] 忽略与当前邀请不匹配的 ACCEPT_INVITE sender={} gameId={} pending={}", + accepterUuid, gameId, invitedGameId); + return; + } if ("landlord".equals(gameId) && expectedAccepts == 2) { // 斗地主三人联机:只接受被邀请过的玩家,防止陌生人冒充 @@ -323,9 +539,13 @@ private void onInviteAccepted(MultiplayerGamePacket packet) { waitingMessage = "等待两位玩家接受邀请 (" + pendingAccepts + "/2)..."; if (pendingAccepts >= 2) { - // 两人都接受,启动游戏(传入三个UUID:主机 + 两个接受者) - UUID[] peers = acceptedPeers.keySet().toArray(new UUID[0]); - UUID p1 = peers[0], p2 = peers[1]; + // 座位严格按 selectedLanPeers 的邀请顺序确定,接受包到达顺序不影响 peer1/peer2。 + List invitedOrder=selectedLanPeersInOrder(); + if(invitedOrder.size()!=2||!acceptedPeers.keySet().containsAll(invitedOrder)){ + LOGGER.warn("[游戏机联机] 斗地主接受名单与邀请顺序不一致,暂不启动"); + return; + } + UUID p1=invitedOrder.get(0),p2=invitedOrder.get(1); Screen gs = new com.wzz.game_console.client.screens.games.landlord .LandlordGameScreen(true, lanHostUuid, p1, p2); // 先清理再切屏:避免 setScreen 触发 removed() 时误发 INVITE_CANCELLED @@ -360,12 +580,9 @@ public void tick() { lastRefreshTime = tickCount; } // 等待超时机制:长时间无人接受则自动取消邀请 - if (state == LobbyState.WAITING && waitingStartTick > 0 + if (state == LobbyState.WAITING && waitingStartTick >= 0 && tickCount - waitingStartTick > WAIT_TIMEOUT_TICKS) { - notifyInviteCancelled(); // 超时前先通知被邀者,避免对方无限等待 - state = LobbyState.MODE_SELECT; - resetLanWaitState(); - waitingMessage = "等待超时,邀请已取消"; // resetLanWaitState会清空,重新设置提示 + terminateWaiting("等待超时,邀请已取消"); } // 收到的邀请超时清理:避免过期的邀请弹窗一直遮挡界面 if (pendingInvite != null && System.currentTimeMillis() - pendingInviteArrivalMs > INVITE_TIMEOUT_MS) { @@ -394,7 +611,7 @@ public void render(GuiGraphics g, int mx, int my, float pt) { case MODE_SELECT -> renderModeSelect(g, mx, my); case PLAYER_LIST -> renderPlayerList(g, mx, my); case PLAYER_LIST_MULTI -> renderPlayerListMulti(g, mx, my); - case WAITING -> renderWaiting(g); + case WAITING -> renderWaiting(g, mx, my); } GameRenderHelper.drawBottomBar(g, font, width, height, "ESC 返回"); } @@ -402,20 +619,27 @@ public void render(GuiGraphics g, int mx, int my, float pt) { private void renderGameSelect(GuiGraphics g, int mx, int my) { int cx = width / 2; - g.drawCenteredString(font, "选择多人游戏", cx, 38, 0xCCCCCC); + // ★ Bug修复:11 张卡一页画不下(1080p 自动缩放下超出屏幕且无法点选), + // 参照 renderPlayerList 的分页模式,只渲染当前页 + int totalPages = Math.max(1, (MP_GAMES.size() + GAMES_PER_PAGE - 1) / GAMES_PER_PAGE); + if (gamesPage >= totalPages) gamesPage = totalPages - 1; + // 页码指示沿用玩家列表的写法(标题带 当前页/总页数) + g.drawCenteredString(font, "选择多人游戏 (" + (gamesPage + 1) + "/" + totalPages + ")", cx, 38, 0xCCCCCC); int startY = 55; int cardW = 220; int cardH = 24; hoveredGameIndex = -1; - for (int i = 0; i < MP_GAMES.size(); i++) { + int startIdx = gamesPage * GAMES_PER_PAGE; + int endIdx = Math.min(startIdx + GAMES_PER_PAGE, MP_GAMES.size()); + for (int i = startIdx; i < endIdx; i++) { MultiplayerGame game = MP_GAMES.get(i); int cardX = cx - cardW / 2; - int cardY = startY + i * (cardH + 3); + int cardY = startY + (i - startIdx) * (cardH + 3); boolean hover = mx >= cardX && mx <= cardX + cardW && my >= cardY && my <= cardY + cardH; - if (hover) hoveredGameIndex = i; + if (hover) hoveredGameIndex = i; // 绝对索引,点击处理与原逻辑一致 int bg = hover ? 0xFF252555 : 0xFF1A1A38; g.fill(cardX, cardY, cardX + cardW, cardY + cardH, bg); @@ -434,6 +658,12 @@ private void renderGameSelect(GuiGraphics g, int mx, int my) { int mw = font.width(modes.toString()); g.drawString(font, modes.toString(), cardX + cardW - mw - 5, cardY + 8, 0x888888); } + + // 翻页提示:游戏列表用滚轮/PageUp/PageDown 翻页(玩家列表用的是底部按钮位,此处空间不足) + if (totalPages > 1) { + g.drawCenteredString(font, "滚轮 / PgUp·PgDn 翻页", cx, + startY + (endIdx - startIdx) * (cardH + 3) + 4, 0x666666); + } } private void renderModeSelect(GuiGraphics g, int mx, int my) { @@ -454,19 +684,19 @@ private void renderModeSelect(GuiGraphics g, int mx, int my) { if (game.supportsAI) { boolean h = drawModeButton(g, mx, my, cx - btnW/2, startY + modeIdx * 30, btnW, btnH, "🤖 玩家 vs 人机", 0xFF2A4A14); - if (h) hoveredModeIndex = 0; + if (h) hoveredModeIndex = modeIdx; modeIdx++; } if (game.supportsLocal) { boolean h = drawModeButton(g, mx, my, cx - btnW/2, startY + modeIdx * 30, btnW, btnH, "👥 本地双人", 0xFF4A3A14); - if (h) hoveredModeIndex = 1; + if (h) hoveredModeIndex = modeIdx; modeIdx++; } if (game.supportsLAN) { boolean h = drawModeButton(g, mx, my, cx - btnW/2, startY + modeIdx * 30, btnW, btnH, "🌐 局域网对战", 0xFF143A4A); - if (h) hoveredModeIndex = 2; + if (h) hoveredModeIndex = modeIdx; modeIdx++; } @@ -581,12 +811,12 @@ private void renderPlayerListMulti(GuiGraphics g, int mx, int my) { GameRenderHelper.drawSecondaryButton(g, font, "◀ 返回", cx - 40, height - 28, 80, 18, mx, my); } - private void renderWaiting(GuiGraphics g) { + private void renderWaiting(GuiGraphics g, int mx, int my) { int cx = width / 2, cy = height / 2; - // 动画点 - String dots = ".".repeat((int)(tickCount / 10 % 4)); + // 动画点(查表,帧表为类级预计算常量) + String dots = WAIT_DOTS[(int)(tickCount / 10 % WAIT_DOTS.length)]; g.drawCenteredString(font, waitingMessage + dots, cx, cy - 10, 0xFFFF44); - GameRenderHelper.drawSecondaryButton(g, font, "取消", cx - 40, cy + 10, 80, 18, 0, 0); + GameRenderHelper.drawSecondaryButton(g, font, "取消", cx - 40, cy + 10, 80, 18, mx, my); } private void renderInviteNotification(GuiGraphics g, int mx, int my) { @@ -624,18 +854,25 @@ public boolean mouseClicked(double mx, double my, int btn) { // 接受按钮:nx+20, ny+nh-28, 宽110, 高22 if (mx >= nx + 20 && mx <= nx + 130 && my >= ny + nh - 28 && my <= ny + nh - 6) { - String gameId = pendingInvite.getGameId(); + String gameId = pendingInvite.getGameId(); + UUID inviteNonce = MultiplayerInviteAttempt.parse(pendingInvite.getData()); // 主机 UUID 以服务端盖章的发送者身份为准(targetPlayer 仍是收件人即自己) - UUID hostUuid = pendingInvite.getSenderUuid(); - if (hostUuid == null) { - // 缺失发送者身份的畸形邀请,直接丢弃 + UUID hostUuid = pendingInvite.getSenderUuid(); + if (hostUuid == null || inviteNonce == null) { + // 缺失发送者身份或邀请尝试标识的畸形邀请,直接丢弃 pendingInvite = null; return true; } ModNetworks.PACKET_HANDLER.sendToServer(new MultiplayerGamePacket( MultiplayerGamePacket.PacketType.ACCEPT_INVITE, - hostUuid, gameId, "" + hostUuid, gameId, MultiplayerInviteAttempt.encode(inviteNonce) )); + // 记录已接受邀请的主机:主机此后流产(INVITE_CANCELLED)时用于兜底通知, + // 否则已切到对局界面等待的候选者收不到任何通知 + acceptedInviteHostUuid = hostUuid; + acceptedInviteGameId = gameId; + acceptedInviteNonce = inviteNonce; + acceptedInviteMs = System.currentTimeMillis(); pendingInvite = null; // 被邀请方作为 CLIENT 直接启动游戏 // CLIENT 侧:以 isHost=false 启动对应联机实例 @@ -648,13 +885,14 @@ public boolean mouseClicked(double mx, double my, int btn) { case "chess" -> new com.wzz.game_console.client.screens.games.ChessGameScreen(false, hostUuid); case "go" -> new com.wzz.game_console.client.screens.games.gogame.GoGameScreen(false, hostUuid); // 斗地主CLIENT:等HOST推送初始状态(含玩家索引) - case "landlord" -> new com.wzz.game_console.client.screens.games.landlord.LandlordGameScreen(false, hostUuid); + case "landlord" -> new com.wzz.game_console.client.screens.games.landlord.LandlordGameScreen(false, hostUuid, inviteNonce); default -> null; }; if (clientScreen != null) { Minecraft.getInstance().setScreen(clientScreen); } else { state = LobbyState.WAITING; + waitingStartTick = tickCount; // 补超时起点:否则主机永远不开局时客机无限等待 waitingMessage = "已接受邀请,等待 " + inviterName + " 开始 " + gameId + "..."; } return true; @@ -662,16 +900,7 @@ public boolean mouseClicked(double mx, double my, int btn) { // 拒绝按钮:nx+nw-130, ny+nh-28, 宽110, 高22 if (mx >= nx + nw - 130 && mx <= nx + nw - 20 && my >= ny + nh - 28 && my <= ny + nh - 6) { - // 拒绝消息发给服务端盖章的邀请者(targetPlayer 仍是自己,不能用) - UUID inviterUuid = pendingInvite.getSenderUuid(); - if (inviterUuid != null) { - ModNetworks.PACKET_HANDLER.sendToServer(new MultiplayerGamePacket( - MultiplayerGamePacket.PacketType.DECLINE_INVITE, - inviterUuid, - pendingInvite.getGameId(), "" - )); - } - pendingInvite = null; + declinePendingInvite(); return true; } return true; // 弹窗显示时屏蔽所有背景点击 @@ -687,13 +916,17 @@ public boolean mouseClicked(double mx, double my, int btn) { } } case MODE_SELECT -> { - if (hoveredModeIndex == 0) { - launchGame("ai"); + MultiplayerGame selected = MP_GAMES.get(selectedGameIndex); + int modeIndex = 0; + if (selected.supportsAI && hoveredModeIndex == modeIndex++) { + launchGame("AI"); return true; - } else if (hoveredModeIndex == 1) { - launchGame("local"); + } + if (selected.supportsLocal && hoveredModeIndex == modeIndex++) { + launchGame("LOCAL_TWO_PLAYER"); return true; - } else if (hoveredModeIndex == 2) { + } + if (selected.supportsLAN && hoveredModeIndex == modeIndex) { // 局域网 - 斗地主需要选2人 MultiplayerGame curGame = MP_GAMES.get(selectedGameIndex); if ("landlord".equals(curGame.id())) { @@ -725,8 +958,10 @@ public boolean mouseClicked(double mx, double my, int btn) { } if (hoveredPlayerIndex >= 0 && hoveredPlayerIndex < onlinePlayers.size()) { UUID target = onlinePlayers.get(hoveredPlayerIndex).uuid(); - sendInvite(target); + invitedAttemptNonce = UUID.randomUUID(); + sendInvite(target, invitedAttemptNonce); invitedPlayer = target; + invitedGameId = MP_GAMES.get(selectedGameIndex).id(); expectedAccepts = 1; pendingAccepts = 0; acceptedPeers.clear(); @@ -789,16 +1024,22 @@ public boolean mouseClicked(double mx, double my, int btn) { private void launchGame(String mode) { MultiplayerGame game = MP_GAMES.get(selectedGameIndex); + boolean ai = "AI".equalsIgnoreCase(mode) || "HUMAN".equalsIgnoreCase(mode); + boolean localTwoPlayer = "LOCAL_TWO_PLAYER".equalsIgnoreCase(mode) || "LOCAL".equalsIgnoreCase(mode); Screen gameScreen = switch (game.id) { - case "gomoku" -> new com.wzz.game_console.client.screens.games.GomokuScreen(); + case "gomoku" -> new com.wzz.game_console.client.screens.games.GomokuScreen(ai); case "go" -> new com.wzz.game_console.client.screens.games.gogame.GoGameScreen( - new com.wzz.game_console.client.screens.games.gogame.GoGame()); + new com.wzz.game_console.client.screens.games.gogame.GoGame(ai)); case "tictactoe" -> new com.wzz.game_console.client.screens.games.tictactoe.TicTacToeScreen( - com.wzz.game_console.client.screens.games.tictactoe.TicTacToeGame.GameMode.SINGLE_PLAYER); - case "chess" -> new com.wzz.game_console.client.screens.games.ChessGameScreen(); + localTwoPlayer + ? com.wzz.game_console.client.screens.games.tictactoe.TicTacToeGame.GameMode.TWO_PLAYER + : com.wzz.game_console.client.screens.games.tictactoe.TicTacToeGame.GameMode.SINGLE_PLAYER); + case "chess" -> new com.wzz.game_console.client.screens.games.ChessGameScreen( + ai ? com.wzz.game_console.client.screens.games.ChessGameScreen.GameMode.PVA + : com.wzz.game_console.client.screens.games.ChessGameScreen.GameMode.PVP); case "icefire" -> new com.wzz.game_console.client.screens.games.IceFireGameScreen(); - case "colorchase"-> new com.wzz.game_console.client.screens.games.ColorChaseGameScreen(); - case "landlord" -> new com.wzz.game_console.client.screens.games.landlord.LandlordGameScreen(); + case "colorchase"-> new com.wzz.game_console.client.screens.games.ColorChaseGameScreen(localTwoPlayer); + case "landlord" -> new com.wzz.game_console.client.screens.games.landlord.LandlordGameScreen(localTwoPlayer); case "breakout" -> new com.wzz.game_console.client.screens.games.BreakoutScreen(); case "maze" -> new com.wzz.game_console.client.screens.games.MazeGameScreen(); case "snake" -> new com.wzz.game_console.client.screens.games.SnakeGameScreen(); @@ -826,8 +1067,44 @@ private Screen launchGameForLAN(String gameId, UUID remotePeer) { }; } + @Override + public boolean mouseScrolled(double mx, double my, double scrollX, double scrollY) { + // 游戏选择列表滚轮翻页(邀请弹窗显示时不翻页,避免隔空误操作) + if (pendingInvite == null && state == LobbyState.GAME_SELECT) { + int totalPages = Math.max(1, (MP_GAMES.size() + GAMES_PER_PAGE - 1) / GAMES_PER_PAGE); + if (scrollY < 0 && gamesPage < totalPages - 1) { gamesPage++; return true; } + if (scrollY > 0 && gamesPage > 0) { gamesPage--; return true; } + } + return super.mouseScrolled(mx, my, scrollX, scrollY); + } + + private void declinePendingInvite() { + MultiplayerGamePacket invite = pendingInvite; + if (invite == null) return; + UUID inviterUuid = invite.getSenderUuid(); + if (inviterUuid == null) { + LOGGER.warn("[游戏机联机] 无法拒绝缺少邀请方身份的邀请"); + } else { + ModNetworks.PACKET_HANDLER.sendToServer(new MultiplayerGamePacket( + MultiplayerGamePacket.PacketType.DECLINE_INVITE, + inviterUuid, invite.getGameId(), invite.getData())); + } + pendingInvite = null; + inviterName = null; + } + @Override public boolean keyPressed(int key, int scan, int mods) { + if (key == GLFW.GLFW_KEY_ESCAPE && pendingInvite != null) { + declinePendingInvite(); + return true; + } + // 游戏选择列表 PageUp/PageDown 翻页(与滚轮等效) + if (pendingInvite == null && state == LobbyState.GAME_SELECT) { + int totalPages = Math.max(1, (MP_GAMES.size() + GAMES_PER_PAGE - 1) / GAMES_PER_PAGE); + if (key == GLFW.GLFW_KEY_PAGE_DOWN && gamesPage < totalPages - 1) { gamesPage++; return true; } + if (key == GLFW.GLFW_KEY_PAGE_UP && gamesPage > 0) { gamesPage--; return true; } + } if (key == GLFW.GLFW_KEY_ESCAPE) { switch (state) { case MODE_SELECT -> { state = LobbyState.GAME_SELECT; return true; } @@ -848,6 +1125,11 @@ public void removed() { if (state == LobbyState.WAITING) { notifyInviteCancelled(); } + // ★ Bug修复:static pendingInvite/inviterName 在玩家切世界/Singleplayer→Multiplayer + // 后不被清理,旧邀请若未超时,新服务器邀请可能被旧 sender UUID 误清。 + // 退出屏时强制清空 + pendingInvite = null; + inviterName = null; } @Override diff --git a/src/main/java/com/wzz/game_console/client/screens/games/BlackHoleGameScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/BlackHoleGameScreen.java index 58c4ce2..4b9d7ee 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/BlackHoleGameScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/BlackHoleGameScreen.java @@ -25,6 +25,11 @@ public class BlackHoleGameScreen extends Screen { boolean showExitConfirm = false; private static final ResourceLocation BACKGROUND = ResourceUtil.createMinecraftInstance("textures/block/obsidian.png"); + // ★ Bug修复:原版每帧 render 中 new 3 个 ResourceLocation(60FPS=180次/s 反射), + // 提升为 static final,启动时创建一次 + private static final ResourceLocation DIAMOND_TEX = ResourceUtil.createMinecraftInstance("textures/block/diamond_block.png"); + private static final ResourceLocation REDSTONE_TEX = ResourceUtil.createMinecraftInstance("textures/block/redstone_block.png"); + private static final ResourceLocation EMERALD_TEX = ResourceUtil.createMinecraftInstance("textures/block/emerald_block.png"); private GameState gameState; private Player player; @@ -76,6 +81,8 @@ private void initGame() { this.cameraX = 0; this.cameraY = 0; this.cameraZ = 50; + // 重开局时恢复为进行中状态,否则游戏结束后重开会一直停留在结算界面 + this.gameState = GameState.PLAYING; } @Override @@ -86,9 +93,9 @@ public void init() { @Override public void tick() { super.tick(); - gameTime++; - + if (!minecraft.isWindowActive()) Arrays.fill(keys, false); if (gameState == GameState.PLAYING && !showExitConfirm) { // 弹窗期间暂停游戏 + gameTime++; updateGame(); } } @@ -340,16 +347,13 @@ private void renderPlayer(GuiGraphics graphics, PoseStack poseStack) { int y = (int) player.y - size / 2; // 使用钻石块纹理表示玩家 - ResourceLocation diamond = ResourceUtil.createMinecraftInstance("textures/block/diamond_block.png"); - graphics.blit(diamond, x, y, 0, 0, size, size, 16, 16); + graphics.blit(DIAMOND_TEX, x, y, 0, 0, size, size, 16, 16); // 绘制光环效果 drawCircle(graphics, (int) player.x, (int) player.y, size / 2 + 2, 0x4400FFFF); } private void renderEnemies(GuiGraphics graphics, PoseStack poseStack) { - ResourceLocation redstone = ResourceUtil.createMinecraftInstance("textures/block/redstone_block.png"); - for (Enemy enemy : enemies) { if (enemy.size <= 0) continue; @@ -364,13 +368,11 @@ private void renderEnemies(GuiGraphics graphics, PoseStack poseStack) { int x = (int) enemy.x - size / 2; int y = (int) enemy.y - size / 2; - graphics.blit(redstone, x, y, 0, 0, size, size, 16, 16); + graphics.blit(REDSTONE_TEX, x, y, 0, 0, size, size, 16, 16); } } private void renderFoods(GuiGraphics graphics, PoseStack poseStack) { - ResourceLocation emerald = ResourceUtil.createMinecraftInstance("textures/block/emerald_block.png"); - for (Food food : foods) { float distance = (float) Math.sqrt( (food.x - cameraX) * (food.x - cameraX) + @@ -381,7 +383,7 @@ private void renderFoods(GuiGraphics graphics, PoseStack poseStack) { int x = (int) food.x - size / 2; int y = (int) food.y - size / 2; - graphics.blit(emerald, x, y, 0, 0, size, size, 16, 16); + graphics.blit(EMERALD_TEX, x, y, 0, 0, size, size, 16, 16); } } diff --git a/src/main/java/com/wzz/game_console/client/screens/games/BreakoutScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/BreakoutScreen.java index 4fc7d85..cb44030 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/BreakoutScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/BreakoutScreen.java @@ -41,8 +41,8 @@ private void startGame() { } private void calcLayout() { - gameW = Math.min(width - 40, 500); - gameH = Math.min(height - 60, 400); + gameW = Math.max(BRICK_COLS, Math.min(Math.max(BRICK_COLS, width - 40), 500)); + gameH = Math.max(60, Math.min(Math.max(60, height - 60), 400)); gameLeft = (width - gameW) / 2; gameTop = (height - gameH) / 2; brickW = gameW / BRICK_COLS; @@ -54,6 +54,8 @@ private void calcLayout() { @Override public void tick() { tickCount++; if (state != State.PLAYING || showExitConfirm) return; // 弹窗期间暂停游戏 + // ★ 修复:粒子物理移到 tick() 固定频率推进(原来在 render 中 update,帧率依赖且暂停期间不停) + GameRenderHelper.tickParticles(particles); ballX += ballDX; ballY += ballDY; // 墙壁反弹 if (ballX <= gameLeft || ballX + ballS >= gameLeft + gameW) ballDX = -ballDX; @@ -145,17 +147,20 @@ private void renderPlaying(GuiGraphics g) { for (int r = 0; r < BRICK_ROWS; r++) for (int c = 0; c < BRICK_COLS; c++) if (bricks[r][c]) - GameRenderHelper.drawBlock3D(g, gameLeft + c * brickW + 1, gameTop + 20 + r * (brickH + 2), brickW - 2, BRICK_COLORS[r]); + // ★ 修复:砖块碰撞盒是 brickW×brickH,改用矩形重载按真实宽高绘制(原把 brickW-2 当边长画成正方形) + GameRenderHelper.drawBlock3D(g, gameLeft + c * brickW + 1, gameTop + 20 + r * (brickH + 2), brickW - 2, brickH, BRICK_COLORS[r]); // 挡板 - GameRenderHelper.drawBlock3D(g, (int)paddleX, gameTop + gameH - paddleH - 5, paddleW, 0xFF44AAFF); + // ★ 修复:挡板碰撞盒是 paddleW×paddleH,改用矩形重载(原把 paddleW 当边长画成正方形) + GameRenderHelper.drawBlock3D(g, (int)paddleX, gameTop + gameH - paddleH - 5, paddleW, paddleH, 0xFF44AAFF); // 球 g.fill((int)ballX, (int)ballY, (int)ballX + ballS, (int)ballY + ballS, 0xFFFFFFFF); g.fill((int)ballX, (int)ballY, (int)ballX + ballS, (int)ballY + 1, 0xFFFFFFCC); - GameRenderHelper.tickAndRenderParticles(g, particles); + GameRenderHelper.renderParticles(g, particles); // HUD GameRenderHelper.drawTopHUD(g, width, height); - g.drawString(font, "🧱 分数: " + score, 8, 7, 0xFF6644); - g.drawCenteredString(font, "❤ x " + lives, width / 2, 7, 0xFF4444); + // ★ 修复:🧱/❤ 为非 BMP/装饰 emoji,默认字体有豆腐块风险,改为纯文本 + g.drawString(font, "分数: " + score, 8, 7, 0xFF6644); + g.drawCenteredString(font, "生命 x " + lives, width / 2, 7, 0xFF4444); GameRenderHelper.drawBottomBar(g, font, width, height, "鼠标移动 ESC 菜单 R 重开"); } @@ -170,6 +175,7 @@ private void renderGameOver(GuiGraphics g, int mx, int my) { } @Override public boolean mouseClicked(double mx, double my, int btn) { + if (btn != 0) return super.mouseClicked(mx, my, btn); if (showExitConfirm) { int click = GameRenderHelper.getExitConfirmClick(mx, my, width, height); if (click == 1) { showExitConfirm = false; Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } if (click == 2) { showExitConfirm = false; return true; } return true; } int cx = width/2, cy = height/2; if (state == State.MENU && mx >= cx-60 && mx <= cx+60 && my >= cy+45 && my <= cy+67) { startGame(); return true; } diff --git a/src/main/java/com/wzz/game_console/client/screens/games/ChessGameScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/ChessGameScreen.java index bc17468..78bf969 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/ChessGameScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/ChessGameScreen.java @@ -1,6 +1,7 @@ package com.wzz.game_console.client.screens.games; import com.wzz.game_console.client.screens.GameSelectorScreen; +import com.wzz.game_console.client.screens.games.chess.ChessAI; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.screens.Screen; @@ -38,12 +39,14 @@ public class ChessGameScreen extends Screen implements LanMultiplayerScreen { // ══════════════════════════════════════════════ // 游戏模式 // ══════════════════════════════════════════════ - enum GameMode { MENU, PVP, PVA } + public enum GameMode { MENU, PVP, PVA } enum Difficulty { EASY, MEDIUM, HARD } GameMode gameMode = GameMode.MENU; Difficulty difficulty = Difficulty.MEDIUM; boolean showExitConfirm = false; + /** AI 引擎最后一次失败原因(null 表示 OK),用于在 HUD 给玩家反馈 */ + String aiErrorMessage = null; // ══════════════════════════════════════════════ // 布局(自适应屏幕) @@ -58,8 +61,13 @@ enum Difficulty { EASY, MEDIUM, HARD } // 悔棋(最多2步用于人机模式) int[][][] undoBoards = new int[2][][]; boolean[] undoRedTurns = new boolean[2]; + @SuppressWarnings("unchecked") + List[] undoPositionHistories = new List[2]; int undoCount = 0; + /** 局面历史(棋盘内容 + 行棋方),用于中国象棋三次重复和棋判定。 */ + final List positionHistory = new ArrayList<>(); + int selCol = -1, selRow = -1; List legalMoves = new ArrayList<>(); boolean redTurn = true; @@ -76,82 +84,21 @@ enum Difficulty { EASY, MEDIUM, HARD } volatile int[] aiPendingMove = null; // {fc,fr,tc,tr} 由AI线程写入 Thread aiThread = null; long aiStartTick = 0; + /** AI 引擎实例(懒加载,关屏时释放)。volatile 因为由 AI 线程首次创建 */ + private volatile ChessAI chessAI = null; + /** 串行化引擎创建、搜索和销毁,避免 worker 与 removed() 竞态。 */ + private final Object aiLifecycleLock = new Object(); + private volatile boolean aiClosed = false; + /** AI 思考结果异常中止(如引擎无合法走法),防止 tick 死循环重启 */ + boolean aiStalled = false; + /** AI 局代号:重开/悔棋时递增,迟到的 AI 结果落地前比对作废(参照 WesternChessScreen.boardGen) */ + private volatile int aiGen = 0; + /** aiPendingMove 对应的局代号(-1=无),tick 落地前与最新 aiGen 比对,保证旧结果绝不落地 */ + private volatile int aiPendingGen = -1; // ══════════════════════════════════════════════ - // AI 估值参数 + // 走法生成方向常量(避免 AI 搜索中每次调用重复创建数组) // ══════════════════════════════════════════════ - static final int INF = 1_000_000; - - /** 棋子基础分 */ - static final int[] PIECE_VAL = {0, - 10000, // 将 - 200, // 士 - 220, // 象 - 400, // 马 - 900, // 车 - 450, // 炮 - 100 // 兵 - }; - - /** - * 位置价值表,从黑方视角定义(row0=黑方底线) - * 正分=有利,从黑方角度。红方使用时行号镜像(9-r)。 - * 每张表 [col][row],共 9×10。 - */ - // 马 - static final int[][] PST_HORSE = { - { 0, 0, -2, 0, 0, 0, -2, 0, 0, 0}, - { 0, 4, 6, 8, 4, 4, 6, 4, 0, 0}, - { 2, 8, 12, 14, 12, 10, 12, 8, 2, 0}, - { 4, 14, 20, 24, 20, 18, 20, 14, 4, 0}, - { 2, 12, 18, 20, 18, 16, 18, 12, 2, 0}, - { 0, 4, 12, 14, 12, 10, 12, 4, 0, 0}, - { 0, 8, 12, 12, 12, 10, 12, 8, 0, 0}, - { 0, 0, 8, 10, 8, 8, 8, 0, 0, 0}, - { 0, 2, 6, 4, 6, 4, 4, 2, 0, 0}, - { 0, 0, 2, 0, 0, 0, 2, 0, 0, 0}, - }; - // 车 - static final int[][] PST_CHARIOT = { - {14, 14, 12, 18, 16, 18, 12, 14, 14, 0}, - {16, 20, 18, 24, 26, 24, 18, 20, 16, 0}, - {12, 12, 12, 18, 18, 18, 12, 12, 12, 0}, - {12, 18, 16, 22, 22, 22, 16, 18, 12, 0}, - {12, 14, 12, 18, 18, 18, 12, 14, 12, 0}, - {12, 16, 14, 20, 20, 20, 14, 16, 12, 0}, - {12, 12, 12, 18, 18, 18, 12, 12, 12, 0}, - {12, 18, 16, 22, 22, 22, 16, 18, 12, 0}, - {16, 20, 18, 24, 26, 24, 18, 20, 16, 0}, - {14, 14, 12, 18, 16, 18, 12, 14, 14, 0}, - }; - // 炮 - static final int[][] PST_CANNON = { - { 6, 4, 0, -10, -12, -10, 0, 4, 6, 0}, - { 2, 2, 0, -4, -14, -4, 0, 2, 2, 0}, - { 2, 6, 4, 0, -6, 0, 4, 6, 2, 0}, - { 0, 0, 0, 6, 10, 6, 0, 0, 0, 0}, - { 0, 2, 4, 6, 10, 6, 4, 2, 0, 0}, - { 0, 0, 4, 6, 10, 6, 4, 0, 0, 0}, - { 0, 2, 0, 4, 8, 4, 0, 2, 0, 0}, - {-2, -4, -2, 4, 8, 4, -2, -4, -2, 0}, - { 0, 0, 2, 4, 6, 4, 2, 0, 0, 0}, - { 0, 2, 4, 6, 6, 6, 4, 2, 0, 0}, - }; - // 兵(过河前后差别大) - static final int[][] PST_SOLDIER = { - { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, - { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, - { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, - { 8, 18, 28, 40, 40, 40, 28, 18, 8, 0}, // 过河第一行 - {14, 24, 38, 52, 60, 52, 38, 24, 14, 0}, - {22, 34, 50, 64, 76, 64, 50, 34, 22, 0}, - {34, 48, 62, 76, 86, 76, 62, 48, 34, 0}, - { 6, 14, 22, 32, 36, 32, 22, 14, 6, 0}, // 未过河 - { 4, 10, 14, 20, 24, 20, 14, 10, 4, 0}, - { 2, 6, 8, 10, 12, 10, 8, 6, 2, 0}, - }; - - /** 走法生成方向常量(避免 AI 搜索中每次调用重复创建数组) */ static final int[][] DIR_ORTHO = {{0,1},{0,-1},{1,0},{-1,0}}; static final int[][] DIR_DIAG = {{1,1},{1,-1},{-1,1},{-1,-1}}; static final int[][] DIR_ELEPHANT = {{2,2},{2,-2},{-2,2},{-2,-2}}; @@ -198,22 +145,79 @@ private void sendLeaveGameOnce() { sendLeaveGame(); } + private void cleanupAi() { + Thread worker = aiThread; + ChessAI engine; + synchronized (aiLifecycleLock) { + if (aiClosed) return; + aiClosed = true; + aiGen++; + engine = chessAI; + chessAI = null; + } + if (engine != null) engine.cancelSearch(); + if (worker != null) worker.interrupt(); + if (engine != null) engine.shutdown(); + } + @Override public void onClose() { + cleanupAi(); sendLeaveGameOnce(); super.onClose(); } + @Override + public void removed() { + cleanupAi(); + sendLeaveGameOnce(); + super.removed(); + } + @Override public void onRemoteMove(String data) { - if ("RESTART".equals(data)) { resetBoard(); return; } + if ("RESTART".equals(data)) { + if (lanMode == LAN_CLIENT) resetBoard(); + return; + } try { String[] p = data.split(","); + if (p.length < 4) { LOGGER.warn("[中国象棋] 联机走法字段不足: {}", data); return; } int fc = Integer.parseInt(p[0]), fr = Integer.parseInt(p[1]); int tc = Integer.parseInt(p[2]), tr = Integer.parseInt(p[3]); + if (fc < 0 || fc >= COLS || fr < 0 || fr >= ROWS || tc < 0 || tc >= COLS || tr < 0 || tr >= ROWS) { + LOGGER.warn("[中国象棋] 联机走法坐标越界: {}", data); return; + } + // ★ 修复:远程走法落地前校验(坐标越界已在上面过滤),防伪造/乱序报文打乱本地棋盘: + // ① 当前须轮到远程方(LAN 约定 HOST 执红、CLIENT 执黑)且对局未结束; + // ② from 处须存在远程方棋子; + // ③ 走法须在现有合法走法生成结果内(computeLegal 已过滤走后自将)。 + // 任一不满足仅记日志丢弃,不落盘 + boolean remoteRed = lanMode == LAN_CLIENT; + if (gameOver || lanMode == LAN_NONE || redTurn != remoteRed) { + LOGGER.warn("[中国象棋] 丢弃非远程回合/对局已结束的联机走法: {} (redTurn={}, lanMode={})", + data, redTurn, lanMode); + return; + } + int fromPiece = board[fc][fr]; + if (fromPiece == 0 || remoteRed != (fromPiece > 0)) { + LOGGER.warn("[中国象棋] 联机走法起点无远程方棋子: {}", data); + return; + } + boolean legal = false; + for (int[] mv : computeLegal(fc, fr)) { + if (mv[0] == tc && mv[1] == tr) { legal = true; break; } + } + if (!legal) { + LOGGER.warn("[中国象棋] 联机走法不在合法走法列表内: {}", data); + return; + } receivingRemoteMove = true; - doMove(fc, fr, tc, tr); - receivingRemoteMove = false; + try { + doMove(fc, fr, tc, tr); + } finally { + receivingRemoteMove = false; + } } catch (Exception ignored) {} } @@ -222,7 +226,7 @@ public void onRemoteMove(String data) { private void sendLanMove(int fc, int fr, int tc, int tr) { if (lanMode == LAN_NONE) return; - sendMove(fc + "," + fr + "," + tc + "," + tr); + sendMoveEnvelope(fc + "," + fr + "," + tc + "," + tr); } // ══════════════════════════════════════════════ @@ -232,6 +236,11 @@ public ChessGameScreen() { super(Component.literal("中国象棋")); } + public ChessGameScreen(GameMode mode) { + this(); + startGame(mode); + } + @Override public void init() { super.init(); @@ -264,10 +273,27 @@ void resetBoard() { redTurn=true; gameOver=false; resultMsg=""; redInCheck=blackInCheck=false; lastFC=lastFR=lastTC=lastTR=-1; - undoCount=0; Arrays.fill(undoBoards,null); + undoCount=0; Arrays.fill(undoBoards,null); Arrays.fill(undoPositionHistories,null); + positionHistory.clear(); + positionHistory.add(positionKey()); aiPendingMove=null; - if (aiThread!=null) aiThread.interrupt(); + aiGen++; // 重开:旧 AI 线程的迟到结果一律作废 + Thread oldWorker = aiThread; + if (oldWorker != null) oldWorker.interrupt(); + ChessAI oldEngine; + synchronized (aiLifecycleLock) { + oldEngine = chessAI; + chessAI = null; + } + if (oldEngine != null) { + oldEngine.cancelSearch(); + Thread disposer = new Thread(oldEngine::shutdown, "ChessAI-dispose"); + disposer.setDaemon(true); + disposer.start(); + } aiThinking.set(false); + aiStalled = false; + checkNoLegalMovesEnd(); // 兜底判负检测(初始局面恒有合法走法,此处为统一入口) } // ══════════════════════════════════════════════ @@ -278,13 +304,15 @@ public void tick() { tick++; if (gameMode != GameMode.PVA || gameOver || redTurn) return; // AI的轮到了 + if (aiPendingMove != null && aiPendingGen != aiGen) aiPendingMove = null; // 过期AI结果(悔棋/重开竞态),落地前丢弃 if (aiPendingMove != null && !aiThinking.get()) { // 应用AI计算好的落子 + aiStalled = false; // 恢复引擎状态 int[] mv = aiPendingMove; aiPendingMove = null; doMove(mv[0], mv[1], mv[2], mv[3]); selCol=selRow=-1; legalMoves.clear(); - } else if (!aiThinking.get() && aiPendingMove == null) { + } else if (!aiThinking.get() && aiPendingMove == null && !aiStalled) { // 启动AI思考 launchAI(); } @@ -293,19 +321,69 @@ public void tick() { void launchAI() { aiThinking.set(true); aiStartTick = tick; + final int gen = aiGen; // 捕获局代号,线程写回前比对,旧对局的结果不落地 int[][] snapshot = deepCopy(board); - int depth = switch (difficulty) { - case EASY -> 2; - case MEDIUM -> 3; - case HARD -> 4; + // 难度 → AI 搜索时间/深度(内置引擎与外挂引擎都尊重时间预算) + long budgetMs = switch (difficulty) { + case EASY -> 600; + case MEDIUM -> 1500; + case HARD -> 3000; + }; + int maxDepth = switch (difficulty) { + case EASY -> 3; + case MEDIUM -> 5; + case HARD -> 8; }; aiThread = new Thread(() -> { + ChessAI engine = null; try { - int[] best = aiBestMove(snapshot, false, depth); // false=黑方走 + synchronized (aiLifecycleLock) { + if (aiClosed || Thread.currentThread().isInterrupted()) return; + engine = chessAI; + } + if (engine == null) { + ChessAI candidate = ChessAI.create(); + synchronized (aiLifecycleLock) { + if (!aiClosed && gen == aiGen && chessAI == null + && !Thread.currentThread().isInterrupted()) { + chessAI = candidate; + engine = candidate; + } + } + if (engine != candidate) candidate.shutdown(); + if (engine == null) return; + } + engine.setSearchTime(budgetMs); + engine.setMaxDepth(maxDepth); + int[] best = engine.getBestMove(snapshot, false); + if (Thread.currentThread().isInterrupted() || gen != aiGen || aiClosed) return; + if (best == null) { + aiStalled = true; + aiErrorMessage = "AI 引擎无合法走法"; + } + aiPendingGen = gen; aiPendingMove = best; - } catch (Exception ignored) { + if (best != null) aiErrorMessage = null; + } catch (Throwable t) { + if (!aiClosed) { + aiErrorMessage = "AI 引擎异常: " + t.getClass().getSimpleName() + " - " + t.getMessage(); + } + ChessAI failedEngine = null; + synchronized (aiLifecycleLock) { + if (chessAI == engine) { + failedEngine = chessAI; + chessAI = null; + } + } + if (failedEngine != null) failedEngine.shutdown(); } finally { - aiThinking.set(false); + // ★ Bug修复:仅当本线程代数仍是当前代数时才清 thinking 标志。 + // 迟到线程(悔棋/重开已递增 aiGen)若无条件清除,会误清新一轮 + // 搜索刚置位的 aiThinking,导致 tick 重复 launchAI。 + // 旧代数的标志已由 resetBoard/undoMove 主动清除,无需迟到线程代劳 + if (gen == aiGen) { + aiThinking.set(false); + } } }, "ChessAI"); aiThread.setDaemon(true); @@ -337,9 +415,10 @@ public void render(GuiGraphics g, int mx, int my, float pt) { if (gameOver || showExitConfirm) g.flush(); if (gameOver) drawGameOver(g); if (showExitConfirm) drawExitConfirm(g, mx, my); - // 兜底:当前回合方无合法走法时立即判负结算(被将死/困毙), - // 避免玩家卡死无任何提示(正常路径由 doMove 检测,此处兼顾悔棋等边缘情况) - checkNoLegalMovesEnd(); + // 再次flush确保弹窗内容在super.render的widget批处理之前完成提交 + if (gameOver || showExitConfirm) g.flush(); + // 注:无合法走法判负检测已从每帧 render 移至 doMove/undoMove/resetBoard 末尾, + // 避免 render 每帧做 isCheckmate 全盘扫描的性能开销 } super.render(g, mx, my, pt); } @@ -573,6 +652,15 @@ void drawHUD(GuiGraphics g){ g.drawString(font,rLbl+rSuf,bx,botY+8, redInCheck?0xFFFF4444:redTurn?0xFFFFBB44:0xFF886644); + // AI 异常提示条(仅在引擎失败时显示) + if (aiErrorMessage != null && !gameOver && gameMode == GameMode.PVA) { + int errY = botY + 36; + String err = "⚠ " + aiErrorMessage; + int ew = font.width(err); + g.fill(bx - 2, errY - 2, bx + ew + 4, errY + 12, 0xCC550000); + g.drawString(font, err, bx, errY, 0xFFFF8888); + } + // 列坐标 String[] cl={"九","八","七","六","五","四","三","二","一"}; for(int c=0;c(positionHistory); undoCount++; } else { undoBoards[0]=undoBoards[1]; undoRedTurns[0]=undoRedTurns[1]; + undoPositionHistories[0]=undoPositionHistories[1]; undoBoards[1]=deepCopy(board); undoRedTurns[1]=redTurn; + undoPositionHistories[1]=new ArrayList<>(positionHistory); } board[tc][tr]=board[fc][fr]; board[fc][fr]=0; @@ -768,26 +866,11 @@ void doMove(int fc,int fr,int tc,int tr){ redTurn=!redTurn; redInCheck=isInCheck(true); blackInCheck=isInCheck(false); - if(isCheckmate(redTurn)){ - gameOver=true; - if(gameMode==GameMode.PVA) - resultMsg=(redTurn?"AI胜利!玩家被将死。":"玩家胜利!AI被将死。"); - else if(lanMode==LAN_HOST) - resultMsg=(redTurn?"黑方(对手)胜利!":"红方(你)胜利!"); - else if(lanMode==LAN_CLIENT) - resultMsg=(redTurn?"黑方(你)胜利!":"红方(对手)胜利!"); - else - resultMsg=(redTurn?"黑":"红")+"方胜利!"+(redTurn?"红":"黑")+"方被将死!"; - } else if(isStalemate(redTurn)){ - gameOver=true; - if(gameMode==GameMode.PVA) - resultMsg=(redTurn?"AI胜利!玩家无子可动。":"玩家胜利!AI无子可动。"); - else if(lanMode==LAN_HOST) - resultMsg=(redTurn?"黑方(对手)胜!":"红方(你)胜!"); - else if(lanMode==LAN_CLIENT) - resultMsg=(redTurn?"黑方(你)胜!":"红方(对手)胜!"); - else - resultMsg=(redTurn?"红":"黑")+"方无子可动,"+(redTurn?"黑":"红")+"方胜!"; + positionHistory.add(positionKey()); + checkNoLegalMovesEnd(); // 走子后:严格区分将死、困毙与仍可继续 + if (!gameOver && countPosition(positionKey()) >= 3) { + gameOver = true; + resultMsg = "三次重复局面,和棋!"; } } @@ -798,14 +881,20 @@ void undoMove(){ undoCount=Math.max(0,undoCount-steps); board=deepCopy(undoBoards[undoCount]); redTurn=undoRedTurns[undoCount]; + positionHistory.clear(); + positionHistory.addAll(undoPositionHistories[undoCount]); undoBoards[undoCount]=null; + undoPositionHistories[undoCount]=null; selCol=selRow=-1; legalMoves.clear(); redInCheck=isInCheck(true); blackInCheck=isInCheck(false); gameOver=false; resultMsg=""; lastFC=lastFR=lastTC=lastTR=-1; aiPendingMove=null; + aiGen++; // 悔棋:旧 AI 线程的迟到结果一律作废 if(aiThread!=null) aiThread.interrupt(); aiThinking.set(false); + aiStalled = false; + checkNoLegalMovesEnd(); // 悔棋后兜底:当前回合方无合法走法时立即结算 } // ══════════════════════════════════════════════ @@ -845,6 +934,8 @@ List pseudoMoves(int[][] b,int col,int row){ void tryAdd(int[][] b,List m,int c,int r,boolean red){ if(c<0||c>=COLS||r<0||r>=ROWS) return; int t=b[c][r]; + // 将/帅不能作为普通吃子目标,胜负通过将死判定。 + if (Math.abs(t) == GENERAL) return; if(t==0||(red&&t<0)||(!red&&t>0)) m.add(new int[]{c,r}); } @@ -886,7 +977,7 @@ void chariotMoves(int[][] b,int c,int r,boolean red,List m){ if(nc<0||nc>=COLS||nr<0||nr>=ROWS) break; int t=b[nc][nr]; if(t==0){m.add(new int[]{nc,nr});continue;} - if((red&&t<0)||(!red&&t>0)) m.add(new int[]{nc,nr}); + if(Math.abs(t)!=GENERAL&&((red&&t<0)||(!red&&t>0))) m.add(new int[]{nc,nr}); break; } } @@ -899,7 +990,7 @@ void cannonMoves(int[][] b,int c,int r,boolean red,List m){ if(nc<0||nc>=COLS||nr<0||nr>=ROWS) break; int t=b[nc][nr]; if(!jumped){ if(t==0) m.add(new int[]{nc,nr}); else jumped=true; } - else { if(t!=0){ if((red&&t<0)||(!red&&t>0)) m.add(new int[]{nc,nr}); break; } } + else { if(t!=0){ if(Math.abs(t)!=GENERAL&&((red&&t<0)||(!red&&t>0))) m.add(new int[]{nc,nr}); break; } } } } } @@ -918,27 +1009,7 @@ boolean inPalace(int c,int r,boolean red){ boolean isInCheck(boolean isRed){ return inCheckOnBoard(board,isRed); } boolean inCheckOnBoard(int[][] b,boolean isRed){ - int gc=-1,gr=-1; - outer:for(int c=0;c0)==isRed) continue; - for(int[] a:pseudoMoves(b,c,r)) if(a[0]==gc&&a[1]==gr) return true; - } - // 飞将 - if(gc>=3&&gc<=5){ - for(int r=0;r0)||(!isRed&&p<0)) if(!computeLegal(c,r).isEmpty()) return false; - } - return true; - } - boolean isStalemate(boolean isRed){ - if(isInCheck(isRed)) return false; - return isCheckmate(isRed); - } - - // ══════════════════════════════════════════════ - // AI 引擎 — 负极大值 + α-β 剪枝 - // ══════════════════════════════════════════════ - - /** 生成所有棋子的所有伪合法落子,已按价值粗排序(吃子优先) */ - List allPseudoMoves(int[][] b, boolean red){ - List caps=new ArrayList<>(), quiets=new ArrayList<>(); - for(int c=0;c0)||(!red&&p<0)){ - for(int[] mv:pseudoMoves(b,c,r)){ - int[] full={c,r,mv[0],mv[1]}; - if(b[mv[0]][mv[1]]!=0) caps.add(full); - else quiets.add(full); - } - } + boolean inCheck = isInCheck(redTurn); + if (hasLegalMove(redTurn)) return; + gameOver = true; + if (inCheck) { + if(gameMode==GameMode.PVA) + resultMsg=(redTurn?"AI胜利!玩家被将死。":"玩家胜利!AI被将死。"); + else if(lanMode==LAN_HOST) + resultMsg=(redTurn?"黑方(对手)胜利!":"红方(你)胜利!"); + else if(lanMode==LAN_CLIENT) + resultMsg=(redTurn?"黑方(你)胜利!":"红方(对手)胜利!"); + else + resultMsg=(redTurn?"黑":"红")+"方胜利!"+(redTurn?"红":"黑")+"方被将死!"; + } else { + if(gameMode==GameMode.PVA) + resultMsg=(redTurn?"AI胜利!玩家困毙。":"玩家胜利!AI困毙。"); + else if(lanMode==LAN_HOST) + resultMsg=(redTurn?"黑方(对手)胜利!":"红方(你)胜利!"); + else if(lanMode==LAN_CLIENT) + resultMsg=(redTurn?"黑方(你)胜利!":"红方(对手)胜利!"); + else + resultMsg=(redTurn?"黑":"红")+"方胜利!"+(redTurn?"红":"黑")+"方困毙!"; } - caps.addAll(quiets); - return caps; } - /** 静态局面估值(从红方角度:正=红优,负=黑优) */ - int evaluate(int[][] b){ - int score=0; + boolean hasLegalMove(boolean isRed){ for(int c=0;c0; int abs=Math.abs(p); - int base=PIECE_VAL[abs]; - int pst=pstBonus(abs,c,r,red); - score += red?(base+pst):-(base+pst); + int p=board[c][r]; + if((isRed&&p>0)||(!isRed&&p<0)) if(!computeLegal(c,r).isEmpty()) return true; } - return score; + return false; } - - /** 棋子位置额外得分(从该方视角) */ - int pstBonus(int abs,int col,int row,boolean red){ - // 红方行号镜像 - int r=red?(9-row):row; - return switch(abs){ - case GENERAL -> 0; - case HORSE -> colRow(PST_HORSE, col,r); - case CHARIOT -> colRow(PST_CHARIOT,col,r); - case CANNON -> colRow(PST_CANNON, col,r); - case SOLDIER -> colRow(PST_SOLDIER,col,r); - default -> 0; - }; + boolean isCheckmate(boolean isRed){ + return isInCheck(isRed) && !hasLegalMove(isRed); } - - int colRow(int[][] t,int c,int r){ - if(c<0||c>=t.length||r<0||r>=t[0].length) return 0; - return t[c][r]; + boolean isStalemate(boolean isRed){ + return !isInCheck(isRed) && !hasLegalMove(isRed); } - /** - * 负极大值搜索 + α-β 剪枝 - * @param b 当前棋盘 - * @param isRed 当前走子方是否为红方 - * @param depth 剩余搜索深度 - * @param alpha α(当前走子方的最低保证) - * @param beta β(对手的最高接受) - * @return 当前走子方视角的局面分 - */ - int negamax(int[][] b,boolean isRed,int depth,int alpha,int beta){ - if(Thread.currentThread().isInterrupted()) return 0; - if(depth==0) return isRed?evaluate(b):-evaluate(b); - - List moves=allPseudoMoves(b,isRed); - if(moves.isEmpty()) return -INF+1; // 无子可动(将死或困毙) - - int best=-INF; - for(int[] mv:moves){ - // make move(原地修改,避免 deepCopy 产生大量垃圾对象) - int captured=b[mv[2]][mv[3]]; - int piece=b[mv[0]][mv[1]]; - b[mv[2]][mv[3]]=piece; b[mv[0]][mv[1]]=0; - if(inCheckOnBoard(b,isRed)){ - // unmake - b[mv[0]][mv[1]]=piece; b[mv[2]][mv[3]]=captured; - continue; // 走后自将,跳过 - } - int val=-negamax(b,!isRed,depth-1,-beta,-alpha); - // unmake - b[mv[0]][mv[1]]=piece; b[mv[2]][mv[3]]=captured; - if(val>best) best=val; - if(val>alpha) alpha=val; - if(alpha>=beta) break; // β 剪枝 + private String positionKey() { + StringBuilder key = new StringBuilder(COLS * ROWS + 1); + key.append(redTurn ? 'r' : 'b'); + for (int c = 0; c < COLS; c++) { + for (int r = 0; r < ROWS; r++) key.append((char) ('0' + board[c][r] + 7)); } - return best; + return key.toString(); } - /** 根节点搜索,返回最佳落子 {fc,fr,tc,tr} */ - int[] aiBestMove(int[][] b,boolean isRed,int depth){ - List moves=allPseudoMoves(b,isRed); - int best=-INF; int[] bestMv=null; - for(int[] mv:moves){ - if(Thread.currentThread().isInterrupted()) break; - // make/unmake 替代 deepCopy - int captured=b[mv[2]][mv[3]]; - int piece=b[mv[0]][mv[1]]; - b[mv[2]][mv[3]]=piece; b[mv[0]][mv[1]]=0; - if(inCheckOnBoard(b,isRed)){ - b[mv[0]][mv[1]]=piece; b[mv[2]][mv[3]]=captured; - continue; - } - int val=-negamax(b,!isRed,depth-1,-INF,INF); - b[mv[0]][mv[1]]=piece; b[mv[2]][mv[3]]=captured; - if(val>best||bestMv==null){best=val;bestMv=mv;} - } - return bestMv; + private int countPosition(String key) { + int count = 0; + for (String previous : positionHistory) if (previous.equals(key)) count++; + return count; } // ══════════════════════════════════════════════ diff --git a/src/main/java/com/wzz/game_console/client/screens/games/ColorChaseGameScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/ColorChaseGameScreen.java index 2f34697..ce837f6 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/ColorChaseGameScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/ColorChaseGameScreen.java @@ -15,6 +15,7 @@ import java.util.HashSet; import java.util.Random; import java.util.Set; +import java.util.UUID; public class ColorChaseGameScreen extends Screen implements LanMultiplayerScreen { private static final Logger LOGGER = LoggerFactory.getLogger(ColorChaseGameScreen.class); @@ -52,9 +53,10 @@ private enum GameMode { MENU, SINGLE, TWO_PLAYER } private int p2X = GRID_SIZE * 3 / 4, p2Y = GRID_SIZE / 2; private boolean p2Dead = false; private int p2Score = 0; - private long p2LastSafe; + private long p2LastSafe = 0; // ─────── 共用状态 ─────── + private static final int MAX_LEVEL = 999; private int targetColor = 0; private int level = 1; private boolean gameRunning = true; @@ -62,13 +64,13 @@ private enum GameMode { MENU, SINGLE, TWO_PLAYER } private String winnerText = ""; // ─────── 时间 ─────── - private long lastColorChangeTime; + private long lastColorChangeTime = 0; private long colorChangeInterval = 3000; private static final long INPUT_COOLDOWN = 80; private static final long DEATH_GRACE_PERIOD = 2000; private static final long GAME_START_PROTECTION = 1200; private long p1LastInput = 0, p2LastInput = 0; - private long gameStartTime; + private long gameStartTime = 0; // ─────── 长按 ─────── private final Set heldKeys = new HashSet<>(); @@ -83,12 +85,20 @@ private enum GameMode { MENU, SINGLE, TWO_PLAYER } private boolean lanLeaveSent = false; // HOST 控制 P1(WASD),CLIENT 控制 P2(方向键) // HOST 每 tick 发送完整状态给 CLIENT + private UUID stateSessionId = UUID.randomUUID(); + private long stateSequence = 0; + private final RealtimeLanState.Receiver stateReceiver = new RealtimeLanState.Receiver(); /** 本地模式(单机/本地双人)构造器 */ public ColorChaseGameScreen() { super(net.minecraft.network.chat.Component.literal("颜色追逐")); } + public ColorChaseGameScreen(boolean twoPlayer) { + this(); + initGame(twoPlayer); + } + /** LAN 联机构造:HOST 控制P1,CLIENT 控制P2 */ public ColorChaseGameScreen(boolean isHost, java.util.UUID remote) { super(net.minecraft.network.chat.Component.literal("颜色追逐-联机")); @@ -107,11 +117,11 @@ public ColorChaseGameScreen(boolean isHost, java.util.UUID remote) { */ @Override public void onRemoteState(java.util.UUID senderUuid, String data) { - if (lanMode == LAN_CLIENT) { - if (remotePeer == null || !remotePeer.equals(senderUuid)) { - LOGGER.warn("[颜色追逐] 丢弃来源非法的状态包: sender={},期望对端={}", senderUuid, remotePeer); - return; - } + // GAME_STATE_SYNC 在 HOST 侧同样必须验证服务端盖章的发送者, + // 只接受当前已配对对端,拒绝第三方注入状态。 + if (lanMode != LAN_CLIENT || remotePeer == null || !remotePeer.equals(senderUuid)) { + LOGGER.warn("[颜色追逐] 丢弃来源或角色非法的状态包: sender={},角色={},期望对端={}", senderUuid, lanMode, remotePeer); + return; } this.onRemoteState(data); } @@ -148,39 +158,47 @@ public void onClose() { */ @Override public void onRemoteState(String data) { - try { - String[] parts = data.split(";", 2); - String[] f = parts[0].split(","); - p1X = Integer.parseInt(f[0]); p1Y = Integer.parseInt(f[1]); p1Dead = f[2].equals("1"); - p2X = Integer.parseInt(f[3]); p2Y = Integer.parseInt(f[4]); p2Dead = f[5].equals("1"); - targetColor = Integer.parseInt(f[6]); - p1Score = Integer.parseInt(f[7]); p2Score = Integer.parseInt(f[8]); - level = Integer.parseInt(f[9]); - gameOver = f[10].equals("1"); - gameRunning = !gameOver; - if (gameOver && f.length > 11) winnerText = f[11]; - // 同步格子色 - if (parts.length > 1 && !parts[1].isEmpty()) { - String[] cells = parts[1].split(","); - int idx = 0; - for (int x = 0; x < GRID_SIZE && idx < cells.length; x++) - for (int y = 0; y < GRID_SIZE && idx < cells.length; y++) - grid[x][y] = Integer.parseInt(cells[idx++]); - } - } catch (Exception ignored) {} + if (lanMode != LAN_CLIENT) return; + var received = stateReceiver.receive(data, RealtimeLanState::parseColor); + if (received == null) return; + var s = received.snapshot(); + if (received.newRound()) { + heldKeys.clear(); + showExitConfirm = false; + } + gameMode = GameMode.TWO_PLAYER; + p1X = s.p1X(); p1Y = s.p1Y(); p1Dead = s.p1Dead(); + p2X = s.p2X(); p2Y = s.p2Y(); p2Dead = s.p2Dead(); + targetColor = s.target(); + p1Score = s.score1(); p2Score = s.score2(); + level = s.level(); + gameOver = s.gameOver(); + gameRunning = !gameOver; + winnerText = s.winner(); + for (int x = 0; x < GRID_SIZE; x++) { + System.arraycopy(s.grid()[x], 0, grid[x], 0, GRID_SIZE); + } } /** HOST 收到 CLIENT 的输入,CLIENT 收到 HOST 的 RESTART 信号 */ @Override public void onRemoteMove(String data) { - // CLIENT 侧:HOST 通知重开 - if ("RESTART".equals(data)) { - initGame(true); + if (data == null) return; + if (lanMode == LAN_CLIENT) { + if (stateReceiver.restart(data)) { + initGame(true); + showExitConfirm = false; + } return; } if (lanMode != LAN_HOST || p2Dead || !gameRunning) return; + data = RealtimeLanState.decodeInput(stateSessionId, data); + if (data == null) return; try { String[] p = data.split(","); + // ★ Bug修复:原版对 1 字段报文("1"或"")会抛 AIOOBE 静默吞, + // 玩家看不到任何反馈。加 length 校验:必须 4 字段才解析方向 + if (p.length < 4) return; long now = System.currentTimeMillis(); if (now - p2LastInput < INPUT_COOLDOWN) return; boolean moved = false; @@ -198,6 +216,7 @@ public void onRemoteMove(String data) { /** 构建完整状态字符串(HOST→CLIENT) */ private String buildColorChaseState() { StringBuilder sb = new StringBuilder(); + sb.append("v2|").append(stateSessionId).append('|').append(++stateSequence).append('|'); sb.append(p1X).append(',').append(p1Y).append(',').append(p1Dead?1:0).append(',') .append(p2X).append(',').append(p2Y).append(',').append(p2Dead?1:0).append(',') .append(targetColor).append(',') @@ -234,10 +253,23 @@ public void init() { gameStartY = (this.height - GAME_HEIGHT) / 2; } + @Override + public void removed() { + // ★ Bug修复:玩家按住 W/A/S/D 退出 ColorChase 切到 GameSelector, + // 新 screen 的 keyReleased 因 screen 切换被吞,旧 key 仍被判定为按住。 + // 在 removed() 清空 heldKeys 防泄漏到其他屏 + sendLeaveGameOnce(); + heldKeys.clear(); + super.removed(); + } + // ══════════════════════════════════════ // 初始化 // ══════════════════════════════════════ private void initGame(boolean twoPlayer) { + if (lanMode == LAN_HOST) { + stateSessionId = UUID.randomUUID(); + } gameMode = twoPlayer ? GameMode.TWO_PLAYER : GameMode.SINGLE; gameRunning = true; gameOver = false; @@ -281,6 +313,8 @@ private void spawnSafeSpots(int count) { @Override public void tick() { tickCount++; + // ★ 失焦清键:Screen 基类无 windowFocusChanged 钩子,每 tick 探针 MC 窗口活动状态 + if (!minecraft.isWindowActive() && !heldKeys.isEmpty()) heldKeys.clear(); if (lanMode == LAN_CLIENT) { // CLIENT:仅发送P2按键输入,游戏逻辑全部由HOST驱动; // 直接 return,本地 processHeldKeys/updateGame 等逻辑在联机CLIENT端不会执行 @@ -289,14 +323,17 @@ public void tick() { int d = heldKeys.contains(GLFW.GLFW_KEY_DOWN) ? 1 : 0; int l = heldKeys.contains(GLFW.GLFW_KEY_LEFT) ? 1 : 0; int r = heldKeys.contains(GLFW.GLFW_KEY_RIGHT) ? 1 : 0; - sendInput(u+","+d+","+l+","+r); + String input = stateReceiver.input(u+","+d+","+l+","+r); + if (input != null) sendInputEnvelope(input); } return; } if (gameMode != GameMode.MENU && gameRunning && !gameOver && !showExitConfirm) { // 弹窗期间暂停游戏 processHeldKeys(); updateGame(); - if (lanMode == LAN_HOST) sendState(buildColorChaseState()); // 广播状态给CLIENT + if (lanMode == LAN_HOST) sendStateEnvelope(buildColorChaseState()); + } else if (lanMode == LAN_HOST && gameMode != GameMode.MENU && tickCount % 20 == 0) { + sendStateEnvelope(buildColorChaseState()); } } @@ -374,13 +411,16 @@ private void updateGame() { playColorChangeSound(); int gained = level * 10; - if (!p1Dead) p1Score += gained; - if (gameMode == GameMode.TWO_PLAYER && !p2Dead) p2Score += gained; + // ★ Bug修复:原版分数/level 无限增长,3 小时极限对局后 p1Score + // 累加到 Integer.MAX_VALUE 后溢出翻负,UI 立即显示负数。 + // 加饱和上限(单人 999999,双人各半)+level 上限 999 + if (!p1Dead) p1Score = Math.min(p1Score + gained, 999_999); + if (gameMode == GameMode.TWO_PLAYER && !p2Dead) p2Score = Math.min(p2Score + gained, 999_999); // 升级 int combined = (gameMode == GameMode.TWO_PLAYER) ? p1Score + p2Score : p1Score; if (combined > 0 && combined % (gameMode == GameMode.TWO_PLAYER ? 80 : 50) == 0) { - level++; + if (level < 999) level++; colorChangeInterval = Math.max(500, colorChangeInterval - 200); } } @@ -756,38 +796,61 @@ private void renderGameOver(GuiGraphics g) { // ══════════════════════════════════════ // 键盘事件 // ══════════════════════════════════════ + /** 弹窗打开时间戳:关闭时据此平移 p1LastSafe/p2LastSafe,补偿暂停期间流逝的墙钟时间 */ + private long pauseStartTime = 0; + + /** 关闭弹窗恢复游戏:平移死亡宽限计时基准与变色计时基准,防止弹窗停留后一恢复就被误判死亡/立即强制变色+白送分 */ + private void resumeFromExitConfirm() { + long pausedMs = System.currentTimeMillis() - pauseStartTime; + p1LastSafe += pausedMs; + p2LastSafe += pausedMs; + // ★ Bug修复:lastColorChangeTime 未随暂停时长平移,弹窗停留超过变色间隔后 + // 恢复瞬间 now-lastColorChangeTime 已超限,会立即变色并白送一轮分数 + lastColorChangeTime += pausedMs; + showExitConfirm = false; + } + @Override public boolean keyPressed(int key, int scan, int mods) { - // 修复:弹窗打开时不注册按键(此前add在拦截检查之前,导致弹窗期间仍能移动) - if (key != GLFW.GLFW_KEY_ESCAPE && showExitConfirm) return true; + // ★ Bug修复:弹窗期按 R(82) 无法关闭弹窗只能按 ESC,违反常见约定 + // (R 在 Minecraft 大多数屏都用作"重开/取消弹窗")。弹窗期仅拦截 + // 非 ESC/非 R 的输入,让玩家可用 R 关闭弹窗 + if (key != GLFW.GLFW_KEY_ESCAPE && key != GLFW.GLFW_KEY_R && showExitConfirm) return true; if (key == GLFW.GLFW_KEY_ESCAPE) { - if (showExitConfirm) { showExitConfirm = false; return true; } + if (showExitConfirm) { resumeFromExitConfirm(); return true; } if (gameMode == GameMode.MENU || lanMode != LAN_NONE) { sendLeaveGameOnce(); // 联机退出时通知对端,避免对方无限等待 Minecraft.getInstance().setScreen(new GameSelectorScreen()); } else if (gameOver) { gameMode = GameMode.MENU; gameRunning = false; } else { + pauseStartTime = System.currentTimeMillis(); showExitConfirm = true; heldKeys.clear(); // 清空已按住的键,防止弹窗前按住的WASD继续移动 } return true; } + // R 键优先处理:弹窗期关闭弹窗,游戏期重开。R 不入 heldKeys + if (key == GLFW.GLFW_KEY_R) { + if (showExitConfirm) { resumeFromExitConfirm(); return true; } + if (lanMode == LAN_CLIENT) return true; + if (gameMode != GameMode.MENU) { + initGame(gameMode == GameMode.TWO_PLAYER); + if (lanMode == LAN_HOST) + sendInputEnvelope("RESTART|" + stateSessionId + "|" + (++stateSequence)); + } + return true; + } + heldKeys.add(key); + // ★ Bug修复:gameOver 状态下不应累积按键,否则 HOST 复活瞬间 + // processHeldKeys 会立即消费旧按键导致角色瞬移一格 + if (gameOver || !gameRunning) return true; if (gameMode == GameMode.MENU) return super.keyPressed(key, scan, mods); - if (gameOver && key == GLFW.GLFW_KEY_R) { - if (lanMode == LAN_CLIENT) return true; // CLIENT 不能单方面重开 - initGame(gameMode == GameMode.TWO_PLAYER); - // HOST 重开后,下一帧的 sendState 会自动同步新状态给 CLIENT - // 但 CLIENT 的 gameOver 还是 true,需要发一个明确的重开信号 - if (lanMode == LAN_HOST) sendInput("RESTART"); - return true; - } - return super.keyPressed(key, scan, mods); } @@ -799,7 +862,7 @@ public boolean keyReleased(int key, int scan, int mods) { @Override public boolean mouseClicked(double mx, double my, int btn) { - if (showExitConfirm) { int click = GameRenderHelper.getExitConfirmClick((int)mx, (int)my, width, height); if (click == 1) { showExitConfirm = false; sendLeaveGameOnce(); Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } if (click == 2) { showExitConfirm = false; return true; } return true; } + if (showExitConfirm) { int click = GameRenderHelper.getExitConfirmClick((int)mx, (int)my, width, height); if (click == 1) { showExitConfirm = false; sendLeaveGameOnce(); Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } if (click == 2) { resumeFromExitConfirm(); return true; } return true; } if (gameMode == GameMode.MENU) { int cx = this.width/2, cy = this.height/2; if (mx>=cx-155&&mx<=cx-15&&my>=cy-54&&my<=cy-28) { initGame(false); return true; } @@ -809,7 +872,12 @@ public boolean mouseClicked(double mx, double my, int btn) { int ww=320, wh=190; int wx=(this.width-ww)/2, wy=(this.height-wh)/2; int btnY = wy+wh-54; - if (mx>=wx+20&&mx<=wx+ww-20&&my>=btnY&&my<=btnY+20) { initGame(gameMode == GameMode.TWO_PLAYER); return true; } + if (mx>=wx+20&&mx<=wx+ww-20&&my>=btnY&&my<=btnY+20) { + boolean twoPlayer = gameMode == GameMode.TWO_PLAYER; + initGame(twoPlayer); + if (lanMode == LAN_HOST) sendInputEnvelope("RESTART|" + stateSessionId + "|" + (++stateSequence)); + return true; + } if (mx>=wx+20&&mx<=wx+ww-20&&my>=btnY+24&&my<=btnY+44) { sendLeaveGameOnce(); gameMode = GameMode.MENU; gameRunning = false; return true; } } return super.mouseClicked(mx, my, btn); diff --git a/src/main/java/com/wzz/game_console/client/screens/games/DiceGuessingScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/DiceGuessingScreen.java index f655eb6..ce9c5e3 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/DiceGuessingScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/DiceGuessingScreen.java @@ -10,6 +10,7 @@ import net.minecraft.sounds.SoundEvents; import net.neoforged.api.distmarker.Dist; import net.neoforged.api.distmarker.OnlyIn; +import org.lwjgl.glfw.GLFW; import java.util.Random; @@ -67,6 +68,9 @@ public DiceGuessingScreen() { @Override public void init() { + // ★ Bug修复:缩放 init() 重复添加 exitButton/resetButton + this.clearWidgets(); + super.init(); int centerX = this.width / 2; int centerY = this.height / 2; @@ -457,7 +461,7 @@ private int getWinRateColor(int winRate) { @Override public boolean keyPressed(int keyCode, int scanCode, int modifiers) { - if (keyCode == 256) { if (showExitConfirm) { showExitConfirm = false; } else { showExitConfirm = true; } return true; } + if (keyCode == GLFW.GLFW_KEY_ESCAPE) { if (showExitConfirm) { showExitConfirm = false; } else { showExitConfirm = true; } return true; } if (showExitConfirm) return true; return super.keyPressed(keyCode, scanCode, modifiers); } diff --git a/src/main/java/com/wzz/game_console/client/screens/games/FlappyBirdScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/FlappyBirdScreen.java index f17bc31..c266659 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/FlappyBirdScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/FlappyBirdScreen.java @@ -45,6 +45,8 @@ private void flap() { @Override public void tick() { tickCount++; if (state != State.PLAYING || showExitConfirm) return; // 弹窗期间暂停游戏 + // ★ 修复:粒子物理移到 tick() 固定频率推进(原来在 render 中 update,帧率依赖且暂停期间不停) + GameRenderHelper.tickParticles(particles); birdVel += 0.35f; birdY += birdVel; tickCounter++; @@ -85,7 +87,7 @@ private void flap() { Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } if (showExitConfirm) return true; - if (key == GLFW.GLFW_KEY_R && state == State.GAME_OVER) { startGame(); return true; } + if (key == GLFW.GLFW_KEY_R && state != State.MENU) { startGame(); return true; } if (state == State.PLAYING && (key == GLFW.GLFW_KEY_SPACE || key == GLFW.GLFW_KEY_W || key == GLFW.GLFW_KEY_UP)) flap(); return true; } @@ -154,11 +156,12 @@ private void renderPlaying(GuiGraphics g) { // 小鸟 drawBird(g, width / 4, (int)birdY); - GameRenderHelper.tickAndRenderParticles(g, particles); + GameRenderHelper.renderParticles(g, particles); // HUD GameRenderHelper.drawTopHUD(g, width, height); - g.drawString(font, "🐦 分数: " + score, 8, 7, 0xFFDD44); + // ★ 修复:🐦 为非 BMP emoji,默认字体有豆腐块风险,改为纯文本 + g.drawString(font, "分数: " + score, 8, 7, 0xFFDD44); GameRenderHelper.drawBottomBar(g, font, width, height, "空格/点击 飞 ESC 菜单 R 重开"); } diff --git a/src/main/java/com/wzz/game_console/client/screens/games/FruitNinjaScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/FruitNinjaScreen.java index 0ec00d0..c752e4b 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/FruitNinjaScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/FruitNinjaScreen.java @@ -31,6 +31,15 @@ private enum State { MENU, PLAYING, GAME_OVER } private final Random random = new Random(); private final List particles = new ArrayList<>(); private final List floats = new ArrayList<>(); + /** 生命值 HUD 帧表:索引 = 心数(0~20),启动时预计算避免每帧 repeat 分配 */ + private static final String[] HEARTS_FRAMES = new String[21]; + static { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < HEARTS_FRAMES.length; i++) { + HEARTS_FRAMES[i] = sb.toString(); + sb.append("❤"); + } + } public FruitNinjaScreen() { super(Component.literal("水果忍者")); } @@ -43,8 +52,11 @@ private void startGame() { @Override public void tick() { tickCount++; - floats.removeIf(f -> { f.update(); return !f.isAlive(); }); if (state != State.PLAYING || showExitConfirm) return; // 弹窗期间暂停游戏 + // ★ 修复:浮字推进原在状态守卫之前执行,暂停/菜单期间仍会飘走耗尽,移到守卫之后 + floats.removeIf(f -> { f.update(); return !f.isAlive(); }); + // ★ 修复:粒子物理移到 tick() 固定频率推进(原来在 render 中 update,帧率依赖且暂停期间不停) + GameRenderHelper.tickParticles(particles); spawnTimer++; if (spawnTimer >= Math.max(8, 25 - score / 5)) { @@ -156,15 +168,32 @@ private void renderPlaying(GuiGraphics g, int mx, int my) { for (float[] f : fruits) { if (f[5] == 0) { if ((int)f[4] == -1) { - // 炸弹:黑色圆 + 红色引线 - GameRenderHelper.drawCircle(g, (int)f[0], (int)f[1], FRUIT_SIZE/2, 0xFF111111); - GameRenderHelper.drawCircle(g, (int)f[0], (int)f[1], FRUIT_SIZE/2 - 2, 0xFF2A2A2A); - // 引线 - g.fill((int)f[0], (int)f[1] - FRUIT_SIZE/2 - 4, (int)f[0]+2, (int)f[1] - FRUIT_SIZE/2, 0xFFFF4400); + // 炸弹:黑色方形 + 红色十字标记(与圆形水果明显区分) + int bx = (int)f[0], by = (int)f[1]; + int half = FRUIT_SIZE / 2; + // 黑色方形主体(区别于圆形水果,轮廓更锐利) + g.fill(bx - half, by - half, bx + half, by + half, 0xFF111111); + g.fill(bx - half + 3, by - half + 3, bx + half - 3, by + half - 3, 0xFF2A2A2A); + // 红色十字危险标记 + g.fill(bx - 2, by - half + 4, bx + 2, by + half - 4, 0xFFFF2200); + g.fill(bx - half + 4, by - 2, bx + half - 4, by + 2, 0xFFFF2200); + // 引线(顶部,更明显) + // ★ Bug修复:fill(x1,y1,x2,y2,color) 后两参数是绝对坐标,此前误传成宽高字面量, + // 炸弹坐标较大时矩形会从屏幕左上角一路拉伸过来,改为 x1+宽/y1+高 换算出正确的 x2/y2 + g.fill(bx - 1, by - half - 8, bx + 2, by - half, 0xFFFF6600); + g.fill(bx - 4, by - half - 10, bx + 5, by - half - 7, 0xFFFF6600); // 警告圈(红色闪烁轮廓) int bombPulse = (int)(System.currentTimeMillis() / 300) % 2 == 0 ? 0xFFFF2200 : 0xFF880000; - GameRenderHelper.drawCircle(g, (int)f[0], (int)f[1], FRUIT_SIZE/2 + 2, bombPulse); - g.drawCenteredString(font, "💣", (int)f[0], (int)f[1] - 4, 0xFFFFFFFF); + GameRenderHelper.drawCircle(g, bx, by, half + 3, bombPulse); + // ★ Bug修复:默认 Minecraft 字体不包含 emoji "💣",豆腐块概率高。 + // 改用 ASCII 字符 "B"(黑底白字 + 红色描边),跨字体/语言包稳定可读。 + int bw = font.width("B"); + // 红色描边(4 方向各偏移 1px) + g.drawString(font, "B", bx - bw / 2 - 1, by - 4, 0xFFFF0000); + g.drawString(font, "B", bx - bw / 2 + 1, by - 4, 0xFFFF0000); + g.drawString(font, "B", bx - bw / 2, by - 5, 0xFFFF0000); + g.drawString(font, "B", bx - bw / 2, by - 3, 0xFFFF0000); + g.drawString(font, "B", bx - bw / 2, by - 4, 0xFFFFFFFF); } else { int color = FRUIT_COLORS[(int)f[4]]; GameRenderHelper.drawCircle(g, (int)f[0], (int)f[1], FRUIT_SIZE/2, color); @@ -173,13 +202,18 @@ private void renderPlaying(GuiGraphics g, int mx, int my) { } } } - GameRenderHelper.tickAndRenderParticles(g, particles); + GameRenderHelper.renderParticles(g, particles); for (GameRenderHelper.FloatingText ft : floats) ft.render(g, font); // HUD GameRenderHelper.drawTopHUD(g, width, height); - g.drawString(font, "🍉 分数: " + score, 8, 7, 0xFF4444); - String livesStr = "❤".repeat(Math.max(0, lives)); + // ★ 修复:🍉 为非 BMP emoji,默认字体有豆腐块风险,改为纯文本 + g.drawString(font, "分数: " + score, 8, 7, 0xFF4444); + // ★ Bug修复:lives 无上限时 "❤".repeat(lives) 字符串爆炸,font.width + // 返回极大值,width - width - 8 变成巨大负数,字符串渲染到屏幕外。 + // ★ 性能:改为启动时预计算 0~20 帧表,render 每帧只做一次数组索引, + // 不再每帧 repeat 分配新字符串 + String livesStr = HEARTS_FRAMES[Math.max(0, Math.min(lives, HEARTS_FRAMES.length - 1))]; g.drawString(font, livesStr, width - font.width(livesStr) - 8, 7, 0xFF4444); if (comboCount >= 3) g.drawCenteredString(font, "✦ Combo x" + comboCount + " ✦", width/2, 7, 0xFFAA00); GameRenderHelper.drawBottomBar(g, font, width, height, "按住鼠标滑动切水果 ESC 菜单 R 重开"); @@ -195,6 +229,7 @@ private void renderGameOver(GuiGraphics g, int mx, int my) { } @Override public boolean mouseClicked(double mx, double my, int btn) { + if (btn != 0) return super.mouseClicked(mx, my, btn); if (showExitConfirm) { int click = GameRenderHelper.getExitConfirmClick(mx, my, width, height); if (click == 1) { showExitConfirm = false; Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } if (click == 2) { showExitConfirm = false; return true; } return true; } int cx = width/2, cy = height/2; if (state == State.MENU && mx >= cx-60 && mx <= cx+60 && my >= cy+45 && my <= cy+67) { startGame(); return true; } diff --git a/src/main/java/com/wzz/game_console/client/screens/games/GomokuAI.java b/src/main/java/com/wzz/game_console/client/screens/games/GomokuAI.java new file mode 100644 index 0000000..f3311b0 --- /dev/null +++ b/src/main/java/com/wzz/game_console/client/screens/games/GomokuAI.java @@ -0,0 +1,820 @@ +package com.wzz.game_console.client.screens.games; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +/** + * 五子棋 AI,移植自 TouhouLittleMaid 项目的 ZhiZhangAIService + * (原作者 anlingyi,源自 xechat-idea,Apache 2.0)。 + * + * 算法:迭代加深 + 极小极大 + Alpha-Beta 剪枝 + 棋型评分 + Zobrist 局面缓存 + VCF/VCT 算杀。 + * + * 棋子约定:0=空 1=黑 2=白。单机模式下本 AI 固定执白(2)。 + */ +public class GomokuAI { + + public static final int EMPTY = 0; + public static final int BLACK = 1; + public static final int WHITE = 2; + + /** + * 难度档位,配置与 TouhouLittleMaid 的 MaidGomokuAI 完全一致。 + */ + public enum Difficulty { + EASY("简单", 1, 10, 0, 6), + NORMAL("普通", 4, 10, 0, 6), + HARD("困难", 6, 10, 1, 8), + HELL("地狱", 8, 10, 1, 10); + + public final String label; + final int depth; + final int maxNodes; + /** 算杀 0.不开启 1.VCT 2.VCF */ + final int vcx; + final int vcxDepth; + + Difficulty(String label, int depth, int maxNodes, int vcx, int vcxDepth) { + this.label = label; + this.depth = depth; + this.maxNodes = maxNodes; + this.vcx = vcx; + this.vcxDepth = vcxDepth; + } + + /** 循环切换到下一档难度 */ + public Difficulty next() { + Difficulty[] all = values(); + return all[(ordinal() + 1) % all.length]; + } + } + + /** Zobrist 表按支持的最大棋盘尺寸预生成(实际棋盘 9~19 路,由 getMove 传入的 board 推导) */ + private static final int MAX_SIZE = 19; + /** 单机模式 AI 固定执白 */ + private static final int AI = WHITE; + /** 执白偏防守的进攻系数 */ + private static final float ATTACK = 0.5f; + + private static final int INFINITY = 999999999; + + private final Difficulty difficulty; + + private int[][] chessData; + /** 实际棋盘尺寸(9~19),每次 getMove 由 board.length 推导,避免按固定 15 索引越界/漏看 */ + private int boardSize; + private int rounds; + private Point bestPoint; + private long hashcode; + private Map situationCacheMap; + + public GomokuAI(Difficulty difficulty) { + this.difficulty = difficulty; + } + + /** + * 计算 AI 落子坐标,返回 {x, y};无可落子时返回 null。 + * + * @param board 当前棋盘,0=空 1=黑 2=白 + */ + public int[] getMove(int[][] board) { + initChessData(board); + this.bestPoint = null; + + // 最低难度:单步启发式,不做搜索 + if (this.difficulty.depth < 2) { + Point p = getBestPoint(); + return p == null ? null : new int[]{p.x, p.y}; + } + + int depth = this.difficulty.depth; + if (depth > 4 && this.rounds < 4) { + // 前三个回合降低搜索深度,加快落子 + depth = 4; + } + + // 先尝试算杀(VCF/VCT) + if (this.difficulty.vcx > 0) { + this.bestPoint = deepening(1, this.difficulty.vcxDepth, this.difficulty.vcx == 2); + } + + // 算杀未命中,退回到极大极小搜索 + if (this.bestPoint == null) { + this.bestPoint = deepeningMinimax(2, depth); + } + + this.situationCacheMap = null; + return this.bestPoint == null ? null : new int[]{this.bestPoint.x, this.bestPoint.y}; + } + + // ══════════════════════════════════════════ + // 棋盘数据 + // ══════════════════════════════════════════ + + private void initChessData(int[][] board) { + // ★ Bug修复:棋盘尺寸以传入棋盘为准(GomokuScreen 支持 9~19 路切换), + // 不再按固定 15 索引——9~13 路时原先会 AIOOBE 使 AI 线程死亡导致卡死, + // 17/19 路时 AI 对 15 路域外全盲 + this.boardSize = board.length; + this.chessData = new int[boardSize][boardSize]; + this.hashcode = 0; + int chessTotal = 0; + for (int i = 0; i < boardSize; i++) { + for (int j = 0; j < boardSize; j++) { + int type = board[i][j]; + if (type != EMPTY) { + putChess(new Point(i, j, type)); + chessTotal++; + } + } + } + this.rounds = chessTotal / 2 + 1; + } + + private void putChess(Point point) { + this.chessData[point.x][point.y] = point.type; + calculateHashCode(point); + } + + private void revokeChess(Point point) { + this.chessData[point.x][point.y] = EMPTY; + calculateHashCode(point); + } + + /** 增量维护当前局面的 Zobrist 哈希 */ + private long calculateHashCode(Point point) { + this.hashcode ^= point.type == BLACK + ? BLACK_ZOBRIST[point.x][point.y] + : WHITE_ZOBRIST[point.x][point.y]; + return this.hashcode; + } + + // ══════════════════════════════════════════ + // 极大极小搜索 + // ══════════════════════════════════════════ + + private Point deepeningMinimax(int depth, int maxDepth) { + this.situationCacheMap = new HashMap<>(2048); + Point best = null; + for (; depth <= maxDepth; depth += 2) { + int score = minimax(0, depth, -INFINITY, INFINITY); + best = this.bestPoint; + if (Math.abs(score) >= INFINITY - 1) { + // 找到必胜/必败解,提前终止 + break; + } + } + return best; + } + + private int minimax(int type, int depth, int alpha, int beta) { + boolean isRoot = type == 0; + if (isRoot) { + type = AI; + } + boolean isAI = type == AI; + // 记录入口窗口:搜索结束按此分类缓存边界(fail-hard 返回值是界不是精确分) + int origAlpha = alpha, origBeta = beta; + SituationCache cache = this.situationCacheMap.get(this.hashcode); + if (cache != null && cache.depth >= depth) { + if (cache.flag == SituationCache.FLAG_EXACT) { + return cache.score; + } + if (cache.flag == SituationCache.FLAG_LOWER && cache.score > alpha) { + alpha = cache.score; + } else if (cache.flag == SituationCache.FLAG_UPPER && cache.score < beta) { + beta = cache.score; + } + if (alpha >= beta) { + return cache.score; + } + } + + if (depth == 0) { + return evaluateAll(); + } + + List pointList = getHeuristicPoints(type); + if (isRoot && pointList.size() == 1) { + this.bestPoint = pointList.get(0); + return this.bestPoint.score; + } + + List bestPointList = new ArrayList<>(); + for (Point point : pointList) { + if (point.score >= ChessModel.LIANWU.score) { + // 落子即连五,直接给最值 + point.score = isAI ? INFINITY - 1 : -INFINITY + 1; + } else { + putChess(point); + point.score = minimax(3 - type, depth - 1, alpha, beta); + revokeChess(point); + } + + if (isAI) { + if (point.score >= alpha) { + if (isRoot) { + if (point.score > alpha && this.rounds <= 1) { + bestPointList.clear(); + } + bestPointList.add(point); + } + alpha = point.score; + } + } else { + if (point.score < beta) { + beta = point.score; + } + } + + if (alpha >= beta) { + break; + } + } + + if (isRoot) { + int count = bestPointList.size(); + if (count == 1) { + this.bestPoint = bestPointList.get(0); + } else if (this.rounds > 1) { + // 多解时随机取最佳/次佳,增加棋风变化 + this.bestPoint = getRandomBestPoint(bestPointList); + } else { + this.bestPoint = getBestPoint(bestPointList); + } + } + + int score = isAI ? alpha : beta; + // fail-hard α-β:返回值按初始窗口分类为精确分/上界/下界,供缓存正确复用 + int flag = score <= origAlpha ? SituationCache.FLAG_UPPER + : score >= origBeta ? SituationCache.FLAG_LOWER + : SituationCache.FLAG_EXACT; + this.situationCacheMap.put(this.hashcode, new SituationCache(score, depth, flag)); + return score; + } + + // ══════════════════════════════════════════ + // 算杀(VCF / VCT) + // ══════════════════════════════════════════ + + private Point deepening(int depth, int maxDepth, boolean isVcf) { + this.situationCacheMap = new HashMap<>(2048); + Point point = null; + for (; depth <= maxDepth; depth += 2) { + point = vcx(0, depth, isVcf); + if (point != null) { + break; + } + } + return point; + } + + private Point vcx(int type, int depth, boolean isVcf) { + SituationCache cache = this.situationCacheMap.get(this.hashcode); + if (cache != null && cache.depth >= depth) { + return cache.point; + } + if (depth == 0) { + return null; + } + + boolean isRoot = type == 0; + if (isRoot) { + type = AI; + } + boolean isAI = type == AI; + + Point best = null; + List pointList = getVcxPoints(type, isVcf); + for (Point point : pointList) { + if (point.score >= RiskScore.HIGH_RISK.score) { + // 已形成必胜棋型:AI 落子直接返回,对手落子则算杀失败 + return isAI ? point : null; + } + + putChess(point); + best = vcx(3 - type, depth - 1, isVcf); + revokeChess(point); + + if (best == null) { + if (isAI) { + continue; + } + // 对手拦截成功,算杀失败 + return null; + } + + best = point; + if (isAI) { + break; + } + } + + this.situationCacheMap.put(this.hashcode, new SituationCache(best, depth)); + return best; + } + + // ══════════════════════════════════════════ + // 候选点生成 + // ══════════════════════════════════════════ + + private List getHeuristicPoints(int type) { + int max = this.difficulty.maxNodes; + List highPriorityPointList = new ArrayList<>(); + List lowPriorityPointList = new ArrayList<>(); + List alternatePointList = new ArrayList<>(); + List killPointList = new ArrayList<>(); + + int dangerLevel = 0; + for (int i = 0; i < boardSize; i++) { + for (int j = 0; j < boardSize; j++) { + if (this.chessData[i][j] != EMPTY) { + continue; + } + + Point point = new Point(i, j, type); + int score = evaluate(point); + if (score >= ChessModel.LIANWU.score) { + // 自己可连五,直接返回 + return Collections.singletonList(point); + } + if (dangerLevel == 2) { + continue; + } + if (score >= RiskScore.MEDIUM_RISK.score) { + killPointList.add(point); + } + + Point foePoint = new Point(i, j, 3 - type); + int foeScore = evaluate(foePoint); + int level = 0; + if (foeScore >= ChessModel.LIANWU.score) { + level = 2; + } else if (foeScore >= RiskScore.MEDIUM_RISK.score) { + level = 1; + } + + if (level > 0) { + if (dangerLevel < level) { + dangerLevel = level; + highPriorityPointList.clear(); + } + highPriorityPointList.add(point); + } + if (dangerLevel > 0) { + continue; + } + + if (RiskScore.between(score, RiskScore.LOW_RISK, RiskScore.MEDIUM_RISK) + || RiskScore.between(foeScore, RiskScore.LOW_RISK, RiskScore.MEDIUM_RISK)) { + highPriorityPointList.add(point); + continue; + } + + if (highPriorityPointList.isEmpty()) { + if (score >= ChessModel.CHONGSI.score || foeScore >= ChessModel.CHONGSI.score) { + lowPriorityPointList.add(point); + continue; + } + if (lowPriorityPointList.isEmpty() && score >= ChessModel.MIANYI.score) { + alternatePointList.add(point); + } + } + } + } + + if (dangerLevel < 2 && !killPointList.isEmpty()) { + return killPointList; + } + + List pointList; + if (highPriorityPointList.isEmpty()) { + if (lowPriorityPointList.isEmpty()) { + if (alternatePointList.isEmpty()) { + return randomPoint(type, 1); + } + Collections.shuffle(alternatePointList); + pointList = alternatePointList; + } else { + pointList = lowPriorityPointList; + } + } else { + pointList = highPriorityPointList; + } + + pointList.sort((p1, p2) -> p1.score == p2.score ? 0 : p1.score > p2.score ? -1 : 1); + return pointList.subList(0, Math.min(pointList.size(), max)); + } + + private List getVcxPoints(int type, boolean isVcf) { + boolean isAI = type == AI; + List attackPointList = new ArrayList<>(); + List defensePointList = new ArrayList<>(); + List vcxPointList = new ArrayList<>(); + + boolean isDanger = false; + for (int i = 0; i < boardSize; i++) { + for (int j = 0; j < boardSize; j++) { + if (this.chessData[i][j] != EMPTY) { + continue; + } + + Point point = new Point(i, j, type); + int score = evaluate(point); + if (score >= ChessModel.LIANWU.score) { + return Collections.singletonList(point); + } + if (isDanger) { + continue; + } + + Point foePoint = new Point(i, j, 3 - type); + int foeScore = evaluate(foePoint); + if (foeScore >= ChessModel.LIANWU.score) { + isDanger = true; + defensePointList.clear(); + defensePointList.add(point); + continue; + } + + if (score >= RiskScore.MEDIUM_RISK.score) { + attackPointList.add(point); + continue; + } + + if (isAI) { + if (checkSituation(point, ChessModel.CHONGSI)) { + vcxPointList.add(point); + } else if (!isVcf && checkSituation(point, ChessModel.HUOSAN)) { + vcxPointList.add(point); + } + } else { + if (!isVcf + && (checkSituation(point, ChessModel.CHONGSI) || foeScore >= ChessModel.HUOSI.score)) { + defensePointList.add(point); + } + } + } + } + + List pointList = new ArrayList<>(); + if (!isDanger) { + if (!attackPointList.isEmpty()) { + attackPointList.sort((p1, p2) -> p1.score == p2.score ? 0 : p1.score > p2.score ? -1 : 1); + if (isAI) { + return attackPointList; + } + pointList.addAll(attackPointList); + } + if (!vcxPointList.isEmpty()) { + pointList.addAll(vcxPointList); + } + } + + if (!defensePointList.isEmpty()) { + if (isAI) { + pointList.addAll(defensePointList); + } else { + pointList.addAll(0, defensePointList); + } + } + return pointList; + } + + // ══════════════════════════════════════════ + // 评估 + // ══════════════════════════════════════════ + + private int evaluate(Point point) { + int score = 0; + int huosanTotal = 0; + int chongsiTotal = 0; + int tfTotal = 0; + + for (int i = 1; i < 5; i++) { + String situation = getSituation(point, i); + ChessModel model = getChessModel(situation); + if (model != null) { + switch (model) { + case HUOSAN: + huosanTotal++; + if (checkSituation(situation, ChessModel.CHONGSI)) { + tfTotal++; + } + break; + case CHONGSI: + chongsiTotal++; + break; + default: + break; + } + score += model.score; + } + } + + if (chongsiTotal > 1 || tfTotal > 1) { + score += RiskScore.HIGH_RISK.score; + } else if ((chongsiTotal > 0 && huosanTotal > 0) || (tfTotal > 0 && huosanTotal > 1)) { + score += RiskScore.MEDIUM_RISK.score; + } else if (huosanTotal > 1) { + score += RiskScore.LOW_RISK.score; + } + + point.score = score; + return score; + } + + /** 以 AI 视角评估整个局面,分值越大对 AI 越有利 */ + private int evaluateAll() { + int aiScore = 0; + int foeScore = 0; + for (int i = 0; i < boardSize; i++) { + for (int j = 0; j < boardSize; j++) { + int type = this.chessData[i][j]; + if (type == EMPTY) { + continue; + } + int val = evaluate(new Point(i, j, type)); + if (type == AI) { + aiScore += val; + } else { + foeScore += val; + } + } + } + return Math.round(aiScore * ATTACK) - foeScore; + } + + private boolean checkSituation(Point point, ChessModel... chessModels) { + for (int i = 1; i < 5; i++) { + String situation = getSituation(point, i); + for (ChessModel chessModel : chessModels) { + if (checkSituation(situation, chessModel)) { + return true; + } + } + } + return false; + } + + private boolean checkSituation(String situation, ChessModel chessModel) { + for (String value : chessModel.values) { + if (situation.contains(value)) { + return true; + } + } + return false; + } + + /** 按顺序匹配棋型,优先级高的棋型先命中 */ + private ChessModel getChessModel(String situation) { + for (ChessModel chessModel : ChessModel.values()) { + for (String value : chessModel.values) { + if (situation.contains(value)) { + return chessModel; + } + } + } + return null; + } + + // ══════════════════════════════════════════ + // 点位选取 + // ══════════════════════════════════════════ + + private Point getBestPoint() { + Point best = null; + int score = -INFINITY; + for (int i = 0; i < boardSize; i++) { + for (int j = 0; j < boardSize; j++) { + if (this.chessData[i][j] != EMPTY) { + continue; + } + Point p = new Point(i, j, AI); + int val = Math.round(evaluate(p) * ATTACK) + evaluate(new Point(i, j, 3 - AI)); + if (val > score) { + score = val; + best = p; + } + } + } + return best; + } + + private Point getBestPoint(List pointList) { + Point bestPoint = null; + int bestScore = -INFINITY; + for (Point point : pointList) { + int score = Math.round(evaluate(point) * ATTACK) + + evaluate(new Point(point.x, point.y, 3 - point.type)); + if (score > bestScore) { + bestScore = score; + bestPoint = point; + } + } + return bestPoint; + } + + private Point getRandomBestPoint(List pointList) { + Point bestPoint = null; + Point secondPoint = null; + int bestScore = -INFINITY; + int secondScore = -INFINITY; + for (Point point : pointList) { + int score = Math.round(evaluate(point) * ATTACK) + + evaluate(new Point(point.x, point.y, 3 - point.type)); + if (score > bestScore) { + bestScore = score; + bestPoint = point; + } + if (score > secondScore && score < bestScore) { + secondScore = score; + secondPoint = point; + } + } + if (secondPoint == null) { + return bestPoint; + } + return Math.random() < 0.5 ? bestPoint : secondPoint; + } + + private List randomPoint(int type, int num) { + List pointList = new ArrayList<>(); + for (int i = 0; i < boardSize; i++) { + for (int j = 0; j < boardSize; j++) { + if (this.chessData[i][j] == EMPTY) { + pointList.add(new Point(i, j, type)); + } + } + } + Collections.shuffle(pointList); + return pointList.subList(0, Math.min(num, pointList.size())); + } + + // ══════════════════════════════════════════ + // 棋型串 + // ══════════════════════════════════════════ + + private String getSituation(Point point, int direction) { + direction = direction * 2 - 1; + StringBuilder sb = new StringBuilder(); + appendChess(sb, point, direction, 4); + appendChess(sb, point, direction, 3); + appendChess(sb, point, direction, 2); + appendChess(sb, point, direction, 1); + sb.append(1); // 当前棋子统一标记为 1(己方) + appendChess(sb, point, direction + 1, 1); + appendChess(sb, point, direction + 1, 2); + appendChess(sb, point, direction + 1, 3); + appendChess(sb, point, direction + 1, 4); + return sb.toString(); + } + + private void appendChess(StringBuilder sb, Point point, int direction, int offset) { + int chess = relativePoint(point, direction, offset); + if (chess == -1) { + // 越界按对方棋子处理:直接跳过会让串变短,边缘棋型被系统性错判 + //(如边缘活四被当冲四)。'2' 在黑白两种视角反转后都表示对方棋子 + sb.append('2'); + return; + } + if (point.type == WHITE) { + // 白棋方做颜色反转,复用黑棋棋型表 + if (chess > 0) { + chess = 3 - chess; + } + } + sb.append(chess); + } + + /** + * 获取相对点位棋子。 + * + * @param direction 1.左横 2.右横 3.上纵 4.下纵 5.左斜上 6.左斜下 7.右斜上 8.右斜下 + * @return -1:越界 0:空位 1:黑棋 2:白棋 + */ + private int relativePoint(Point point, int direction, int offset) { + int x = point.x; + int y = point.y; + switch (direction) { + case 1: x -= offset; break; + case 2: x += offset; break; + case 3: y -= offset; break; + case 4: y += offset; break; + case 5: x += offset; y -= offset; break; + case 6: x -= offset; y += offset; break; + case 7: x -= offset; y -= offset; break; + case 8: x += offset; y += offset; break; + default: break; + } + if (x < 0 || y < 0 || x >= boardSize || y >= boardSize) { + return -1; + } + return this.chessData[x][y]; + } + + // ══════════════════════════════════════════ + // 内部类型 + // ══════════════════════════════════════════ + + private static class Point { + final int x; + final int y; + int type; + int score; + + Point(int x, int y, int type) { + this.x = x; + this.y = y; + this.type = type; + } + } + + private static class SituationCache { + /** minimax 边界标志:精确分 */ + static final int FLAG_EXACT = 0; + /** minimax 边界标志:真实分 ≥ score(fail-high 下界) */ + static final int FLAG_LOWER = 1; + /** minimax 边界标志:真实分 ≤ score(fail-low 上界) */ + static final int FLAG_UPPER = 2; + /** VCX 缓存的点位 */ + private final Point point; + /** 缓存的分数 */ + private final int score; + /** 缓存的搜索深度 */ + private final int depth; + /** minimax 边界标志(VCX 条目恒为 EXACT,不参与边界判定) */ + private final int flag; + + SituationCache(int score, int depth) { + this(score, depth, FLAG_EXACT); + } + + SituationCache(int score, int depth, int flag) { + this.point = null; + this.score = score; + this.depth = depth; + this.flag = flag; + } + + SituationCache(Point point, int depth) { + this.point = point; + this.score = 0; + this.depth = depth; + this.flag = FLAG_EXACT; + } + } + + private enum ChessModel { + LIANWU(10000000, new String[]{"11111"}), + HUOSI(1000000, new String[]{"011110"}), + HUOSAN(10000, new String[]{"001110", "011100", "010110", "011010"}), + CHONGSI(9000, new String[]{"11110", "01111", "10111", "11011", "11101"}), + HUOER(100, new String[]{"001100", "011000", "000110", "001010", "010100"}), + HUOYI(80, new String[]{"010200", "002010", "020100", "001020", "201000", "000102", "000201"}), + MIANSAN(30, new String[]{"001112", "010112", "011012", "211100", "211010"}), + MIANER(10, new String[]{"011200", "001120", "002110", "021100", "110000", "000011", "000112", "211000"}), + MIANYI(1, new String[]{"001200", "002100", "000210", "000120", "210000", "000012"}); + + final int score; + final String[] values; + + ChessModel(int score, String[] values) { + this.score = score; + this.values = values; + } + } + + private enum RiskScore { + HIGH_RISK(800000), + MEDIUM_RISK(500000), + LOW_RISK(100000); + + final int score; + + RiskScore(int score) { + this.score = score; + } + + static boolean between(int score, RiskScore leftScore, RiskScore rightScore) { + return score >= leftScore.score && score < rightScore.score; + } + } + + /** 黑白双方各 MAX_SIZE×MAX_SIZE 个格子的 Zobrist 随机值(按最大棋盘预生成,各尺寸共用) */ + private static final long[][] BLACK_ZOBRIST = new long[MAX_SIZE][MAX_SIZE]; + private static final long[][] WHITE_ZOBRIST = new long[MAX_SIZE][MAX_SIZE]; + + static { + ThreadLocalRandom random = ThreadLocalRandom.current(); + for (int i = 0; i < MAX_SIZE; i++) { + for (int j = 0; j < MAX_SIZE; j++) { + BLACK_ZOBRIST[i][j] = random.nextLong(); + WHITE_ZOBRIST[i][j] = random.nextLong(); + } + } + } +} diff --git a/src/main/java/com/wzz/game_console/client/screens/games/GomokuScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/GomokuScreen.java index 3201c19..01d3b10 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/GomokuScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/GomokuScreen.java @@ -20,8 +20,7 @@ public class GomokuScreen extends Screen implements LanMultiplayerScreen { private static final Logger LOGGER = LoggerFactory.getLogger(GomokuScreen.class); boolean showExitConfirm = false; - private static final int BOARD_SIZE = 15; - /** 四方向偏移(横、竖、两对角线),避免每次评估重复创建 */ + /** 四方向偏移(横、竖、两对角线),避免每次评估重复创建 */ private static final int[][] DIRS = {{0, 1}, {1, 0}, {1, 1}, {1, -1}}; private State state = State.MENU; private int[][] board = new int[15][15]; @@ -34,19 +33,38 @@ public class GomokuScreen extends Screen implements LanMultiplayerScreen { private final List particles = new ArrayList<>(); private int lastMoveX = -1; private int lastMoveY = -1; - private boolean hellMode = false; + private GomokuAI.Difficulty difficulty = GomokuAI.Difficulty.NORMAL; + private int boardSize = 15; + private GomokuAI ai; + private final boolean localTwoPlayer; private int lanMode = 0; private UUID remotePeer = null; private boolean isMyTurn = true; /** 防重复发送 LEAVE_GAME 标志 */ private boolean lanLeaveSent = false; + /** AI 后台思考结果(null=无解),由主线程 tick 消费 */ + private volatile int[] aiPending = null; + /** AI 后台思考是否已完成 */ + private volatile boolean aiDone = false; + /** 是否已有 AI 线程在思考(仅主线程访问) */ + private boolean aiComputing = false; + /** 对局代次,重开/退出后丢弃残留的 AI 结果 */ + private int aiGeneration = 0; + private volatile Thread aiWorker; public GomokuScreen() { super(Component.literal("五子棋")); + this.localTwoPlayer = false; + } + + public GomokuScreen(boolean aiMode) { + super(Component.literal("五子棋")); + this.localTwoPlayer = !aiMode; } public GomokuScreen(boolean isHost, UUID remote) { super(Component.literal("五子棋-联机")); + this.localTwoPlayer = false; this.lanMode = isHost ? 1 : 2; this.remotePeer = remote; this.isMyTurn = isHost; @@ -84,18 +102,65 @@ private void sendLeaveGameOnce() { @Override public void onClose() { this.sendLeaveGameOnce(); + stopAiWorker(); super.onClose(); } + @Override + public void removed() { + sendLeaveGameOnce(); + stopAiWorker(); + super.removed(); + } + + private void stopAiWorker() { + Thread worker = aiWorker; + synchronized (this) { + aiGeneration++; + aiComputing = false; + aiDone = false; + aiPending = null; + aiWorker = null; + } + if (worker != null && worker != Thread.currentThread()) { + worker.interrupt(); + try { worker.join(1000L); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); } + } + } + public void onRemoteMove(String data) { - if ("RESTART".equals(data)) { + if (this.lanMode == 0 || data == null) return; + if (data.startsWith("RESTART")) { + // 只有 HOST 可以发起重开,客户端不接受对端客户端的重开请求。 + if (this.lanMode != 2) return; + // ★ 修复 LAN 棋盘尺寸不同步死锁:HOST 报文携带棋盘尺寸 "RESTART:", + // 接收端先同步 boardSize 再重开,否则两端各画各的棋盘、走法互相越界。 + // 兼容无后缀旧报文 "RESTART":按默认 15 处理;解析 try-catch 防坏包 + try { + if (data.contains(":")) { + int size = Integer.parseInt(data.substring(data.indexOf(':') + 1).trim()); + this.boardSize = Math.max(9, Math.min(19, size)); // 钳制到合法范围 9-19 + } else { + this.boardSize = 15; + } + } catch (NumberFormatException e) { + LOGGER.warn("[五子棋] RESTART 报文棋盘尺寸非法: {}", data); + this.boardSize = 15; + } this.startGame(); } else { + if (this.state != State.PLAYING || this.winner != 0 || this.isMyTurn) return; try { String[] p = data.split(","); + if (p.length < 2) return; // 报文不足两个字段,丢弃 int x = Integer.parseInt(p[0]); int y = Integer.parseInt(p[1]); - if (this.board == null || this.board[x][y] != 0) { + // ★ Bug修复:远程报文越界防御——x/y 可能为负、>= boardSize、 + // 或 boardSize 切换后旧报文指向不存在的下标。原始 AIOOBE 被 + // 外层 try 静默吞,玩家看到"对手不动"。这里加双重范围校验。 + if (this.board == null || x < 0 || x >= this.boardSize + || y < 0 || y >= this.boardSize || this.board[x][y] != 0) { return; } @@ -121,8 +186,37 @@ public void onRemoteMove(String data) { } } + private int cycleBoardSize() { + // ★ 用户需求:9~19 全档可选。原版只有 9/13/15/19 四档,缺 11/17。 + // 循环顺序:9 → 11 → 13 → 15 → 17 → 19 → 9 ... + if (this.boardSize == 9) return 11; + if (this.boardSize == 11) return 13; + if (this.boardSize == 13) return 15; + if (this.boardSize == 15) return 17; + if (this.boardSize == 17) return 19; + return 9; + } + + private int[] getStarPoints() { + // 各档星位(按 1-based 算的奇数坐标;与原版 9/13/19 一致,新增 11/17 用近似中心点) + if (this.boardSize == 9) return new int[]{2, 6}; + if (this.boardSize == 11) return new int[]{2, 5, 8}; + if (this.boardSize == 13) return new int[]{3, 7, 11}; + if (this.boardSize == 15) return new int[]{3, 7, 11}; + if (this.boardSize == 17) return new int[]{3, 8, 13}; + if (this.boardSize == 19) return new int[]{3, 9, 15}; + return new int[]{3, 7, 11}; + } + private void startGame() { - this.board = new int[15][15]; + stopAiWorker(); + synchronized (this) { + this.aiGeneration++; + this.aiPending = null; + this.aiDone = false; + this.aiComputing = false; + } + this.board = new int[this.boardSize][this.boardSize]; this.playerTurn = true; this.winner = 0; this.state = State.PLAYING; @@ -130,172 +224,86 @@ private void startGame() { this.lastMoveX = -1; this.lastMoveY = -1; this.isMyTurn = this.lanMode != 2; + this.ai = this.lanMode == 0 && !this.localTwoPlayer ? new GomokuAI(this.difficulty) : null; } public void tick() { this.tickCount++; - if (this.lanMode == 0) { + if (this.lanMode == 0 && !this.localTwoPlayer && !showExitConfirm) { if (this.state == State.PLAYING && !this.playerTurn && this.winner == 0) { - this.aiMove(); - if (this.checkWin(2)) { - this.winner = 2; - this.state = State.GAME_OVER; - } else if (this.isBoardFull()) { - this.winner = 0; - this.state = State.GAME_OVER; - } else { - this.playerTurn = true; + this.tickAiTurn(); + } + } + } + + private void tickAiTurn() { + if (!this.aiComputing) { + // 启动后台线程计算落子,避免阻塞渲染线程 + this.aiComputing = true; + this.aiDone = false; + if (this.ai == null) { + this.ai = new GomokuAI(this.difficulty); + } + final GomokuAI ai = this.ai; + final int[][] snapshot = new int[this.board.length][]; + for (int i = 0; i < this.board.length; i++) { + snapshot[i] = this.board[i].clone(); + } + final int gen; + synchronized (this) { + gen = this.aiGeneration; + } + Thread t = new Thread(() -> { + int[] move = null; + try { + move = ai.getMove(snapshot); + } catch (Throwable t1) { + LOGGER.warn("[五子棋] AI 计算失败", t1); + } finally { + synchronized (this) { + if (gen == this.aiGeneration) { + this.aiPending = move; + this.aiDone = true; + } + if (Thread.currentThread() == this.aiWorker) this.aiWorker = null; + } } - } + }, "GomokuAI"); + this.aiWorker = t; + t.setDaemon(true); + t.start(); + return; } - } - private void aiMove() { - int[] best = this.hellMode ? this.findBestMoveHellMode() : this.findBestMoveNormal(); - if (best != null) { + if (this.aiDone) { + // 后台计算完成,在主线程落地走法 + this.aiDone = false; + this.aiComputing = false; + int[] best = this.aiPending; + this.aiPending = null; + boolean validMove = best != null && best.length >= 2 + && best[0] >= 0 && best[0] < this.boardSize + && best[1] >= 0 && best[1] < this.boardSize + && this.board[best[0]][best[1]] == 0; + if (!validMove) return; this.board[best[0]][best[1]] = 2; this.lastMoveX = best[0]; this.lastMoveY = best[1]; - } - } - - private int[] findBestMoveNormal() { - int[] bestMove = null; - int bestScore = Integer.MIN_VALUE; - - for (int i = 0; i < 15; i++) { - for (int j = 0; j < 15; j++) { - if (this.board[i][j] == 0 && this.hasNeighbor(i, j)) { - int scoreAI = this.evaluatePosition(i, j, 2); - int scorePlayer = this.evaluatePosition(i, j, 1); - int total = scoreAI * 2 + scorePlayer; - if (total > bestScore) { - bestScore = total; - bestMove = new int[]{i, j}; - } - } - } - } - - if (bestMove == null) { - bestMove = new int[]{7, 7}; - } - - return bestMove; - } - - private int[] findBestMoveHellMode() { - int bestScore = Integer.MIN_VALUE; - int[] bestMove = null; - int depth = 4; - - for (int i = 0; i < 15; i++) { - for (int j = 0; j < 15; j++) { - if (this.board[i][j] == 0 && this.hasNeighbor(i, j)) { - this.board[i][j] = 2; - int score = this.minimax(depth - 1, false, Integer.MIN_VALUE, Integer.MAX_VALUE, i, j, 2); - this.board[i][j] = 0; - if (score > bestScore) { - bestScore = score; - bestMove = new int[]{i, j}; - } - } - } - } - - return bestMove != null ? bestMove : new int[]{7, 7}; - } - - private int minimax(int depth, boolean isMaximizing, int alpha, int beta, int lastX, int lastY, int lastPlayer) { - // 仅检查上一步落子是否获胜,避免每个节点全盘扫描 - if (this.checkWinAt(lastX, lastY, lastPlayer)) { - return lastPlayer == 2 ? 100000 + depth : -100000 - depth; - } - if (depth == 0) { - return this.evaluateBoard(); - } - List candidates = this.generateCandidateMoves(); - if (candidates.isEmpty()) return this.evaluateBoard(); - int bestScore = isMaximizing ? Integer.MIN_VALUE : Integer.MAX_VALUE; - - for (int[] move : candidates) { - int i = move[0]; - int j = move[1]; - int player = isMaximizing ? 2 : 1; - this.board[i][j] = player; - int score = this.minimax(depth - 1, !isMaximizing, alpha, beta, i, j, player); - this.board[i][j] = 0; - if (isMaximizing) { - bestScore = Math.max(bestScore, score); - alpha = Math.max(alpha, score); + if (this.checkWin(2)) { + this.winner = 2; + this.state = State.GAME_OVER; + } else if (this.isBoardFull()) { + this.winner = 0; + this.state = State.GAME_OVER; } else { - bestScore = Math.min(bestScore, score); - beta = Math.min(beta, score); - } - - if (beta <= alpha) { - break; + this.playerTurn = true; } } - - return bestScore; - } - - private List generateCandidateMoves() { - List allMoves = new ArrayList<>(); - - for (int i = 0; i < 15; i++) { - for (int j = 0; j < 15; j++) { - if (this.board[i][j] == 0 && this.hasNeighbor(i, j)) { - allMoves.add(new int[]{i, j}); - } - } - } - - allMoves.sort((a, b) -> { - int scoreA = evaluatePositionPattern(a[0], a[1], 2); - int scoreB = evaluatePositionPattern(b[0], b[1], 2); - return Integer.compare(scoreB, scoreA); - }); - return allMoves.subList(0, Math.min(10, allMoves.size())); - } - - private int evaluateBoard() { - int score = 0; - - for (int i = 0; i < 15; i++) { - for (int j = 0; j < 15; j++) { - if (this.board[i][j] == 2) { - score += this.evaluatePositionHell(i, j, 2); - } else if (this.board[i][j] == 1) { - score -= this.evaluatePositionHell(i, j, 1); - } - } - } - - return score; - } - - private boolean checkGameOver() { - return this.checkWinner() != 0 || this.isBoardFull(); - } - - private int checkWinner() { - for (int i = 0; i < 15; i++) { - for (int j = 0; j < 15; j++) { - int cell = this.board[i][j]; - if (cell != 0 && this.checkWinAt(i, j, cell)) { - return cell; - } - } - } - - return 0; } private boolean isBoardFull() { - for (int i = 0; i < 15; i++) { - for (int j = 0; j < 15; j++) { + for (int i = 0; i < this.boardSize; i++) { + for (int j = 0; j < this.boardSize; j++) { if (this.board[i][j] == 0) { return false; } @@ -305,245 +313,8 @@ private boolean isBoardFull() { return true; } - private int evaluatePosition(int x, int y, int player) { - int score = 0; - - for (int[] d : DIRS) { - int count = 1; - int blocks = 0; - int emptyEnds = 0; - - for (int i = 1; i < 5; i++) { - int nx = x + d[0] * i; - int ny = y + d[1] * i; - if (!this.inBoard(nx, ny)) { - blocks++; - break; - } - - if (this.board[nx][ny] != player) { - if (this.board[nx][ny] == 0) { - emptyEnds++; - } else { - blocks++; - } - break; - } - - count++; - } - - for (int i = 1; i < 5; i++) { - int nx = x - d[0] * i; - int ny = y - d[1] * i; - if (!this.inBoard(nx, ny)) { - blocks++; - break; - } - - if (this.board[nx][ny] != player) { - if (this.board[nx][ny] == 0) { - emptyEnds++; - } else { - blocks++; - } - break; - } - - count++; - } - - score += this.getPatternScore(count, blocks, emptyEnds); - } - - return score; - } - - private int evaluatePositionPattern(int x, int y, int player) { - int score = 0; - - for (int[] d : DIRS) { - int count = 1; - int block = 0; - - for (int i = 1; i < 5; i++) { - int nx = x + d[0] * i; - int ny = y + d[1] * i; - if (!this.inBoard(nx, ny)) { - block++; - break; - } - - if (this.board[nx][ny] != player) { - if (this.board[nx][ny] != 0) { - block++; - } - break; - } - - count++; - } - - for (int i = 1; i < 5; i++) { - int nx = x - d[0] * i; - int ny = y - d[1] * i; - if (!this.inBoard(nx, ny)) { - block++; - break; - } - - if (this.board[nx][ny] != player) { - if (this.board[nx][ny] != 0) { - block++; - } - break; - } - - count++; - } - - score += this.getPatternScore2(count, block); - } - - return score; - } - - private int evaluatePositionHell(int x, int y, int player) { - int score = 0; - - for (int[] d : DIRS) { - int count = 1; - int blocks = 0; - int emptyEnds = 0; - - for (int i = 1; i < 5; i++) { - int nx = x + d[0] * i; - int ny = y + d[1] * i; - if (!this.inBoard(nx, ny)) { - blocks++; - break; - } - - if (this.board[nx][ny] != player) { - if (this.board[nx][ny] == 0) { - emptyEnds++; - } else { - blocks++; - } - break; - } - - count++; - } - - for (int i = 1; i < 5; i++) { - int nx = x - d[0] * i; - int ny = y - d[1] * i; - if (!this.inBoard(nx, ny)) { - blocks++; - break; - } - - if (this.board[nx][ny] != player) { - if (this.board[nx][ny] == 0) { - emptyEnds++; - } else { - blocks++; - } - break; - } - - count++; - } - - if (count >= 5) { - score += 100000; - } else if (count == 4 && blocks == 0) { - score += 10000; - } else if (count == 4 && blocks == 1) { - score += 1000; - } else if (count == 3 && blocks == 0 && emptyEnds == 2) { - score += 500; - } else if (count == 3 && blocks == 0 && emptyEnds == 1) { - score += 200; - } else if (count == 2 && blocks == 0 && emptyEnds == 2) { - score += 50; - } - } - - return score; - } - - private int getPatternScore(int count, int blocks, int emptyEnds) { - if (count >= 5) { - return 100000; - } - - if (count == 4) { - if (blocks == 0) { - return 10000; - } - - if (blocks == 1) { - return 1000; - } - } - - if (count == 3) { - if (blocks == 0 && emptyEnds == 2) { - return 500; - } - - if (blocks == 1 && emptyEnds == 1) { - return 100; - } - } - - if (count == 2) { - if (blocks == 0 && emptyEnds == 2) { - return 50; - } - - if (blocks == 1 && emptyEnds == 1) { - return 10; - } - } - - return count == 1 && blocks < 2 ? 5 : 0; - } - - private int getPatternScore2(int count, int blocks) { - if (count >= 5) { - return 100000; - } else if (count == 4) { - return blocks == 0 ? 10000 : 3000; - } else if (count == 3) { - return blocks == 0 ? 1000 : 300; - } else if (count == 2) { - return blocks == 0 ? 200 : 50; - } else { - return count == 1 ? 10 : 0; - } - } - private boolean inBoard(int x, int y) { - return x >= 0 && x < 15 && y >= 0 && y < 15; - } - - private boolean hasNeighbor(int x, int y) { - for (int dx = -2; dx <= 2; dx++) { - for (int dy = -2; dy <= 2; dy++) { - if (dx != 0 || dy != 0) { - int nx = x + dx; - int ny = y + dy; - if (this.inBoard(nx, ny) && this.board[nx][ny] != 0) { - return true; - } - } - } - } - - return false; + return x >= 0 && x < this.boardSize && y >= 0 && y < this.boardSize; } private boolean checkWin(int player) { @@ -585,6 +356,7 @@ private boolean checkWinAt(int x, int y, int player) { } public boolean keyPressed(int key, int scan, int mods) { + if (showExitConfirm && key != 256) return true; if (key != 256) { if (key == 82) { if (this.lanMode == 2) { @@ -593,17 +365,22 @@ public boolean keyPressed(int key, int scan, int mods) { this.startGame(); if (this.lanMode == 1) { - this.sendMove("RESTART"); + this.sendMoveEnvelope("RESTART:" + this.boardSize); // 携带棋盘尺寸,防止两端尺寸不同步 } + return true; + } else if (key == 83) { + if (this.state == State.MENU) { + this.boardSize = this.cycleBoardSize(); + } return true; } else if (key == 72) { if (this.state == State.MENU) { - this.hellMode = !this.hellMode; + this.difficulty = this.difficulty.next(); } return true; } else { - return true; + return super.keyPressed(key, scan, mods); } } else { if (showExitConfirm) { showExitConfirm = false; return true; } @@ -619,6 +396,7 @@ public boolean keyPressed(int key, int scan, int mods) { } public boolean mouseClicked(double mx, double my, int btn) { + if (btn != 0) return super.mouseClicked(mx, my, btn); if (showExitConfirm) { int click = GameRenderHelper.getExitConfirmClick(mx, my, width, height); if (click == 1) { showExitConfirm = false; this.sendLeaveGameOnce(); Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } if (click == 2) { showExitConfirm = false; return true; } return true; } int cx = this.width / 2; int cy = this.height / 2; @@ -629,7 +407,12 @@ public boolean mouseClicked(double mx, double my, int btn) { } if (mx >= cx - 60 && mx <= cx + 60 && my >= cy + 73 && my <= cy + 95) { - this.hellMode = !this.hellMode; + this.boardSize = this.cycleBoardSize(); + return true; + } + + if (mx >= cx - 60 && mx <= cx + 60 && my >= cy + 95 && my <= cy + 117) { + this.difficulty = this.difficulty.next(); return true; } } @@ -640,7 +423,7 @@ public boolean mouseClicked(double mx, double my, int btn) { if (this.lanMode != 2) { this.startGame(); if (this.lanMode == 1) { - this.sendMove("RESTART"); + this.sendMoveEnvelope("RESTART:" + this.boardSize); // 携带棋盘尺寸,防止两端尺寸不同步 } } return true; @@ -659,15 +442,15 @@ public boolean mouseClicked(double mx, double my, int btn) { } if (this.state == State.PLAYING && this.winner == 0) { - boolean canMove = this.lanMode == 0 ? this.playerTurn : this.isMyTurn; + boolean canMove = this.lanMode == 0 ? (this.localTwoPlayer || this.playerTurn) : this.isMyTurn; if (!canMove) { return true; } - int hx = ((int)mx - this.boardStartX) / this.cellSize; - int hy = ((int)my - this.boardStartY) / this.cellSize; - if (hx >= 0 && hx < 15 && hy >= 0 && hy < 15 && this.board[hx][hy] == 0) { - int myPiece = this.lanMode == 2 ? 2 : 1; + int hx = Math.floorDiv((int)mx - this.boardStartX, this.cellSize); + int hy = Math.floorDiv((int)my - this.boardStartY, this.cellSize); + if (hx >= 0 && hx < this.boardSize && hy >= 0 && hy < this.boardSize && this.board[hx][hy] == 0) { + int myPiece = this.lanMode == 0 ? (this.playerTurn ? 1 : 2) : (this.lanMode == 2 ? 2 : 1); this.board[hx][hy] = myPiece; this.lastMoveX = hx; this.lastMoveY = hy; @@ -687,20 +470,20 @@ public boolean mouseClicked(double mx, double my, int btn) { this.state = State.GAME_OVER; if (this.lanMode != 0) { this.isMyTurn = false; - this.sendMove(hx + "," + hy); + this.sendMoveEnvelope(hx + "," + hy); } } else if (this.isBoardFull()) { this.winner = 0; this.state = State.GAME_OVER; if (this.lanMode != 0) { this.isMyTurn = false; - this.sendMove(hx + "," + hy); + this.sendMoveEnvelope(hx + "," + hy); } } else if (this.lanMode == 0) { - this.playerTurn = false; + this.playerTurn = !this.playerTurn; } else { this.isMyTurn = false; - this.sendMove(hx + "," + hy); + this.sendMoveEnvelope(hx + "," + hy); } return true; @@ -711,9 +494,9 @@ public boolean mouseClicked(double mx, double my, int btn) { } public void render(GuiGraphics g, int mx, int my, float pt) { - this.cellSize = GameRenderHelper.calcCellSize(this.width, this.height, 17, 17, 40); - this.boardStartX = (this.width - 15 * this.cellSize) / 2; - this.boardStartY = (this.height - 15 * this.cellSize) / 2; + this.cellSize = GameRenderHelper.calcCellSize(this.width, this.height, this.boardSize + 2, this.boardSize + 2, 40); + this.boardStartX = (this.width - this.boardSize * this.cellSize) / 2; + this.boardStartY = (this.height - this.boardSize * this.cellSize) / 2; GameRenderHelper.fillDarkBackground(g, this.width, this.height); switch (this.state) { case MENU: @@ -745,23 +528,25 @@ private void renderMenu(GuiGraphics g, int mx, int my) { } GameRenderHelper.drawPrimaryButton(g, this.font, "开始游戏", cx - 60, cy + 45, 120, 22, mx, my); - String hellLabel = this.hellMode ? "地狱模式: 开 [H]" : "地狱模式: 关 [H]"; - GameRenderHelper.drawSecondaryButton(g, this.font, hellLabel, cx - 60, cy + 73, 120, 18, mx, my); + String sizeLabel = "棋盘大小: " + this.boardSize + "x" + this.boardSize + " [S]"; + GameRenderHelper.drawSecondaryButton(g, this.font, sizeLabel, cx - 60, cy + 73, 120, 18, mx, my); + String diffLabel = "难度: " + this.difficulty.label + " [H]"; + GameRenderHelper.drawSecondaryButton(g, this.font, diffLabel, cx - 60, cy + 95, 120, 18, mx, my); } private void renderPlaying(GuiGraphics g, int mx, int my) { - int bw = 15 * this.cellSize; + int bw = this.boardSize * this.cellSize; g.fill(this.boardStartX - 4, this.boardStartY - 4, this.boardStartX + bw + 4, this.boardStartY + bw + 4, -12965360); g.fill(this.boardStartX - 2, this.boardStartY - 2, this.boardStartX + bw + 2, this.boardStartY + bw + 2, -2968436); - for (int i = 0; i < 15; i++) { + for (int i = 0; i < this.boardSize; i++) { int x = this.boardStartX + i * this.cellSize + this.cellSize / 2; int y = this.boardStartY + i * this.cellSize + this.cellSize / 2; g.fill(x, this.boardStartY + this.cellSize / 2, x + 1, this.boardStartY + bw - this.cellSize / 2, -16777216); g.fill(this.boardStartX + this.cellSize / 2, y, this.boardStartX + bw - this.cellSize / 2, y + 1, -16777216); } - int[] stars = new int[]{3, 7, 11}; + int[] stars = this.getStarPoints(); for (int sx : stars) { for (int sy : stars) { @@ -773,8 +558,8 @@ private void renderPlaying(GuiGraphics g, int mx, int my) { int stoneR = this.cellSize / 2 - 2; - for (int x = 0; x < 15; x++) { - for (int y = 0; y < 15; y++) { + for (int x = 0; x < this.boardSize; x++) { + for (int y = 0; y < this.boardSize; y++) { if (this.board[x][y] != 0) { int scx = this.boardStartX + x * this.cellSize + this.cellSize / 2; int scy = this.boardStartY + y * this.cellSize + this.cellSize / 2; @@ -791,21 +576,26 @@ private void renderPlaying(GuiGraphics g, int mx, int my) { } } - if (this.playerTurn && this.winner == 0) { - int hx = (mx - this.boardStartX) / this.cellSize; - int hy = (my - this.boardStartY) / this.cellSize; - if (hx >= 0 && hx < 15 && hy >= 0 && hy < 15 && this.board[hx][hy] == 0) { + boolean canPreview = this.lanMode == 0 ? (this.localTwoPlayer || this.playerTurn) : this.isMyTurn; + if (canPreview && this.winner == 0) { + int hx = Math.floorDiv(mx - this.boardStartX, this.cellSize); + int hy = Math.floorDiv(my - this.boardStartY, this.cellSize); + if (hx >= 0 && hx < this.boardSize && hy >= 0 && hy < this.boardSize && this.board[hx][hy] == 0) { int scx = this.boardStartX + hx * this.cellSize + this.cellSize / 2; int scy = this.boardStartY + hy * this.cellSize + this.cellSize / 2; - GameRenderHelper.drawCircle(g, scx, scy, stoneR, 1712394513); + boolean white = this.lanMode == 2 || (this.localTwoPlayer && !this.playerTurn); + int previewColor = white ? 0x66EEEEEE : 0x66111111; + GameRenderHelper.drawCircle(g, scx, scy, stoneR, previewColor); } } GameRenderHelper.tickAndRenderParticles(g, this.particles); GameRenderHelper.drawTopHUD(g, this.width, this.height); - String modeTag = this.hellMode ? " [地狱]" : " [普通]"; + String modeTag = this.localTwoPlayer ? " [本地双人]" : " [" + this.difficulty.label + "]"; String turnText; - if (this.lanMode == 0) { + if (this.localTwoPlayer) { + turnText = this.playerTurn ? "黑棋回合" : "白棋回合"; + } else if (this.lanMode == 0) { turnText = this.playerTurn ? "⚫ 你的回合 - 黑棋" : "⚪ AI思考中..."; } else { boolean mine = this.isMyTurn; @@ -816,7 +606,7 @@ private void renderPlaying(GuiGraphics g, int mx, int my) { g.drawString(this.font, turnText + modeTag, 8, 7, 16777215); GameRenderHelper.drawBottomBar( - g, this.font, this.width, this.height, "ESC 菜单 R 重开 H 切换难度 鼠标点击落子" + g, this.font, this.width, this.height, "ESC 菜单 R 重开 H 切换难度 S 棋盘大小 鼠标点击落子" ); } @@ -832,12 +622,16 @@ private void renderGameOver(GuiGraphics g, int mx, int my) { win = false; mainMsg = "🤝 平局!"; subMsg = "棋盘已满,不分胜负"; + } else if (this.localTwoPlayer) { + win = true; + mainMsg = this.winner == 1 ? "黑棋获胜!" : "白棋获胜!"; + subMsg = "五子连线"; } else if (this.lanMode == 0) { win = this.winner == 1; mainMsg = win ? "🎉 你赢了!" : "AI 获胜!"; subMsg = win ? "恭喜你连成五子!" - : (this.hellMode ? "地狱AI不好惹,再接再厉!" : "再接再厉!"); + : ("难度[" + this.difficulty.label + "]的AI获胜,再接再厉!"); } else { int myPiece = this.lanMode == 1 ? 1 : 2; win = this.winner == myPiece; diff --git a/src/main/java/com/wzz/game_console/client/screens/games/IceFireGameScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/IceFireGameScreen.java index 6d1d5c2..8c51643 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/IceFireGameScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/IceFireGameScreen.java @@ -52,16 +52,25 @@ public class IceFireGameScreen extends Screen implements LanMultiplayerScreen { public static final int LAN_NONE = 0, LAN_HOST = 1, LAN_CLIENT = 2; private int lanMode = LAN_NONE; private java.util.UUID remotePeer = null; - /** CLIENT 收到的主机状态(逗号分隔整数) */ - private volatile String receivedState = null; + private final RealtimeLanState.Receiver stateReceiver = new RealtimeLanState.Receiver(); + /** LAN_CLIENT 收到 STATE 的最后 tick 计数,用于检测 HOST 崩溃/掉线 */ + private long lastStateReceivedTick = 0; + /** CLIENT 超过此 tick 数未收到 STATE 视为 HOST 已掉线(约 3 秒) */ + private static final long CLIENT_STATE_TIMEOUT_TICKS = 60; + /** HOST/CLIENT 只接受已定义的难度,状态报文会携带该值。 */ + private static final int MAX_DIFFICULTY = 2; /** HOST 收到的客户端输入掩码 (bit0=左 bit1=右 bit2=跳) */ private volatile int receivedClientInput = 0; + /** HOST 最后收到客端输入的 tick,用于防止网络中断后沿用旧输入。 */ + private volatile long lastClientInputTick = 0; /** 独立的跳跃请求标志,防止被移动掩码覆盖导致跳跃丢失 */ private volatile boolean clientJumpRequested = false; - /** 活跃实例(供静态网络回调使用) */ - public static volatile IceFireGameScreen activeInstance = null; /** 防重复发送 LEAVE_GAME 标志 */ private boolean lanLeaveSent = false; + /** 当前 HOST 状态会话及单调序号;每次重开都会换会话。 */ + private UUID stateSessionId = UUID.randomUUID(); + private long stateSequence = 0; + // stateSequence remains monotonic across rounds for snapshot-only restart recovery. /** 单机构造 */ public IceFireGameScreen() { @@ -85,11 +94,11 @@ public IceFireGameScreen(boolean isHost, java.util.UUID remote) { */ @Override public void onRemoteState(java.util.UUID senderUuid, String data) { - if (lanMode == LAN_CLIENT) { - if (remotePeer == null || !remotePeer.equals(senderUuid)) { - LOGGER.warn("[冰火人] 丢弃来源非法的状态包: sender={},期望对端={}", senderUuid, remotePeer); - return; - } + // GAME_STATE_SYNC 在 HOST 侧同样必须验证服务端盖章的发送者, + // 只接受当前已配对对端,拒绝第三方注入状态。 + if (lanMode == LAN_NONE || remotePeer == null || !remotePeer.equals(senderUuid)) { + LOGGER.warn("[冰火人] 丢弃来源非法的状态包: sender={},期望对端={}", senderUuid, remotePeer); + return; } this.onRemoteState(data); } @@ -100,12 +109,13 @@ public void onRemoteState(java.util.UUID senderUuid, String data) { */ @Override public void onRemoteMove(java.util.UUID senderUuid, String data) { - if (lanMode == LAN_HOST) { - if (remotePeer == null || !remotePeer.equals(senderUuid)) { - LOGGER.warn("[冰火人] 丢弃来源非法的输入包: sender={},期望对端={}", senderUuid, remotePeer); - return; - } + if (remotePeer == null || !remotePeer.equals(senderUuid)) { + LOGGER.warn("[冰火人] 丢弃来源非法的联机报文: sender={},期望对端={}", senderUuid, remotePeer); + return; } + // HOST 只消费 CLIENT 输入;CLIENT 只消费 HOST 的 RESTART 通知。 + if (lanMode == LAN_HOST && (data == null || data.startsWith("RESTART"))) return; + if (lanMode == LAN_CLIENT && (data == null || !data.startsWith("RESTART"))) return; this.onRemoteMove(data); } @@ -122,22 +132,37 @@ public void onClose() { super.onClose(); } - /** CLIENT 收到 HOST 广播的完整状态 */ + /** 大厅路由在客户端主线程调用,先验证快照再更新接收时间。 */ @Override - public void onRemoteState(String data) { receivedState = data; } + public void onRemoteState(String data) { + if (lanMode != LAN_CLIENT) return; + if (applyReceivedState(data)) lastStateReceivedTick = tickCount; + } - /** HOST 收到 CLIENT 发来的输入掩码(bit0=左 bit1=右 bit2=跳,含一次性 "4" 跳跃包) */ @Override public void onRemoteMove(String data) { + if (data == null) return; + if (lanMode == LAN_CLIENT) { + if (stateReceiver.restart(data)) { + session = new GameSession(difficulty); + session.init(); + gameState = GameState.PLAYING; + heldKeys.clear(); + showExitConfirm = false; + lastStateReceivedTick = tickCount; + } + return; + } + if (lanMode != LAN_HOST || session == null || gameState != GameState.PLAYING) return; + data = RealtimeLanState.decodeInput(stateSessionId, data); + if (data == null) return; try { int val = Integer.parseInt(data.trim()); - // 修复:统一跳跃位语义——CLIENT 每 tick 发送的掩码可能带跳跃位(5/6/7), - // 原先只识别纯跳跃包 "4" 导致掩码中的跳跃被忽略 - if ((val & 4) != 0) { - clientJumpRequested = true; // 独立处理跳跃,防止被移动掩码覆盖 - } - receivedClientInput = val & 3; // 只保留左右移动位 - } catch (Exception ignored) {} + if (val < 0 || val > 7) return; + if ((val & 4) != 0) clientJumpRequested = true; + receivedClientInput = val & 3; + lastClientInputTick = tickCount; + } catch (NumberFormatException ignored) {} } // ══════════════════════════════════════ @@ -284,8 +309,8 @@ private void renderHUD(GuiGraphics g) { g.fill(6, 4, 18, 5, 0xFF88CCFF); g.drawString(font, "冰人 WASD", 22, 7, 0x88CCFF); - // 钻石进度(中央) - String prog = "💎 " + session.getDiamonds() + " / " + session.getTotalDiamonds() + // 钻石进度(中央)★ 修复:💎 为非 BMP emoji,默认字体有豆腐块风险,改为文本 + String prog = "钻石 " + session.getDiamonds() + " / " + session.getTotalDiamonds() + " 关卡 " + session.getLevel(); g.drawCenteredString(font, prog, width / 2, 7, 0xFFFF44); @@ -315,7 +340,7 @@ private void renderGameOver(GuiGraphics g) { win ? 0xFF44FF44 : 0xFFFF4444); if (win) { - drawShadowedCenteredText(g, "🎉 游戏通关! 🎉", cx, cy - 50, 0x44FF44, 1); + drawShadowedCenteredText(g, "游戏通关!", cx, cy - 50, 0x44FF44, 1); g.drawCenteredString(font, "恭喜两位勇士一起完成了所有关卡!", cx, cy - 30, 0xCCFFCC); } else { drawShadowedCenteredText(g, "游戏结束!", cx, cy - 50, 0xFF4444, 1); @@ -353,35 +378,67 @@ private void drawShadowedCenteredText(GuiGraphics g, String text, int x, int y, @Override public void init() { super.init(); - activeInstance = this; - // LAN 联机:跳过菜单直接开始 - if (lanMode != LAN_NONE) startGame(); + // LAN 联机:跳过菜单直接开始;仅在本局尚未创建 session 时启动, + // 否则窗口缩放重调 init() 会把整局重置 + if (lanMode != LAN_NONE && !lanLeaveSent && session == null) startGame(); } @Override public void removed() { + sendLeaveGameOnce(); + heldKeys.clear(); + session = null; super.removed(); - if (activeInstance == this) activeInstance = null; } @Override public void tick() { tickCount++; - - // ★ Bug修复:LAN_CLIENT 即使在 GAME_OVER 状态也要处理来自 HOST 的最新状态, - // 以便跟随 HOST 的重开信号(hostGameOver=0 → CLIENT 从 GAME_OVER 恢复 PLAYING) - if (lanMode == LAN_CLIENT && session != null - && receivedState != null && !receivedState.isEmpty()) { - applyReceivedState(receivedState); - receivedState = null; + // ★ 失焦清键:Screen 基类无 windowFocusChanged 钩子,每 tick 探针 MC 窗口活动状态, + // 切窗/弹系统窗时收不到 keyReleased 也不影响,焦点回来时按键集合已被清空 + if (!minecraft.isWindowActive() && !heldKeys.isEmpty()) heldKeys.clear(); + + // ★ Bug修复:LAN_CLIENT 长时间未收到 HOST 状态 → HOST 崩溃/掉线 + // 原版 CLIENT 会永远卡在原 gameState 上需按 ESC 才能退。 + // 首次收到 STATE 时初始化 lastStateReceivedTick(防止刚启动就被超时踢出) + if (lanMode == LAN_CLIENT && lastStateReceivedTick == 0 + && tickCount > CLIENT_STATE_TIMEOUT_TICKS) { + // 启动后完整超时窗口仍没收到 STATE,认为 HOST 实际未联机 + sendLeaveGameOnce(); + Minecraft.getInstance().setScreen(new GameSelectorScreen()); + return; + } + if (lanMode == LAN_CLIENT && lastStateReceivedTick > 0 + && tickCount - lastStateReceivedTick > CLIENT_STATE_TIMEOUT_TICKS + && gameState == GameState.PLAYING) { + // 游戏中长时间未收到 STATE,提示 HOST 已掉线并退出 + sendLeaveGameOnce(); + Minecraft.getInstance().setScreen(new GameSelectorScreen()); + return; } + if (lanMode == LAN_HOST && session != null && gameState == GameState.GAME_OVER) { + if (tickCount % 20 == 0) sendStateToClient(); + return; + } if (session == null || gameState != GameState.PLAYING) return; - if (showExitConfirm) { - // 弹窗期间暂停本地模拟,但 HOST 仍须向 CLIENT 广播最新状态,避免 CLIENT 卡在过期状态 + // ★ Bug修复:原版把 isGameOver 检查放在所有分支末尾,意味着如果弹窗期间 + // session 已 gameOver(玩家先掉下去再按 ESC),代码在 380 行就 return, + // gameState 永远卡在 PLAYING + showExitConfirm,玩家退出弹窗后看到 + // "还在玩但人已经死了"的混乱状态。提前在弹窗早退前做检查: + if (session.isGameOver() && lanMode != LAN_CLIENT) { + gameState = GameState.GAME_OVER; + showExitConfirm = false; + heldKeys.clear(); if (lanMode == LAN_HOST) sendStateToClient(); return; } + if (showExitConfirm) { + // 弹窗期间暂停本地模拟;CLIENT 继续发送零输入,避免 HOST 沿用最后一次移动。 + if (lanMode == LAN_CLIENT) sendFireInput(0); + else if (lanMode == LAN_HOST) sendStateToClient(); + return; + } switch (lanMode) { case LAN_NONE -> { @@ -392,7 +449,10 @@ public void tick() { case LAN_HOST -> { // HOST:处理本地冰人输入 + 来自网络的火人输入 processIceInput(); - applyClientFireInput(receivedClientInput); + boolean staleClientInput = tickCount - lastClientInputTick > CLIENT_STATE_TIMEOUT_TICKS; + if (staleClientInput) clientJumpRequested = false; + int clientInput = staleClientInput ? 0 : receivedClientInput; + applyClientFireInput(clientInput); session.update(); // 序列化状态并发送给客端 sendStateToClient(); @@ -402,7 +462,7 @@ public void tick() { sendFireInputToHost(); } } - if (session.isGameOver() && lanMode != LAN_CLIENT) gameState = GameState.GAME_OVER; + // (isGameOver 检查已前移至 380 行早退之前,这里不再重复) } /** 单机:冰人 WASD,火人方向键 */ @@ -448,7 +508,7 @@ private void applyClientFireInput(int mask) { /** HOST:将游戏状态发送到客端 */ private void sendStateToClient() { if (session == null) return; - sendState(buildStateString()); + sendStateEnvelope("v2|" + stateSessionId + "|" + (++stateSequence) + "|" + buildStateString()); } /** @@ -470,7 +530,8 @@ private String buildStateString() { .append((int)(fire.x*10)).append(',').append((int)(fire.y*10)).append(',') .append(fire.onGround?1:0).append(',').append(fire.dead?1:0).append(',') .append(s.getDiamonds()).append(',').append(s.getTotalDiamonds()).append(',') - .append(s.isGameOver()?1:0).append(',').append(s.isVictory()?1:0); + .append(s.isGameOver()?1:0).append(',').append(s.isVictory()?1:0).append(',') + .append(s.difficulty); // 追加已收集钻石坐标(CLIENT 用来清除地图中的钻石 tile) sb.append(';'); java.util.List collected = s.map.collectedPositions; @@ -487,62 +548,50 @@ private void sendFireInputToHost() { if (heldKeys.contains(GLFW.GLFW_KEY_LEFT)) mask |= 1; if (heldKeys.contains(GLFW.GLFW_KEY_RIGHT)) mask |= 2; if (heldKeys.contains(GLFW.GLFW_KEY_UP)) mask |= 4; // 跳跃也在掩码中持续发送 - sendInput(String.valueOf(mask)); + sendFireInput(mask); } - /** CLIENT:将收到的状态字符串应用到本地 session(只更新显示,不跑物理) */ - private void applyReceivedState(String st) { - if (st == null || session == null) return; - try { - // 格式:"基础字段...;x1_y1|x2_y2|..." - String[] parts = st.split(";", 2); - String[] p = parts[0].split(","); - - int lv = Integer.parseInt(p[0]); - // 关卡切换时重新加载地图(双端种子相同,初始钻石位置一致) - if (lv != session.level) { - session.level = lv; - session.map.load(lv, session.difficulty); - } - session.ice.x = Integer.parseInt(p[1]) / 10f; - session.ice.y = Integer.parseInt(p[2]) / 10f; - session.ice.onGround = p[3].equals("1"); - session.ice.dead = p[4].equals("1"); - session.fire.x = Integer.parseInt(p[5]) / 10f; - session.fire.y = Integer.parseInt(p[6]) / 10f; - session.fire.onGround = p[7].equals("1"); - session.fire.dead = p[8].equals("1"); - session.map.collected = Integer.parseInt(p[9]); - session.map.total = Integer.parseInt(p[10]); - if (p[11].equals("1")) { - session.gameOver = true; - session.victory = p[12].equals("1"); - gameState = GameState.GAME_OVER; - } else if (gameState == GameState.GAME_OVER) { - // HOST 已重开(gameOver=0),CLIENT 跟随恢复 PLAYING - // 必须重载地图(即使同关),否则之前收集的钻石格子仍保持 AIR 不回生 - session.level = lv; - session.map.load(lv, session.difficulty); - session.gameOver = false; - session.victory = false; - gameState = GameState.PLAYING; - } + private void sendFireInput(int mask) { + String input = stateReceiver.input(String.valueOf(mask)); + if (input != null) sendInputEnvelope(input); + } - // ★ 关键修复:把 HOST 已收集的钻石格改成 AIR,确保 CLIENT 地图一致 - if (parts.length > 1 && !parts[1].isEmpty()) { - for (String coord : parts[1].split("\\|")) { - String[] xy = coord.split("_"); - if (xy.length == 2) { - int cx = Integer.parseInt(xy[0]); - int cy = Integer.parseInt(xy[1]); - // 只有当前仍是 DIAMOND 时才改(避免重复操作) - if (session.map.get(cx, cy) == Tile.DIAMOND) { - session.map.tiles[cx][cy] = Tile.AIR; - } - } - } + private boolean applyReceivedState(String data) { + int defaultDifficulty = session == null ? difficulty : session.difficulty; + var received = stateReceiver.receive(data, payload -> RealtimeLanState.parseIce(payload, defaultDifficulty)); + if (received == null) return false; + var s = received.snapshot(); + if (session == null || received.newRound() || session.difficulty != s.difficulty() + || (session.gameOver && !s.gameOver())) { + session = new GameSession(s.difficulty()); + session.init(); + heldKeys.clear(); + showExitConfirm = false; + } + difficulty = s.difficulty(); + if (session.level != s.level()) { + session.level = s.level(); + session.map.load(s.level(), s.difficulty()); + } + session.ice.x = s.iceX(); + session.ice.y = s.iceY(); + session.ice.onGround = s.iceGround(); + session.ice.dead = s.iceDead(); + session.fire.x = s.fireX(); + session.fire.y = s.fireY(); + session.fire.onGround = s.fireGround(); + session.fire.dead = s.fireDead(); + session.map.collected = s.collected(); + session.map.total = s.total(); + session.gameOver = s.gameOver(); + session.victory = s.victory(); + gameState = s.gameOver() ? GameState.GAME_OVER : GameState.PLAYING; + for (var cell : s.removedDiamonds()) { + if (session.map.get(cell.x(), cell.y()) == Tile.DIAMOND) { + session.map.tiles[cell.x()][cell.y()] = Tile.AIR; } - } catch (Exception ignored) {} + } + return true; } // ──────────────── 输入处理 ──────────────── @@ -563,6 +612,8 @@ public boolean keyPressed(int key, int scan, int mods) { switch (gameState) { case PLAYING -> { + // R 键重开(HUD 底部提示"R 重开") + if (key == GLFW.GLFW_KEY_R && lanMode != LAN_CLIENT) { restart(); return true; } // 冰人跳(单机或HOST) if (lanMode != LAN_CLIENT && key == GLFW.GLFW_KEY_W && session != null) session.iceAction(Action.JUMP); @@ -610,9 +661,11 @@ public boolean mouseClicked(double mx, double my, int btn) { } private void startGame() { + if (lanMode == LAN_HOST) stateSessionId = UUID.randomUUID(); // 检查外部导入设置是否覆盖了难度 if (GameSettings.getConfiguredGames().contains("icefire")) { - difficulty = GameSettings.getInt("icefire", "difficulty", difficulty); + difficulty = Math.max(0, Math.min(MAX_DIFFICULTY, + GameSettings.getInt("icefire", "difficulty", difficulty))); } session = new GameSession(difficulty); session.init(); @@ -620,7 +673,19 @@ private void startGame() { heldKeys.clear(); } private void restart() { - if (session != null) { session.restart(); gameState = GameState.PLAYING; heldKeys.clear(); } + if (session != null) { + if (lanMode == LAN_HOST) { + stateSessionId = UUID.randomUUID(); + sendInputEnvelope("RESTART|" + stateSessionId + "|" + (++stateSequence)); + } + session.restart(); + gameState = GameState.PLAYING; + heldKeys.clear(); + lastStateReceivedTick = tickCount; + receivedClientInput = 0; + lastClientInputTick = tickCount; + clientJumpRequested = false; + } } @Override public boolean isPauseScreen() { return false; } @@ -689,11 +754,15 @@ void update() { fire.update(map, ice, particles); ice.update(map, fire, particles); } - particles.removeIf(p -> !p.alive); for (Particle p : particles) p.update(); + // ★ Bug修复:removeIf 移到 for 之后,新增粒子能在本帧继续推进 + particles.removeIf(p -> !p.alive); - // 死亡检测 - if (ice.checkHazard(map) || fire.checkHazard(map)) { + // 死亡检测:危险方块(熔岩/水) + // 坠落出地图由 GamePlayer.update 直接置 dead=true(y > GAME_H+20), + // 而 checkHazard 对已 dead 的玩家会提前返回 false,因此需显式把 dead 纳入结束条件, + // 否则掉出地图底部的角色不会触发关卡失败/死亡结算。 + if (ice.checkHazard(map) || fire.checkHazard(map) || ice.dead || fire.dead) { gameOver = true; victory = false; return; } diff --git a/src/main/java/com/wzz/game_console/client/screens/games/JumpGameScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/JumpGameScreen.java index 38df322..f48b419 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/JumpGameScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/JumpGameScreen.java @@ -180,6 +180,7 @@ public void init() { void startGame() { platforms.clear(); + sortedDirty = true; // 平台列表已变,深度排序缓存失效 particles.clear(); score=0; combo=0; gameOver=false; charging=false; charge=0; currentPlatIdx=0; predictWX=null; predictWZ=null; predictWY=null; @@ -193,7 +194,7 @@ void startGame() { // 玩家站在第一个平台上 Platform p0 = platforms.get(0); player = new Player(); - player.wx = p0.wx; player.wy = 0; player.wz = p0.wz; + player.wx = p0.wx; player.wy = PLAT_H; player.wz = p0.wz; player.dirX = 1; player.dirZ = 0; // 默认朝右 updateCamera(true); @@ -205,6 +206,7 @@ void addPlatform() { float nx = last.wx + DIST_MAX, nz = last.wz + DIST_MAX; float hw = PLAT_MIN_W + rng.nextFloat()*(PLAT_MAX_W-PLAT_MIN_W); PlatType t= PlatType.values()[rng.nextInt(PlatType.values().length)]; + boolean placed = false; // 尝试生成不与旧平台重叠的位置(跳过相邻平台,间距由 DIST 控制) for (int attempt = 0; attempt < 25; attempt++) { float angle = rng.nextFloat() * (float)Math.PI / 2f @@ -224,11 +226,31 @@ void addPlatform() { float minD = hw + p.hw + 1.5f; // 额外间距防止方块角部视觉重叠 if (dx*dx + dz*dz < minD*minD) { ok = false; break; } } - if (ok) break; + if (ok) { placed = true; break; } + } + // 保底:所有尝试都重叠时,在最远距离用最小半宽再试(极罕见,仅密集布局时触发) + if (!placed) { + for (int attempt = 0; attempt < 10 && !placed; attempt++) { + float angle = rng.nextFloat() * (float)Math.PI / 2f - (float)Math.PI / 4f; + float dist = 6.4f + attempt * 0.5f; + float finalAngle = (float)Math.PI / 4f + angle; + nx = last.wx + dist * (float)Math.cos(finalAngle); + nz = last.wz + dist * (float)Math.sin(finalAngle); + hw = PLAT_MIN_W; + boolean ok = true; + for (int i = Math.max(0, platforms.size()-6); i < platforms.size()-1; i++) { + Platform p = platforms.get(i); + float dx = nx - p.wx, dz = nz - p.wz; + float minD = hw + p.hw + 1.8f; + if (dx*dx + dz*dz < minD*minD) { ok = false; break; } + } + if (ok) placed = true; + } } Platform np = new Platform(nx, nz, hw, t); np.lit = false; platforms.add(np); + sortedDirty = true; // 平台列表已变,深度排序缓存失效 } // ══════════════════════════════════════════════ @@ -237,6 +259,11 @@ void addPlatform() { @Override public void tick() { tick++; + // ★ 失焦清键:Screen 基类无 windowFocusChanged 钩子,每 tick 探针 MC 窗口活动状态, + // 切窗时收不到 keyReleased 也无影响(与 ESC 弹窗清蓄力逻辑同源) + if (!minecraft.isWindowActive() && (charging || charge > 0 || predictWX != null)) { + charging = false; charge = 0; predictWX = null; predictWZ = null; predictWY = null; + } if (showExitConfirm) return; // 弹窗期间暂停游戏(含物理/蓄力/粒子) // ── 物理更新(gameOver 时也继续,保证掉落动画正常播放)── @@ -291,6 +318,7 @@ public void tick() { while (platforms.size() > 20 && currentPlatIdx > 5) { platforms.remove(0); currentPlatIdx--; + sortedDirty = true; // 平台列表已变,深度排序缓存失效 } } @@ -303,6 +331,7 @@ void checkLanding() { if ((float)Math.sqrt(dxCur*dxCur+dzCur*dzCur) <= cur.hw) { player.onGround = true; player.wx = cur.wx; player.wz = cur.wz; // 归位到平台中心 + player.wy = PLAT_H; // 站在平台顶面 player.vy = 0; player.vx = 0; player.vz = 0; player.scaleY = 1f; player.scaleXZ = 1f; player.landBounce = 0.15f; @@ -333,6 +362,7 @@ void checkLanding() { if (landed != null) { // 成功落地 player.onGround = true; + player.wy = PLAT_H; // 站在平台顶面 player.vy = 0; player.vx = 0; player.vz = 0; player.scaleY = 1f; player.scaleXZ = 1f; player.landBounce = 0.25f; @@ -461,11 +491,18 @@ void updateCamera(boolean snap) { // ══════════════════════════════════════════════ // 投影工具 // ══════════════════════════════════════════════ + // ★ 性能:project 结果轮转静态槽(渲染仅主线程调用,逐个扫描全部调用点后确认 + // 同一时刻最多 6 个投影结果存活(renderPlayerBlock),16 槽留足余量不会互相踩, + // 避免每帧数千次 new float[2] 分配) + private static final float[][] PROJECT_SLOTS = new float[16][2]; + private static int projectSlot = 0; + float[] project(float wx, float wy, float wz) { float rx = wx-camSX, rz = wz-camSZ; - float sx = width/2f + (rx-rz)*ISO_X*32; - float sy = height/2f + (rx+rz)*ISO_Y*32 - wy*ISO_H*32; - return new float[]{sx,sy}; + float[] out = PROJECT_SLOTS[projectSlot = (projectSlot + 1) % PROJECT_SLOTS.length]; + out[0] = width/2f + (rx-rz)*ISO_X*32; + out[1] = height/2f + (rx+rz)*ISO_Y*32 - wy*ISO_H*32; + return out; } // ══════════════════════════════════════════════ @@ -478,6 +515,9 @@ public void render(GuiGraphics g, int mx, int my, float pt) { renderPlatforms(g); renderPredictLine(g); renderParticles(g); + // 玩家绘制与平台批次分离,避免 GuiGraphics 批量渲染时眼睛/嘴巴等 + // 文字与平台填充混合导致"绿色方块长眼睛"等 z-fighting 视觉 + g.flush(); renderPlayer(g); renderHUD(g); if (gameOver) renderGameOver(g); @@ -492,14 +532,9 @@ public void renderBackground(GuiGraphics graphics, int mouseX, int mouseY, float } public void renderBackground(GuiGraphics g) { - // 渐变天空 - for (int i=0;i(platforms) + sort) + private final List sortedPlatforms = new ArrayList<>(); + private boolean sortedDirty = true; + void renderPlatforms(GuiGraphics g) { // 按深度从后到前排序(简单:先渲染远的) - List sorted = new ArrayList<>(platforms); - sorted.sort((a,b) -> { - float da = (a.wx-camSX)+(a.wz-camSZ); - float db = (b.wx-camSX)+(b.wz-camSZ); - return Float.compare(da,db); - }); - for (Platform p : sorted) { + if (sortedDirty) { + sortedPlatforms.clear(); + sortedPlatforms.addAll(platforms); + sortedPlatforms.sort((a,b) -> { + float da = (a.wx-camSX)+(a.wz-camSZ); + float db = (b.wx-camSX)+(b.wz-camSZ); + return Float.compare(da,db); + }); + sortedDirty = false; + } + for (Platform p : sortedPlatforms) { int idx = platforms.indexOf(p); if (Math.abs(idx-currentPlatIdx)>5) continue; renderPlatform(g, p, idx==currentPlatIdx+1); @@ -618,7 +662,7 @@ void renderCylinder(GuiGraphics g, float wx, float wz, float hw, float h, void renderBlock(GuiGraphics g, float wx, float wz, float hw, float h, int col, int top, boolean next) { - // 等轴方块:左面、右面、顶面 + // 等轴方块:左面、前面、顶面 // 顶面 float[] tfl=project(wx-hw,h,wz-hw), tfr=project(wx+hw,h,wz-hw); float[] tbr=project(wx+hw,h,wz+hw), tbl=project(wx-hw,h,wz+hw); @@ -627,14 +671,10 @@ void renderBlock(GuiGraphics g, float wx, float wz, float hw, float h, float[] bfl=project(wx-hw,0,wz-hw), bbr2=project(wx-hw,0,wz+hw); float[] lT1=project(wx-hw,h,wz-hw), lT2=project(wx-hw,h,wz+hw); fillQuad(g,bfl,bbr2,lT2,lT1,shadeColor(col,0.55f)); - // 右侧面(中) - float[] bfr=project(wx+hw,0,wz-hw); - // 右前侧 - float[] bbl=project(wx-hw,0,wz-hw), rT1=project(wx-hw,h,wz-hw), rT2=project(wx+hw,h,wz-hw); - float[] rfB=project(wx+hw,0,wz-hw); - // 右后侧 - float[] rB2=project(wx+hw,0,wz+hw), rT3=project(wx+hw,h,wz+hw), rT4=project(wx+hw,h,wz-hw); - fillQuad(g, rfB, rB2, rT3, rT4, shadeColor(col, 0.75f)); + // 前侧面(中亮) + float[] bbl=project(wx-hw,0,wz-hw), bfr=project(wx+hw,0,wz-hw); + float[] rT1=project(wx-hw,h,wz-hw), rT2=project(wx+hw,h,wz-hw); + fillQuad(g,bbl,bfr,rT2,rT1,shadeColor(col,0.75f)); } void renderBook(GuiGraphics g, float wx, float wz, float hw, float h) { @@ -653,25 +693,27 @@ void renderBook(GuiGraphics g, float wx, float wz, float hw, float h) { float[] t0=project(wx-hw,h,wz-hw),t1=project(wx+hw,h,wz-hw); float[] t2=project(wx+hw,h,wz+hw),t3=project(wx-hw,h,wz+hw); fillQuad(g,t0,t1,t2,t3,0xFFEFF6FF); - // 书脊线 - float[] s0=project(wx,h+0.01f,wz-hw), s1=project(wx,h+0.01f,wz+hw); - drawIsoLine(g,s0,s1,0xFF93C5FD); + // 书脊(深蓝色细条,替代原来点状线) + float[] spine0 = project(wx - hw * 0.08f, h + 0.01f, wz - hw); + float[] spine1 = project(wx + hw * 0.08f, h + 0.01f, wz - hw); + float[] spine2 = project(wx + hw * 0.08f, h + 0.01f, wz + hw); + float[] spine3 = project(wx - hw * 0.08f, h + 0.01f, wz + hw); + fillQuad(g, spine0, spine1, spine2, spine3, 0xFF1E40AF); } void renderMusicBlock(GuiGraphics g, float wx, float wz, float hw, float h) { // 绿色音符方块 int col=0xFF065F46, top=0xFF059669; renderBlock(g,wx,wz,hw,h,col,top,false); - // 音符符号(顶面中心画两个小方块) - float[] n1=project(wx-0.2f,h+0.02f,wz); - float[] n2=project(wx+0.2f,h+0.02f,wz); - int ns=4; - g.fill((int)n1[0]-ns,(int)n1[1]-ns,(int)n1[0]+ns,(int)n1[1]+ns,0xFF6EE7B7); - g.fill((int)n2[0]-ns,(int)n2[1]-ns,(int)n2[0]+ns,(int)n2[1]+ns,0xFF6EE7B7); - float[] n3=project(wx-0.2f,h+0.3f,wz); - float[] n4=project(wx+0.2f,h+0.3f,wz); - g.fill((int)n3[0]-2,(int)n3[1],(int)n3[0]+2,(int)n1[1],0xFF6EE7B7); - g.fill((int)n4[0]-2,(int)n4[1],(int)n4[0]+2,(int)n2[1],0xFF6EE7B7); + // 音符符号(顶面中心画一个音符头+符干) + float[] nh = project(wx, h + 0.02f, wz); + int ns = 5; + int noteColor = 0xFF6EE7B7; + g.fill((int)nh[0] - ns, (int)nh[1] - ns, (int)nh[0] + ns, (int)nh[1] + ns, noteColor); // 符头 + // 符干(粗2px竖线) + float[] stemTop = project(wx, h + 0.35f, wz); + int stemX = (int)nh[0]; + g.fill(stemX - 1, (int)stemTop[1], stemX + 1, (int)nh[1] - ns, noteColor); } // ── 预测轨迹 ──────────────────────────────────── @@ -803,7 +845,8 @@ void renderHUD(GuiGraphics g) { g.pose().translate(width/2f,72,0); float cs=1f+0.1f*(float)Math.sin(tick*0.15); g.pose().scale(cs,cs,1); - String ct="🔥 "+combo+" 连击!"; + // ★ 修复:🔥 为非 BMP emoji,默认字体有豆腐块风险,改为 ASCII 文本 + String ct="COMBO x"+combo+" 连击!"; g.drawString(font,ct,-font.width(ct)/2,0,(ca<<24)|0x00FF6600); g.pose().popPose(); } @@ -859,7 +902,8 @@ void renderGameOver(GuiGraphics g) { g.drawCenteredString(font,"GAME OVER",cx,wy+20,0xFFEF4444); g.drawCenteredString(font,"得分 "+score,cx,wy+44,0xFFFFFFFF); if (score>=bestScore && score>0) - g.drawCenteredString(font,"🏆 新纪录!",cx,wy+62,0xFFFFDD00); + // ★ 修复:🏆 为非 BMP emoji,默认字体有豆腐块风险,改为 ASCII 文本 + g.drawCenteredString(font,"NEW! 新纪录!",cx,wy+62,0xFFFFDD00); else g.drawCenteredString(font,"最高 "+bestScore,cx,wy+62,0xFF94A3B8); @@ -901,6 +945,10 @@ public boolean keyPressed(int key,int scan,int mods) { if (key==GLFW.GLFW_KEY_ESCAPE) { if (showExitConfirm) { showExitConfirm = false; return true; } if (gameOver) { Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } + // ★ Bug修复:弹窗打开时立即清空蓄力状态(不触发跳跃),否则真实鼠标/空格松开事件 + // 会被弹窗期间的输入拦截吞掉,charging 悬空为 true,关闭弹窗后 tick() 继续默默蓄力, + // 玩家下次点击松开时会在毫无预期的情况下打出满蓄力跳跃 + charging = false; charge = 0; predictWX = null; predictWZ = null; predictWY = null; showExitConfirm = true; return true; } if (showExitConfirm) return true; @@ -927,24 +975,32 @@ void fillQuad(GuiGraphics g, float[] p0, float[] p1, float[] p2, float[] p3, int fillTri(g, p0, p2, p3, color); } + // ★ 性能:fillTri 内部暂存缓冲复用(渲染仅主线程调用),避免每次调用及逐行扫描时分配新数组 + private static final float[][] TRI_PTS = new float[3][2]; + private static final float[][] TRI_SEGS = new float[3][4]; + void fillTri(GuiGraphics g, float[] a, float[] b, float[] c, int color) { int y0=(int)Math.min(a[1],Math.min(b[1],c[1])); int y1=(int)Math.max(a[1],Math.max(b[1],c[1])); if (y0==y1) return; - float[][] pts={{a[0],a[1]},{b[0],b[1]},{c[0],c[1]}}; + // 拷贝坐标值到静态暂存(值拷贝,不影响调用方数组) + TRI_PTS[0][0]=a[0]; TRI_PTS[0][1]=a[1]; + TRI_PTS[1][0]=b[0]; TRI_PTS[1][1]=b[1]; + TRI_PTS[2][0]=c[0]; TRI_PTS[2][1]=c[1]; // 按y排序 - if(pts[0][1]>pts[1][1]){float[] t=pts[0];pts[0]=pts[1];pts[1]=t;} - if(pts[1][1]>pts[2][1]){float[] t=pts[1];pts[1]=pts[2];pts[2]=t;} - if(pts[0][1]>pts[1][1]){float[] t=pts[0];pts[0]=pts[1];pts[1]=t;} + if(TRI_PTS[0][1]>TRI_PTS[1][1]){float[] t=TRI_PTS[0];TRI_PTS[0]=TRI_PTS[1];TRI_PTS[1]=t;} + if(TRI_PTS[1][1]>TRI_PTS[2][1]){float[] t=TRI_PTS[1];TRI_PTS[1]=TRI_PTS[2];TRI_PTS[2]=t;} + if(TRI_PTS[0][1]>TRI_PTS[1][1]){float[] t=TRI_PTS[0];TRI_PTS[0]=TRI_PTS[1];TRI_PTS[1]=t;} + // 三条边线段(顶点在循环外只填一次,与原 segs={0→1,1→2,0→2} 完全一致) + TRI_SEGS[0][0]=TRI_PTS[0][0]; TRI_SEGS[0][1]=TRI_PTS[0][1]; TRI_SEGS[0][2]=TRI_PTS[1][0]; TRI_SEGS[0][3]=TRI_PTS[1][1]; + TRI_SEGS[1][0]=TRI_PTS[1][0]; TRI_SEGS[1][1]=TRI_PTS[1][1]; TRI_SEGS[1][2]=TRI_PTS[2][0]; TRI_SEGS[1][3]=TRI_PTS[2][1]; + TRI_SEGS[2][0]=TRI_PTS[0][0]; TRI_SEGS[2][1]=TRI_PTS[0][1]; TRI_SEGS[2][2]=TRI_PTS[2][0]; TRI_SEGS[2][3]=TRI_PTS[2][1]; for (int y=y0;y<=y1;y++) { if (y<0||y>=height) continue; float xl=width,xr=0; // 求y行的x范围 - float[][] segs={{pts[0][0],pts[0][1],pts[1][0],pts[1][1]}, - {pts[1][0],pts[1][1],pts[2][0],pts[2][1]}, - {pts[0][0],pts[0][1],pts[2][0],pts[2][1]}}; - for (float[] seg:segs) { - float x1=seg[0],y1f=seg[1],x2=seg[2],y2f=seg[3]; + for (int s=0;s<3;s++) { + float x1=TRI_SEGS[s][0],y1f=TRI_SEGS[s][1],x2=TRI_SEGS[s][2],y2f=TRI_SEGS[s][3]; if ((yMath.max(y1f,y2f))) continue; float t=(y2f==y1f)?0:(y-y1f)/(y2f-y1f); float xi=x1+(x2-x1)*t; diff --git a/src/main/java/com/wzz/game_console/client/screens/games/KlotskiScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/KlotskiScreen.java index d14dd64..a8aeaa1 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/KlotskiScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/KlotskiScreen.java @@ -39,7 +39,9 @@ public class KlotskiScreen extends Screen { private static final int C_SEL_BDR = 0xFF00CCFF; // 选中边框 // ── 状态 ────────────────────────────────────────── - private Piece[][] board = new Piece[BH][BW]; + // ★ Bug修复:不能带初始化器,否则 init() 的 board==null 首次判断恒 false,首次打开棋盘为空。 + // board 只在 loadLevel 中分配,init()(setScreen 首次打开必经)据此加载第一关 + private Piece[][] board; private List pieces = new ArrayList<>(); private Piece selected = null; private int tileSize, bx, by; @@ -90,7 +92,11 @@ public KlotskiScreen() { tileSize = Math.max(36, Math.min(64, max)); bx = (width - BW * tileSize) / 2; by = (height - BH * tileSize) / 2; - if (!won && pieces.isEmpty()) loadLevel(currentLevel); + // ★ Bug修复:原版 !won && pieces.isEmpty() 条件过宽,玩家赢了之后 + // pieces 被清空 + won=true,缩放窗口后 !won=false 不进,但代码意图是 + // 防止"已进行中重置";若逻辑分支(赢后 pieces 残留)不同则可能重置进度。 + // 改用 board == null 作为首次进入判断,board 是 loadLevel 唯一来源 + if (board == null) loadLevel(currentLevel); } // ══════════════════════════════════════════════════ @@ -188,6 +194,9 @@ private void checkWin(Piece p) { } return true; } + // ★ Bug修复:Java (int) 向零截断,棋盘原点左侧/上方不足一格的条带内 (int)((mouse-origin)/cell)=0 + // 会误命中第0行/列,先按负坐标/超出棋盘统一处理 + if (mx < bx || my < by) { selected = null; return true; } int gx = (int)((mx - bx) / tileSize), gy = (int)((my - by) / tileSize); if (gx < 0 || gx >= BW || gy < 0 || gy >= BH) { selected = null; return true; } Piece clicked = board[gy][gx]; diff --git a/src/main/java/com/wzz/game_console/client/screens/games/LanMultiplayerScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/LanMultiplayerScreen.java index 8f7d34d..c8f8c17 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/LanMultiplayerScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/LanMultiplayerScreen.java @@ -4,6 +4,10 @@ import com.wzz.game_console.network.MultiplayerGamePacket; import java.util.UUID; +import java.util.Map; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.concurrent.atomic.AtomicLong; /** * 所有支持局域网联机的游戏 Screen 都实现此接口。 @@ -14,6 +18,133 @@ */ public interface LanMultiplayerScreen { + /** Per-screen transport state; WeakHashMap avoids retaining closed screens. */ + Map LAN_SEQUENCERS = + java.util.Collections.synchronizedMap(new WeakHashMap<>()); + + final class SessionSequencer { + private UUID sessionId = UUID.randomUUID(); + private final AtomicLong nextSequence = new AtomicLong(); + private final Map received = new java.util.HashMap<>(); + private final Map> retiredSessions = new java.util.HashMap<>(); + + /** 单键保留的退役会话上限:失控/恶意对端反复换新会话时 FIFO 淘汰最旧,防内存无界增长。 */ + private static final int MAX_RETIRED_SESSIONS_PER_KEY = 64; + + public synchronized UUID sessionId() { return sessionId; } + public synchronized long nextSequence() { return nextSequence.getAndIncrement(); } + + /** + * Start a new locally-originated transport session. A restart must + * use sequence zero in a fresh session so later moves cannot overtake + * the restart and apply to the previous board. + */ + public synchronized UUID rotateSession() { + sessionId = UUID.randomUUID(); + nextSequence.set(0L); + return sessionId; + } + + public synchronized boolean accept(UUID sender, MultiplayerGamePacket.PacketType type, + MultiplayerGamePacket.DataEnvelope envelope) { + // null 判定必须先于 legacy()(防御性;生产入口 acceptLanEnvelope 已先判空, + // 但本方法为公开 API,不得对 null 信封抛 NPE)。测试源码集无 Minecraft 类路径, + // PacketType 不可引用,该路径由压测 harness 覆盖。 + if (envelope == null) return false; + if (envelope.legacy()) return true; + if (sender == null || type == null || envelope.sessionId() == null) return false; + return acceptSession(sender, type.name(), envelope.sessionId(), envelope.sequence()); + } + + /** Package-private transport test hook that avoids loading Minecraft packet types. */ + synchronized boolean acceptSession(UUID sender, String channel, UUID incomingSession, long sequence) { + if (sender == null || channel == null || incomingSession == null) return false; + return acceptSession(new ReceiveKey(sender, channel), incomingSession, sequence); + } + + /** + * Sender-gated acceptance used by the envelope entry point. A sender outside + * this game's peer set must neither consume sequence slots nor rotate sessions. + */ + synchronized boolean acceptFromPeer(UUID sender, UUID expectedPeer, String channel, + UUID incomingSession, long sequence) { + if (sender == null || expectedPeer == null || !expectedPeer.equals(sender)) return false; + return acceptSession(sender, channel, incomingSession, sequence); + } + + private boolean acceptSession(ReceiveKey key, UUID incomingSession, long sequence) { + if (sequence < 0L) return false; + ReceivedSequence previous = received.get(key); + if (previous != null) { + if (!previous.sessionId().equals(incomingSession)) { + if (sequence != 0L) return false; + Set retired = retiredSessions.get(key); + if (retired != null && retired.contains(incomingSession)) return false; + retireSession(key, previous.sessionId()); + } else if (sequence <= previous.sequence()) { + return false; + } + } + received.put(key, new ReceivedSequence(incomingSession, sequence)); + return true; + } + + private void retireSession(ReceiveKey key, UUID sessionId) { + // LinkedHashSet 保证按插入序 FIFO 淘汰(HashSet 迭代序不定)。 + // 被淘汰的极旧会话若被重放会再次被接受一次——这是有界内存的既定取舍: + // 重放防护窗口 = 最近 64 次会话轮换,正常对局每局至多轮换数次,窗口远超需要。 + Set retired = retiredSessions.computeIfAbsent(key, ignored -> new java.util.LinkedHashSet<>()); + while (retired.size() >= MAX_RETIRED_SESSIONS_PER_KEY) { + java.util.Iterator eldest = retired.iterator(); + eldest.next(); + eldest.remove(); + } + retired.add(sessionId); + } + + private record ReceiveKey(UUID sender, String channel) {} + private record ReceivedSequence(UUID sessionId, long sequence) {} + } + + /** Session UUID used for locally generated GAME_* envelopes. */ + default UUID getLanSessionId() { + return LAN_SEQUENCERS.computeIfAbsent(this, ignored -> new SessionSequencer()).sessionId(); + } + + /** Monotonically increasing sequence for locally generated GAME_* envelopes. */ + default long nextLanSequence() { + return LAN_SEQUENCERS.computeIfAbsent(this, ignored -> new SessionSequencer()).nextSequence(); + } + + /** + * GAME_* 包的来源校验:发送者是否为本对局的合法对端。 + * 默认与 LEAVE_GAME 一致(仅 getLanPeer(),多方对局重写 isLeaveFromPeer 即可一并生效)。 + * 在 sequencer 之前拦截,防止第三方伪造 sender 占用 sequence 槽位或轮换会话。 + */ + default boolean isGamePacketFromPeer(UUID sender) { + return isLeaveFromPeer(sender); + } + + /** Parse and de-duplicate an incoming GAME_* data field. Legacy bare data is accepted. */ + default MultiplayerGamePacket.DataEnvelope acceptLanEnvelope(UUID sender, String data) { + return acceptLanEnvelope(MultiplayerGamePacket.PacketType.GAME_MOVE, sender, data); + } + + /** Parse and de-duplicate an incoming envelope for a specific route. */ + default MultiplayerGamePacket.DataEnvelope acceptLanEnvelope( + MultiplayerGamePacket.PacketType type, UUID sender, String data) { + if (!isGamePacketFromPeer(sender)) return null; + MultiplayerGamePacket.DataEnvelope envelope = MultiplayerGamePacket.parseData(data); + if (envelope == null) return null; + return LAN_SEQUENCERS.computeIfAbsent(this, ignored -> new SessionSequencer()).accept(sender, type, envelope) + ? envelope : null; + } + + /** Envelope local game data without changing existing sendMove/sendState callers. */ + default String envelopeLanData(String body) { + return MultiplayerGamePacket.envelopeData(getLanSessionId(), nextLanSequence(), body); + } + // ── 联机角色常量 ───────────────────────────────────────────────── int LAN_NONE = 0; // 单机 int LAN_HOST = 1; // 主机(先手/发起方) @@ -71,10 +202,18 @@ default void onRemoteGameOver(UUID senderUuid, String data) { onRemoteGameOver(data); } + /** 收到对方的 LEAVE_GAME 包(对方退出对局,可选实现)。 */ + default void onRemoteLeave(String senderName) {} + /** - * 收到对方的 LEAVE_GAME 包(对方退出对局,可选实现)。 + * LEAVE_GAME/断线通知的来源校验:发送者是否为本对局的合法对端。 + * 默认仅接受 getLanPeer() 一人;多方对局(如斗地主三人)应重写以接受任一对端。 + * 防止第三方伪造 LEAVE_GAME 关闭无关玩家的对局界面。 */ - default void onRemoteLeave(String senderName) {} + default boolean isLeaveFromPeer(UUID sender) { + UUID peer = getLanPeer(); + return peer != null && sender != null && peer.equals(sender); + } // ── 便捷发包工具方法(接口 default,子类直接调用)───────────────── @@ -88,6 +227,16 @@ peer, getLanGameId(), moveData )); } + /** 向对方发送带 session/sequence envelope 的走法。 */ + default void sendMoveEnvelope(String moveData) { + UUID peer = getLanPeer(); + if (peer == null) return; + ModNetworks.PACKET_HANDLER.sendToServer(new MultiplayerGamePacket( + MultiplayerGamePacket.PacketType.GAME_MOVE, + peer, getLanGameId(), envelopeLanData(moveData) + )); + } + /** 向对方发送完整状态(实时游戏用,HOST 调用) */ default void sendState(String stateData) { UUID peer = getLanPeer(); @@ -98,6 +247,16 @@ peer, getLanGameId(), stateData )); } + /** 向对方发送带 session/sequence envelope 的状态。 */ + default void sendStateEnvelope(String stateData) { + UUID peer = getLanPeer(); + if (peer == null) return; + ModNetworks.PACKET_HANDLER.sendToServer(new MultiplayerGamePacket( + MultiplayerGamePacket.PacketType.GAME_STATE_SYNC, + peer, getLanGameId(), envelopeLanData(stateData) + )); + } + /** 向对方发送输入(实时游戏用,CLIENT 调用) */ default void sendInput(String inputData) { UUID peer = getLanPeer(); @@ -108,6 +267,26 @@ peer, getLanGameId(), inputData )); } + /** 向对方发送带 session/sequence envelope 的 GAME_OVER。 */ + default void sendGameOverEnvelope(String data) { + UUID peer = getLanPeer(); + if (peer == null) return; + ModNetworks.PACKET_HANDLER.sendToServer(new MultiplayerGamePacket( + MultiplayerGamePacket.PacketType.GAME_OVER, + peer, getLanGameId(), envelopeLanData(data) + )); + } + + /** 向对方发送输入的 envelope 版本。 */ + default void sendInputEnvelope(String inputData) { + UUID peer = getLanPeer(); + if (peer == null) return; + ModNetworks.PACKET_HANDLER.sendToServer(new MultiplayerGamePacket( + MultiplayerGamePacket.PacketType.GAME_MOVE, + peer, getLanGameId(), envelopeLanData(inputData) + )); + } + /** 退出对局时通知对方(在 onClose 或等效退出路径调用) */ default void sendLeaveGame() { UUID peer = getLanPeer(); diff --git a/src/main/java/com/wzz/game_console/client/screens/games/Match3GameScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/Match3GameScreen.java index 278a159..a48ca8c 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/Match3GameScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/Match3GameScreen.java @@ -34,6 +34,18 @@ public class Match3GameScreen extends Screen { Items.LAPIS_LAZULI, Items.COAL }; + + /** ★ 性能:渲染用物品栈缓存(按下标懒初始化),避免 renderGameGrid 每帧 new ItemStack ×64 */ + private static final ItemStack[] STACK_CACHE = new ItemStack[GAME_ITEMS.length]; + + private static ItemStack cachedStack(int itemIndex) { + ItemStack stack = STACK_CACHE[itemIndex]; + if (stack == null) { + stack = new ItemStack(GAME_ITEMS[itemIndex]); + STACK_CACHE[itemIndex] = stack; + } + return stack; + } private int[][] gameGrid; private boolean[][] selectedGrid; @@ -63,8 +75,21 @@ public Match3GameScreen() { private void initializeGame() { gameGrid = new int[GRID_SIZE][GRID_SIZE]; selectedGrid = new boolean[GRID_SIZE][GRID_SIZE]; - + selectedX = -1; + selectedY = -1; + score = 0; + animations.clear(); + // 随机填充游戏网格,避免初始匹配 + randomFillAvoidingMatches(); + // ★ 防死局:初始化后若整盘不存在任何可消交换,整体重roll(最多50次,仍失败保留最后结果) + for (int attempt = 0; attempt < 50 && !hasAnyMove(); attempt++) { + randomFillAvoidingMatches(); + } + } + + /** 随机填充整个网格,填充过程避免直接形成三连 */ + private void randomFillAvoidingMatches() { for (int x = 0; x < GRID_SIZE; x++) { for (int y = 0; y < GRID_SIZE; y++) { do { @@ -101,7 +126,7 @@ private boolean wouldCreateMatch(int x, int y, int itemType) { } private void calcDynamicLayout() { - CELL_SIZE = Math.max(16, Math.min((width - 80) / GRID_SIZE, (height - 100) / GRID_SIZE)); + CELL_SIZE = Math.max(1, Math.min(Math.max(1, (width - 20) / GRID_SIZE), Math.max(1, (height - 40) / GRID_SIZE))); GRID_START_X = (width - GRID_SIZE * CELL_SIZE) / 2; GRID_START_Y = (height - GRID_SIZE * CELL_SIZE) / 2; } @@ -155,10 +180,10 @@ private void renderGameGrid(GuiGraphics guiGraphics, int mouseX, int mouseY) { guiGraphics.fill(screenX, screenY, screenX + CELL_SIZE, screenY + CELL_SIZE, backgroundColor); guiGraphics.fill(screenX + 1, screenY + 1, screenX + CELL_SIZE - 1, screenY + CELL_SIZE - 1, 0xFF222222); - // 渲染物品 - Item item = GAME_ITEMS[gameGrid[x][y]]; - ItemStack itemStack = new ItemStack(item); - guiGraphics.renderItem(itemStack, screenX + 8, screenY + 8); + // 渲染物品(复用缓存栈,只读不修改) + if (gameGrid[x][y] >= 0 && gameGrid[x][y] < GAME_ITEMS.length) { + guiGraphics.renderItem(cachedStack(gameGrid[x][y]), screenX + Math.max(0, (CELL_SIZE - 16) / 2), screenY + Math.max(0, (CELL_SIZE - 16) / 2)); + } } } } @@ -188,6 +213,9 @@ private boolean isMouseOver(int mouseX, int mouseY, int x, int y) { public boolean mouseClicked(double mouseX, double mouseY, int button) { if (showExitConfirm) { int click = GameRenderHelper.getExitConfirmClick(mouseX, mouseY, width, height); if (click == 1) { showExitConfirm = false; Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } if (click == 2) { showExitConfirm = false; return true; } return true; } if (button == 0) { // 左键点击 + // ★ Bug修复:Java (int) 向零截断,网格原点左侧/上方不足一格的条带内 (int)((mouse-origin)/cell)=0 + // 会误命中第0行/列,先按负坐标守卫(与网格外点击同样交给 super 处理) + if (mouseX < GRID_START_X || mouseY < GRID_START_Y) return super.mouseClicked(mouseX, mouseY, button); int gridX = (int) ((mouseX - GRID_START_X) / CELL_SIZE); int gridY = (int) ((mouseY - GRID_START_Y) / CELL_SIZE); @@ -345,6 +373,37 @@ private void fillEmptySpaces() { } } } + // ★ 防死局:填满后若不存在任何可消交换,整体重roll(最多50次,仍失败保留最后结果) + for (int attempt = 0; attempt < 50 && !hasAnyMove(); attempt++) { + randomFillAvoidingMatches(); + } + } + + /** 枚举所有相邻交换:临时交换→有无消除→换回。判断当前棋盘是否还有可行棋步 */ + private boolean hasAnyMove() { + for (int x = 0; x < GRID_SIZE; x++) { + for (int y = 0; y < GRID_SIZE; y++) { + if (x + 1 < GRID_SIZE) { + swapCells(x, y, x + 1, y); + boolean match = !findAllMatches().isEmpty(); + swapCells(x, y, x + 1, y); + if (match) return true; + } + if (y + 1 < GRID_SIZE) { + swapCells(x, y, x, y + 1); + boolean match = !findAllMatches().isEmpty(); + swapCells(x, y, x, y + 1); + if (match) return true; + } + } + } + return false; + } + + private void swapCells(int x1, int y1, int x2, int y2) { + int temp = gameGrid[x1][y1]; + gameGrid[x1][y1] = gameGrid[x2][y2]; + gameGrid[x2][y2] = temp; } private void clearSelection() { diff --git a/src/main/java/com/wzz/game_console/client/screens/games/MazeGameScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/MazeGameScreen.java index c31a040..f5ac127 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/MazeGameScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/MazeGameScreen.java @@ -19,8 +19,8 @@ public class MazeGameScreen extends Screen { boolean showExitConfirm = false; private int TILE_SIZE = 20; - private static final int MAZE_WIDTH = 21; // 奇数 - private static final int MAZE_HEIGHT = 21; // 奇数 + private int MAZE_WIDTH = 21; // 奇数,随关卡变化 + private int MAZE_HEIGHT = 21; // 奇数,随关卡变化 private char[][] maze; private int playerX, playerY; @@ -28,10 +28,18 @@ public class MazeGameScreen extends Screen { private boolean gameOver; private boolean gameWon; private int startX, startY; + private int currentLevel = 1; private long lastGhostMoveTime; - private final long ghostMoveInterval = 500; // 鬼魂移动间隔(毫秒) + private long ghostMoveInterval = 500; // 鬼魂移动间隔(毫秒) + private int ghostVisionRange = 12; // 鬼魂视野范围(曼哈顿距离) + private long lastPathFindTime = 0; + private static final long PATH_FIND_INTERVAL = 1000; // 寻路冷却(毫秒) + private long gameWonTime = 0; // 胜利时间戳,用于自动进入下一关 + private static final long LEVEL_ADVANCE_DELAY = 2000; // 自动进入下一关的延迟(毫秒) private List ghostPath = new ArrayList<>(); private final Random random = new Random(); + /** 当前关卡最大层数(通关后回到第 1 关,生成更大的迷宫作为奖励) */ + private static final int MAX_LEVEL = 5; public MazeGameScreen() { super(Component.literal("迷宫游戏")); @@ -40,17 +48,24 @@ public MazeGameScreen() { @Override public void init() { - // 窗口缩放时 Screen.resize 会重调 init,widget 需要先清理避免叠加 + // resize() 会重新调用 init;先清理旧控件,再按当前关卡重算棋盘几何。 super.init(); this.clearWidgets(); - // 计算绘制起始位置,使迷宫居中 + int size = levelMazeSize(); + MAZE_WIDTH = size; + MAZE_HEIGHT = size; TILE_SIZE = Math.max(8, Math.min((this.width - 40) / MAZE_WIDTH, (this.height - 80) / MAZE_HEIGHT)); startX = (this.width - MAZE_WIDTH * TILE_SIZE) / 2; startY = (this.height - MAZE_HEIGHT * TILE_SIZE) / 2; int centerX = this.width / 2; this.addRenderableWidget(Button.builder(Component.literal("重新开始"), b -> { + currentLevel = 1; + int newSize = levelMazeSize(); + MAZE_WIDTH = newSize; + MAZE_HEIGHT = newSize; generateMaze(); + init(); }).pos(centerX - 50, this.height - 30).size(100, 20).build()); this.addRenderableWidget(Button.builder(Component.literal("返回"), b -> { @@ -111,8 +126,13 @@ private void generateMaze() { // 在玩家后方生成鬼魂 placeGhostBehindPlayer(); + // 根据等级调整难度:等级越高鬼魂视野越远、移动越快 + ghostVisionRange = Math.min(MAZE_WIDTH, 6 + (currentLevel - 1) * 2); + ghostMoveInterval = Math.max(200, 500 - (currentLevel - 1) * 30); + gameOver = false; gameWon = false; + gameWonTime = 0; lastGhostMoveTime = System.currentTimeMillis(); ghostPath.clear(); } @@ -209,8 +229,9 @@ public void render(@NotNull GuiGraphics graphics, int mouseX, int mouseY, float graphics.drawCenteredString(this.font, "游戏结束! 被鬼抓住了!", this.width / 2, 30, 0xFFFF0000); } else if (gameWon) { graphics.drawCenteredString(this.font, "恭喜! 你逃出了迷宫!", this.width / 2, 30, 0xFF00FF00); + graphics.drawCenteredString(this.font, "即将进入第" + (currentLevel + 1) + "关...", this.width / 2, 50, 0xFFFFFF00); } else { - graphics.drawCenteredString(this.font, "WASD移动 - 找到出口并避开鬼魂!", this.width / 2, 30, 0xFFFFFF); + graphics.drawCenteredString(this.font, "第" + currentLevel + "关 - WASD移动 - 找到出口并避开鬼魂!", this.width / 2, 30, 0xFFFFFF); // 显示鬼魂距离 int distance = Math.abs(playerX - ghostX) + Math.abs(playerY - ghostY); graphics.drawCenteredString(this.font, "鬼魂距离: " + distance, this.width / 2, 50, @@ -225,8 +246,29 @@ public void render(@NotNull GuiGraphics graphics, int mouseX, int mouseY, float public void tick() { super.tick(); + // 胜利后自动进入下一关(退出弹窗期间暂停,避免干扰) + if (gameWon && !showExitConfirm) { + long now = System.currentTimeMillis(); + if (gameWonTime == 0) { + gameWonTime = now; + } else if (now - gameWonTime > LEVEL_ADVANCE_DELAY) { + currentLevel++; + if (currentLevel > MAX_LEVEL) currentLevel = 1; // 通关后回到第 1 关循环 + gameWonTime = 0; + // 同步更新迷宫尺寸到新关卡 + int size = levelMazeSize(); + MAZE_WIDTH = size; + MAZE_HEIGHT = size; + TILE_SIZE = Math.max(8, Math.min((this.width - 40) / MAZE_WIDTH, (this.height - 80) / MAZE_HEIGHT)); + startX = (this.width - MAZE_WIDTH * TILE_SIZE) / 2; + startY = (this.height - MAZE_HEIGHT * TILE_SIZE) / 2; + generateMaze(); + } + return; + } + // 定期移动鬼魂(退出弹窗期间暂停,避免弹窗时被鬼抓住) - if (!gameOver && !gameWon && !showExitConfirm + if (!gameOver && !showExitConfirm && System.currentTimeMillis() - lastGhostMoveTime > ghostMoveInterval) { moveGhost(); lastGhostMoveTime = System.currentTimeMillis(); @@ -238,6 +280,13 @@ public void tick() { } } + /** 计算当前关卡的迷宫尺寸(21x21 ~ 31x31) */ + private int levelMazeSize() { + // 21 / 23 / 25 / 27 / 29 五档,超过 MAX_LEVEL 后回到第 1 关 + int idx = Math.min(MAX_LEVEL - 1, currentLevel - 1); + return 21 + idx * 2; + } + @Override public boolean keyPressed(int keyCode, int scanCode, int modifiers) { if (keyCode == GLFW.GLFW_KEY_ESCAPE) { @@ -272,6 +321,7 @@ public boolean keyPressed(int keyCode, int scanCode, int modifiers) { // 检查是否到达出口 if (maze[playerY][playerX] == 'E') { gameWon = true; + gameWonTime = System.currentTimeMillis(); // 开始计时,准备进入下一关 if (Minecraft.getInstance().player != null) { Minecraft.getInstance().player.playSound(SoundEvents.PLAYER_LEVELUP, 1.0F, 1.0F); } @@ -287,17 +337,66 @@ public boolean keyPressed(int keyCode, int scanCode, int modifiers) { } private void moveGhost() { - // 使用A*寻路算法找到最短路径 - ghostPath = findPath(ghostX, ghostY, playerX, playerY); - - if (ghostPath != null && ghostPath.size() > 1) { - // 沿着路径移动一步 - int[] nextStep = ghostPath.get(1); - ghostX = nextStep[0]; - ghostY = nextStep[1]; + int distance = Math.abs(playerX - ghostX) + Math.abs(playerY - ghostY); + + if (distance <= ghostVisionRange) { + // 玩家在视野范围内:追击模式 + long now = System.currentTimeMillis(); + boolean shouldRecalculate = ghostPath.isEmpty() || (now - lastPathFindTime > PATH_FIND_INTERVAL); + + if (shouldRecalculate) { + ghostPath = findPath(ghostX, ghostY, playerX, playerY); + lastPathFindTime = now; + } + + if (ghostPath != null && ghostPath.size() > 1) { + // ★ Bug修复:原 30% 概率随机走概率过低,鬼魂几乎必追到玩家。 + // 改为 50% 概率走最优路径 + 50% 随机游走,让玩家有"躲鬼"机会。 + // 距离越近概率越偏向追击,但即便贴身仍有 1/4 的机会脱身。 + double r = random.nextDouble(); + double chaseProb = 0.5; + if (r < chaseProb) { + // 走最优路径 + int[] nextStep = ghostPath.get(1); + ghostX = nextStep[0]; + ghostY = nextStep[1]; + } else { + // 随机走,让玩家有机会脱身 + List moves = getAvailableMoves(); + if (!moves.isEmpty()) { + int[] move = moves.get(random.nextInt(moves.size())); + ghostX = move[0]; + ghostY = move[1]; + } + } + } else { + simpleChase(); + } } else { - // 如果找不到路径,使用简单追踪 - simpleChase(); + // 玩家在视野外:巡逻模式(随机游走) + wanderRandomly(); + } + } + + private List getAvailableMoves() { + List moves = new ArrayList<>(); + int[][] directions = {{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; + for (int[] dir : directions) { + int nx = ghostX + dir[0]; + int ny = ghostY + dir[1]; + if (nx >= 0 && nx < MAZE_WIDTH && ny >= 0 && ny < MAZE_HEIGHT && maze[ny][nx] != '#') { + moves.add(new int[]{nx, ny}); + } + } + return moves; + } + + private void wanderRandomly() { + List moves = getAvailableMoves(); + if (!moves.isEmpty()) { + int[] move = moves.get(random.nextInt(moves.size())); + ghostX = move[0]; + ghostY = move[1]; } } diff --git a/src/main/java/com/wzz/game_console/client/screens/games/MemoryCardScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/MemoryCardScreen.java index 6d1fbe4..439e967 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/MemoryCardScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/MemoryCardScreen.java @@ -112,11 +112,21 @@ public void init() { }).pos(centerX - 50, this.height - 60).size(100, 20).build()); } + /** 弹窗打开时间戳:关闭时据此平移 startTime,补偿暂停期间流逝的墙钟时间 */ + private long pauseStartTime = 0; + + /** 关闭弹窗恢复游戏:平移 startTime,避免"用时"把弹窗停留时长也算进去 */ + private void resumeFromExitConfirm() { + startTime += System.currentTimeMillis() - pauseStartTime; + showExitConfirm = false; + } + @Override public boolean keyPressed(int keyCode, int scanCode, int modifiers) { if (keyCode == GLFW.GLFW_KEY_ESCAPE) { - if (showExitConfirm) { showExitConfirm = false; return true; } + if (showExitConfirm) { resumeFromExitConfirm(); return true; } if (gameWon) { Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } + pauseStartTime = System.currentTimeMillis(); showExitConfirm = true; return true; } if (showExitConfirm) return true; @@ -210,12 +220,13 @@ private int getColorForValue(int value) { @Override public boolean mouseClicked(double mouseX, double mouseY, int button) { if (showExitConfirm) { + if (button != 0) return true; int click = GameRenderHelper.getExitConfirmClick((int)mouseX, (int)mouseY, width, height); if (click == 1) { showExitConfirm = false; Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } - if (click == 2) { showExitConfirm = false; return true; } + if (click == 2) { resumeFromExitConfirm(); return true; } return true; } - if (gameWon || waitingForFlipBack) return super.mouseClicked(mouseX, mouseY, button); + if (gameWon || waitingForFlipBack || button != 0) return super.mouseClicked(mouseX, mouseY, button); // 检查是否点击了卡片 for (int y = 0; y < GRID_ROWS; y++) { @@ -284,6 +295,7 @@ private void handleCardClick(Card card) { @Override public void tick() { super.tick(); + if (!minecraft.isWindowActive()) showExitConfirm = false; if (showExitConfirm) return; // 弹窗期间暂停翻回倒计时与计时器 // 延迟倒计时结束后将两张未匹配的卡片翻回 if (waitingForFlipBack && flipBackDelay > 0) { diff --git a/src/main/java/com/wzz/game_console/client/screens/games/MemoryGameScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/MemoryGameScreen.java index ceac547..f3577fe 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/MemoryGameScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/MemoryGameScreen.java @@ -75,6 +75,8 @@ public MemoryGameScreen() { @Override public void init() { + // ★ Bug修复:缩放 init() 重复添加 startButton/resetButton + this.clearWidgets(); super.init(); this.gridStartX = (this.width - GRID_WIDTH) / 2; this.gridStartY = (this.height - GRID_HEIGHT) / 2; @@ -169,8 +171,9 @@ private int brightenColor(int color, float factor) { @Override public boolean mouseClicked(double mouseX, double mouseY, int button) { - if (showExitConfirm) { int click = GameRenderHelper.getExitConfirmClick(mouseX, mouseY, width, height); if (click == 1) { showExitConfirm = false; Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } if (click == 2) { showExitConfirm = false; return true; } return true; } - if (gameState == GameState.WAITING_INPUT) { + if (showExitConfirm) { + if (button != 0) return true; int click = GameRenderHelper.getExitConfirmClick(mouseX, mouseY, width, height); if (click == 1) { showExitConfirm = false; Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } if (click == 2) { resumeFromExitConfirm(); return true; } return true; } + if (button == 0 && gameState == GameState.WAITING_INPUT) { int clickedCell = getCellAtPosition((int)mouseX, (int)mouseY); if (clickedCell != -1) { handleCellClick(clickedCell); @@ -286,6 +289,7 @@ private void playFailSound() { @Override public void tick() { super.tick(); + if (!minecraft.isWindowActive()) showExitConfirm = false; if (showExitConfirm) return; long currentTime = System.currentTimeMillis(); if (highlightedCell != -1) { @@ -316,17 +320,21 @@ public void tick() { } } + /** 关闭弹窗恢复游戏:平移所有定时基准,补偿暂停期间流逝的墙钟时间 */ + private void resumeFromExitConfirm() { + long pausedMs = System.currentTimeMillis() - pauseStartTime; + if (nextSequenceItemTime > 0) nextSequenceItemTime += pausedMs; + if (nextRoundTime > 0) nextRoundTime += pausedMs; + if (highlightStartTime > 0) highlightStartTime += pausedMs; + if (lastSequenceTime > 0) lastSequenceTime += pausedMs; + showExitConfirm = false; + } + @Override public boolean keyPressed(int keyCode, int scanCode, int modifiers) { if (keyCode == 256) { if (showExitConfirm) { - // 关闭弹窗:平移所有定时基准,补偿暂停期间流逝的墙钟时间 - long pausedMs = System.currentTimeMillis() - pauseStartTime; - if (nextSequenceItemTime > 0) nextSequenceItemTime += pausedMs; - if (nextRoundTime > 0) nextRoundTime += pausedMs; - if (highlightStartTime > 0) highlightStartTime += pausedMs; - if (lastSequenceTime > 0) lastSequenceTime += pausedMs; - showExitConfirm = false; + resumeFromExitConfirm(); } else { pauseStartTime = System.currentTimeMillis(); showExitConfirm = true; diff --git a/src/main/java/com/wzz/game_console/client/screens/games/Minecraft2DScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/Minecraft2DScreen.java index 00926de..9b4e2c1 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/Minecraft2DScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/Minecraft2DScreen.java @@ -194,7 +194,10 @@ private void setSlot(int i, Block b, net.minecraft.world.item.Item item, int cou // ══════════════ TICK ══════════════ @Override public void tick(){ - super.tick();if(!started)return; + super.tick(); + // ★ 失焦清键:Screen 基类无 windowFocusChanged 钩子,每 tick 探针 MC 窗口活动状态 + if (!minecraft.isWindowActive()) { for (int i = 0; i < keys.length; i++) if (keys[i]) { Arrays.fill(keys, false); break; } } + if(!started)return; if(showExitConfirm)return; // 弹窗期间冻结物理/挖矿/饥饿 tick++;dayTick=(dayTick+1)%2400; if(dead){if(System.currentTimeMillis()-deadAt>3000)respawn();return;} @@ -509,13 +512,25 @@ private String bname(Block b){ if(b==Blocks.BEDROCK)return "基岩(不可破)";return b.getDescriptionId(); } + /** 弹窗打开时间戳:关闭时据此平移 deadAt,防止死亡后弹窗停留超过重生等待时长导致瞬间重生 */ + private long pauseStartTime = 0; + + private void resumeFromExitConfirm() { + if (dead && deadAt > 0) deadAt += System.currentTimeMillis() - pauseStartTime; + showExitConfirm = false; + } + // ══════════════ 输入 ══════════════ @Override public boolean keyPressed(int k,int sc,int m){ - if(!started)return super.keyPressed(k,sc,m); + if(!started){ + // 修复:菜单态不拦截 ESC 会走默认 onClose() 退回 Minecraft 世界,改为返回游戏选择界面 + if(k==GLFW.GLFW_KEY_ESCAPE){Minecraft.getInstance().setScreen(new GameSelectorScreen());return true;} + return super.keyPressed(k,sc,m); + } // 修复:退出确认弹窗打开时,仅允许 ESC(再次按 ESC 关闭弹窗),拦截移动等所有游戏按键输入 if(k==GLFW.GLFW_KEY_ESCAPE){ - if(showExitConfirm){showExitConfirm=false;} - else{showExitConfirm=true;Arrays.fill(keys,false);} // 清空已按住的按键,防止打开弹窗前按住的 WASD 继续移动 + if(showExitConfirm){resumeFromExitConfirm();} + else{showExitConfirm=true;pauseStartTime=System.currentTimeMillis();Arrays.fill(keys,false);} // 清空已按住的按键,防止打开弹窗前按住的 WASD 继续移动 return true; } if(showExitConfirm) return true; @@ -527,7 +542,7 @@ private String bname(Block b){ @Override public boolean keyReleased(int k,int sc,int m){if(k>=0&&k=bx2&&mx<=bx2+bw&&my>=by2&&my<=by2+bh){started=true;return true;} diff --git a/src/main/java/com/wzz/game_console/client/screens/games/MinesweeperScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/MinesweeperScreen.java index 6d44ff9..e24b699 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/MinesweeperScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/MinesweeperScreen.java @@ -27,21 +27,15 @@ private enum State { MENU, PLAYING, GAME_OVER } private int flagCount; private final List particles = new ArrayList<>(); private final Random random = new Random(); + private boolean firstClick; public MinesweeperScreen() { super(Component.literal("扫雷")); } private void startGame() { grid = new Cell[gridSize][gridSize]; - won = false; flagCount = 0; + won = false; flagCount = 0; firstClick = true; for (int y = 0; y < gridSize; y++) for (int x = 0; x < gridSize; x++) grid[y][x] = new Cell(); - int placed = 0; - while (placed < mineCount) { - int x = random.nextInt(gridSize), y = random.nextInt(gridSize); - if (!grid[y][x].mine) { grid[y][x].mine = true; placed++; } - } - for (int y = 0; y < gridSize; y++) - for (int x = 0; x < gridSize; x++) grid[y][x].adj = countAdj(x, y); state = State.PLAYING; particles.clear(); } @@ -54,6 +48,38 @@ private int countAdj(int x, int y) { return c; } + private void placeMinesAvoiding(int avoidX, int avoidY) { + int placed = 0; + // ★ Bug修复:原版用 mineCount*100 步上限 + 3x3 避让,极端小棋盘+多雷时 + // attempts 会提前耗尽 → placed < mineCount → 雷数偏少。 + // 改为更宽松的上限,再补一道兜底:如果仍放不够,则从 firstClick 周围 3x3 + // 之外的全 board 随机补雷(不会破坏 firstClick 的安全性)。 + int maxAttempts = mineCount * 200; + int attempts = 0; + while (placed < mineCount && attempts++ < maxAttempts) { + int x = random.nextInt(gridSize), y = random.nextInt(gridSize); + if (grid[y][x].mine) continue; + if (Math.abs(x - avoidX) <= 1 && Math.abs(y - avoidY) <= 1) continue; + grid[y][x].mine = true; placed++; + } + // 兜底:在避让区外继续补雷,确保雷数与配置一致 + attempts = 0; + while (placed < mineCount && attempts++ < mineCount * 50) { + int x = random.nextInt(gridSize), y = random.nextInt(gridSize); + if (grid[y][x].mine) continue; + if (Math.abs(x - avoidX) <= 1 && Math.abs(y - avoidY) <= 1) continue; + grid[y][x].mine = true; placed++; + } + // 终极兜底:若仍不足(理论上只发生在 3x3 >= gridSize*gridSize),从避让区补 + while (placed < mineCount) { + int x = random.nextInt(gridSize), y = random.nextInt(gridSize); + if (grid[y][x].mine) continue; + grid[y][x].mine = true; placed++; + } + for (int y = 0; y < gridSize; y++) + for (int x = 0; x < gridSize; x++) grid[y][x].adj = countAdj(x, y); + } + private void reveal(int x, int y) { if (x < 0 || x >= gridSize || y < 0 || y >= gridSize) return; Cell c = grid[y][x]; @@ -77,7 +103,11 @@ private void checkWin() { if (Minecraft.getInstance().player != null) Minecraft.getInstance().player.playSound(SoundEvents.PLAYER_LEVELUP, 1F, 1F); } - @Override public void tick() { tickCount++; } + @Override public void tick() { + tickCount++; + // ★ 修复:粒子物理移到 tick() 固定频率推进,弹窗暂停期间冻结(原来在 render 中 update) + if (!showExitConfirm) GameRenderHelper.tickParticles(particles); + } @Override public boolean keyPressed(int key, int scan, int mods) { if (key == GLFW.GLFW_KEY_ESCAPE) { @@ -109,11 +139,15 @@ private void checkWin() { } if (state != State.PLAYING) return super.mouseClicked(mx, my, btn); + // ★ Bug修复:Java (int) 向零截断,棋盘原点左侧/上方不足一格的条带内 (int)((mouse-origin)/cell)=0 + // 会误命中第0行/列,先按负坐标守卫(与棋盘外点击同样交给 super 处理) + if (mx < offsetX || my < offsetY) return super.mouseClicked(mx, my, btn); int gx = (int)((mx - offsetX) / cellSize); int gy = (int)((my - offsetY) / cellSize); if (gx < 0 || gx >= gridSize || gy < 0 || gy >= gridSize) return super.mouseClicked(mx, my, btn); if (btn == 0) { + if (firstClick) { placeMinesAvoiding(gx, gy); firstClick = false; } reveal(gx, gy); if (Minecraft.getInstance().player != null) Minecraft.getInstance().player.playSound(SoundEvents.STONE_BUTTON_CLICK_ON, 0.5F, 1F); } else if (btn == 1 && !grid[gy][gx].revealed) { @@ -162,7 +196,15 @@ private void renderPlaying(GuiGraphics g, int mx, int my) { if (c.revealed) { if (c.mine) { g.fill(sx, sy, sx + cellSize, sy + cellSize, 0xFFCC2222); - g.drawCenteredString(font, "💣", sx + cellSize / 2, sy + (cellSize - 8) / 2, 0xFFFFFF); + // ★ 修复:默认字体无 U+1F4A3(💣),豆腐块概率高,改为自绘地雷:黑色圆身 + 四向短刺 + 灰色高光 + int mcx = sx + cellSize / 2, mcy = sy + cellSize / 2; + int mr = Math.max(2, cellSize / 4); + GameRenderHelper.drawCircle(g, mcx, mcy, mr, 0xFF111111); + g.fill(mcx, mcy - mr - 2, mcx + 1, mcy - mr + 1, 0xFF111111); + g.fill(mcx, mcy + mr - 1, mcx + 1, mcy + mr + 2, 0xFF111111); + g.fill(mcx - mr - 2, mcy, mcx - mr + 1, mcy + 1, 0xFF111111); + g.fill(mcx + mr - 1, mcy, mcx + mr + 2, mcy + 1, 0xFF111111); + g.fill(mcx - mr / 2, mcy - mr / 2, mcx, mcy, 0xFF888888); } else { g.fill(sx, sy, sx + cellSize, sy + cellSize, 0xFF1A1A22); if (c.adj > 0 && c.adj <= 8) @@ -175,7 +217,16 @@ private void renderPlaying(GuiGraphics g, int mx, int my) { g.fill(sx, sy, sx + 1, sy + cellSize, GameRenderHelper.brighten(bg, 1.15f)); g.fill(sx + cellSize - 1, sy, sx + cellSize, sy + cellSize, GameRenderHelper.darken(bg, 0.7f)); g.fill(sx, sy + cellSize - 1, sx + cellSize, sy + cellSize, GameRenderHelper.darken(bg, 0.6f)); - if (c.flagged) g.drawCenteredString(font, "🚩", sx + cellSize / 2, sy + (cellSize - 8) / 2, 0xFF4444); + if (c.flagged) { + // ★ 修复:默认字体无 U+1F6A9(🚩),豆腐块概率高,改为自绘小旗:浅灰旗杆 + 红色三角旗 + int fx = sx + cellSize / 2, fy = sy + cellSize / 2; + int fh = Math.max(3, cellSize / 4); + g.fill(fx - 1, fy - fh, fx + 1, fy + fh, 0xFFCCCCCC); + for (int i = 0; i < fh; i++) { + int w = fh - i; + g.fill(fx + 1, fy - fh + i, fx + 1 + w, fy - fh + i + 1, 0xFFEE3333); + } + } } // 网格线 g.fill(sx + cellSize, sy, sx + cellSize + 1, sy + cellSize, 0x22FFFFFF); @@ -183,7 +234,7 @@ private void renderPlaying(GuiGraphics g, int mx, int my) { } } - GameRenderHelper.tickAndRenderParticles(g, particles); + GameRenderHelper.renderParticles(g, particles); GameRenderHelper.drawTopHUD(g, width, height); g.drawString(font, "地雷: " + mineCount, 8, 7, 0xFF4444); g.drawCenteredString(font, "标旗: " + flagCount + " / " + mineCount, width / 2, 7, 0xFFFF44); diff --git a/src/main/java/com/wzz/game_console/client/screens/games/MoleHitbox.java b/src/main/java/com/wzz/game_console/client/screens/games/MoleHitbox.java new file mode 100644 index 0000000..37888b6 --- /dev/null +++ b/src/main/java/com/wzz/game_console/client/screens/games/MoleHitbox.java @@ -0,0 +1,18 @@ +package com.wzz.game_console.client.screens.games; + +final class MoleHitbox { + private MoleHitbox() {} + + static boolean containsVisiblePart(int holeX, int holeY, int holeSize, int moleSize, + float moleOffset, boolean hasMole, + double mouseX, double mouseY) { + if (!hasMole) return false; + int moleX = holeX + (holeSize - moleSize) / 2; + int moleY = (int) (holeY + holeSize - moleSize + moleOffset); + int visibleTop = Math.max(holeY, moleY); + int visibleBottom = Math.min(holeY + holeSize, moleY + moleSize); + return visibleTop < visibleBottom + && mouseX >= moleX && mouseX < moleX + moleSize + && mouseY >= visibleTop && mouseY < visibleBottom; + } +} diff --git a/src/main/java/com/wzz/game_console/client/screens/games/MouseTunnelGameScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/MouseTunnelGameScreen.java index 05a2bdd..b33b40c 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/MouseTunnelGameScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/MouseTunnelGameScreen.java @@ -45,6 +45,7 @@ private enum GameState { private long survivalTime = 0; private int score = 0; private int bestScore = 0; + private boolean isWin = false; // 通道数据 private List tunnelSegments = new ArrayList<>(); @@ -54,6 +55,8 @@ private enum GameState { // 鼠标追踪 private int playerX; private int playerY; + private int latestMouseX; + private int latestMouseY; private boolean mouseInTunnel = true; // 游戏参数 @@ -97,6 +100,9 @@ public MouseTunnelGameScreen() { @Override public void init() { + // ★ Bug修复:窗口缩放会重调 init(),不加 clearWidgets() 每次缩放 + // 都会叠加新按钮,玩家点击可能被最底层旧按钮拦截 + this.clearWidgets(); super.init(); boolean playing = gameState == GameState.PLAYING; if (!playing) { @@ -109,15 +115,18 @@ public void init() { tunnelSegmentCount = (this.width / SEGMENT_WIDTH) + 20; // 额外20段作为缓冲 this.startButton = Button.builder(Component.literal("开始游戏"), button -> startGame()) - .bounds(this.width / 2 - 50, this.height / 2 + 50, 100, 20) + .bounds(this.width / 2 - 50, this.height / 2 + 30, 100, 20) .build(); this.addRenderableWidget(this.startButton); this.exitButton = Button.builder(Component.literal("返回"), button -> Minecraft.getInstance().setScreen(new GameSelectorScreen())) - .bounds(this.width / 2 - 50, this.height / 2 + 80, 100, 20) + .bounds(this.width / 2 - 50, this.height / 2 + 60, 100, 20) .build(); this.addRenderableWidget(this.exitButton); if (playing) { + // 游戏进行中用不到这两个菜单按钮,隐藏避免误点(窗口缩放重建按钮后同样处理) + this.startButton.visible = false; + this.exitButton.visible = false; // 游戏进行中:保留现有通道与进度,仅在段数不足时补充新段以覆盖新窗口宽度 while (tunnelSegments.size() < tunnelSegmentCount) { TunnelSegment lastSegment = tunnelSegments.get(tunnelSegments.size() - 1); @@ -160,9 +169,12 @@ private void startGame() { difficulty = 1; scrollOffset = 0; mouseInTunnel = true; + latestMouseX = playerX; + latestMouseY = playerY; // 重置难度计时基准与宽限计时,避免开局瞬间触发难度提升或误判游戏结束 lastDifficultyIncrease = System.currentTimeMillis(); outOfTunnelSince = 0; + isWin = false; generateInitialTunnel(); @@ -181,12 +193,9 @@ public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTi GameRenderHelper.fillDarkBackground(graphics, width, height); if (gameState == GameState.PLAYING) { - // 更新鼠标位置 - playerX = mouseX; - playerY = mouseY; - - // 检查碰撞(弹窗期间暂停判定,否则宽限计时会持续走完导致暂停中被判失败) - if (!showExitConfirm) checkCollision(); + // 游戏逻辑统一在 tick() 推进;render 只读取已更新的位置。 + playerX = latestMouseX; + playerY = latestMouseY; // 渲染游戏 renderGame(graphics); @@ -264,7 +273,7 @@ private void renderMenu(GuiGraphics graphics) { graphics.drawString(this.font, title, (this.width - titleWidth) / 2, this.height / 2 - 50, 0xFFFFFF); if (gameState == GameState.GAME_OVER) { - String gameOverText = "游戏结束!"; + String gameOverText = isWin ? "胜利!" : "游戏结束!"; String finalScoreText = "最终分数: " + score; String bestScoreText = "最佳分数: " + bestScore; @@ -292,6 +301,12 @@ private void renderMenu(GuiGraphics graphics) { } } + @Override + public void mouseMoved(double mx, double my) { + latestMouseX = (int) mx; + latestMouseY = (int) my; + } + private void checkCollision() { // 计算当前鼠标位置对应的通道段 int segmentIndex = (playerX + scrollOffset) / SEGMENT_WIDTH; @@ -328,33 +343,54 @@ private void gameOver() { bestScore = score; } + // ★ Bug修复:100 分胜利 / 碰墙失败后确保退出弹窗状态被清掉, + // 否则开始/返回按钮可见但被 showExitConfirm 拦截点击,导致"100分后无反应" + showExitConfirm = false; + exitDialogOpenedAtMs = 0; + // 显示按钮 this.startButton.visible = true; this.exitButton.visible = true; - playFailSound(); + if (isWin) { + playSuccessSound(); + } else { + playFailSound(); + } } @Override public void tick() { super.tick(); - if (gameState == GameState.PLAYING && mouseInTunnel && !showExitConfirm) { // 弹窗期间暂停滚动与计时 + if (gameState == GameState.PLAYING && !showExitConfirm) { // 弹窗期间暂停滚动与计时 + playerX = latestMouseX; + playerY = latestMouseY; + checkCollision(); + if (!mouseInTunnel) return; // 更新生存时间 long currentTime = System.currentTimeMillis(); survivalTime = currentTime - gameStartTime; - score = (int)(survivalTime / 100); // 每100毫秒1分 + MouseTunnelProgress.Snapshot progress = MouseTunnelProgress.calculate( + survivalTime, currentTime - lastDifficultyIncrease); + score = progress.score(); - // 滚动通道 - scrollOffset += SCROLL_SPEED + (difficulty - 1); - - // 增加难度 - if (currentTime - lastDifficultyIncrease > 10000) { // 每10秒增加难度 + // Increase difficulty before evaluating victory so every scheduled level is observable. + for (int i = 0; i < progress.difficultyIncreases(); i++) { difficulty++; - lastDifficultyIncrease = currentTime; + lastDifficultyIncrease += MouseTunnelProgress.DIFFICULTY_INTERVAL_MILLIS; generateMoreChallengingTunnel(); } + if (progress.won()) { + isWin = true; + gameOver(); + return; + } + + // 滚动通道 + scrollOffset += SCROLL_SPEED + (difficulty - 1); + // 生成新的通道段 - 修改触发条件 if (scrollOffset >= SEGMENT_WIDTH) { scrollOffset -= SEGMENT_WIDTH; diff --git a/src/main/java/com/wzz/game_console/client/screens/games/MouseTunnelProgress.java b/src/main/java/com/wzz/game_console/client/screens/games/MouseTunnelProgress.java new file mode 100644 index 0000000..ad2670f --- /dev/null +++ b/src/main/java/com/wzz/game_console/client/screens/games/MouseTunnelProgress.java @@ -0,0 +1,20 @@ +package com.wzz.game_console.client.screens.games; + +final class MouseTunnelProgress { + static final int WIN_SCORE = 100; + static final long SCORE_INTERVAL_MILLIS = 100; + static final long DIFFICULTY_INTERVAL_MILLIS = 3_000; + + record Snapshot(int score, int difficultyIncreases, boolean won) {} + + private MouseTunnelProgress() {} + + static Snapshot calculate(long survivalMillis, long sinceLastDifficultyIncrease) { + long elapsed = Math.max(0, survivalMillis); + int score = (int) Math.min(Integer.MAX_VALUE, elapsed / SCORE_INTERVAL_MILLIS); + long difficultyMillis = Math.min(Math.max(0, sinceLastDifficultyIncrease), + WIN_SCORE * SCORE_INTERVAL_MILLIS); + int increases = (int) (difficultyMillis / DIFFICULTY_INTERVAL_MILLIS); + return new Snapshot(score, increases, score >= WIN_SCORE); + } +} diff --git a/src/main/java/com/wzz/game_console/client/screens/games/PianoTilesGameScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/PianoTilesGameScreen.java index 7a93cea..f706025 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/PianoTilesGameScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/PianoTilesGameScreen.java @@ -95,6 +95,13 @@ public enum HitResult { private static final int MISS_THRESHOLD = 350; // 新增:错过阈值350毫秒 private static final String MUSIC_FOLDER = ExternalFileManager.MUSIC_FOLDER; private static final String VOICE_FOLDER = ExternalFileManager.VOICE_FOLDER; + private static final long MAX_PTS_BYTES = 32L * 1024 * 1024; + private static final int MAX_AUDIO_BYTES = 24 * 1024 * 1024; + private static final int MAX_AUDIO_BASE64_CHARS = ((MAX_AUDIO_BYTES + 2) / 3) * 4; + private static final int MAX_NOTES = 100_000; + private static final int MAX_METADATA_LENGTH = 256; + private static final long MAX_NOTE_TIMESTAMP_MS = 24L * 60 * 60 * 1000; + private static final Set SAFE_AUDIO_FORMATS = Set.of("wav", "aif", "aiff", "au"); public static class GameAudioPlayer { private javax.sound.sampled.Clip backgroundClip; @@ -104,9 +111,9 @@ public static class GameAudioPlayer { private String tempAudioFile = null; // 临时音频文件路径 public boolean loadBackgroundMusic(String filePath) { + javax.sound.sampled.Clip candidate = null; try { debugLog("尝试加载音频文件: " + filePath); - closeAudio(); File audioFile = new File(filePath); debugLog("文件对象创建完成"); debugLog("文件存在检查: " + audioFile.exists()); @@ -150,48 +157,51 @@ public boolean loadBackgroundMusic(String filePath) { javax.sound.sampled.AudioInputStream audioStream = javax.sound.sampled.AudioSystem.getAudioInputStream(bis)) { debugLog("音频流创建成功"); debugLog("开始创建音频剪辑..."); - backgroundClip = javax.sound.sampled.AudioSystem.getClip(); - backgroundClip.open(audioStream); + candidate = javax.sound.sampled.AudioSystem.getClip(); + candidate.open(audioStream); debugLog("音频剪辑创建成功"); - audioLength = backgroundClip.getMicrosecondLength(); + long candidateLength = candidate.getMicrosecondLength(); + closeAudio(); + backgroundClip = candidate; + candidate = null; + audioLength = candidateLength; isLoaded = true; debugLog("音频加载成功,长度: " + (audioLength / 1000000) + " 秒"); setVolume(volume); return true; } } catch (Exception e) { + if (candidate != null) try { candidate.close(); } catch (Exception ignored) {} debugLog("音频加载失败: " + e.getMessage()); e.printStackTrace(); - isLoaded = false; return false; } } public boolean loadBackgroundMusicFromMemory(byte[] audioData, String format) { + if (audioData == null || audioData.length == 0) { + debugLog("音频数据为空"); + return false; + } + javax.sound.sampled.Clip candidate = null; try { debugLog("尝试直接从内存播放音频,数据大小: " + audioData.length + " bytes"); - closeAudio(); - if (audioData == null || audioData.length == 0) { - debugLog("音频数据为空"); - return false; - } try (ByteArrayInputStream bais = new ByteArrayInputStream(audioData); javax.sound.sampled.AudioInputStream audioStream = javax.sound.sampled.AudioSystem.getAudioInputStream(bais)) { - debugLog("音频输入流创建成功"); - backgroundClip = javax.sound.sampled.AudioSystem.getClip(); - backgroundClip.open(audioStream); - debugLog("音频剪辑创建成功"); - audioLength = backgroundClip.getMicrosecondLength(); + candidate = javax.sound.sampled.AudioSystem.getClip(); + candidate.open(audioStream); + long candidateLength = candidate.getMicrosecondLength(); + closeAudio(); + backgroundClip = candidate; + candidate = null; + audioLength = candidateLength; isLoaded = true; - debugLog("直接从内存加载音频成功,长度: " + (audioLength / 1000000) + " 秒"); setVolume(volume); return true; } - } catch (Exception e) { + if (candidate != null) try { candidate.close(); } catch (Exception ignored) {} debugLog("从内存加载音频失败: " + e.getMessage()); - e.printStackTrace(); - isLoaded = false; return false; } } @@ -200,7 +210,6 @@ public boolean loadBackgroundMusicFromData(byte[] audioData, String format) { try { debugLog("尝试从音频数据加载,数据大小: " + (audioData != null ? audioData.length : 0) + " bytes,格式: " + format); - closeAudio(); if (audioData == null || audioData.length == 0) { debugLog("音频数据为空"); return false; @@ -216,19 +225,19 @@ public boolean loadBackgroundMusicFromData(byte[] audioData, String format) { } debugLog("=== 策略2: 尝试临时文件播放 ==="); try { - tempAudioFile = createTempAudioFile(audioData, format); - if (tempAudioFile == null) { + String candidatePath = createTempAudioFile(audioData, format); + if (candidatePath == null) { debugLog("策略2失败:创建临时音频文件失败"); } else { - debugLog("临时音频文件创建成功: " + tempAudioFile); - - boolean result = loadBackgroundMusic(tempAudioFile); + debugLog("临时音频文件创建成功: " + candidatePath); + boolean result = loadBackgroundMusic(candidatePath); if (result) { + tempAudioFile = candidatePath; debugLog("策略2成功:从临时文件播放"); return true; - } else { - debugLog("策略2失败:从临时文件加载音频失败"); } + try { Files.deleteIfExists(Paths.get(candidatePath)); } catch (Exception ignored) {} + debugLog("策略2失败:从临时文件加载音频失败"); } } catch (Exception e) { debugLog("策略2异常:" + e.getMessage()); @@ -239,7 +248,6 @@ public boolean loadBackgroundMusicFromData(byte[] audioData, String format) { } catch (Exception e) { debugLog("从音频数据加载失败: " + e.getMessage()); e.printStackTrace(); - isLoaded = false; return false; } } @@ -250,79 +258,25 @@ private void debugLog(String message) { } private String createTempAudioFile(byte[] audioData, String format) { + Path tempFile = null; try { - // 确保voice文件夹存在 Path voiceDir = ExternalFileManager.getVoiceDir(); - if (!Files.exists(voiceDir)) { - Files.createDirectories(voiceDir); - debugLog("创建voice文件夹: " + voiceDir.toAbsolutePath()); - } - - // 确定文件扩展名 - String suffix; - if (format != null) { - suffix = format.startsWith(".") ? format : "." + format; - } else { - suffix = ".wav"; // 默认扩展名 - } - String fileName = "temp_audio_" + System.currentTimeMillis() + "_" + - (int)(Math.random() * 1000) + suffix; - Path tempFile = voiceDir.resolve(fileName); - - debugLog("创建临时音频文件: " + tempFile.toAbsolutePath()); - try (FileOutputStream fos = new FileOutputStream(tempFile.toFile()); - BufferedOutputStream bos = new BufferedOutputStream(fos)) { - - bos.write(audioData); - bos.flush(); // 强制刷新缓冲区 - fos.getFD().sync(); // 强制同步到磁盘 - - } // 自动关闭文件流 - debugLog("音频数据写入完成,文件大小: " + Files.size(tempFile) + " bytes"); - int maxRetries = 10; - for (int i = 0; i < maxRetries; i++) { - if (Files.exists(tempFile) && Files.isReadable(tempFile) && Files.size(tempFile) == audioData.length) { - debugLog("文件验证成功,尝试次数: " + (i + 1)); - break; - } - - if (i < maxRetries - 1) { - debugLog("文件验证失败,等待重试... (尝试 " + (i + 1) + "/" + maxRetries + ")"); - try { - Thread.sleep(50); // 等待50毫秒 - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } - } else { - debugLog("文件验证最终失败"); - return null; - } - } - - // 最终验证 - if (!Files.exists(tempFile)) { - debugLog("最终检查:临时文件不存在"); - return null; + if (voiceDir == null) return null; + Files.createDirectories(voiceDir); + String normalizedFormat = format == null ? "wav" + : format.trim().toLowerCase(Locale.ROOT).replaceFirst("^\\.", ""); + if (!SAFE_AUDIO_FORMATS.contains(normalizedFormat)) return null; + tempFile = Files.createTempFile(voiceDir, "temp_audio_", "." + normalizedFormat); + Files.write(tempFile, audioData); + if (!Files.isReadable(tempFile) || Files.size(tempFile) != audioData.length) { + throw new IOException("临时音频文件验证失败"); } - - if (!Files.isReadable(tempFile)) { - debugLog("最终检查:临时文件不可读"); - return null; - } - - long actualSize = Files.size(tempFile); - if (actualSize != audioData.length) { - debugLog("最终检查:文件大小不匹配,期望: " + audioData.length + ", 实际: " + actualSize); - return null; - } - - debugLog("临时文件创建和验证成功"); - return tempFile.toAbsolutePath().toString(); - + return tempFile.toAbsolutePath().normalize().toString(); } catch (Exception e) { debugLog("创建临时音频文件异常: " + e.getMessage()); - e.printStackTrace(); + if (tempFile != null) { + try { Files.deleteIfExists(tempFile); } catch (Exception ignored) {} + } return null; } } @@ -337,9 +291,15 @@ public void play() { } } - public void resume() { + public void resume(long audioScheduledTime) { if (isLoaded && backgroundClip != null) { - backgroundClip.start(); + // ★ 前导期暂停恢复修复:未到起播时刻(audioScheduledTime)不能立即 start(), + // 否则音频提前起播,且 tick 的延迟起播分支因 isPlaying() 恒真被跳过(音画失准)。 + // 未到时刻则不动,交给 tick 的延迟起播分支按时起播; + // 正常播放后 audioScheduledTime 已归零,此处恒满足、行为与原来一致 + if (System.currentTimeMillis() >= audioScheduledTime) { + backgroundClip.start(); + } } } @@ -418,59 +378,105 @@ public void closeAudio() { } } - // 修改loadSongFromFile方法 - 支持嵌入音频 private SongInfo loadSongFromFile(String filePath) throws IOException { + Path songPath = Paths.get(filePath).toAbsolutePath().normalize(); + long fileSize = Files.size(songPath); + if (fileSize <= 0 || fileSize > MAX_PTS_BYTES) { + throw new IOException("谱面文件大小必须在 1 字节到 32 MiB 之间"); + } SongInfo song = new SongInfo(); - try (BufferedReader reader = new BufferedReader( - new InputStreamReader(new FileInputStream(filePath), StandardCharsets.UTF_8))) { - + try (BufferedReader reader = Files.newBufferedReader(songPath, StandardCharsets.UTF_8)) { String line; while ((line = reader.readLine()) != null) { if (line.startsWith("Name:")) { - song.name = line.substring(5).trim(); + song.name = boundedMetadata(line.substring(5), "歌曲名"); } else if (line.startsWith("Artist:")) { - song.artist = line.substring(7).trim(); + song.artist = boundedMetadata(line.substring(7), "艺术家"); } else if (line.startsWith("BPM:")) { - song.bpm = Integer.parseInt(line.substring(4).trim()); + try { + song.bpm = Integer.parseInt(line.substring(4).trim()); + } catch (NumberFormatException e) { + throw new IOException("BPM 格式无效", e); + } + if (song.bpm < 20 || song.bpm > 1000) throw new IOException("BPM 超出 20-1000 范围"); } else if (line.startsWith("Difficulty:")) { - song.difficulty = line.substring(11).trim(); + song.difficulty = boundedMetadata(line.substring(11), "难度"); } else if (line.startsWith("AudioFormat:")) { - song.audioFormat = line.substring(12).trim(); + String format = line.substring(12).trim().toLowerCase(Locale.ROOT).replaceFirst("^\\.", ""); + if (!SAFE_AUDIO_FORMATS.contains(format)) throw new IOException("不支持的音频格式"); + song.audioFormat = format; } else if (line.startsWith("AudioData:")) { - // 解码Base64音频数据 String audioBase64 = line.substring(10).trim(); + if (audioBase64.length() > MAX_AUDIO_BASE64_CHARS) throw new IOException("嵌入音频超过 24 MiB 上限"); try { song.audioData = Base64.getDecoder().decode(audioBase64); - } catch (Exception e) { - System.err.println("解码音频数据失败: " + e.getMessage()); - song.audioData = null; - } - } else if (line.startsWith("Audio:")) { - // 兼容旧格式(路径方式) - String audioPath = line.substring(6).trim(); - if (song.audioData == null) { // 如果没有嵌入的音频数据,则使用路径 - song.audioFile = audioPath; + } catch (IllegalArgumentException e) { + throw new IOException("嵌入音频 Base64 无效", e); } + if (song.audioData.length > MAX_AUDIO_BYTES) throw new IOException("嵌入音频超过 24 MiB 上限"); + song.audioFile = null; + } else if (line.startsWith("Audio:") && song.audioData == null) { + song.audioFile = resolveLegacyAudioPath(line.substring(6).trim()).toString(); } else if (line.startsWith("Note:")) { - String noteData = line.substring(5).trim(); - String[] parts = noteData.split(","); + if (song.notes.size() >= MAX_NOTES) throw new IOException("音符数量超过 100000 上限"); + String[] parts = line.substring(5).trim().split(","); if (parts.length >= 3) { - int lane = Integer.parseInt(parts[0].split(":")[1]); - long timestamp = Long.parseLong(parts[1].split(":")[1]); - int noteType = Integer.parseInt(parts[2].split(":")[1]); - song.notes.add(new Note(lane, timestamp, noteType)); + try { + String[] kv0 = parts[0].split(":"); + String[] kv1 = parts[1].split(":"); + String[] kv2 = parts[2].split(":"); + if (kv0.length < 2 || kv1.length < 2 || kv2.length < 2) continue; + int lane = Integer.parseInt(kv0[1]); + long timestamp = Long.parseLong(kv1[1]); + int noteType = Integer.parseInt(kv2[1]); + if (lane >= 0 && lane < LANE_COUNT && noteType >= 0 && noteType < LANE_COUNT + && timestamp >= 0 && timestamp <= MAX_NOTE_TIMESTAMP_MS) { + song.notes.add(new Note(lane, timestamp, noteType)); + } + } catch (NumberFormatException | ArrayIndexOutOfBoundsException ignored) { + // 畸形音符不会破坏其余有效音符。 + } } } } } - - // 按时间排序音符 song.notes.sort(Comparator.comparingLong(n -> n.timestamp)); - return song; } + private static String boundedMetadata(String value, String field) throws IOException { + String trimmed = value.trim(); + if (trimmed.length() > MAX_METADATA_LENGTH) throw new IOException(field + "超过 256 字符上限"); + return trimmed; + } + + private static Path resolveLegacyAudioPath(String value) throws IOException { + if (value.isBlank() || value.startsWith("\\\\") || value.startsWith("//")) { + throw new IOException("旧版音频路径无效"); + } + Path relative; + try { + relative = Paths.get(value); + } catch (RuntimeException e) { + throw new IOException("旧版音频路径无效", e); + } + if (relative.isAbsolute() || relative.getRoot() != null) throw new IOException("旧版音频路径必须是相对路径"); + for (Path part : relative) { + if ("..".equals(part.toString())) throw new IOException("旧版音频路径不允许目录穿越"); + } + Path[] allowedRoots = {ExternalFileManager.getMusicDir(), ExternalFileManager.getVoiceDir()}; + for (Path root : allowedRoots) { + if (root == null) continue; + Path normalizedRoot = root.toAbsolutePath().normalize(); + Path candidate = normalizedRoot.resolve(relative).normalize(); + if (candidate.startsWith(normalizedRoot) && Files.isRegularFile(candidate) && Files.isReadable(candidate)) { + return candidate; + } + } + throw new IOException("旧版音频文件不在允许目录内或不可读"); + } + // 修改loadSelectedSong方法 - 支持嵌入音频加载 private void loadSelectedSong() { if (selectedSongIndex >= 0 && selectedSongIndex < availableSongs.size()) { @@ -479,26 +485,40 @@ private void loadSelectedSong() { if ("默认歌曲".equals(songName)) { loadDefaultSong(); } else { + GameAudioPlayer candidatePlayer = null; try { - Path songFile = ExternalFileManager.getMusicDir().resolve(songName + ".pts"); - if (Files.exists(songFile)) { - currentSong = loadSongFromFile(songFile.toString()); - - // 优先使用嵌入的音频数据 - if (currentSong.audioData != null && currentSong.audioData.length > 0) { - audioPlayer.loadBackgroundMusicFromData(currentSong.audioData, currentSong.audioFormat); - } else if (currentSong.audioFile != null && !currentSong.audioFile.isEmpty()) { - // 兼容旧格式,使用路径加载 - audioPlayer.loadBackgroundMusic(currentSong.audioFile); - } else { - // 无音频数据:释放旧 clip(closeAudio 置 isLoaded=false 并真正关闭), - // 否则 startGame().play() 会播上一首歌的音频配新谱面 - audioPlayer.closeAudio(); + Path musicDir = ExternalFileManager.getMusicDir(); + if (musicDir == null) throw new IOException("音乐目录不可用"); + Path songFile = musicDir.resolve(songName + ".pts").normalize(); + if (!Files.isRegularFile(songFile)) { + // Preserve the actual filename on case-sensitive filesystems. + try (var paths = Files.list(musicDir)) { + songFile = paths.filter(Files::isRegularFile) + .filter(p -> p.getFileName().toString().toLowerCase(Locale.ROOT) + .equals((songName + ".pts").toLowerCase(Locale.ROOT))) + .findFirst().orElse(null); } - } else { + } + if (songFile == null || !Files.isRegularFile(songFile)) { loadDefaultSong(); + return; + } + SongInfo candidateSong = loadSongFromFile(songFile.toString()); + candidatePlayer = new GameAudioPlayer(); + boolean audioReady = true; + if (candidateSong.audioData != null && candidateSong.audioData.length > 0) { + audioReady = candidatePlayer.loadBackgroundMusicFromData(candidateSong.audioData, candidateSong.audioFormat); + } else if (candidateSong.audioFile != null && !candidateSong.audioFile.isEmpty()) { + audioReady = candidatePlayer.loadBackgroundMusic(candidateSong.audioFile); } + if (!audioReady) throw new IOException("音频加载失败"); + GameAudioPlayer oldPlayer = audioPlayer; + currentSong = candidateSong; + audioPlayer = candidatePlayer; + candidatePlayer = null; + if (oldPlayer != null) oldPlayer.closeAudio(); } catch (Exception e) { + if (candidatePlayer != null) candidatePlayer.closeAudio(); loadDefaultSong(); } } @@ -562,6 +582,13 @@ private void loadSelectedSong() { // UI按钮 private Button startButton, pauseButton, backButton; private Button prevSongButton, nextSongButton, selectSongButton, importSongButton; + private boolean importInProgress; + private String importMessage; + private long importMessageUntil; + private volatile long importGeneration; + private volatile Thread importThread; + private volatile Frame importFrame; + private volatile FileDialog importDialog; // 特效类 public static class HitEffect { @@ -613,8 +640,8 @@ public void resize(Minecraft minecraft, int width, int height) { private void calculateLayout() { // 计算游戏区域大小 - gameAreaWidth = Math.min(400, this.width - 100); - gameAreaHeight = Math.min(600, this.height - 150); + gameAreaWidth = Math.max(LANE_COUNT, Math.min(400, this.width - 100)); + gameAreaHeight = Math.max(1, Math.min(600, this.height - 150)); // 确保是4的倍数以便平分轨道 gameAreaWidth = (gameAreaWidth / 4) * 4; @@ -662,10 +689,11 @@ private void setupButtons() { this.addRenderableWidget(nextSongButton); // 导入按钮 - importSongButton = Button.builder(Component.literal("\u5bfc\u5165"), button -> { // "导入" - importSongFromDialog(); + importSongButton = Button.builder(Component.literal(importInProgress ? "导入中..." : "导入"), button -> { + if (!importInProgress) importSongFromDialog(); playSound(SoundEvents.UI_BUTTON_CLICK.value()); }).bounds(buttonStartX, buttonY + 30, buttonWidth, buttonHeight).build(); + importSongButton.active = !importInProgress; this.addRenderableWidget(importSongButton); } else { @@ -711,19 +739,19 @@ private void loadAvailableSongs() { try { Path musicDir = ExternalFileManager.getMusicDir(); - if (Files.exists(musicDir)) { + if (musicDir != null && Files.isDirectory(musicDir)) { // try-with-resources 关闭目录流:Files.list 打开的句柄不关会在 Windows 上泄漏 try (var paths = Files.list(musicDir)) { - paths.filter(path -> path.toString().endsWith(".pts")) - .forEach(path -> { - String fileName = path.getFileName().toString(); - String songName = fileName.substring(0, fileName.lastIndexOf('.')); - availableSongs.add(songName); - }); + paths.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".pts")) + .map(path -> path.getFileName().toString()) + .sorted(String.CASE_INSENSITIVE_ORDER) + .forEach(fileName -> availableSongs.add( + fileName.substring(0, fileName.lastIndexOf('.')))); } } } catch (Exception e) { - // 静默处理错误 + // 目录不可用时保留默认歌曲 } if (availableSongs.isEmpty()) { @@ -778,7 +806,7 @@ private void closeExitConfirmDialog() { @Override public boolean keyPressed(int keyCode, int scanCode, int modifiers) { if (keyCode == GLFW.GLFW_KEY_ESCAPE) { if (showExitConfirm) { closeExitConfirmDialog(); } else { openExitConfirmDialog(); } return true; } - if (showExitConfirm) return true; + if (showExitConfirm || countdownRemaining > 0) return true; // 如果在歌曲选择模式,使用默认的键盘处理 if (songSelectMode) { // 可以添加方向键切换歌曲的功能 @@ -895,11 +923,13 @@ private void backToSongSelect() { noteIndex = 0; score = 0; combo = 0; + maxCombo = 0; // 修复:返回选歌界面时同步清零最大连击,避免残留到下一局 perfectHits = 0; greatHits = 0; goodHits = 0; missedHits = 0; totalPausedTime = 0; + countdownRemaining = 0; // 返回选歌时取消未走完的恢复倒计时,避免残留状态影响下一局 audioPlayer.stop(); this.clearWidgets(); @@ -921,11 +951,24 @@ private void initializeGame() { gamePaused = false; gameOver = false; currentGameTime = 0; + countdownRemaining = 0; // 取消未走完的恢复倒计时 } private void startGame() { // 修复:移除全局停声(会误停游戏世界其他声音),改为只停本界面自己的音频 audioPlayer.stop(); + // 开局补齐全量状态重置(逐项对齐 initializeGame):开始按钮直接调用本方法时 + // 不会先走 initializeGame,否则上一局的分数/连击/判定统计/特效会残留到新局 + score = 0; + combo = 0; + maxCombo = 0; + perfectHits = 0; + greatHits = 0; + goodHits = 0; + missedHits = 0; + hitEffects.clear(); + currentGameTime = 0; + countdownRemaining = 0; gameActive = true; gamePaused = false; gameOver = false; @@ -966,9 +1009,13 @@ private void endGame() { @Override public void tick() { super.tick(); + if (!minecraft.isWindowActive()) { + leftMouseDown = false; + currentClickLane = -1; + } - // 恢复倒计时处理(在 gamePaused 检查之前执行) - if (countdownRemaining > 0) { + // 恢复倒计时处理(在 gamePaused 检查之前执行);退出弹窗/选歌界面期间冻结倒计时 + if (countdownRemaining > 0 && !showExitConfirm && !songSelectMode) { long elapsed = System.currentTimeMillis() - countdownStartTime; if (elapsed >= 1000) { countdownRemaining--; @@ -982,13 +1029,15 @@ public void tick() { if (audioScheduledTime > 0) { audioScheduledTime += pausedMs; // 同步推迟音频启动 } - audioPlayer.resume(); + audioPlayer.resume(audioScheduledTime); } } } // 音频延迟启动:游戏开始后等待 lead 时间才播放音频,使音符从顶端开始对齐 - if (audioScheduledTime > 0 && System.currentTimeMillis() >= audioScheduledTime && !audioPlayer.isPlaying()) { + if (audioScheduledTime > 0 && System.currentTimeMillis() >= audioScheduledTime + && !gamePaused && !showExitConfirm && !songSelectMode && gameActive && !gameOver + && !audioPlayer.isPlaying()) { audioScheduledTime = 0; audioPlayer.play(); } @@ -996,7 +1045,9 @@ public void tick() { if (!songSelectMode && gameActive && !gamePaused && !gameOver && leftMouseDown && !showExitConfirm) { long currentTime = System.currentTimeMillis(); if (currentTime - lastClickTime > 100) { // 每100ms最多触发一次 - double mouseX = minecraft.mouseHandler.xpos() * minecraft.getWindow().getGuiScaledWidth() / minecraft.getWindow().getScreenWidth(); + // ★ Bug修复:原版 (int)mouseX 直接截断,GUI scale=4 下玩家鼠标 + // 跨整像素边界时丢精度偏 1-3 像素,点击车道错一格。加 0.5 四舍五入 + double mouseX = minecraft.mouseHandler.xpos() * minecraft.getWindow().getGuiScaledWidth() / minecraft.getWindow().getScreenWidth() + 0.5; if (mouseX >= gameStartX && mouseX < gameStartX + gameAreaWidth) { int lane = (int) ((mouseX - gameStartX) / laneWidth); @@ -1315,8 +1366,8 @@ private void renderGameUI(GuiGraphics guiGraphics) { guiGraphics.drawString(font, cd, -tw / 2, -4, 0xFFFFDD44); guiGraphics.pose().popPose(); } else { - String pauseText = "u6e38u620fu6682u505c"; - String resumeHint = "u70b9u51fb'u7ee7u7eed'u6216u6309u7a7au683cu952eu6062u590du6e38u620f"; + String pauseText = "\u6e38\u620f\u6682\u505c"; + String resumeHint = "\u70b9\u51fb'\u7ee7\u7eed'\u6216\u6309\u7a7a\u683c\u952e\u6062\u590d\u6e38\u620f"; int pauseX = (this.width - font.width(pauseText)) / 2; int hintX = (this.width - font.width(resumeHint)) / 2; @@ -1401,6 +1452,16 @@ private void renderSongSelection(GuiGraphics guiGraphics) { guiGraphics.drawString(font, instruction, instructionX, instructionY, 0xFFAAAAAA); instructionY += font.lineHeight + 3; } + + if (importMessage != null) { + if (importInProgress || System.currentTimeMillis() <= importMessageUntil) { + int color = importMessage.startsWith("导入失败") ? 0xFFFF5555 : 0xFF55FF55; + guiGraphics.drawCenteredString(font, importMessage, width / 2, + Math.min(height - font.lineHeight - 8, instructionY + 5), color); + } else { + importMessage = null; + } + } } private void renderGameOverScreen(GuiGraphics guiGraphics) { @@ -1476,43 +1537,158 @@ private void renderGameOverScreen(GuiGraphics guiGraphics) { private long lastClickTime = 0; private int currentClickLane = -1; - /** 打开文件对话框导入 .pts 谱面 */ + /** 打开文件对话框导入 .pts 谱面。对话框和文件/音频 I/O 均不得阻塞 Minecraft 客户端线程。 */ private void importSongFromDialog() { - try { - Frame frame = new Frame(); - frame.setAlwaysOnTop(true); - FileDialog dialog = new FileDialog(frame, "选择导入的谱面文件 (.pts)", FileDialog.LOAD); - dialog.setFile("*.pts"); - dialog.setVisible(true); - String filePath = dialog.getFile(); - String dirPath = dialog.getDirectory(); - frame.dispose(); - - if (filePath == null || dirPath == null) return; + long generation = ++importGeneration; + importInProgress = true; + importMessage = "正在选择谱面..."; + importMessageUntil = Long.MAX_VALUE; + updateImportButton(); + + Thread importer = new Thread(() -> { + Path candidateFile = null; + GameAudioPlayer preparedPlayer = null; + try { + Frame frame = new Frame(); + importFrame = frame; + frame.setAlwaysOnTop(true); + frame.setLocationRelativeTo(null); + FileDialog dialog = new FileDialog(frame, "选择导入的谱面文件 (.pts)", FileDialog.LOAD); + importDialog = dialog; + dialog.setFile("*.pts"); + dialog.setLocationRelativeTo(frame); + dialog.setVisible(true); + + String filePath = dialog.getFile(); + String dirPath = dialog.getDirectory(); + if (filePath == null || dirPath == null) { + finishImport(generation, null, null, null, null); + return; + } + checkImportActive(generation); + Path source = new File(dirPath, filePath).toPath().toAbsolutePath().normalize(); + String sourceName = source.getFileName().toString(); + if (!Files.isRegularFile(source) || !sourceName.toLowerCase(Locale.ROOT).endsWith(".pts")) { + throw new IOException("请选择有效的 .pts 谱面文件"); + } + Path musicDir = ExternalFileManager.getMusicDir(); + if (musicDir == null) throw new IOException("音乐目录不可用"); + musicDir = musicDir.toAbsolutePath().normalize(); + Files.createDirectories(musicDir); + Path destination = musicDir.resolve(sourceName).normalize(); + if (!destination.startsWith(musicDir)) throw new IOException("谱面文件名无效"); + candidateFile = Files.createTempFile(musicDir, ".import-", ".pts"); + copyPtsWithLimit(source, candidateFile); + + SongInfo preparedSong = loadSongFromFile(candidateFile.toString()); + preparedPlayer = new GameAudioPlayer(); + boolean audioReady = true; + if (preparedSong.audioData != null && preparedSong.audioData.length > 0) { + audioReady = preparedPlayer.loadBackgroundMusicFromData(preparedSong.audioData, preparedSong.audioFormat); + } else if (preparedSong.audioFile != null && !preparedSong.audioFile.isEmpty()) { + audioReady = preparedPlayer.loadBackgroundMusic(preparedSong.audioFile); + } + if (!audioReady) throw new IOException("音频加载失败"); + checkImportActive(generation); + try { + Files.move(candidateFile, destination, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException unsupported) { + Files.move(candidateFile, destination, StandardCopyOption.REPLACE_EXISTING); + } + candidateFile = null; + String importedSongName = sourceName.substring(0, sourceName.lastIndexOf('.')); + finishImport(generation, importedSongName, preparedSong, preparedPlayer, null); + preparedPlayer = null; + } catch (InterruptedException cancelled) { + Thread.currentThread().interrupt(); + finishImport(generation, null, null, null, null); + } catch (Throwable t) { + finishImport(generation, null, null, null, + t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage()); + } finally { + if (preparedPlayer != null) preparedPlayer.closeAudio(); + if (candidateFile != null) try { Files.deleteIfExists(candidateFile); } catch (Exception ignored) {} + FileDialog dialog = importDialog; + if (dialog != null) dialog.dispose(); + Frame frame = importFrame; + if (frame != null) frame.dispose(); + if (importThread == Thread.currentThread()) importThread = null; + importDialog = null; + importFrame = null; + } + }, "GameConsole-RhythmImport"); + importer.setDaemon(true); + importThread = importer; + importer.start(); + } - File srcFile = new File(dirPath, filePath); - if (!srcFile.exists() || !srcFile.getName().endsWith(".pts")) return; + private void checkImportActive(long generation) throws InterruptedException { + if (Thread.currentThread().isInterrupted() || generation != importGeneration) { + throw new InterruptedException("导入已取消"); + } + } - Path musicDir = ExternalFileManager.getMusicDir(); - if (!Files.exists(musicDir)) Files.createDirectories(musicDir); + private static void copyPtsWithLimit(Path source, Path destination) throws IOException { + long copied = 0; + byte[] buffer = new byte[8192]; + try (InputStream in = Files.newInputStream(source); OutputStream out = Files.newOutputStream(destination)) { + int count; + while ((count = in.read(buffer)) >= 0) { + if (count == 0) continue; + copied += count; + if (copied > MAX_PTS_BYTES) throw new IOException("谱面文件超过 32 MiB 上限"); + out.write(buffer, 0, count); + } + } + if (copied == 0) throw new IOException("谱面文件为空"); + } - Path destPath = musicDir.resolve(srcFile.getName()); - Files.copy(srcFile.toPath(), destPath, StandardCopyOption.REPLACE_EXISTING); + private void finishImport(long generation, String importedSongName, SongInfo preparedSong, + GameAudioPlayer preparedPlayer, String error) { + Minecraft.getInstance().execute(() -> { + if (generation != importGeneration || Minecraft.getInstance().screen != this) { + if (preparedPlayer != null) preparedPlayer.closeAudio(); + return; + } - // 重新加载歌曲列表 - loadAvailableSongs(); - if (!availableSongs.isEmpty()) { - selectedSongIndex = availableSongs.size() - 1; - loadSelectedSong(); + importInProgress = false; + if (error != null) { + importMessage = "导入失败:" + error; + } else if (preparedSong == null) { + importMessage = "已取消导入"; + } else { + GameAudioPlayer oldPlayer = audioPlayer; + currentSong = preparedSong; + audioPlayer = preparedPlayer; + if (oldPlayer != null) oldPlayer.closeAudio(); + + loadAvailableSongs(); + selectedSongIndex = 0; + for (int i = 0; i < availableSongs.size(); i++) { + if (availableSongs.get(i).equalsIgnoreCase(importedSongName)) { + selectedSongIndex = i; + break; + } + } + importMessage = "导入成功:" + importedSongName; } - } catch (Exception e) { - System.err.println("导入谱面失败: " + e.getMessage()); + importMessageUntil = System.currentTimeMillis() + 5000L; + updateImportButton(); + }); + } + + private void updateImportButton() { + if (importSongButton != null) { + importSongButton.active = !importInProgress; + importSongButton.setMessage(Component.literal(importInProgress ? "导入中..." : "导入")); } } @Override public boolean mouseClicked(double mouseX, double mouseY, int button) { if (showExitConfirm) { int click = GameRenderHelper.getExitConfirmClick(mouseX, mouseY, width, height); if (click == 1) { showExitConfirm = false; Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } if (click == 2) { closeExitConfirmDialog(); return true; } return true; } + // 倒计时显示仍处于暂停态,必须吞掉点击,避免穿透到底层轨道或控件。 + if (showExitConfirm || countdownRemaining > 0) return true; if (songSelectMode || !gameActive || gamePaused || gameOver || button != 0) { return super.mouseClicked(mouseX, mouseY, button); } @@ -1658,21 +1834,33 @@ public boolean isPauseScreen() { return false; } + private void cancelImport() { + importGeneration++; + importInProgress = false; + Thread importer = importThread; + if (importer != null) importer.interrupt(); + FileDialog dialog = importDialog; + if (dialog != null) dialog.dispose(); + Frame frame = importFrame; + if (frame != null) frame.dispose(); + importThread = null; + importDialog = null; + importFrame = null; + } + @Override public void onClose() { - super.onClose(); + cancelImport(); // 只停止本界面自己启动的音频实例,不再停止游戏全局声音 - if (audioPlayer != null) { - audioPlayer.closeAudio(); - } + if (audioPlayer != null) audioPlayer.closeAudio(); + super.onClose(); Minecraft.getInstance().setScreen(new GameSelectorScreen()); } @Override public void removed() { + cancelImport(); + if (audioPlayer != null) audioPlayer.closeAudio(); super.removed(); - if (audioPlayer != null) { - audioPlayer.closeAudio(); - } } } \ No newline at end of file diff --git a/src/main/java/com/wzz/game_console/client/screens/games/PipePuzzleScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/PipePuzzleScreen.java index d49c7af..abb08df 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/PipePuzzleScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/PipePuzzleScreen.java @@ -65,8 +65,8 @@ private enum State { MENU, PLAYING } private void recalcLayout() { gridSize = difficulty.size; - int maxT = Math.min((width-160)/gridSize, (height-100)/gridSize); - tileSize = Math.max(28, Math.min(52, maxT)); + int maxT = Math.min(Math.max(1, (width - 20) / gridSize), Math.max(1, (height - 60) / gridSize)); + tileSize = Math.max(1, Math.min(52, maxT)); startX = (width - gridSize * tileSize) / 2; startY = (height - gridSize * tileSize) / 2 + 10; } @@ -77,8 +77,10 @@ private void initPuzzle() { gameWon = false; moves = 0; winTick = -1; flowPath = new ArrayList<>(); flowProg = 0f; PipeType[] rots = {PipeType.STRAIGHT, PipeType.CORNER, PipeType.T_SHAPE}; - // 生成棋盘直到存在至少一条可行通路,避免随机无解软锁 + // 生成棋盘:保证初始状态未连通,但存在可解路径 + int attempts = 0; do { + if (attempts++ > 200) break; grid = new PipeTile[gridSize][gridSize]; for (int y=0;y0;r--) t.rotate(); } - } while (findFlowPath().isEmpty()); + // ★ Bug修复:在 do-while 里只检查"已连通"和"可解性"还不够, + // 由于小尺寸(如 4x4)随机排列中两端点可能同时连上导致开局即通, + // 即使路径未到终点,玩家旋转一次就会接通;这里额外要求 START 周围 + // 至少 1 个相邻管口的"无效初始方向"——即 START.RIGHT / START.DOWN + // 没有被同向相邻管的开口接住,避免开局第一格/末格已经与管线合流。 + } while (!findFlowPath().isEmpty() || !isSolvable() || startAlreadyHalfConnected()); updateFlow(); } + /** START 与最近邻管的初始开口错开,强制玩家至少旋转一次才能联通 */ + private boolean startAlreadyHalfConnected() { + if (gridSize < 2) return false; + // 起点 (0,0) 开口方向固定为 RIGHT+DOWN,只要任一邻格碰巧朝向 START 对应方向 + // 就意味着旋转次数 < 即可连通;视为"开局即通"重排一次。 + PipeTile right = grid[0][1]; + if (right.type.isRotatable() && right.getOpenings().contains(Direction.LEFT)) { + // 起点 RIGHT 直接接上 (0,1) 的 LEFT,管线可一路向右 + return true; + } + PipeTile down = grid[1][0]; + if (down.type.isRotatable() && down.getOpenings().contains(Direction.UP)) { + return true; + } + return false; + } + private void rotatePipe(int x, int y) { if (gameWon) return; PipeTile t = grid[y][x]; @@ -135,6 +159,49 @@ private boolean dfs(int x, int y, boolean[][] v, List path) { path.remove(path.size()-1); return false; } + /** 检查是否存在某种旋转方案使起点到终点有通路(忽略当前旋转,只看管道类型是否支持) */ + private boolean isSolvable() { + boolean[][] visited = new boolean[gridSize][gridSize]; + return solvableDFS(0, 0, null, visited); + } + + private boolean solvableDFS(int x, int y, Direction from, boolean[][] visited) { + if (x == gridSize - 1 && y == gridSize - 1) return true; + if (x < 0 || x >= gridSize || y < 0 || y >= gridSize || visited[y][x]) return false; + visited[y][x] = true; + PipeTile tile = grid[y][x]; + for (Direction d : Direction.values()) { + int nx = x + d.dx, ny = y + d.dy; + if (nx >= 0 && nx < gridSize && ny >= 0 && ny < gridSize && !visited[ny][nx]) { + Direction opposite = d.getOpposite(); + // 当前管道需在同一旋转下同时接纳"来向"与"去向" + if (canHandle(tile, from, d) && canOpenInDirection(grid[ny][nx], opposite)) { + if (solvableDFS(nx, ny, opposite, visited)) return true; + } + } + } + visited[y][x] = false; + return false; + } + + /** 管道是否存在一个旋转同时支持"来向"与"去向"(from 为 null 表示起点,无来向约束) */ + private boolean canHandle(PipeTile tile, Direction from, Direction out) { + for (int rot = 0; rot < tile.type.rotations.length; rot++) { + List openings = tile.type.getOpenings(rot); + boolean hasFrom = from == null || openings.contains(from); + if (hasFrom && openings.contains(out)) return true; + } + return false; + } + + /** 管道类型是否允许在某个方向上开口(尝试所有旋转) */ + private boolean canOpenInDirection(PipeTile tile, Direction d) { + for (int rot = 0; rot < tile.type.rotations.length; rot++) { + if (tile.type.getOpenings(rot).contains(d)) return true; + } + return false; + } + @Override public void tick() { tickCount++; if (showExitConfirm) return; // 弹窗期间暂停流量动画 @@ -142,6 +209,7 @@ private boolean dfs(int x, int y, boolean[][] v, List path) { } @Override public boolean mouseClicked(double mx, double my, int btn) { + if (btn != 0) return super.mouseClicked(mx, my, btn); if (showExitConfirm) { int click = GameRenderHelper.getExitConfirmClick(mx, my, width, height); if (click == 1) { showExitConfirm = false; Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } if (click == 2) { showExitConfirm = false; return true; } return true; } if (state == State.MENU) { int cx=width/2, cy=height/2; @@ -158,7 +226,7 @@ private boolean dfs(int x, int y, boolean[][] v, List path) { int cx=width/2, cardY=height/2-55; if (mx>=cx-60&&mx<=cx+60&&my>=cardY+70&&my<=cardY+92) { initPuzzle(); return true; } } - if (mx>=startX && mx<=startX+gridSize*tileSize && my>=startY && my<=startY+gridSize*tileSize) { + if (mx>=startX && mx=startY && my=0&&gx=0&&gy path) { @Override public void mouseMoved(double mx, double my) { hovX=-1; hovY=-1; - if (mx>=startX&&mx<=startX+gridSize*tileSize&&my>=startY&&my<=startY+gridSize*tileSize) { + if (mx>=startX&&mx=startY&&my= 0 + ? completedSeconds + : Math.max(0, nowMillis - startMillis) / 1_000; + } +} diff --git a/src/main/java/com/wzz/game_console/client/screens/games/PuzzleGameScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/PuzzleGameScreen.java index a7ab38b..5c5520e 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/PuzzleGameScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/PuzzleGameScreen.java @@ -32,7 +32,7 @@ public class PuzzleGameScreen extends Screen { private int emptyX, emptyY; private boolean gameWon; private int moves; - private long startTime; + private final PuzzleElapsedTimer elapsedTimer = new PuzzleElapsedTimer(); private ResourceLocation puzzleImage; private int startX, startY; private final Random random = new Random(); @@ -109,7 +109,7 @@ private void initializeGame() { gameWon = false; moves = 0; - startTime = System.currentTimeMillis(); + elapsedTimer.restart(System.currentTimeMillis()); // 计算绘制起始位置,使拼图居中 startX = (this.width - (PUZZLE_COLS * PIECE_SIZE)) / 2; @@ -163,10 +163,11 @@ private void swapPieces(int x1, int y1, int x2, int y2) { @Override public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (button != 0) return super.mouseClicked(mouseX, mouseY, button); if (showExitConfirm) { int click = GameRenderHelper.getExitConfirmClick((int)mouseX, (int)mouseY, width, height); if (click == 1) { showExitConfirm = false; Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } - if (click == 2) { showExitConfirm = false; return true; } + if (click == 2) { resumeFromExitConfirm(); return true; } return true; } boolean b = super.mouseClicked(mouseX, mouseY, button); @@ -216,6 +217,7 @@ private void checkPuzzleComplete() { } if (!gameWon) { gameWon = true; + elapsedTimer.complete(System.currentTimeMillis()); if (Minecraft.getInstance().player != null) { Minecraft.getInstance().player.playSound(SoundEvents.PLAYER_LEVELUP, 1.0F, 1.0F); } @@ -293,7 +295,7 @@ public void render(@NotNull GuiGraphics graphics, int mouseX, int mouseY, float } // 显示游戏信息 - long elapsedSeconds = (System.currentTimeMillis() - startTime) / 1000; + long elapsedSeconds = elapsedTimer.elapsedSeconds(System.currentTimeMillis()); graphics.drawString(font, "移动次数: " + moves, 10, 10, 0xFFFFFF, false); graphics.drawString(font, "时间: " + elapsedSeconds + "秒", 10, 25, 0xFFFFFF, false); @@ -307,11 +309,21 @@ public void render(@NotNull GuiGraphics graphics, int mouseX, int mouseY, float if (showExitConfirm) GameRenderHelper.drawExitConfirmOverlay(graphics, font, width, height, mouseX, mouseY); } + /** 弹窗打开时间戳:关闭时据此平移 startTime,补偿暂停期间流逝的墙钟时间 */ + private long pauseStartTime = 0; + + /** 关闭弹窗恢复游戏:平移计时基准,避免"用时"把弹窗停留时长也算进去 */ + private void resumeFromExitConfirm() { + elapsedTimer.offsetStart(System.currentTimeMillis() - pauseStartTime); + showExitConfirm = false; + } + @Override public boolean keyPressed(int keyCode, int scanCode, int modifiers) { if (keyCode == GLFW.GLFW_KEY_ESCAPE) { - if (showExitConfirm) { showExitConfirm = false; return true; } + if (showExitConfirm) { resumeFromExitConfirm(); return true; } if (gameWon) { Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } + pauseStartTime = System.currentTimeMillis(); showExitConfirm = true; return true; } if (showExitConfirm) return true; diff --git a/src/main/java/com/wzz/game_console/client/screens/games/RealtimeLanState.java b/src/main/java/com/wzz/game_console/client/screens/games/RealtimeLanState.java new file mode 100644 index 0000000..dca2ba8 --- /dev/null +++ b/src/main/java/com/wzz/game_console/client/screens/games/RealtimeLanState.java @@ -0,0 +1,185 @@ +package com.wzz.game_console.client.screens.games; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; + +final class RealtimeLanState { + private RealtimeLanState() {} + + record Received(T snapshot, boolean newRound) {} + record Cell(int x, int y) {} + record Color(int p1X, int p1Y, boolean p1Dead, int p2X, int p2Y, boolean p2Dead, + int target, int score1, int score2, int level, boolean gameOver, + String winner, int[][] grid) {} + record Ice(int level, float iceX, float iceY, boolean iceGround, boolean iceDead, + float fireX, float fireY, boolean fireGround, boolean fireDead, + int collected, int total, boolean gameOver, boolean victory, + int difficulty, List removedDiamonds) {} + + static final class Receiver { + private UUID session; + private long sequence = -1; + private final Set retired = new HashSet<>(); + + // State sequences span rounds within one screen, so a lost RESTART is recoverable. + Received receive(String data, Function decoder) { + if (data == null) return null; + try { + UUID incoming = null; + long next = -1; + String payload = data; + if (data.startsWith("v2|")) { + String[] fields = data.split("\\|", 4); + if (fields.length != 4) return null; + incoming = UUID.fromString(fields[1]); + next = Long.parseLong(fields[2]); + if (next < 1 || next <= sequence || retired.contains(incoming)) return null; + payload = fields[3]; + } else if (session != null) { + return null; + } + T snapshot = decoder.apply(payload); + if (snapshot == null) return null; + boolean newRound = incoming != null && !incoming.equals(session); + if (incoming != null) { + if (newRound && session != null) retired.add(session); + session = incoming; + sequence = next; + } + return new Received<>(snapshot, newRound); + } catch (IllegalArgumentException ex) { + return null; + } + } + + String input(String payload) { + return session == null ? null : "INPUT|" + session + "|" + payload; + } + + boolean restart(String data) { + if (data == null) return false; + if ("RESTART".equals(data)) return session == null; + try { + String[] fields = data.split("\\|", -1); + if (fields.length != 3 || !"RESTART".equals(fields[0])) return false; + UUID incoming = UUID.fromString(fields[1]); + long next = Long.parseLong(fields[2]); + if (next < 1 || next <= sequence || incoming.equals(session) + || retired.contains(incoming)) return false; + if (session != null) retired.add(session); + session = incoming; + sequence = next; + return true; + } catch (IllegalArgumentException ex) { + return false; + } + } + } + + static String decodeInput(UUID session, String data) { + if (session == null || data == null) return null; + String[] fields = data.split("\\|", 3); + if (fields.length != 3 || !"INPUT".equals(fields[0]) || fields[2].isEmpty()) return null; + try { + return session.equals(UUID.fromString(fields[1])) ? fields[2] : null; + } catch (IllegalArgumentException ex) { + return null; + } + } + + static Color parseColor(String payload) { + if (payload == null) return null; + try { + return parseColorStrict(payload); + } catch (IllegalArgumentException ex) { + return null; + } + } + + private static Color parseColorStrict(String payload) { + String[] sections = payload.split(";", -1); + if (sections.length != 2) return null; + String[] f = sections[0].split(",", -1); + if (f.length != 11 && f.length != 12) return null; + int p1X = integer(f[0], 0, 15), p1Y = integer(f[1], 0, 15); + boolean p1Dead = flag(f[2]); + int p2X = integer(f[3], 0, 15), p2Y = integer(f[4], 0, 15); + boolean p2Dead = flag(f[5]); + int target = integer(f[6], 0, 7); + int score1 = integer(f[7], 0, Integer.MAX_VALUE); + int score2 = integer(f[8], 0, Integer.MAX_VALUE); + int level = integer(f[9], 1, 999); + boolean gameOver = flag(f[10]); + String[] cells = sections[1].split(",", -1); + if (cells.length != 256) return null; + int[][] grid = new int[16][16]; + for (int i = 0; i < cells.length; i++) { + try { + grid[i / 16][i % 16] = integer(cells[i], 0, 7); + } catch (IllegalArgumentException ex) { + grid[i / 16][i % 16] = 0; + } + } + return new Color(p1X, p1Y, p1Dead, p2X, p2Y, p2Dead, target, score1, score2, + level, gameOver, gameOver && f.length == 12 ? f[11] : "", grid); + } + + static Ice parseIce(String payload, int defaultDifficulty) { + if (payload == null) return null; + try { + return parseIceStrict(payload, defaultDifficulty); + } catch (IllegalArgumentException ex) { + return null; + } + } + + private static Ice parseIceStrict(String payload, int defaultDifficulty) { + String[] sections = payload.split(";", -1); + if (sections.length > 2) return null; + String[] f = sections[0].split(",", -1); + if (f.length != 13 && f.length != 14) return null; + int level = integer(f[0], 1, 99); + int iceXRaw = Integer.parseInt(f[1]), iceYRaw = Integer.parseInt(f[2]); + boolean iceGround = flag(f[3]), iceDead = flag(f[4]); + int fireXRaw = Integer.parseInt(f[5]), fireYRaw = Integer.parseInt(f[6]); + boolean fireGround = flag(f[7]), fireDead = flag(f[8]); + // Jumping above the map and the final falling step may leave its visible bounds. + if (iceXRaw < 0 || iceXRaw > 3200 || iceYRaw < -2400 || iceYRaw > 4800 + || fireXRaw < 0 || fireXRaw > 3200 || fireYRaw < -2400 || fireYRaw > 4800) { + return null; + } + float iceX = iceXRaw / 10f, iceY = iceYRaw / 10f; + float fireX = fireXRaw / 10f, fireY = fireYRaw / 10f; + int collected = integer(f[9], 0, 300), total = integer(f[10], 0, 300); + if (collected > total) return null; + boolean gameOver = flag(f[11]), victory = flag(f[12]); + if (victory && !gameOver) return null; + int difficulty = f.length == 14 ? integer(f[13], 0, 2) : defaultDifficulty; + List removed = new ArrayList<>(); + if (sections.length == 2 && !sections[1].isEmpty()) { + for (String coord : sections[1].split("\\|", -1)) { + String[] xy = coord.split("_", -1); + if (xy.length != 2) return null; + removed.add(new Cell(integer(xy[0], 0, 19), integer(xy[1], 0, 14))); + } + } + return new Ice(level, iceX, iceY, iceGround, iceDead, fireX, fireY, fireGround, + fireDead, collected, total, gameOver, victory, difficulty, List.copyOf(removed)); + } + + private static int integer(String value, int min, int max) { + int parsed = Integer.parseInt(value); + if (parsed < min || parsed > max) throw new IllegalArgumentException("out of range"); + return parsed; + } + + private static boolean flag(String value) { + if ("0".equals(value)) return false; + if ("1".equals(value)) return true; + throw new IllegalArgumentException("invalid flag"); + } +} diff --git a/src/main/java/com/wzz/game_console/client/screens/games/SnakeGameScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/SnakeGameScreen.java index d2d66f6..0087a3e 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/SnakeGameScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/SnakeGameScreen.java @@ -23,6 +23,9 @@ private enum State { MENU, PLAYING, GAME_OVER } private final List snake = new ArrayList<>(); private int[] food; private int dx = 1, dy = 0; + // 上一次 tick 实际执行移动的方向:按键防反向应与它比较, + // 而不是与可能已被本次按键改过的 dx/dy 比较,避免一 tick 内连按两键 180° 掉头秒死 + private int lastDx = 1, lastDy = 0; private int tickCounter = 0, score = 0; private long tickCount = 0; private final List particles = new ArrayList<>(); @@ -37,6 +40,7 @@ private void startGame() { snake.clear(); snake.add(new int[]{GRID_W / 2, GRID_H / 2}); dx = 1; dy = 0; score = 0; + lastDx = 1; lastDy = 0; spawnFood(); state = State.PLAYING; particles.clear(); floats.clear(); @@ -73,6 +77,9 @@ private void spawnFood() { if (Minecraft.getInstance().player != null) Minecraft.getInstance().player.playSound(SoundEvents.GENERIC_EXPLODE.value(), 0.5F, 1.0F); return; } + // 记录本 tick 实际执行移动的方向,作为下次按键防反向的基准 + lastDx = dx; + lastDy = dy; snake.add(0, new int[]{nx, ny}); if (eatingFood) { score++; @@ -94,13 +101,14 @@ private void spawnFood() { else { Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } } if (showExitConfirm) return true; - if (state == State.GAME_OVER && key == GLFW.GLFW_KEY_R) { startGame(); return true; } + if (state != State.MENU && key == GLFW.GLFW_KEY_R) { startGame(); return true; } if (state == State.PLAYING) { switch (key) { - case GLFW.GLFW_KEY_W, GLFW.GLFW_KEY_UP -> { if (dy != 1) { dx=0; dy=-1; } } - case GLFW.GLFW_KEY_S, GLFW.GLFW_KEY_DOWN -> { if (dy != -1) { dx=0; dy=1; } } - case GLFW.GLFW_KEY_A, GLFW.GLFW_KEY_LEFT -> { if (dx != 1) { dx=-1; dy=0; } } - case GLFW.GLFW_KEY_D, GLFW.GLFW_KEY_RIGHT -> { if (dx != -1) { dx=1; dy=0; } } + // 与 lastDx/lastDy(上一次实际移动方向)比较,同 tick 内连按两键也不会 180° 掉头 + case GLFW.GLFW_KEY_W, GLFW.GLFW_KEY_UP -> { if (lastDy != 1) { dx=0; dy=-1; } } + case GLFW.GLFW_KEY_S, GLFW.GLFW_KEY_DOWN -> { if (lastDy != -1) { dx=0; dy=1; } } + case GLFW.GLFW_KEY_A, GLFW.GLFW_KEY_LEFT -> { if (lastDx != -1) { dx=-1; dy=0; } } + case GLFW.GLFW_KEY_D, GLFW.GLFW_KEY_RIGHT -> { if (lastDx != 1) { dx=1; dy=0; } } } } return true; diff --git a/src/main/java/com/wzz/game_console/client/screens/games/SokobanScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/SokobanScreen.java index c9fe15e..9bf59c2 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/SokobanScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/SokobanScreen.java @@ -12,9 +12,7 @@ import net.neoforged.api.distmarker.OnlyIn; import org.lwjgl.glfw.GLFW; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; +import java.util.Random; @OnlyIn(Dist.CLIENT) public class SokobanScreen extends Screen { @@ -25,171 +23,178 @@ public class SokobanScreen extends Screen { private int levelWidth, levelHeight; private int startX, startY; private int currentLevel = 1; - private final List levels = new ArrayList<>(); + /** 重开盐:loadLevel 时混入 nanoTime 派生量,使按 R 能生成不同布局(关卡首次进入仍稳定) */ + private long reshuffleSalt = 0L; public SokobanScreen() { super(Component.literal("推箱子游戏")); - initializeLevels(); - loadLevel(currentLevel); + generateLevel(currentLevel); } - private void initializeLevels() { - levels.add(new char[][]{ - {'#','#','#','#','#'}, - {'#',' ',' ',' ','#'}, - {'#',' ','$','.','#'}, - {'#','@',' ',' ','#'}, - {'#','#','#','#','#'} - }); - - levels.add(new char[][]{ - {'#','#','#','#','#'}, - {'#','.','#',' ','#'}, - {'#',' ','$',' ','#'}, - {'#','@',' ',' ','#'}, - {'#','#','#','#','#'} - }); - - levels.add(new char[][]{ - {'#','#','#','#','#','#'}, - {'#',' ',' ',' ','.','#'}, - {'#','.','$','$','@','#'}, - {'#',' ',' ',' ',' ','#'}, - {'#','#','#','#','#','#'} - }); - - levels.add(new char[][]{ - {'#','#','#','#','#','#'}, - {'#','.',' ',' ',' ','#'}, - {'#',' ','#','$','@','#'}, - {'#',' ',' ',' ',' ','#'}, - {'#','#','#','#','#','#'} - }); - - levels.add(new char[][]{ - {'#','#','#','#','#','#','#'}, - {'#',' ',' ',' ','.',' ','#'}, - {'#',' ',' ','$','#',' ','#'}, - {'#','.','$','@','$','.','#'}, - {'#',' ','#',' ','#',' ','#'}, - {'#',' ',' ',' ',' ',' ','#'}, - {'#','#','#','#','#','#','#'} - }); - - levels.add(new char[][]{ - {'#','#','#','#','#','#','#'}, - {'#','.',' ','#',' ','.','#'}, - {'#',' ','$',' ','$',' ','#'}, - {'#',' ',' ','@',' ',' ','#'}, - {'#',' ','$',' ','$',' ','#'}, - {'#','.',' ','#',' ','.','#'}, - {'#','#','#','#','#','#','#'} - }); - - levels.add(new char[][]{ - {'#','#','#','#','#','#','#'}, - {'#',' ','.',' ','.','.','#'}, - {'#',' ','$',' ','$',' ','#'}, - {'#','$',' ','@',' ','$','#'}, - {'#',' ','$',' ','$',' ','#'}, - {'#','.','.',' ','.',' ','#'}, - {'#','#','#','#','#','#','#'} - }); - - levels.add(new char[][]{ - {'#','#','#','#','#','#','#','#'}, - {'#','.','#',' ','#','.',' ','#'}, - {'#',' ','$',' ','$',' ',' ','#'}, - {'#',' ',' ','@',' ',' ',' ','#'}, - {'#',' ','$',' ','$',' ',' ','#'}, - {'#','.','#',' ','#','.',' ','#'}, - {'#','#','#','#','#','#','#','#'} - }); - - levels.add(new char[][]{ - {'#','#','#','#','#','#','#','#','#'}, - {'#',' ','.','#','.','.',' ',' ','#'}, - {'#',' ','$',' ','$',' ','$',' ','#'}, - {'#',' ',' ','@',' ',' ',' ',' ','#'}, - {'#',' ','$',' ','$',' ','$',' ','#'}, - {'#',' ','.','#','.','.',' ',' ','#'}, - {'#','#','#','#','#','#','#','#','#'} - }); - - levels.add(new char[][]{ - {'#','#','#','#','#','#','#','#','#','#'}, - {'#','.',' ','.','.','.',' ','.','.','#'}, - {'#',' ',' ',' ',' ',' ',' ',' ',' ','#'}, - {'#',' ','$','$','$','$','$','$',' ','#'}, - {'#',' ',' ',' ','@',' ',' ',' ',' ','#'}, - {'#',' ','$','$','$','$','$','$',' ','#'}, - {'#',' ',' ',' ',' ',' ',' ',' ',' ','#'}, - {'#','.',' ','.','.','.','.','.',' ','#'}, - {'#','#','#','#','#','#','#','#','#','#'} - }); - - levels.add(new char[][]{ - {'#','#','#','#','#','#','#','#','#','#'}, - {'#','.','#',' ',' ',' ',' ',' ',' ','#'}, - {'#',' ','#',' ',' ',' ',' ',' ','#','#'}, - {'#',' ',' ','$',' ','#',' ','$',' ','#'}, - {'#',' ',' ',' ','@','#',' ','#',' ','#'}, - {'#',' ',' ',' ',' ',' ',' ',' ',' ','#'}, - {'#',' ','#','$','#',' ',' ',' ',' ','#'}, - {'#','.','#',' ',' ',' ','.',' ',' ','#'}, - {'#','#','#','#','#','#','#','#','#','#'} - }); - - levels.add(new char[][]{ - {'#','#','#','#','#','#','#','#'}, - {'#','.','#','#',' ',' ','.','#'}, - {'#',' ','#',' ','$',' ',' ','#'}, - {'#',' ','$','@',' ',' ','#','#'}, - {'#',' ',' ',' ',' ',' ',' ','#'}, - {'#',' ','#',' ','#',' ',' ','#'}, - {'#','#','#','#','#','#','#','#'} - }); - - levels.add(new char[][]{ - {'#','#','#','#','#','#','#','#'}, - {'#',' ',' ','#',' ',' ',' ','#'}, // 修复:末列原为'.'缺右墙,导致关卡不可解 - {'#',' ','.','$',' ',' ',' ','#'}, // 同步补目标点:左上封闭区的箱子只能推到此格,保证2箱2目标可通关 - {'#',' ','#',' ','#',' ','#','#'}, - {'#',' ',' ','@',' ',' ','#','#'}, - {'#',' ','$','#',' ',' ',' ','#'}, - {'#',' ',' ','.','#',' ',' ','#'}, - {'#','#','#','#','#','#','#','#'} - }); + private void generateLevel(int levelNum) { + // ★ 修复零箱局:打乱收尾可能把全部 '+' 转 '.' 且 boxCount 递减到 0, + // 生成没有箱子的死局。收尾 boxCount<=0 时不再接受该结果,改为整关 + // 重新生成(最多 10 次,仍失败保留最后一次)。首次尝试种子与原版 + // 一致,正常关卡生成的地图不变。 + for (int attempt = 0; attempt < 10; attempt++) { + if (generateLevelOnce(levelNum, attempt)) return; + } } - private void loadLevel(int levelNum) { - if (levelNum < 1 || levelNum > levels.size()) { - currentLevel = 1; // 循环回到第一关 - } else { - currentLevel = levelNum; + /** 生成一关并写入 level 字段;返回 false 表示本次生成出零箱局,需要重试 */ + private boolean generateLevelOnce(int levelNum, int attempt) { + // 种子混入重开盐:按 R 重开时盐值变化,同一关可生成不同布局,帮助玩家逃离死局 + Random rand = new Random(levelNum * 7919L + 271L + attempt * 104729L + reshuffleSalt); + + // ★ Bug修复:原版关卡参数增速过缓(gridSize 每 3 关 +1,boxCount 每 4 关 +1), + // 玩家通关 5~6 关仍感觉不到明显难度提升。重新调参为: + // gridSize = 5 + (levelNum-1)/1.5 → 第 1 关 5x5,第 5 关 8x8,第 10 关 11x11 + // boxCount = 1 + (levelNum-1)/2 → 第 1 关 1 个,第 5 关 3 个,第 10 关 5 个 + // obstacleCount = 1 + (levelNum-1)/2 → 第 1 关 1 个,第 5 关 3 个,第 10 关 5 个 + int gridSize = Math.min(5 + (levelNum - 1) * 2 / 3, 14); + int boxCount = Math.min(1 + (levelNum - 1) / 2, 6); + int obstacleCount = Math.min(1 + (levelNum - 1) / 2, 8); + + // 创建网格 + char[][] grid = new char[gridSize][gridSize]; + levelWidth = gridSize; + levelHeight = gridSize; + + // 填充边界墙 + for (int y = 0; y < gridSize; y++) + for (int x = 0; x < gridSize; x++) + grid[y][x] = (x == 0 || x == gridSize - 1 || y == 0 || y == gridSize - 1) ? '#' : ' '; + + // 放置内部障碍物 + for (int i = 0; i < obstacleCount; i++) { + for (int o = 0; o < 30; o++) { + int wx = 1 + rand.nextInt(gridSize - 2); + int wy = 1 + rand.nextInt(gridSize - 2); + if (grid[wy][wx] == ' ') { + grid[wy][wx] = '#'; + break; + } + } } - level = copyLevel(levels.get(currentLevel - 1)); - levelWidth = level[0].length; - levelHeight = level.length; + // 初始状态:箱子全部在目标点上(已解决状态 '+') + int placed = 0; + for (int p = 0; p < 500 && placed < boxCount; p++) { + int bx = 1 + rand.nextInt(gridSize - 2); + int by = 1 + rand.nextInt(gridSize - 2); + if (grid[by][bx] == ' ') { + grid[by][bx] = '+'; + placed++; + } + } + boxCount = Math.max(placed, 1); + if (placed == 0) { + grid[gridSize / 2][gridSize / 2] = '+'; + boxCount = 1; + } - // 查找玩家位置 - for (int y = 0; y < levelHeight; y++) { - for (int x = 0; x < levelWidth; x++) { - if (level[y][x] == '@') { - playerX = x; - playerY = y; + // 放置玩家在左上角空地 + playerX = 1; + playerY = 1; + if (grid[1][1] != ' ') { + outer: + for (int y = 1; y < gridSize - 1; y++) + for (int x = 1; x < gridSize - 1; x++) + if (grid[y][x] == ' ') { playerX = x; playerY = y; break outer; } + } + grid[playerY][playerX] = '@'; + + // 打乱阶段:随机移动玩家来推动箱子离开目标点 + // 启发式生成 + 死角校验重试,并不保证一定可解,极端情况仍可能需按 R 重开换一张布局 + // 种子 = 关卡号 + attempt + 重开盐(盐在 loadLevel 时变化,按 R 可换布局) + int[] dx = {1, -1, 0, 0}; + int[] dy = {0, 0, 1, -1}; + int pushes = 0; + // ★ Bug修复:原版用固定 300 步上限,大关卡(gridSize=14 + boxCount=6)下 + // 玩家推不完所有箱子,最终被安全处理降为少箱关,玩家感觉"难度没有递增"。 + // 改为 1500 + boxCount*500 步(最大 ~4500 步),覆盖所有当前关卡配置; + // 同时若仍推不完,则按"实际推动的箱子数"动态下调 boxCount 目标点, + // 保持"已生成箱子 == 已设置目标点",不会出现开局即通也不会无解。 + int maxSteps = 1500 + boxCount * 500; + for (int step = 0; step < maxSteps && pushes < boxCount; step++) { + int dir = rand.nextInt(4); + int nx = playerX + dx[dir]; + int ny = playerY + dy[dir]; + if (nx <= 0 || nx >= gridSize - 1 || ny <= 0 || ny >= gridSize - 1) continue; + if (grid[ny][nx] == '#') continue; + + if (grid[ny][nx] == '+' || grid[ny][nx] == '$') { + boolean wasOnTarget = (grid[ny][nx] == '+'); + int bx = nx + dx[dir]; + int by = ny + dy[dir]; + if (bx <= 0 || bx >= gridSize - 1 || by <= 0 || by >= gridSize - 1) continue; + if (grid[by][bx] == '#' || grid[by][bx] == '+' || grid[by][bx] == '$') continue; + + // 死角校验:箱子被推到非目标点的角落(两个正交相邻方向均为墙/边界)后永远推不动, + // 本轮打乱作废,由 generateLevel 以 attempt+1 重来(沿用 attempt 上限模式) + if (grid[by][bx] != '.' && isDeadCorner(grid, gridSize, bx, by)) { + level = grid; + return false; } + + char oldPos = grid[playerY][playerX]; + grid[playerY][playerX] = (oldPos == '*') ? '.' : ' '; + grid[ny][nx] = wasOnTarget ? '*' : '@'; + grid[by][bx] = '$'; + playerX = nx; + playerY = ny; + if (wasOnTarget) pushes++; + } else if (grid[ny][nx] == '.') { + char oldPos = grid[playerY][playerX]; + grid[playerY][playerX] = (oldPos == '*') ? '.' : ' '; + playerX = nx; + playerY = ny; + grid[playerY][playerX] = '*'; + } else if (grid[ny][nx] == ' ') { + char oldPos = grid[playerY][playerX]; + grid[playerY][playerX] = (oldPos == '*') ? '.' : ' '; + playerX = nx; + playerY = ny; + grid[playerY][playerX] = '@'; } } - } - private char[][] copyLevel(char[][] original) { - char[][] copy = new char[original.length][]; - for (int i = 0; i < original.length; i++) { - copy[i] = Arrays.copyOf(original[i], original[i].length); + // 最终安全处理:如果仍有 '+' 未被推动,转为 '.'(移除未打乱的箱子) + // 确保不会出现开局即胜利的情况 + // ★ 同步把 boxCount 调成实际成功推动数,避免"玩家推完原 boxCount 但还有多余目标点"导致无法通关 + for (int y = 1; y < gridSize - 1; y++) { + for (int x = 1; x < gridSize - 1; x++) { + if (grid[y][x] == '+') { + grid[y][x] = '.'; + boxCount--; + } + } } - return copy; + + level = grid; + // boxCount<=0('+' 全转 '.' 且无箱可推)时返回 false,由 generateLevel 重试 + return boxCount > 0; + } + + /** 死角判定:箱子四周存在一组正交相邻方向(上/下/左/右)均为墙或边界(调用前需确认箱子不在目标点上) */ + private boolean isDeadCorner(char[][] grid, int gridSize, int bx, int by) { + boolean up = by - 1 < 0 || grid[by - 1][bx] == '#'; + boolean down = by + 1 >= gridSize || grid[by + 1][bx] == '#'; + boolean left = bx - 1 < 0 || grid[by][bx - 1] == '#'; + boolean right = bx + 1 >= gridSize || grid[by][bx + 1] == '#'; + return (up && left) || (up && right) || (down && left) || (down && right); + } + + private void loadLevel(int levelNum) { + if (levelNum < 1) levelNum = 1; + currentLevel = levelNum; + // 更新重开盐:按 R 重开同一关时种子随之变化,可生成不同布局逃离死局 + reshuffleSalt += System.nanoTime(); + generateLevel(currentLevel); + // ★ Bug修复:generateLevel 会随关卡数增大 levelWidth/levelHeight, + // 但 TILE_SIZE/startX/startY 及重置/下一关按钮的尺寸位置只在首次 init() 时算过一次; + // 跳关/重置若不重新 init(),画面会继续沿用旧关卡的几何参数导致错位甚至棋盘溢出可视区。 + init(); } @Override @@ -306,7 +311,7 @@ public void render(GuiGraphics guiGraphics, int mouseX, int mouseY, float partia } // 显示当前关卡和操作提示 - guiGraphics.drawCenteredString(font, "关卡: " + currentLevel + "/" + levels.size(), + guiGraphics.drawCenteredString(font, "关卡: " + currentLevel, width / 2, startY - 30, 0xFFFFFF); guiGraphics.drawCenteredString(font, "WASD移动 | R重置 | N下一关", width / 2, startY - 15, 0xAAAAAA); @@ -380,7 +385,6 @@ private void checkWinCondition() { if (Minecraft.getInstance().player != null) { Minecraft.getInstance().player.playSound(SoundEvents.PLAYER_LEVELUP, 1.0F, 1.0F); } - init(); // 重新初始化UI } } } \ No newline at end of file diff --git a/src/main/java/com/wzz/game_console/client/screens/games/SudokuGameScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/SudokuGameScreen.java index b0be1fb..047167e 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/SudokuGameScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/SudokuGameScreen.java @@ -34,6 +34,7 @@ public class SudokuGameScreen extends Screen { private boolean gameCompleted = false; private boolean rewardGiven = false; // 新增:防止重复给奖励 private long startTime; + private long completedTimeMs; // 通关时的总用时,通关后不再累加 private int hintsUsed = 0; private int maxHints = 3; @@ -121,6 +122,7 @@ private void generateNewPuzzle() { gameCompleted = false; rewardGiven = false; // 重置奖励状态 startTime = System.currentTimeMillis(); + completedTimeMs = 0; hintsUsed = 0; clearArrays(); @@ -180,18 +182,28 @@ private void createPuzzleFromSolution() { System.arraycopy(solution[i], 0, puzzle[i], 0, GRID_SIZE); } - // 根据难度移除数字 - int cellsToRemove = 81 - currentDifficulty.filledCells; - Set removedCells = new HashSet<>(); - - while (removedCells.size() < cellsToRemove) { - int row = random.nextInt(GRID_SIZE); - int col = random.nextInt(GRID_SIZE); - String cellKey = row + "," + col; - - if (!removedCells.contains(cellKey)) { - puzzle[row][col] = 0; - removedCells.add(cellKey); + // ★ 修复:挖洞不保证唯一解 → 每挖一格都校验解仍唯一(多解则回填)。 + // 候选格随机顺序遍历一遍,挖到多少是多少(不强求达到目标提示数) + List candidates = new ArrayList<>(); + for (int row = 0; row < GRID_SIZE; row++) { + for (int col = 0; col < GRID_SIZE; col++) { + candidates.add(new int[]{row, col}); + } + } + Collections.shuffle(candidates, random); + + int targetFilled = currentDifficulty.filledCells; + int removed = 0; + int targetRemoved = GRID_SIZE * GRID_SIZE - targetFilled; + for (int[] cell : candidates) { + if (removed >= targetRemoved) break; + int row = cell[0], col = cell[1]; + int backup = puzzle[row][col]; + puzzle[row][col] = 0; + if (countSolutions(puzzle, 2) > 1) { + puzzle[row][col] = backup; // 出现多解,回填该格 + } else { + removed++; } } @@ -203,6 +215,30 @@ private void createPuzzleFromSolution() { } } + /** + * 回溯统计解的数量(找到 limit 个即提前停止),用于挖洞时的唯一解校验。 + * 基于 {@link #solveSudoku(int[][])} 的填数逻辑改造:顺序取数、只计数不产出解。 + */ + private int countSolutions(int[][] grid, int limit) { + if (limit <= 0) return 0; + for (int row = 0; row < GRID_SIZE; row++) { + for (int col = 0; col < GRID_SIZE; col++) { + if (grid[row][col] == 0) { + int count = 0; + for (int num = 1; num <= 9 && count < limit; num++) { + if (isValidMove(grid, row, col, num)) { + grid[row][col] = num; + count += countSolutions(grid, limit - count); + grid[row][col] = 0; + } + } + return count; + } + } + } + return 1; // 无空格:找到一个完整解 + } + private boolean isValidMove(int[][] grid, int row, int col, int num) { // 检查行(跳过自身格,否则已填数字会与自身冲突,导致所有格子恒判为错误) for (int c = 0; c < GRID_SIZE; c++) { @@ -304,12 +340,12 @@ private int getCellColor(int row, int col) { // 同行同列高亮 if (row == selectedRow || col == selectedCol) { - return 0xFF2C2C2C; + return 0xFF8AB4E0; // 浅蓝(比选中格更浅,对比黑色数字足够) } // 同3x3区域高亮 if ((row / 3) == (selectedRow / 3) && (col / 3) == (selectedCol / 3)) { - return 0xFF2C2C2C; + return 0xFFBCD4F0; // 更浅的蓝灰色,区分同行/同列 } // 交替颜色的3x3方块 @@ -326,6 +362,10 @@ private int getNumberColor(int row, int col) { if (errors[row][col]) { return 0xFFFFFFFF; // 错误数字用白色 } + // 选中格子背景为蓝色,用白色数字确保对比度 + if (row == selectedRow && col == selectedCol) { + return 0xFFFFFFFF; + } if (fixed[row][col]) { return 0xFF000000; // 固定数字用黑色 } @@ -361,11 +401,21 @@ private void renderUI(GuiGraphics guiGraphics) { guiGraphics.drawString(font, title, titleX, gameStartY - 40, 0xFFFFFFFF); // 游戏信息 - long playTime = (System.currentTimeMillis() - startTime) / 1000; + // ★ Bug修复:游戏结束后顶部小字"时间"也必须冻结, + // 原代码用三元表达式虽然正确,但若 gameCompleted 切换瞬时出现一帧 + // 未冻结的累计时间会让玩家看到秒数跳变。这里再补一次显式分支, + // 并给"已通关"标一个绿色✓避免与计时器混淆。 + long playTime; + if (gameCompleted) { + playTime = completedTimeMs / 1000; + } else { + playTime = (System.currentTimeMillis() - startTime) / 1000; + } String timeText = String.format("时间: %02d:%02d", playTime / 60, playTime % 60); - String hintsText = "剩余提示: " + (maxHints - hintsUsed); + int timeColor = gameCompleted ? 0xFF66FF66 : 0xFFCCCCCC; + guiGraphics.drawString(font, timeText, gameStartX, gameStartY - 20, timeColor); - guiGraphics.drawString(font, timeText, gameStartX, gameStartY - 20, 0xFFCCCCCC); + String hintsText = "剩余提示: " + (maxHints - hintsUsed); guiGraphics.drawString(font, hintsText, gameStartX + 150, gameStartY - 20, 0xFFCCCCCC); // 操作说明 @@ -392,7 +442,7 @@ private void renderCompletionScreen(GuiGraphics guiGraphics) { // 完成文本 String congratsText = "恭喜完成!"; - long totalTime = (System.currentTimeMillis() - startTime) / 1000; + long totalTime = gameCompleted ? completedTimeMs / 1000 : (System.currentTimeMillis() - startTime) / 1000; String timeText = String.format("用时: %02d:%02d", totalTime / 60, totalTime % 60); String difficultyText = "难度: " + currentDifficulty.name; String hintsText = "使用提示: " + hintsUsed + "/" + maxHints; @@ -425,14 +475,14 @@ public boolean mouseClicked(double mouseX, double mouseY, int button) { return true; } if (click == 2) { - showExitConfirm = false; + resumeFromExitConfirm(); return true; } return true; } if (button == 0 && !gameCompleted) { // 左键点击 - int gridX = (int) (mouseX - gameStartX) / CELL_SIZE; - int gridY = (int) (mouseY - gameStartY) / CELL_SIZE; + int gridX = Math.floorDiv((int) mouseX - gameStartX, CELL_SIZE); + int gridY = Math.floorDiv((int) mouseY - gameStartY, CELL_SIZE); if (gridX >= 0 && gridX < GRID_SIZE && gridY >= 0 && gridY < GRID_SIZE) { selectedRow = gridY; @@ -445,11 +495,25 @@ public boolean mouseClicked(double mouseX, double mouseY, int button) { return super.mouseClicked(mouseX, mouseY, button); } + /** 弹窗打开时间戳:关闭时据此平移 startTime,补偿暂停期间流逝的墙钟时间 */ + private long pauseStartTime = 0; + + /** 关闭弹窗恢复游戏:平移 startTime,避免"用时"把弹窗停留时长也算进去 */ + private void resumeFromExitConfirm() { + startTime += System.currentTimeMillis() - pauseStartTime; + showExitConfirm = false; + } + @Override public boolean keyPressed(int keyCode, int scanCode, int modifiers) { if (keyCode == GLFW.GLFW_KEY_ESCAPE) { // 修复:通关后 ESC 也走确认弹窗,与其他游戏保持一致(原先会绕过弹窗直接退出) - showExitConfirm = !showExitConfirm; + if (showExitConfirm) { + resumeFromExitConfirm(); + } else { + pauseStartTime = System.currentTimeMillis(); + showExitConfirm = true; + } return true; } // 弹窗打开期间拦截所有游戏按键输入(仅 ESC 除外) @@ -501,6 +565,7 @@ private void inputNumber(int number) { if (isPuzzleComplete()) { gameCompleted = true; + completedTimeMs = System.currentTimeMillis() - startTime; playSound(SoundEvents.UI_TOAST_CHALLENGE_COMPLETE); giveReward(); // 修复:调用统一的奖励方法 } @@ -550,6 +615,7 @@ private void giveHint() { if (isPuzzleComplete()) { gameCompleted = true; + completedTimeMs = System.currentTimeMillis() - startTime; playSound(SoundEvents.UI_TOAST_CHALLENGE_COMPLETE); giveReward(); // 修复:使用提示完成游戏时也给奖励 } diff --git a/src/main/java/com/wzz/game_console/client/screens/games/TetrisGameScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/TetrisGameScreen.java index 316230a..450887d 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/TetrisGameScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/TetrisGameScreen.java @@ -102,6 +102,8 @@ private int[][] rotate(int[][] s) { @Override public void tick() { tickCount++; if (state != State.PLAYING || showExitConfirm) return; // 弹窗期间暂停游戏 + // ★ 修复:粒子物理移到 tick() 固定频率推进(原来在 render 中 update,帧率依赖且暂停期间不停) + GameRenderHelper.tickParticles(particles); tickCounter++; int speed = Math.max(1, 10 - level); if (tickCounter >= speed) { @@ -118,7 +120,7 @@ private int[][] rotate(int[][] s) { Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } if (showExitConfirm) return true; - if (state == State.GAME_OVER && key == GLFW.GLFW_KEY_R) { startGame(); return true; } + if (state != State.MENU && key == GLFW.GLFW_KEY_R) { startGame(); return true; } if (state != State.PLAYING) return true; switch (key) { case GLFW.GLFW_KEY_A, GLFW.GLFW_KEY_LEFT -> { if (canPlace(current, cx-1, cy)) cx--; } @@ -197,7 +199,7 @@ private void renderPlaying(GuiGraphics g) { offsetX + (cx + j + 1) * cellSize - 1, offsetY + (ghostY + i + 1) * cellSize - 1, GameRenderHelper.withAlpha(currentColor, 40)); - GameRenderHelper.tickAndRenderParticles(g, particles); + GameRenderHelper.renderParticles(g, particles); // HUD GameRenderHelper.drawTopHUD(g, width, height); diff --git a/src/main/java/com/wzz/game_console/client/screens/games/TowerDefenseScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/TowerDefenseScreen.java index 3f6116d..5e925c9 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/TowerDefenseScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/TowerDefenseScreen.java @@ -45,6 +45,8 @@ public class TowerDefenseScreen extends Screen { private static final int MAX_WAVE = 10; // 最大波次,超过即胜利 private boolean gameStarted = false; private boolean gameOver = false; + /** 通关后区分胜利/失败,渲染时显示对应文案与按钮 */ + private boolean victory = false; // 游戏对象列表 private final List towers = new ArrayList<>(); @@ -73,8 +75,10 @@ public TowerDefenseScreen() { @Override public void init() { + // ★ Bug修复:同 WhackAMoleScreen,缩放 init() 重复叠加 6 个按钮 + this.clearWidgets(); super.init(); - + // 塔选择按钮 this.addRenderableWidget(Button.builder(Component.literal("弓箭塔 (10金币)"), button -> { if (coins >= 10) { @@ -293,6 +297,8 @@ public boolean keyPressed(int keyCode, int scanCode, int modifiers) { @Override public boolean mouseClicked(double mouseX, double mouseY, int button) { if (showExitConfirm) { int click = GameRenderHelper.getExitConfirmClick(mouseX, mouseY, width, height); if (click == 1) { showExitConfirm = false; Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } if (click == 2) { showExitConfirm = false; return true; } return true; } + // 结算画面(胜利/失败)下禁止再建塔扣币 + if (gameOver || !gameStarted) return super.mouseClicked(mouseX, mouseY, button); if (selectedTowerType != null && button == 0) { GridPos pos = screenToGrid((int)mouseX, (int)mouseY); if (canPlaceTower(pos.x, pos.y) && coins >= selectedTowerType.cost) { @@ -456,10 +462,15 @@ private Enemy findNearestEnemy(Tower tower) { private void checkGameOver() { if (health <= 0) { gameOver = true; + victory = false; gameStarted = false; - } else if (wave > MAX_WAVE && enemies.isEmpty() && enemiesSpawned >= (wave - 1) * 10) { - // 超过最大波次且所有敌人已消灭 → 胜利 + } else if (wave > MAX_WAVE && enemies.isEmpty()) { + // ★ Bug修复:原版要求 enemiesSpawned >= (wave - 1) * 10,即第 11 波需 + // 累计生成 100 个敌人才算胜利,但 MAX_WAVE=10 期间至多 ~110 个, + // 公式过于严苛,玩家很可能永远赢不了。简化为"波次 > MAX_WAVE 且 + // 当前场上无敌人"即胜利,更符合玩家直觉。补一个 victory 字段。 gameOver = true; + victory = true; gameStarted = false; } } @@ -470,6 +481,7 @@ private void resetGame() { wave = 1; gameStarted = false; gameOver = false; + victory = false; enemiesSpawned = 0; enemySpawnTimer = 0; diff --git a/src/main/java/com/wzz/game_console/client/screens/games/WesternChessScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/WesternChessScreen.java index 7265b3c..8d034c9 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/WesternChessScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/WesternChessScreen.java @@ -63,10 +63,17 @@ private enum S { MENU, PLAYING, OVER } private int[] epTarget = null; private int[] lastFrom=null, lastTo=null; private String resultMsg = ""; + /** 对局结果:1=白胜 -1=黑胜 0=和(renderOver 据此渲染胜方样式,避免按 vsAI 误判) */ + private int resultOutcome = 0; + /** 50回合规则半回合计数:兵动/吃子清零,其余 +1 */ + private int halfmoveClock = 0; + /** 局面 key 历史(棋盘内容+行棋方+易位权+ep),用于三次重复和棋判定 */ + private final List positionKeys = new ArrayList<>(); private long tickN = 0; private int cellSize, bx, by; private final List particles = new ArrayList<>(); - private boolean aiThinking = false; + private volatile boolean aiThinking = false; + private volatile Thread aiThread; private boolean inCheck = false; private boolean promoPending = false; private int promoRow, promoCol; @@ -119,14 +126,74 @@ public void onClose() { super.onClose(); } + /** AI 后台线程完成时用于判断棋局是否已退出,避免线程结束回调在已离开的对局上落子/播放音效 */ + private volatile boolean disposed = false; + /** AI 局代号:initBoard(重开/重连)时递增,迟到的 AI 结果落地前比对作废 */ + private volatile int boardGen = 0; + + @Override + public void removed() { + sendLeaveGameOnce(); + disposed = true; + boardGen++; + Thread worker = aiThread; + if (worker != null) { + worker.interrupt(); + if (worker != Thread.currentThread()) { + try { worker.join(750L); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); } + } + } + aiThread = null; + aiThinking = false; + super.removed(); + } + @Override public void onRemoteMove(String data) { - if ("RESTART".equals(data)) { initBoard(); return; } + if ("RESTART".equals(data)) { + if (lanMode == LAN_CLIENT) initBoard(); + return; + } try { String[] p = data.split(","); + if (p.length < 5) { LOGGER.warn("[国际象棋] 联机走法字段不足: {}", data); return; } int[] m = new int[]{Integer.parseInt(p[0]),Integer.parseInt(p[1]),Integer.parseInt(p[2]),Integer.parseInt(p[3]),Integer.parseInt(p[4])}; + if (m[0]<0||m[0]>=8||m[1]<0||m[1]>=8||m[2]<0||m[2]>=8||m[3]<0||m[3]>=8) { + LOGGER.warn("[国际象棋] 联机走法坐标越界: {}", data); return; + } + if (m[4]SP_PROMOTE) { + LOGGER.warn("[国际象棋] 联机走法类型非法: {}", data); return; + } + // ★ 修复:远程走法落地前校验(坐标/类型越界已在上面过滤),防伪造/乱序报文打乱本地棋盘: + // ① 当前须轮到远程方(LAN 约定 HOST 执白、CLIENT 执黑)且对局进行中; + // ② from 处须存在远程方棋子; + // ③ 走法(含特殊走法标记,如易位/吃过路兵/升变)须在现有合法走法生成结果内。 + // 任一不满足仅记日志丢弃,不落盘 + boolean remoteWhite = lanMode == LAN_CLIENT; + if (state != S.PLAYING || lanMode == LAN_NONE || whiteTurn != remoteWhite) { + LOGGER.warn("[国际象棋] 丢弃非远程回合/对局已结束的联机走法: {} (whiteTurn={}, lanMode={})", + data, whiteTurn, lanMode); + return; + } + int fromPiece = board[m[0]][m[1]]; + if (fromPiece == E || remoteWhite != (fromPiece > 0)) { + LOGGER.warn("[国际象棋] 联机走法起点无远程方棋子: {}", data); + return; + } + boolean legal = false; + for (int[] mv : legalMoves(board, whiteTurn)) { + if (mv[0]==m[0] && mv[1]==m[1] && mv[2]==m[2] && mv[3]==m[3] && mv[4]==m[4]) { legal = true; break; } + } + if (!legal) { + LOGGER.warn("[国际象棋] 联机走法不在合法走法列表内: {}", data); + return; + } if (m[4] == SP_PROMOTE) { // LAN 升变走法:报文携带最终升变子类型(第6字段,缺省兼容旧报文默认升后), // 接收方不弹升变面板、直接按报文完成升变,避免升变子由对手选择导致双端棋盘分叉 int promoType = p.length > 5 ? Integer.parseInt(p[5]) : WQ; + if (promoType < WN || promoType > WQ) { + LOGGER.warn("[国际象棋] 联机升变类型非法: {}", data); return; + } boolean w = board[m[0]][m[1]] > 0; int[][] ep = new int[1][]; boolean[] cf = {wCK,wCQ,bCK,bCQ}; m[4] = SP_NORMAL; @@ -138,6 +205,7 @@ public void onClose() { inCheck=kingInCheck(board,whiteTurn); if (Minecraft.getInstance().player!=null) Minecraft.getInstance().player.playSound(SoundEvents.WOOD_PLACE,0.5f,1.2f); + halfmoveClock=0; // 升变属兵动,50回合计数清零 checkEnd(); // 升变完成后再判定终局(修正原时机错误) return; } @@ -147,6 +215,19 @@ public void onClose() { // ══════════════ 初始化 ══════════════ private void initBoard() { + // 先切换代际,再停止并等待旧 AI;这样旧线程即使已排队回调,也只能被丢弃。 + boardGen++; + Thread oldWorker = aiThread; + if (oldWorker != null && oldWorker != Thread.currentThread()) { + oldWorker.interrupt(); + try { + oldWorker.join(750L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + aiThread = null; + aiThinking = false; board = new int[8][8]; board[0] = new int[]{BR,BN,BB,BQ,BK,BB,BN,BR}; for (int c=0;c<8;c++) board[1][c]=BP; @@ -156,6 +237,9 @@ private void initBoard() { wCK=wCQ=bCK=bCQ=true; epTarget=null; lastFrom=lastTo=null; resultMsg=""; particles.clear(); aiThinking=false; inCheck=false; promoPending=false; pendingLanPromote=null; state=S.PLAYING; + resultOutcome=0; halfmoveClock=0; positionKeys.clear(); // 50回合/重复局面计数随新局清零 + positionKeys.add(positionKey()); // ★ 修复:预置初始局面 key,否则三次重复检测少记一次初始局面(两次回跳即误判和棋) + boardGen++; // AI 局代号:重开/重连后旧 AI 线程的迟到结果一律作废 } // ══════════════ TICK ══════════════ @@ -165,45 +249,70 @@ private void initBoard() { // ★ 关键修复:AI执黑(forWhite=false),仅黑方回合才触发 if (state==S.PLAYING && vsAI && !whiteTurn && !aiThinking && !promoPending) { aiThinking = true; - new Thread(() -> { - int[] best = findBestMove(false); // false = 为黑方找最优 - Minecraft.getInstance().execute(() -> { - if (best != null) applyMove(best, false); - aiThinking = false; - checkEnd(); - }); - }, "chess-ai").start(); + final int gen = boardGen; + // AI 只读取本次计算开始时的局面,绝不与主线程共享可变 board。 + final int[][] snapshot = copy(board); + final boolean[] snapshotCastling = {wCK, wCQ, bCK, bCQ}; + final int[] snapshotEp = epTarget == null ? null : epTarget.clone(); + Thread worker = new Thread(() -> { + try { + int[] best = findBestMove(snapshot, snapshotCastling, snapshotEp, false); + Minecraft.getInstance().execute(() -> { + if (disposed || gen != boardGen) return; + if (best != null) applyMove(best, false); + aiThinking = false; + checkEnd(); + }); + } catch (Throwable t) { + LOGGER.warn("[国际象棋] AI 计算失败", t); + Minecraft.getInstance().execute(() -> { + if (gen == boardGen) aiThinking = false; + }); + } finally { + if (gen == boardGen && disposed) aiThinking = false; + if (Thread.currentThread() == aiThread) aiThread = null; + } + }, "chess-ai"); + aiThread = worker; + worker.setDaemon(true); + worker.start(); } } // ══════════════ 走法生成 ══════════════ /** 完整合法走法(过滤走后王被将的情况) */ private List legalMoves(int[][] b, boolean fw) { + return legalMoves(b, fw, epTarget, null); + } + private List legalMoves(int[][] b, boolean fw, int[] ep, boolean[] cf) { List result = new ArrayList<>(); - for (int[] mv : pseudoMoves(b, fw, epTarget)) { + for (int[] mv : pseudoMoves(b, fw, ep, cf)) { int[][] nb = copy(b); applyOn(nb, mv, null, null); if (!kingInCheck(nb, fw)) result.add(mv); } return result; } - /** 伪合法走法(不检查走后将军)。ep 为该局面的吃过路兵目标格(AI搜索时传节点自身的目标,不能读字段) */ - private List pseudoMoves(int[][] b, boolean fw, int[] ep) { + /** + * 伪合法走法(不检查走后将军)。ep 为该局面的吃过路兵目标格(AI搜索时传节点自身的目标,不能读字段)。 + * cf 为该节点易位权 {wCK,wCQ,bCK,bCQ}:AI 搜索传节点自身副本;传 null 则回退读全局字段。 + */ + private List pseudoMoves(int[][] b, boolean fw, int[] ep, boolean[] cf) { List m = new ArrayList<>(); for (int r=0;r<8;r++) for (int c=0;c<8;c++) { int p = b[r][c]; if (p==E || (fw ? p<0 : p>0)) continue; - addMoves(b, r, c, fw, m, ep); + addMoves(b, r, c, fw, m, ep, cf); } return m; } - private void addMoves(int[][] b, int r, int c, boolean w, List o, int[] ep) { + private void addMoves(int[][] b, int r, int c, boolean w, List o, int[] ep, boolean[] cf) { switch (Math.abs(b[r][c])) { case 1 -> pawnMoves(b,r,c,w,o,ep); case 2 -> knightMoves(b,r,c,w,o); case 3 -> slideMoves(b,r,c,w,o,DIR_BISHOP); case 4 -> slideMoves(b,r,c,w,o,DIR_ROOK); case 5 -> { slideMoves(b,r,c,w,o,DIR_BISHOP); slideMoves(b,r,c,w,o,DIR_ROOK); } - case 6 -> { kingMoves(b,r,c,w,o); castleMoves(b,r,c,w,o); } + case 6 -> { kingMoves(b,r,c,w,o); castleMoves(b,r,c,w,o,cf); } } } private void pawnMoves(int[][] b, int r, int c, boolean w, List o, int[] ep) { @@ -214,20 +323,24 @@ private void pawnMoves(int[][] b, int r, int c, boolean w, List o, int[] } for (int dc : new int[]{-1,1}) { int nc=c+dc; if (!ok(nr,nc)) continue; - if (w?b[nr][nc]<0:b[nr][nc]>0) o.add(mv(r,c,nr,nc,nr==pr?SP_PROMOTE:SP_NORMAL)); + if (w?b[nr][nc]<0:b[nr][nc]>0) { + if (Math.abs(b[nr][nc]) != 6) o.add(mv(r,c,nr,nc,nr==pr?SP_PROMOTE:SP_NORMAL)); + } if (ep!=null && ep[0]==nr && ep[1]==nc) o.add(mv(r,c,nr,nc,SP_EN_PASSANT)); } } private void knightMoves(int[][] b, int r, int c, boolean w, List o) { for (int[] d : DIR_KNIGHT) { int nr=r+d[0],nc=c+d[1]; - if (ok(nr,nc) && !friendly(b[nr][nc],w)) o.add(mv(r,c,nr,nc,SP_NORMAL)); + if (ok(nr,nc) && !friendly(b[nr][nc],w) + && Math.abs(b[nr][nc]) != 6) o.add(mv(r,c,nr,nc,SP_NORMAL)); } } private void slideMoves(int[][] b, int r, int c, boolean w, List o, int[][] dirs) { for (int[] d : dirs) { int nr=r+d[0],nc=c+d[1]; while (ok(nr,nc)) { if (friendly(b[nr][nc],w)) break; + if (Math.abs(b[nr][nc]) == 6) break; o.add(mv(r,c,nr,nc,SP_NORMAL)); if (b[nr][nc]!=E) break; nr+=d[0]; nc+=d[1]; @@ -238,16 +351,21 @@ private void kingMoves(int[][] b, int r, int c, boolean w, List o) { for (int dr=-1;dr<=1;dr++) for (int dc=-1;dc<=1;dc++) { if (dr==0&&dc==0) continue; int nr=r+dr,nc=c+dc; - if (ok(nr,nc) && !friendly(b[nr][nc],w)) o.add(mv(r,c,nr,nc,SP_NORMAL)); + if (ok(nr,nc) && !friendly(b[nr][nc],w) + && Math.abs(b[nr][nc]) != 6) o.add(mv(r,c,nr,nc,SP_NORMAL)); } } - private void castleMoves(int[][] b, int r, int c, boolean w, List o) { + private void castleMoves(int[][] b, int r, int c, boolean w, List o, boolean[] cf) { if ((w&&r!=7)||(!w&&r!=0)||c!=4||kingInCheck(b,w)) return; + // ★ Bug修复:易位权改从节点级参数读取(null 回退全局字段)。搜索中原先直接读 + // 全局字段,会无视 applyOn 在棋盘副本上累计的易位权变更,产生非法易位走法 + boolean wck = cf != null ? cf[0] : wCK, wcq = cf != null ? cf[1] : wCQ; + boolean bck = cf != null ? cf[2] : bCK, bcq = cf != null ? cf[3] : bCQ; int kr=w?WR:BR, cr=w?7:0; - if ((w?wCK:bCK) && b[cr][7]==kr && b[cr][5]==E && b[cr][6]==E + if ((w?wck:bck) && b[cr][7]==kr && b[cr][5]==E && b[cr][6]==E && !isAttacked(b,cr,5,!w) && !isAttacked(b,cr,6,!w)) o.add(mv(r,c,cr,6,SP_CASTLE_K)); - if ((w?wCQ:bCQ) && b[cr][0]==kr && b[cr][1]==E && b[cr][2]==E && b[cr][3]==E + if ((w?wcq:bcq) && b[cr][0]==kr && b[cr][1]==E && b[cr][2]==E && b[cr][3]==E && !isAttacked(b,cr,3,!w) && !isAttacked(b,cr,2,!w)) o.add(mv(r,c,cr,2,SP_CASTLE_Q)); } @@ -269,10 +387,15 @@ private void applyOn(int[][] b, int[] m, boolean[] cf, int[][] ep) { if (Math.abs(p)==6) { if(w){cf[0]=false;cf[1]=false;}else{cf[2]=false;cf[3]=false;} } if (fr==7&&fc==7) cf[0]=false; if (fr==7&&fc==0) cf[1]=false; if (fr==0&&fc==7) cf[2]=false; if (fr==0&&fc==0) cf[3]=false; + // 落点为角格 → 对方车被吃(或己方车经王车易位移动),相应易位权同步清除 + if (tr==7&&tc==7) cf[0]=false; if (tr==7&&tc==0) cf[1]=false; + if (tr==0&&tc==7) cf[2]=false; if (tr==0&&tc==0) cf[3]=false; } } private void applyMove(int[] m, boolean sound) { boolean w = board[m[0]][m[1]]>0; + // 50回合规则计数:兵动/吃子清零,其余 +1(须在 applyOn 改变棋盘前判定) + if (Math.abs(board[m[0]][m[1]])==1 || board[m[2]][m[3]]!=E || m[4]==SP_EN_PASSANT) halfmoveClock=0; else halfmoveClock++; int[][] ep = new int[1][]; boolean[] cf = {wCK,wCQ,bCK,bCQ}; if (board[m[2]][m[3]]!=E || m[4]==SP_EN_PASSANT) GameRenderHelper.spawnParticles(particles, bx+m[3]*cellSize+cellSize/2, by+m[2]*cellSize+cellSize/2, 8, 0xFF6644); @@ -299,20 +422,79 @@ private void completePromo(int t) { Minecraft.getInstance().player.playSound(SoundEvents.PLAYER_LEVELUP,0.5f,1.5f); // LAN:本地选完升变子后才发送走法报文,并把最终子力类型编码进报文(双端对称) if (pendingLanPromote != null) { - sendMove(pendingLanPromote + "," + t); + sendMoveEnvelope(pendingLanPromote + "," + t); pendingLanPromote = null; } checkEnd(); } private void checkEnd() { if (state!=S.PLAYING) return; + // 每次走子完成后入档当前局面 key(含 checkEnd 的所有调用路径:玩家/AI/联机/升变完成) + positionKeys.add(positionKey()); + if (halfmoveClock >= 100) { + state=S.OVER; resultOutcome=0; resultMsg="50回合规则和棋"; + if (Minecraft.getInstance().player!=null) Minecraft.getInstance().player.playSound(SoundEvents.PLAYER_LEVELUP,1f,1f); + return; + } + if (isInsufficientMaterial()) { + state = S.OVER; + resultOutcome = 0; + resultMsg = "子力不足和棋"; + return; + } + String lastKey = positionKeys.get(positionKeys.size()-1); + int rep = 0; + for (String k : positionKeys) if (k.equals(lastKey)) rep++; + if (rep >= 3) { + state=S.OVER; resultOutcome=0; resultMsg="三次重复和棋"; + if (Minecraft.getInstance().player!=null) Minecraft.getInstance().player.playSound(SoundEvents.PLAYER_LEVELUP,1f,1f); + return; + } if (legalMoves(board,whiteTurn).isEmpty()) { state=S.OVER; + resultOutcome = kingInCheck(board,whiteTurn) ? (whiteTurn?-1:1) : 0; // whiteTurn=被将死一方 resultMsg = kingInCheck(board,whiteTurn) ? (whiteTurn?"黑方胜利!将死":"白方胜利!将死") : "僵局!平局"; if (Minecraft.getInstance().player!=null) Minecraft.getInstance().player.playSound(SoundEvents.PLAYER_LEVELUP,1f,1f); } } + /** Automatic dead-position cases covered by the standard insufficient-material rule. */ + private boolean isInsufficientMaterial() { + int nonKings = 0; + int bishops = 0; + int knights = 0; + int bishopSquareColor = -1; + for (int r = 0; r < 8; r++) { + for (int c = 0; c < 8; c++) { + int piece = board[r][c]; + int type = Math.abs(piece); + if (type == 0 || type == WK) continue; + nonKings++; + if (type == WN) knights++; + else if (type == WB) { + bishops++; + int color = (r + c) & 1; + if (bishopSquareColor < 0) bishopSquareColor = color; + else if (bishopSquareColor != color) return false; + } else return false; + } + } + return nonKings == 0 + || (nonKings == 1 && (bishops == 1 || knights == 1)) + // Two bishops on the same square color are also dead material. + || (nonKings == 2 && bishops == 2 && knights == 0); + } + + /** 构造局面 key:棋盘内容 + 行棋方 + 四个易位权 + ep 目标格(三次重复和棋判定用) */ + private String positionKey() { + StringBuilder sb = new StringBuilder(96); + sb.append(whiteTurn?'w':'b') + .append('|').append(wCK?'1':'0').append(wCQ?'1':'0').append(bCK?'1':'0').append(bCQ?'1':'0') + .append('|').append(epTarget==null?"-":epTarget[0]+","+epTarget[1]); + for (int r=0;r<8;r++) for (int c=0;c<8;c++) sb.append('|').append(board[r][c]); + return sb.toString(); + } + // ══════════════ 辅助检测 ══════════════ private boolean kingInCheck(int[][] b, boolean w) { int kr=-1,kc=-1; @@ -347,18 +529,18 @@ private boolean isAttacked(int[][] b, int r, int c, boolean byW) { // ══════════════ AI(深度2 + 500ms超时) ══════════════ /** ★ forWhite=false → 为黑方找最优(分数越小越好) */ - private int[] findBestMove(boolean forWhite) { + private int[] findBestMove(int[][] searchBoard, boolean[] searchCastling, int[] searchEp, boolean forWhite) { aiT0 = System.currentTimeMillis(); - List moves = legalMoves(board, forWhite); + List moves = legalMoves(searchBoard, forWhite, searchEp, searchCastling); if (moves.isEmpty()) return null; // MVV-LVA:吃高价值子优先 - moves.sort((a,b) -> Integer.compare(PIECE_VALUE[Math.abs(board[b[2]][b[3]])], PIECE_VALUE[Math.abs(board[a[2]][a[3]])])); + moves.sort((a,b) -> Integer.compare(PIECE_VALUE[Math.abs(searchBoard[b[2]][b[3]])], PIECE_VALUE[Math.abs(searchBoard[a[2]][a[3]])])); int[] best = moves.get(0); int bestScore = forWhite ? Integer.MIN_VALUE : Integer.MAX_VALUE; - boolean[] cf = {wCK,wCQ,bCK,bCQ}; + boolean[] cf = searchCastling.clone(); for (int[] mv : moves) { if (System.currentTimeMillis()-aiT0 > AI_MS) break; // 超时直接返回 - int[][] nb = copy(board); boolean[] ncf = cf.clone(); int[][] ep = {epTarget}; + int[][] nb = copy(searchBoard); boolean[] ncf = cf.clone(); int[][] ep = {searchEp}; applyOn(nb,mv,ncf,ep); int score = alphaBeta(nb, AI_DEPTH-1, Integer.MIN_VALUE, Integer.MAX_VALUE, !forWhite, ncf, ep[0]); if (forWhite && score>bestScore) { bestScore=score; best=mv; } @@ -372,22 +554,33 @@ private int[] findBestMove(boolean forWhite) { */ private int alphaBeta(int[][] b, int depth, int alpha, int beta, boolean max, boolean[] cf, int[] ep) { if (System.currentTimeMillis()-aiT0 > AI_MS) return evalBoard(b); - List moves = pseudoMoves(b, max, ep); // ← 伪合法,快;ep用搜索节点自身的目标 + List moves = pseudoMoves(b, max, ep, cf); // ← 伪合法,快;ep/cf 用搜索节点自身的副本 if (moves.isEmpty()) return kingInCheck(b,max) ? (max?-99999+depth:99999-depth) : 0; if (depth == 0) return evalBoard(b); // 简单排序:吃子优先 moves.sort((a,bb) -> Integer.compare(PIECE_VALUE[Math.abs(b[bb[2]][bb[3]])], PIECE_VALUE[Math.abs(b[a[2]][a[3]])])); int best = max ? Integer.MIN_VALUE : Integer.MAX_VALUE; + boolean hasLegal = false; // 是否至少评估过一个合法走法 + boolean timedOut = false; // 是否因超时中断(此时不能断言将杀/僵局) for (int[] mv : moves) { - if (System.currentTimeMillis()-aiT0 > AI_MS) break; + if (System.currentTimeMillis()-aiT0 > AI_MS) { timedOut = true; break; } int[][] nb = copy(b); boolean[] ncf = cf!=null?cf.clone():new boolean[]{true,true,true,true}; int[][] epR = {ep}; applyOn(nb,mv,ncf,epR); if (kingInCheck(nb,max)) continue; // 走后王被将 → 不合法 + hasLegal = true; int score = alphaBeta(nb, depth-1, alpha, beta, !max, ncf, epR[0]); if (max) { best=Math.max(best,score); alpha=Math.max(alpha,best); } else { best=Math.min(best,score); beta =Math.min(beta, best); } if (beta<=alpha) break; } + if (!hasLegal) { + // ★ Bug修复:伪合法走法全部非法时,原先退回静态子力分,把将杀/僵局 + // 误当成普通局面。超时中断(一步合法走法都没算到)仍退回静态分, + // 避免把超时误判成必败/必胜 + if (timedOut) return evalBoard(b); + // 被将军 → 将杀分(符号与深度修正和上方 moves.isEmpty() 分支完全一致);否则僵局 0 分 + return kingInCheck(b,max) ? (max?-99999+depth:99999-depth) : 0; + } if (best==Integer.MIN_VALUE||best==Integer.MAX_VALUE) return evalBoard(b); return best; } @@ -405,6 +598,7 @@ private int evalBoard(int[][] b) { // ══════════════ 输入 ══════════════ @Override public boolean mouseClicked(double mx, double my, int btn) { + if (btn != 0) return super.mouseClicked(mx, my, btn); if (showExitConfirm) { int click = GameRenderHelper.getExitConfirmClick(mx, my, width, height); if (click == 1) { showExitConfirm = false; sendLeaveGameOnce(); Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } if (click == 2) { showExitConfirm = false; return true; } return true; } if (state==S.MENU) { if (lanMode!=LAN_NONE) return true; @@ -415,12 +609,16 @@ public boolean mouseClicked(double mx, double my, int btn) { } if (state==S.OVER) { int cx2=width/2, cy2=height/2; - if (mx>=cx2-70&&mx<=cx2+70&&my>=cy2+22&&my<=cy2+40) { initBoard(); return true; } + // 与 R 键重开逻辑保持一致:LAN 下 CLIENT 无权重开,HOST 重开并广播 RESTART,非联机直接重开 + if (mx>=cx2-70&&mx<=cx2+70&&my>=cy2+22&&my<=cy2+40) { + if (lanMode!=LAN_CLIENT) { initBoard(); if (lanMode==LAN_HOST) sendMoveEnvelope("RESTART"); } + return true; + } if (mx>=cx2-70&&mx<=cx2+70&&my>=cy2+44&&my<=cy2+62) { sendLeaveGameOnce(); Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } return super.mouseClicked(mx,my,btn); } if (promoPending) { handlePromoClick((int)mx,(int)my); return true; } - int col=((int)mx-bx)/cellSize, row=((int)my-by)/cellSize; + int col=Math.floorDiv((int)mx-bx,cellSize), row=Math.floorDiv((int)my-by,cellSize); if (col<0||col>=8||row<0||row>=8) return super.mouseClicked(mx,my,btn); // 禁止非己方操作 if (vsAI && (!whiteTurn||aiThinking)) return true; @@ -440,7 +638,7 @@ public boolean mouseClicked(double mx, double my, int btn) { // 升变走法延迟到本地选完升变子后再发送(见 completePromo),报文携带最终子力类型 pendingLanPromote = lanData; } else { - sendMove(lanData); + sendMoveEnvelope(lanData); } } if (!promoPending) checkEnd(); @@ -479,10 +677,10 @@ public boolean keyPressed(int k, int sc, int mod) { } if (showExitConfirm) return true; if (k==GLFW.GLFW_KEY_R) { - if (lanMode!=LAN_CLIENT) { initBoard(); if (lanMode==LAN_HOST) sendMove("RESTART"); } + if (lanMode!=LAN_CLIENT) { initBoard(); if (lanMode==LAN_HOST) sendMoveEnvelope("RESTART"); } return true; } - return true; + return super.keyPressed(k, sc, mod); } // ══════════════ 渲染 ══════════════ @@ -548,6 +746,7 @@ private void renderPiece(GuiGraphics g, int p, int sx, int sy) { if (abs==5) g.fill(cx2-1,sy+3,cx2+2,sy+5,w?0xFFDDAA00:0xFF886600); } private void renderPromoPanel(GuiGraphics g, int mx, int my) { + g.flush(); // 先提交已 batch 的棋盘/棋子,避免升变面板实心背景与之 z-fighting boolean w=(promoRow==0); int cx2=width/2, cy2=height/2; int pw=cellSize*4+20, px2=cx2-pw/2, py2=cy2-30; g.fill(px2-2,py2-2,px2+pw+2,py2+cellSize+44,0xFF000000); @@ -562,9 +761,37 @@ private void renderPromoPanel(GuiGraphics g, int mx, int my) { } } private void renderOver(GuiGraphics g, int mx, int my) { + // 先 flush 之前的棋盘/棋子批次,避免 z-fighting 与文字穿透 + g.flush(); GameRenderHelper.drawGameOverOverlay(g,width,height); - int cx2=width/2, cy2=height/2; boolean win=resultMsg.contains("白方")&&vsAI; - GameRenderHelper.drawGameOverPanel(g,font,cx2,cy2,win,resultMsg.replace("§c","").replace("§a","").replace("§e",""),vsAI?(win?"恭喜战胜AI!":"再接再厉!"):"精彩对局!"); + int cx2=width/2, cy2=height/2; + boolean draw = resultOutcome == 0; + boolean win = resultOutcome == 1; // 白方胜(vsAI 时玩家执白,即"玩家胜") + // 本地双人/联机也按 checkEnd 记录的实际胜方渲染,不再被 vsAI 误判为失败样式 + int outcome = draw ? 0 : (win ? 1 : -1); + if (!draw && lanMode != LAN_NONE) { + boolean iAmWhite = lanMode == LAN_HOST; + outcome = ((iAmWhite && resultOutcome == 1) || (!iAmWhite && resultOutcome == -1)) ? 1 : -1; + } + String title; + String subtitle; + if (draw) { + title = "和棋"; + subtitle = "势均力敌!"; + } else if (lanMode != LAN_NONE) { + // LAN 下按本地座位显示胜负,HOST 执白、CLIENT 执黑。 + boolean iAmWhite = lanMode == LAN_HOST; + boolean iWon = (iAmWhite && resultOutcome == 1) || (!iAmWhite && resultOutcome == -1); + title = iWon ? "你赢了!" : "你输了!"; + subtitle = iWon ? "恭喜取得胜利!" : "再接再厉!"; + } else if (vsAI) { + title = win ? "你赢了!" : "AI 获胜!"; + subtitle = win ? "恭喜战胜AI!" : "再接再厉!"; + } else { + title = resultMsg.replace("§c", "").replace("§a", "").replace("§e", ""); + subtitle = "精彩对局!"; + } + GameRenderHelper.drawGameOverPanel(g,font,cx2,cy2,outcome,title,subtitle); GameRenderHelper.drawPrimaryButton(g,font,"R - 再来一局",cx2-70,cy2+22,140,18,mx,my); GameRenderHelper.drawSecondaryButton(g,font,"ESC - 返回",cx2-70,cy2+44,140,18,mx,my); } diff --git a/src/main/java/com/wzz/game_console/client/screens/games/WhackAMoleScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/WhackAMoleScreen.java index 2c99b3d..24b3669 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/WhackAMoleScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/WhackAMoleScreen.java @@ -10,6 +10,7 @@ import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; import net.minecraft.sounds.SoundEvents; +import org.lwjgl.glfw.GLFW; import net.minecraft.sounds.SoundSource; import net.neoforged.api.distmarker.Dist; import net.neoforged.api.distmarker.OnlyIn; @@ -75,6 +76,9 @@ private void initializeHoles() { @Override public void init() { + // ★ Bug修复:窗口缩放会重调 init(),不加 clearWidgets() 每次缩放 + // 都会叠加 3 个新按钮,玩家点击可能被最底层旧按钮拦截 + this.clearWidgets(); super.init(); calculateLayout(); createButtons(); @@ -223,7 +227,7 @@ private void endGame() { @Override public boolean mouseClicked(double mouseX, double mouseY, int button) { - if (showExitConfirm) { int click = GameRenderHelper.getExitConfirmClick(mouseX, mouseY, width, height); if (click == 1) { showExitConfirm = false; Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } if (click == 2) { showExitConfirm = false; return true; } return true; } + if (showExitConfirm) { int click = GameRenderHelper.getExitConfirmClick(mouseX, mouseY, width, height); if (click == 1) { showExitConfirm = false; Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } if (click == 2) { resumeFromExitConfirm(); return true; } return true; } if (gameState == GameState.PLAYING && button == 0) { // 检查是否点击了地鼠 for (MoleHole hole : holes) { @@ -369,9 +373,9 @@ private void renderGameUI(GuiGraphics guiGraphics) { } private void renderHammer(GuiGraphics guiGraphics, int mouseX, int mouseY) { - // 简单的锤子图标(使用方块模拟) - guiGraphics.fill(mouseX - 2, mouseY - 8, mouseX + 2, mouseY - 4, 0xFF8B4513); // 锤柄 - guiGraphics.fill(mouseX - 6, mouseY - 10, mouseX + 6, mouseY - 6, 0xFF696969); // 锤头 + // 锤子图标:锤头置于鼠标光标处(底部为打击面),锤柄向上延伸 + guiGraphics.fill(mouseX - 6, mouseY - 6, mouseX + 6, mouseY, 0xFF696969); // 锤头(底边与光标对齐) + guiGraphics.fill(mouseX - 2, mouseY - 14, mouseX + 2, mouseY - 6, 0xFF8B4513); // 锤柄 } private void renderGameOver(GuiGraphics guiGraphics) { @@ -421,15 +425,33 @@ private String getRating(int score, float accuracy) { return "多多练习!"; } + /** 弹窗打开时间戳:关闭时据此平移地鼠/游戏计时,补偿暂停期间流逝的墙钟时间 */ + private long pauseStartTime = 0; + + private void resumeFromExitConfirm() { + long pausedMs = System.currentTimeMillis() - pauseStartTime; + gameStartTime += pausedMs; + lastMoleSpawnTime += pausedMs; + for (MoleHole hole : holes) hole.offsetTime(pausedMs); + showExitConfirm = false; + } + @Override public boolean keyPressed(int keyCode, int scanCode, int modifiers) { - if (keyCode == 256) { - if (showExitConfirm) { showExitConfirm = false; } + if (keyCode == GLFW.GLFW_KEY_ESCAPE) { + if (showExitConfirm) { resumeFromExitConfirm(); } else if (gameState == GameState.MENU) { Minecraft.getInstance().setScreen(new GameSelectorScreen()); } // 菜单态ESC直接退出,与其他游戏一致 - else { showExitConfirm = true; } + else { pauseStartTime = System.currentTimeMillis(); showExitConfirm = true; } return true; } + // ★ 修复:showExitConfirm 拦截上移到 R 键处理之前, + // 防止退出确认弹窗期间按 R 直接重开(弹窗仍悬浮在重开后的对局上) if (showExitConfirm) return true; + // ★ 用户体验:R 在 GAME_OVER 或 MENU 时直接重开,符合常见约定 + if (keyCode == GLFW.GLFW_KEY_R && (gameState == GameState.GAME_OVER || gameState == GameState.MENU)) { + startGame(); + return true; + } return super.keyPressed(keyCode, scanCode, modifiers); } @@ -502,6 +524,12 @@ public boolean update(long currentTime) { return false; } + /** ESC 弹窗关闭时补偿暂停期间流逝的墙钟时间,防止暂停期间即将超时的地鼠一恢复就被秒判漏打 */ + public void offsetTime(long pausedMs) { + moleSpawnTime += pausedMs; + hitTime += pausedMs; + } + public void hitMole() { isHit = true; moleSpawnTime = System.currentTimeMillis(); // 重置时间用于下沉动画 @@ -524,34 +552,61 @@ public void render(GuiGraphics guiGraphics) { if (hasMole) { int moleRenderY = (int) (y + HOLE_SIZE - MOLE_SIZE + moleY); int moleRenderX = x + (HOLE_SIZE - MOLE_SIZE) / 2; - + guiGraphics.enableScissor(x, y, x + HOLE_SIZE, y + HOLE_SIZE); try { - ResourceLocation texture = moleType.getTexture(); - // 渲染地鼠头像(从怪物纹理中截取头部) - guiGraphics.blit(texture, - moleRenderX, moleRenderY, - 8, 8, // 纹理上头部的位置 - MOLE_SIZE, MOLE_SIZE, - 64, 64); // MC皮肤纹理尺寸 - } catch (Exception e) { - // 备用渲染 - int color = moleType == MoleType.CREEPER ? 0xFF00FF00 : - moleType == MoleType.SKELETON ? 0xFFCCCCCC : 0xFF00AA00; - guiGraphics.fill(moleRenderX, moleRenderY, - moleRenderX + MOLE_SIZE, moleRenderY + MOLE_SIZE, color); - } + + // ★ Bug修复:原版用 64x64 实体纹理中裁切 8x8 头部再缩放到 32x32, + // 但 OptiFine/资源包常使 zombie/creeper/skeleton 纹理尺寸异常 + // (32x16 / 32x32 / 64x32 等),强制按 64x64 采样会显示错位像素。 + // 这里直接走色块 + 表情符号兜底,兼容性最好,玩家不会看到错位贴图。 + int bodyColor = moleType == MoleType.CREEPER ? 0xFF00CC00 : + moleType == MoleType.SKELETON ? 0xFFCCCCCC : 0xFF2A8A2A; + int eyeColor = moleType == MoleType.CREEPER ? 0xFF003300 : + moleType == MoleType.SKELETON ? 0xFF333333 : 0xFF000000; + // 身体色块 + guiGraphics.fill(moleRenderX, moleRenderY, + moleRenderX + MOLE_SIZE, moleRenderY + MOLE_SIZE, bodyColor); + // 边框 + guiGraphics.fill(moleRenderX, moleRenderY, + moleRenderX + MOLE_SIZE, moleRenderY + 2, 0xFF000000); + guiGraphics.fill(moleRenderX, moleRenderY + MOLE_SIZE - 2, + moleRenderX + MOLE_SIZE, moleRenderY + MOLE_SIZE, 0xFF000000); + guiGraphics.fill(moleRenderX, moleRenderY, + moleRenderX + 2, moleRenderY + MOLE_SIZE, 0xFF000000); + guiGraphics.fill(moleRenderX + MOLE_SIZE - 2, moleRenderY, + moleRenderX + MOLE_SIZE, moleRenderY + MOLE_SIZE, 0xFF000000); + // 两只眼睛 + int eyeSize = Math.max(3, MOLE_SIZE / 6); + int eyeY = moleRenderY + MOLE_SIZE / 3; + guiGraphics.fill(moleRenderX + MOLE_SIZE / 3 - eyeSize / 2, eyeY, + moleRenderX + MOLE_SIZE / 3 + eyeSize / 2, eyeY + eyeSize, eyeColor); + guiGraphics.fill(moleRenderX + 2 * MOLE_SIZE / 3 - eyeSize / 2, eyeY, + moleRenderX + 2 * MOLE_SIZE / 3 + eyeSize / 2, eyeY + eyeSize, eyeColor); + // 嘴 + int mouthY = moleRenderY + 2 * MOLE_SIZE / 3; + guiGraphics.fill(moleRenderX + MOLE_SIZE / 3, mouthY, + moleRenderX + 2 * MOLE_SIZE / 3, mouthY + Math.max(2, eyeSize - 1), eyeColor); + + // ★ 删除上一版"备用 blit 7 参数"逻辑: + // blit 签名 (texture, x, y, uOffset, vOffset, uWidth, vHeight) + // 强制按 64x64 纹理 8,8 偏移裁 32x32,会在非 64x64 资源包下采样错位 + // 导致头部像素显示在身体之外("贴图错位"原 bug)。改用纯色块 + 表情符号 + // 兜底,跨资源包/字体均一致。 // 如果被打中,渲染打击效果 if (isHit) { guiGraphics.fill(moleRenderX, moleRenderY, moleRenderX + MOLE_SIZE, moleRenderY + MOLE_SIZE, 0x80FF0000); } + } finally { + guiGraphics.disableScissor(); + } } } public boolean isClicked(double mouseX, double mouseY) { - return mouseX >= x && mouseX <= x + HOLE_SIZE && - mouseY >= y && mouseY <= y + HOLE_SIZE; + return MoleHitbox.containsVisiblePart(x, y, HOLE_SIZE, MOLE_SIZE, + moleY, hasMole, mouseX, mouseY); } public boolean hasMole() { return hasMole; } diff --git a/src/main/java/com/wzz/game_console/client/screens/games/chess/BuiltInChessAI.java b/src/main/java/com/wzz/game_console/client/screens/games/chess/BuiltInChessAI.java new file mode 100644 index 0000000..8e164a0 --- /dev/null +++ b/src/main/java/com/wzz/game_console/client/screens/games/chess/BuiltInChessAI.java @@ -0,0 +1,458 @@ +package com.wzz.game_console.client.screens.games.chess; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * 内置中国象棋搜索引擎(增强版)。 + *

+ * 算法栈参考 Pikafish/Stockfish 的经典技术: + *

    + *
  • 迭代加深 + 空窗搜索(Aspiration Windows)
  • + *
  • Negamax + α-β 剪枝 + 将军延伸(Check Extension)
  • + *
  • 空步裁剪(Null Move Pruning)— 象棋无顿挫安全
  • + *
  • 无效裁减(Futility Pruning)— 浅层安静走法跳过
  • + *
  • 置换表(Zobrist)+ 走法排序(TT > MVV-LVA > 杀手 > 历史)
  • + *
  • 静态搜索(Quiescence)— 将军时全展开,否则仅吃子
  • + *
  • 评估:子力 + 位置 + 先手优势
  • + *
+ */ +public class BuiltInChessAI implements ChessAI { + + private static final int INF = 1_000_000; + + /** 置换表容量 */ + private static final int TT_SIZE = 1 << 18; + private static final int TT_MASK = TT_SIZE - 1; + + private static final int FLAG_EXACT = 0, FLAG_LOWER = 1, FLAG_UPPER = 2; + private static final int NULL_MOVE_R = 2; // 空步额外减少 + + private static final long[][][] ZOBRIST = new long[2][8][90]; + private static final long ZOBRIST_SIDE; + static { + java.util.Random rnd = new java.util.Random(0xDEAD_BEEF_5EED_CAFEL); + for (int s = 0; s < 2; s++) for (int p = 1; p < 8; p++) for (int i = 0; i < 90; i++) ZOBRIST[s][p][i] = rnd.nextLong(); + ZOBRIST_SIDE = rnd.nextLong(); + } + + private volatile long timeBudgetMs = 1500; + private volatile int maxDepth = 6; + private long timeStartNs; + private long nodeCount; + + /** + * 搜索代际:每次 getBestMove 递增,shouldStop 发现代际失配立即中止。 + * 修复:屏幕重开只 interrupt AI 线程,但内置引擎的搜索循环不响应 interrupt, + * 旧线程可继续跑满整个时间预算;期间新对局再次 launchAI 会与旧线程并发 + * 调用同一实例(TT/killers/timeStartNs 全被交叉写)。代际失效让旧搜索在 + * 下一个检查点立即 SearchAbort(配合 try-finally 回滚,棋盘不残留脏子)。 + */ + private final java.util.concurrent.atomic.AtomicLong activeGen = new java.util.concurrent.atomic.AtomicLong(); + private final ThreadLocal myGen = ThreadLocal.withInitial(() -> -1L); + + private final long[] ttKey = new long[TT_SIZE]; + private final int[] ttMove = new int[TT_SIZE]; + private final int[] ttScore = new int[TT_SIZE]; + private final int[] ttDepth = new int[TT_SIZE]; + private final int[] ttFlag = new int[TT_SIZE]; + + private final int[][] killers = new int[128][2]; + private final int[] history = new int[8100]; + + /** 当前搜索路径上各层的局面 key,用于路径内重复检测(长将循环按和棋评估) */ + private final long[] pathKeys = new long[128]; + + private int nullMovePly = -1; // 最近空步所在层,用于防连续空步 + private final boolean useLmr; // LMR + Delta 裁剪(默认启用,可关闭以对比) + + public BuiltInChessAI() { this(true); } + public BuiltInChessAI(boolean useLmr) { this.useLmr = useLmr; } + + @Override public void setSearchTime(long ms) { this.timeBudgetMs = ms; } + @Override public void setMaxDepth(int depth) { this.maxDepth = depth; } + @Override public void cancelSearch() { activeGen.incrementAndGet(); } + + @Override + public int[] getBestMove(int[][] board, boolean redTurn) { + myGen.set(activeGen.incrementAndGet()); // 使同实例上更早的搜索立即失效 + timeStartNs = System.nanoTime(); nodeCount = 0; nullMovePly = -1; + + long rootKey = zobrist(board, redTurn); + int rootIdx = (int) rootKey & TT_MASK; + List rootMoves = orderedMoves(board, redTurn, 0, ttKey[rootIdx] == rootKey ? ttMove[rootIdx] : 0); + if (rootMoves.isEmpty()) return null; + + java.util.Arrays.fill(pathKeys, 0); + pathKeys[0] = rootKey; + + int[] bestMove = null; + int prevScore = 0; + boolean inCheck = ChessRules.inCheckOnBoard(board, redTurn); + + try { + for (int depth = 1; depth <= maxDepth; depth++) { + int alpha = -INF, beta = INF; + // 深度 ≥ 2 时使用空窗搜索 + if (depth >= 2) { + int window = Math.max(30, 50 - depth * 5); + alpha = Math.max(-INF, prevScore - window); + beta = Math.min(INF, prevScore + window); + } + + // 如果根节点被将军,不使用空窗(避免反复重搜) + if (inCheck) { alpha = -INF; beta = INF; } + + int bestScore = -INF; + int[] depthBest = null; + // 记录本深度初始窗口:循环内 alpha 会被抬升, + // 判断"空窗失败"必须对照初始窗口,否则恒真导致每深度全窗口重搜 + int origAlpha = alpha, origBeta = beta; + + for (int attempt = 0; attempt < 2; attempt++) { + bestScore = -INF; + depthBest = null; + for (int[] mv : rootMoves) { + TrieMove undo = makeMove(board, mv); + // 根层同样要 finally 回滚:negamax 超时抛 SearchAbort 时若不回滚, + // 最后试探的走子会永久残留在调用方棋盘上(吃将/丢子的根源) + try { + if (!ChessRules.inCheckOnBoard(board, redTurn)) { + int score = -negamax(board, !redTurn, depth - 1, -beta, -alpha, 1, 0); + if (score > bestScore) { bestScore = score; depthBest = mv; } + if (score > alpha) alpha = score; + } + } finally { + unmakeMove(board, mv, undo); + } + if (shouldStop()) throw new SearchAbort(); + } + if (depthBest == null) break; + // 失败/失败高 → 扩大窗口重搜 + if (attempt == 0 && (bestScore <= origAlpha || bestScore >= origBeta)) { + alpha = -INF; beta = INF; + continue; + } + break; + } + + if (depthBest != null) { + bestMove = depthBest; + prevScore = bestScore; + storeTt(rootKey, encodeMove(depthBest), bestScore, depth, FLAG_EXACT); + } + if (shouldStop()) break; + } + } catch (SearchAbort ignored) {} + + if (bestMove == null) { + List fallback = orderedMoves(board, redTurn, 0, 0); + if (!fallback.isEmpty()) bestMove = fallback.get(0); + } + return bestMove == null ? null : new int[]{bestMove[0], bestMove[1], bestMove[2], bestMove[3]}; + } + + // ── α-β 搜索 ───────────────────────────────────────── + + private int negamax(int[][] b, boolean red, int depth, int alpha, int beta, int ply, int checkExt) throws SearchAbort { + nodeCount++; + if ((nodeCount & 0xFFF) == 0 && shouldStop()) throw new SearchAbort(); + if (depth <= 0) return quiescence(b, red, alpha, beta, ply, checkExt); + + boolean inCheck = ChessRules.inCheckOnBoard(b, red); + long key = zobrist(b, red); + int idx = (int) key & TT_MASK; + + // 路径内重复检测:同一方再次遇到完全相同局面 = 将军/追赶循环, + // 按和棋评估,避免搜索引擎把长将循环当成可以无限赢下去 + for (int i = ply - 2; i >= 0; i -= 2) { + if (pathKeys[i] == key) return 0; + } + pathKeys[ply] = key; + + // 置换表探测(杀分按 ply 归一到"距根的杀距",避免不同 ply 命中时失真) + if (ttKey[idx] == key && ttDepth[idx] >= depth) { + int ttSc = ttScore[idx]; + if (ttSc > INF - 1000) ttSc -= ply; + else if (ttSc < -(INF - 1000)) ttSc += ply; + if (ttFlag[idx] == FLAG_EXACT) return ttSc; + if (ttFlag[idx] == FLAG_LOWER && ttSc > alpha) alpha = ttSc; + else if (ttFlag[idx] == FLAG_UPPER && ttSc < beta) beta = ttSc; + if (alpha >= beta) return ttSc; + } + + // 空步裁剪:不被将军且深度≥3 且上一步不是空步 + if (!inCheck && depth >= 3 && ply - nullMovePly > 1) { + int savedNullMovePly = nullMovePly; // 保存/恢复而非硬重置,否则祖先帧的防连续空步记录被抹掉 + nullMovePly = ply; + int score = -negamax(b, !red, depth - 3 - NULL_MOVE_R, -beta, -beta + 1, ply + 1, checkExt); + nullMovePly = savedNullMovePly; + if (score >= beta) return beta; + } + + List moves = orderedMoves(b, red, ply, ttKey[idx] == key ? ttMove[idx] : 0); + if (moves.isEmpty()) return -(INF - ply); + + int bestScore = -INF, bestMove = 0, origAlpha = alpha; + + for (int moveIdx = 0; moveIdx < moves.size(); moveIdx++) { + int[] mv = moves.get(moveIdx); + boolean capture = b[mv[2]][mv[3]] != 0; + + // 无效裁减:浅层安静走法,静态评估远低于 α 则跳过 + if (!capture && !inCheck && depth <= 2 && bestScore > -INF + 1000) { + int stand = red ? ChessRules.evaluate(b) : -ChessRules.evaluate(b); + int margin = 300 * depth; + if (stand + margin <= alpha) continue; + } + + // LMR(Late Move Reduction):排序靠后的安静走法降层搜索 + int lmrR = 0; + if (useLmr && !capture && !inCheck && depth >= 3 && moveIdx >= 4) { + lmrR = 1 + (moveIdx - 4) / 4; + if (depth >= 5) lmrR++; + lmrR = Math.min(lmrR, depth - 2); + } + + TrieMove undo = makeMove(b, mv); + // 递归搜索可抛 SearchAbort:必须 finally 回滚,否则异常冒泡后棋盘残留脏子 + try { + // legalMoves 已过滤送将着法,无需逐着再验自将 + boolean givesCheck = ChessRules.inCheckOnBoard(b, !red); + int ext = (givesCheck && checkExt < 2) ? 1 : 0; // 将军延伸上限 2 层 + int baseDepth = depth - 1 + ext; // 可能为 0 或负 + int newCheckExt = checkExt + ext; + int searchDepth = baseDepth - lmrR; // 可能为 0 或负 → 走 quiescence + + int score; + if (searchDepth <= 0) { + if (bestScore == -INF) { + // PV 链首个走法 α=-INF:零宽窗口 (INF-1,INF) 是不可能窗口, + // quiescence 恒 fail-low 返回 ±(INF-1) 垃圾分,必须用全窗口 + score = -quiescence(b, !red, -beta, -alpha, ply + 1, newCheckExt); + } else { + // 零宽窗口试探;命中真实更优(非边界 fail-high)时全窗口重搜 + score = -quiescence(b, !red, -(alpha+1), -alpha, ply + 1, newCheckExt); + if (score > alpha && score < beta) { + score = -quiescence(b, !red, -beta, -alpha, ply + 1, newCheckExt); + } + } + } else { + score = -negamax(b, !red, searchDepth, -beta, -alpha, ply + 1, newCheckExt); + } + // LMR 找到好走法后需用完整深度重搜确认 + if (lmrR > 0 && score > alpha) { + int fullDepth = Math.max(1, baseDepth); // 重搜至少 depth=1 + score = -negamax(b, !red, fullDepth, -beta, -alpha, ply + 1, newCheckExt); + } + + if (score > bestScore) { bestScore = score; bestMove = encodeMove(mv); } + if (score > alpha) alpha = score; + } finally { + unmakeMove(b, mv, undo); + } + if (alpha >= beta) { + if (!capture && bestMove != 0) updateKillerAndHistory(b, mv, ply, depth); + break; + } + } + + int flag = bestScore <= origAlpha ? FLAG_UPPER : bestScore >= beta ? FLAG_LOWER : FLAG_EXACT; + // 杀分按 ply 归一后入表,与探测端的还原对称 + int ttScoreOut = bestScore; + if (ttScoreOut > INF - 1000) ttScoreOut += ply; + else if (ttScoreOut < -(INF - 1000)) ttScoreOut -= ply; + storeTt(key, bestMove, ttScoreOut, depth, flag); + return bestScore; + } + + // ── 静态搜索 ───────────────────────────────────────── + + private int quiescence(int[][] b, boolean red, int alpha, int beta, int ply, int checkExt) throws SearchAbort { + nodeCount++; + if ((nodeCount & 0xFFF) == 0 && shouldStop()) throw new SearchAbort(); + + boolean inCheck = ChessRules.inCheckOnBoard(b, red); + int stand = enhancedEval(b, red); + // 递归深度硬上限:被将军互反将的安静循环可能互相重复,无上限可 StackOverflow + if (ply >= 96) return stand; + if (!inCheck) { + // 被将军时不得 stand-pat(不应将),否则把实际被将死当好局面返回 + if (stand >= beta) return beta; + if (stand > alpha) alpha = stand; + } + + // Delta 裁剪:静态评估加上最大吃子价值仍低于 α → 直接返回 + if (!inCheck && stand + 1100 < alpha && useLmr) { + return alpha; + } + + List moves = inCheck ? orderedMoves(b, red, ply, 0) : captureMoves(b, red); + // 被将军且无合法解将着法 = 被将死,返回杀分(与 negamax 的 moves.isEmpty 一致) + if (inCheck && moves.isEmpty()) return -(INF - ply); + + for (int[] mv : moves) { + boolean capture = b[mv[2]][mv[3]] != 0; + TrieMove undo = makeMove(b, mv); + // 递归可抛 SearchAbort:finally 保证回滚 + try { + if (!ChessRules.inCheckOnBoard(b, red)) { + boolean givesCheck = ChessRules.inCheckOnBoard(b, !red); + int ext = (givesCheck && checkExt < 2) ? 1 : 0; + int newCheckExt = checkExt + ext; + int score = -quiescence(b, !red, -beta, -alpha, ply + 1, newCheckExt); + if (score > alpha) alpha = score; + } + } finally { + unmakeMove(b, mv, undo); + } + if (alpha >= beta) { + if (!capture) { int code = encodeMove(mv); history[code] += 4; } + break; + } + } + return alpha; + } + + // ── 评估 ───────────────────────────────────────────── + + /** + * 增强评估(从走子方视角,正=本方占优): + * 子力 + 位置表 + 子力活性(mobility)+ 将帅安全 + 先手优势。 + */ + private int enhancedEval(int[][] b, boolean red) { + int base = ChessRules.evaluate(b); // 红方视角 + int score = red ? base : -base; // 转走子方视角 + + // 子力活性:己方伪合法走法数 - 对方,限制在 ±12,每步 3 分 + int myMob = ChessRules.countPseudoMoves(b, red); + int oppMob = ChessRules.countPseudoMoves(b, !red); + int mob = Math.max(-12, Math.min(12, myMob - oppMob)); + score += mob * 3; + + // 将帅安全:九宫内己方士/象守卫数量奖励,对方对称 + score += kingSafety(b, red); + score -= kingSafety(b, !red); + + score += 20; // 先手优势(Tempo bonus) + return score; + } + + /** + * 将帅安全:统计九宫内己方士/象守卫数量。 + * 0 个 -12 / 1 个 +0 / 2 个 +12 / 3 个以上 +20。 + */ + private int kingSafety(int[][] b, boolean red) { + int rLo = red ? 7 : 0; + int rHi = red ? 9 : 2; + int defenders = 0; + for (int c = 3; c <= 5; c++) { + for (int r = rLo; r <= rHi; r++) { + int p = b[c][r]; + if (p == 0) continue; + if ((p > 0) == red) { + int abs = Math.abs(p); + if (abs == ChessRules.ADVISOR || abs == ChessRules.ELEPHANT) defenders++; + } + } + } + return defenders == 0 ? -12 : defenders <= 2 ? (defenders - 1) * 12 : 20; + } + + // ── 走法生成与排序 ───────────────────────────────────── + + private List orderedMoves(int[][] b, boolean red, int ply, int ttMoveCode) { + List legal = ChessRules.legalMoves(b, red); + int k0 = kill(ply, 0), k1 = kill(ply, 1); + for (int[] mv : legal) { + boolean capture = b[mv[2]][mv[3]] != 0; + int score; + if (capture) { + int victim = Math.abs(b[mv[2]][mv[3]]); + int attacker = Math.abs(b[mv[0]][mv[1]]); + score = 1_000_000 + ChessRules.PIECE_VAL[victim] * 16 - ChessRules.PIECE_VAL[attacker]; + } else { + score = history[encodeMove(mv)]; + } + int enc = encodeMove(mv); + if (ttMoveCode != 0 && enc == ttMoveCode) score += 100_000_000; + if (enc == k0) score += 10_000_000; + else if (enc == k1) score += 9_000_000; + mv[4] = score; + } + legal.sort(Comparator.comparingInt(m -> -m[4])); + return legal; + } + + private List captureMoves(int[][] b, boolean red) { + List legal = ChessRules.legalMoves(b, red); + List caps = new ArrayList<>(); + for (int[] mv : legal) { + if (b[mv[2]][mv[3]] == 0) continue; + int victim = Math.abs(b[mv[2]][mv[3]]); + int attacker = Math.abs(b[mv[0]][mv[1]]); + mv[4] = ChessRules.PIECE_VAL[victim] * 16 - ChessRules.PIECE_VAL[attacker]; + caps.add(mv); + } + caps.sort(Comparator.comparingInt(m -> -m[4])); + return caps; + } + + private int kill(int ply, int slot) { return (ply >= 0 && ply < killers.length) ? killers[ply][slot] : 0; } + + private void updateKillerAndHistory(int[][] b, int[] mv, int ply, int depth) { + int code = encodeMove(mv); + if (ply >= 0 && ply < killers.length) { + if (killers[ply][0] != code) { killers[ply][1] = killers[ply][0]; killers[ply][0] = code; } + } + history[code] += depth * depth; + } + + // ── 置换表 ───────────────────────────────────────── + + private void storeTt(long key, int move, int score, int depth, int flag) { + int idx = (int) key & TT_MASK; + if (ttDepth[idx] <= depth) { + ttKey[idx] = key; ttMove[idx] = move; ttScore[idx] = score; + ttDepth[idx] = depth; ttFlag[idx] = flag; + } + } + + private static int encodeMove(int[] mv) { return (mv[0] * 10 + mv[1]) * 90 + (mv[2] * 10 + mv[3]); } + + // ── 走法执行 ───────────────────────────────────────── + + private static final class TrieMove { final int captured; TrieMove(int c) { captured = c; } } + + private TrieMove makeMove(int[][] b, int[] mv) { + TrieMove undo = new TrieMove(b[mv[2]][mv[3]]); + b[mv[2]][mv[3]] = b[mv[0]][mv[1]]; b[mv[0]][mv[1]] = 0; + return undo; + } + + private void unmakeMove(int[][] b, int[] mv, TrieMove undo) { + b[mv[0]][mv[1]] = b[mv[2]][mv[3]]; b[mv[2]][mv[3]] = undo.captured; + } + + // ── Zobrist ───────────────────────────────────────── + + private long zobrist(int[][] b, boolean red) { + long h = 0; + for (int c = 0; c < 9; c++) for (int r = 0; r < 10; r++) { + int p = b[c][r]; + if (p == 0) continue; + h ^= ZOBRIST[p > 0 ? 0 : 1][Math.abs(p)][c * 10 + r]; + } + if (red) h ^= ZOBRIST_SIDE; + return h; + } + + private boolean shouldStop() { + if (Thread.currentThread().isInterrupted()) return true; + if (myGen.get() != activeGen.get()) return true; // 已被更新的搜索取代 + return (System.nanoTime() - timeStartNs) >= timeBudgetMs * 1_000_000L; + } + + private static final class SearchAbort extends RuntimeException {} +} \ No newline at end of file diff --git a/src/main/java/com/wzz/game_console/client/screens/games/chess/ChessAI.java b/src/main/java/com/wzz/game_console/client/screens/games/chess/ChessAI.java new file mode 100644 index 0000000..8a4e686 --- /dev/null +++ b/src/main/java/com/wzz/game_console/client/screens/games/chess/ChessAI.java @@ -0,0 +1,117 @@ +package com.wzz.game_console.client.screens.games.chess; + +import com.wzz.game_console.util.GameSettings; + +/** + * 中国象棋 AI 抽象接口(结构仿 {@code GoAI})。 + *

+ * 实现类: + *

    + *
  • {@link BuiltInChessAI} — 内置增强搜索引擎(迭代加深 + α-β + 置换表,纯 Java,开箱即用)
  • + *
  • {@link PikafishChessAI} — 外部 Pikafish 引擎(UCI 协议,需用户安装 Pikafish)
  • + *
+ *

+ * 引擎选择通过 {@code data/game_settings.json} 中的 {@code chess.engine} 配置: + *

+ * {
+ *   "chess": {
+ *     "engine": "built-in",       // "built-in" | "pikafish"
+ *     "pikafishPath": "E:/皮卡鱼 20260131/pikafish-avx2.exe",
+ *     "pikafishThreads": 1
+ *   }
+ * }
+ * 
+ * 若 Pikafish 路径未配置、文件不存在或启动失败,会自动回退到内置引擎。 + */ +public interface ChessAI { + + /** + * 计算最佳走法。 + * + * @param board 当前局面,{@code board[col][row]},0=空 正=红 负=黑 + * @param redTurn true=红方走子,false=黑方走子 + * @return {@code {fc, fr, tc, tr}} 走法;无合法走法返回 {@code null} + */ + int[] getBestMove(int[][] board, boolean redTurn); + + /** + * 设置本次思考的时间预算(毫秒)。外挂引擎对应 UCI 的 {@code movetime}。 + */ + void setSearchTime(long ms); + + /** + * 设置内置引擎的搜索深度上限。外挂引擎忽略此设置(以搜索时间为主)。 + */ + void setMaxDepth(int depth); + + /** 请求当前搜索尽快停止。实现不得阻塞调用线程。 */ + default void cancelSearch() {} + + /** 释放 AI 引擎占用的资源(如外部进程)。默认空实现,子类按需覆盖。 */ + default void shutdown() {} + + /** Pikafish 默认路径(文件夹含空格,由 ProcessBuilder 直接处理) */ + String DEFAULT_PIKAFISH_PATH = "E:/皮卡鱼 20260131/pikafish-avx2.exe"; + + // ── 工厂方法 ────────────────────────────────────────────── + + /** 懒加载的日志记录器(避免静态初始化时 slf4j 不可用) */ + private static org.slf4j.Logger getFactoryLogger() { + return org.slf4j.LoggerFactory.getLogger("ChessAI"); + } + + /** + * 根据 GameSettings 创建 AI 引擎实例。 + * 配置键:chess.engine = "built-in"(默认)或 "pikafish"。 + */ + static ChessAI create() { + String engine; + try { + engine = GameSettings.getString("chess", "engine", "built-in"); + } catch (Throwable t) { + return new BuiltInChessAI(); + } + return create(engine); + } + + /** + * 根据引擎名称创建 AI 实例。 + * + * @param engine "built-in" 或 "pikafish" + */ + static ChessAI create(String engine) { + org.slf4j.Logger logger; + try { + logger = getFactoryLogger(); + } catch (Throwable t) { + return new BuiltInChessAI(); + } + if ("pikafish".equalsIgnoreCase(engine)) { + String path; + try { + path = GameSettings.getString("chess", "pikafishPath", ""); + } catch (Throwable t) { + path = ""; + } + if (path.isEmpty()) { + // ★ Bug修复:原版默认路径硬编码 "E:/皮卡鱼 20260131/pikafish-avx2.exe", + // Mac/Linux/C/D/F 盘用户/无 Pikafish 用户都失败,仅在用户实际有 + // 该盘符和路径时才能用。改为空时直接回退内置引擎,日志提示配置 + path = ""; + logger.info("[中国象棋] Pikafish 路径未配置,使用内置引擎"); + return new BuiltInChessAI(); + } + try { + PikafishChessAI pikafish = new PikafishChessAI(path); + logger.info("[中国象棋] 使用 Pikafish 引擎: {}", path); + return pikafish; + } catch (Exception e) { + logger.warn("[中国象棋] Pikafish 启动失败,回退到内置引擎: {}", e.getMessage()); + return new BuiltInChessAI(); + } + } + // 默认内置引擎 + logger.info("[中国象棋] 使用内置增强搜索引擎"); + return new BuiltInChessAI(); + } +} \ No newline at end of file diff --git a/src/main/java/com/wzz/game_console/client/screens/games/chess/ChessRules.java b/src/main/java/com/wzz/game_console/client/screens/games/chess/ChessRules.java new file mode 100644 index 0000000..a6782c1 --- /dev/null +++ b/src/main/java/com/wzz/game_console/client/screens/games/chess/ChessRules.java @@ -0,0 +1,455 @@ +package com.wzz.game_console.client.screens.games.chess; + +import java.util.ArrayList; +import java.util.List; + +/** + * 中国象棋公共规则库。 + *

+ * 提供两套引擎共用的基础能力:伪合法走法生成、将军检测、FEN / UCI 坐标转换, + * 以及内置引擎使用的棋子价值与位置价值表。 + *

+ * 棋盘约定(与 {@link com.wzz.game_console.client.screens.games.ChessGameScreen} 一致): + * {@code board[col][row]},0=空,正数=红方,负数=黑方;对角线坐标转换见具体方法。 + */ +public final class ChessRules { + + public static final int COLS = 9, ROWS = 10; + public static final int GENERAL = 1, ADVISOR = 2, ELEPHANT = 3, + HORSE = 4, CHARIOT = 5, CANNON = 6, SOLDIER = 7; + + /** 走法生成方向常量(避免搜索中每次调用重复创建数组) */ + private static final int[][] DIR_ORTHO = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}}; + private static final int[][] DIR_DIAG = {{1, 1}, {1, -1}, {-1, 1}, {-1, -1}}; + private static final int[][] DIR_ELEPHANT = {{2, 2}, {2, -2}, {-2, 2}, {-2, -2}}; + private static final int[][] HORSE_LEGS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + private static final int[][][] HORSE_DEST = { + {{2, 1}, {2, -1}}, {{-2, 1}, {-2, -1}}, {{1, 2}, {-1, 2}}, {{1, -2}, {-1, -2}} + }; + + private ChessRules() {} + + // ── 走法生成 ────────────────────────────────────────────── + + /** + * 生成某棋子的伪合法走法(未过滤"走后自将"),返回目标点列表。 + * + * @param b 棋盘 + * @param col 棋子列坐标 + * @param row 棋子行坐标 + * @return {@code [col,row]} 目标点列表 + */ + public static List pseudoMoves(int[][] b, int col, int row) { + int p = b[col][row]; + if (p == 0) return new ArrayList<>(); + boolean red = p > 0; + int abs = Math.abs(p); + List m = new ArrayList<>(); + switch (abs) { + case GENERAL -> generalMoves(b, col, row, red, m); + case ADVISOR -> advisorMoves(b, col, row, red, m); + case ELEPHANT -> elephantMoves(b, col, row, red, m); + case HORSE -> horseMoves(b, col, row, red, m); + case CHARIOT -> chariotMoves(b, col, row, red, m); + case CANNON -> cannonMoves(b, col, row, red, m); + case SOLDIER -> soldierMoves(b, col, row, red, m); + } + return m; + } + + /** 生成红方或黑方的全部伪合法走法,返回 {@code {fc,fr,tc,tr}} */ + public static List allPseudoMoves(int[][] b, boolean red) { + List out = new ArrayList<>(64); + for (int c = 0; c < COLS; c++) { + for (int r = 0; r < ROWS; r++) { + int p = b[c][r]; + if (p == 0) continue; + if ((red && p > 0) || (!red && p < 0)) { + for (int[] t : pseudoMoves(b, c, r)) { + out.add(new int[]{c, r, t[0], t[1]}); + } + } + } + } + return out; + } + + /** + * 生成红方或黑方的全部合法走法(过滤"走后自将"),返回 {@code {fc,fr,tc,tr,scratch}}。 + *

+ * 每个走法占用 5 个 int,第 5 位是排序分草稿槽,由调用方自行写入(供 AI 走法排序复用数组, + * 避免排序过程产生额外对象)。不关心排序的调用方忽略该槽位即可。 + */ + public static List legalMoves(int[][] b, boolean red) { + List out = new ArrayList<>(64); + for (int c = 0; c < COLS; c++) { + for (int r = 0; r < ROWS; r++) { + int p = b[c][r]; + if (p == 0) continue; + if ((red && p > 0) || (!red && p < 0)) { + for (int[] t : pseudoMoves(b, c, r)) { + int tc = t[0], tr = t[1]; + int captured = b[tc][tr]; + int piece = b[c][r]; + b[tc][tr] = piece; + b[c][r] = 0; + if (!inCheckOnBoard(b, red)) out.add(new int[]{c, r, tc, tr, 0}); + b[c][r] = piece; + b[tc][tr] = captured; + } + } + } + } + return out; + } + + private static void tryAdd(int[][] b, List m, int c, int r, boolean red) { + if (c < 0 || c >= COLS || r < 0 || r >= ROWS) return; + int t = b[c][r]; + // 将/帅不是可被捕获的普通棋子;将军与将杀由检查逻辑处理。 + if (Math.abs(t) == GENERAL) return; + if (t == 0 || (red && t < 0) || (!red && t > 0)) m.add(new int[]{c, r}); + } + + private static void generalMoves(int[][] b, int c, int r, boolean red, List m) { + for (int[] d : DIR_ORTHO) { + int nc = c + d[0], nr = r + d[1]; + if (inPalace(nc, nr, red)) tryAdd(b, m, nc, nr, red); + } + } + + private static void advisorMoves(int[][] b, int c, int r, boolean red, List m) { + for (int[] d : DIR_DIAG) { + int nc = c + d[0], nr = r + d[1]; + if (inPalace(nc, nr, red)) tryAdd(b, m, nc, nr, red); + } + } + + private static void elephantMoves(int[][] b, int c, int r, boolean red, List m) { + for (int[] d : DIR_ELEPHANT) { + int nc = c + d[0], nr = r + d[1]; + if (nc < 0 || nc >= COLS || nr < 0 || nr >= ROWS) continue; + if (red && nr < 5) continue; + if (!red && nr > 4) continue; + int mc = c + d[0] / 2, mr = r + d[1] / 2; + if (b[mc][mr] != 0) continue; + tryAdd(b, m, nc, nr, red); + } + } + + private static void horseMoves(int[][] b, int c, int r, boolean red, List m) { + for (int i = 0; i < 4; i++) { + int lc = c + HORSE_LEGS[i][0], lr = r + HORSE_LEGS[i][1]; + if (lc < 0 || lc >= COLS || lr < 0 || lr >= ROWS) continue; + if (b[lc][lr] != 0) continue; + for (int[] d : HORSE_DEST[i]) tryAdd(b, m, c + d[0], r + d[1], red); + } + } + + private static void chariotMoves(int[][] b, int c, int r, boolean red, List m) { + for (int[] d : DIR_ORTHO) { + for (int i = 1; i < 10; i++) { + int nc = c + d[0] * i, nr = r + d[1] * i; + if (nc < 0 || nc >= COLS || nr < 0 || nr >= ROWS) break; + int t = b[nc][nr]; + if (t == 0) { + m.add(new int[]{nc, nr}); + continue; + } + if (Math.abs(t) != GENERAL && ((red && t < 0) || (!red && t > 0))) { + m.add(new int[]{nc, nr}); + } + break; + } + } + } + + private static void cannonMoves(int[][] b, int c, int r, boolean red, List m) { + for (int[] d : DIR_ORTHO) { + boolean jumped = false; + for (int i = 1; i < 10; i++) { + int nc = c + d[0] * i, nr = r + d[1] * i; + if (nc < 0 || nc >= COLS || nr < 0 || nr >= ROWS) break; + int t = b[nc][nr]; + if (!jumped) { + if (t == 0) m.add(new int[]{nc, nr}); + else jumped = true; + } else { + if (t != 0) { + if (Math.abs(t) != GENERAL && ((red && t < 0) || (!red && t > 0))) { + m.add(new int[]{nc, nr}); + } + break; + } + } + } + } + } + + private static void soldierMoves(int[][] b, int c, int r, boolean red, List m) { + int fwd = red ? -1 : 1; + boolean crossed = red ? (r < 5) : (r > 4); + tryAdd(b, m, c, r + fwd, red); + if (crossed) { + tryAdd(b, m, c + 1, r, red); + tryAdd(b, m, c - 1, r, red); + } + } + + /** 是否在九宫格内 */ + public static boolean inPalace(int c, int r, boolean red) { + if (c < 3 || c > 5) return false; + return red ? (r >= 7 && r <= 9) : (r >= 0 && r <= 2); + } + + /** 该走法是否为吃子走法 */ + public static boolean isCapture(int[][] b, int tc, int tr) { + return b[tc][tr] != 0; + } + + /** 检测某方主帅是否处于被将军状态(含飞将) */ + public static boolean inCheckOnBoard(int[][] b, boolean isRed) { + int gc = -1, gr = -1; + outer: + for (int c = 0; c < COLS; c++) { + for (int r = 0; r < ROWS; r++) { + if (b[c][r] == (isRed ? GENERAL : -GENERAL)) { + gc = c; + gr = r; + break outer; + } + } + } + if (gc < 0) return true; + for (int c = 0; c < COLS; c++) { + for (int r = 0; r < ROWS; r++) { + int p = b[c][r]; + if (p == 0) continue; + if ((p > 0) == isRed) continue; + // 攻击检测保留目标的颜色和占位,炮必须隔一子吃实子; + // 用普通棋子替代将帅,绕过实际走法的禁止吃将过滤。 + int target = b[gc][gr]; + b[gc][gr] = isRed ? SOLDIER : -SOLDIER; + try { + for (int[] a : pseudoMoves(b, c, r)) { + if (a[0] == gc && a[1] == gr) return true; + } + } finally { + b[gc][gr] = target; + } + } + } + // 飞将 + if (gc >= 3 && gc <= 5) { + for (int r = 0; r < ROWS; r++) { + if (b[gc][r] == (isRed ? -GENERAL : GENERAL)) { + int r1 = Math.min(gr, r) + 1, r2 = Math.max(gr, r); + boolean clear = true; + for (int rr = r1; rr < r2; rr++) { + if (b[gc][rr] != 0) { + clear = false; + break; + } + } + if (clear) return true; + } + } + } + return false; + } + + // ── FEN / UCI 坐标 ──────────────────────────────────────── + + /** 棋子编号 1..7 对应 FEN 字母(红大写,黑小写) */ + private static final String PIECE_LETTER = "KABNRCP"; + + /** 棋盘转 FEN(中国象棋标准 XFEN) */ + public static String toFen(int[][] b, boolean redTurn) { + StringBuilder sb = new StringBuilder(); + for (int r = 0; r < ROWS; r++) { + int empty = 0; + for (int c = 0; c < COLS; c++) { + int p = b[c][r]; + if (p == 0) { + empty++; + continue; + } + if (empty > 0) { + sb.append(empty); + empty = 0; + } + boolean red = p > 0; + int abs = Math.abs(p); + char ch = PIECE_LETTER.charAt(abs - 1); + if (!red) ch = Character.toLowerCase(ch); + sb.append(ch); + } + if (empty > 0) sb.append(empty); + if (r < ROWS - 1) sb.append('/'); + } + sb.append(' ').append(redTurn ? 'w' : 'b').append(" - - 0 1"); + return sb.toString(); + } + + /** 解析 UCI 走法(如 {@code h2e2})为 {@code {fc,fr,tc,tr}},非法返回 null */ + public static int[] parseUciMove(String uci) { + if (uci == null || uci.length() < 4) return null; + int fc = uci.charAt(0) - 'a'; + int fr = 9 - (uci.charAt(1) - '0'); + int tc = uci.charAt(2) - 'a'; + int tr = 9 - (uci.charAt(3) - '0'); + if (fc < 0 || fc >= COLS || fr < 0 || fr >= ROWS + || tc < 0 || tc >= COLS || tr < 0 || tr >= ROWS) { + return null; + } + return new int[]{fc, fr, tc, tr}; + } + + /** 走法转 UCI 坐标串(UCI rank 0 = 底部=红方底线,需翻转行号) */ + public static String toUciMove(int fc, int fr, int tc, int tr) { + return "" + (char) ('a' + fc) + (9 - fr) + (char) ('a' + tc) + (9 - tr); + } + + // ── 估值表(供内置引擎使用) ──────────────────────────────── + + /** 棋子基础分(下标=棋子编号) */ + public static final int[] PIECE_VAL = { + 0, + 10000, // 将 + 200, // 士 + 220, // 象 + 400, // 马 + 900, // 车 + 450, // 炮 + 100 // 兵 + }; + + /** 位置价值表,从黑方视角定义(row0=黑方底线),红方行号镜像 */ + public static final int[][] PST_HORSE = { + { 0, 0, -2, 0, 0, 0, -2, 0, 0, 0}, + { 0, 4, 6, 8, 4, 4, 6, 4, 0, 0}, + { 2, 8, 12, 14, 12, 10, 12, 8, 2, 0}, + { 4, 14, 20, 24, 20, 18, 20, 14, 4, 0}, + { 2, 12, 18, 20, 18, 16, 18, 12, 2, 0}, + { 0, 4, 12, 14, 12, 10, 12, 4, 0, 0}, + { 0, 8, 12, 12, 12, 10, 12, 8, 0, 0}, + { 0, 0, 8, 10, 8, 8, 8, 0, 0, 0}, + { 0, 2, 6, 4, 6, 4, 4, 2, 0, 0}, + { 0, 0, 2, 0, 0, 0, 2, 0, 0, 0}, + }; + + public static final int[][] PST_CHARIOT = { + {14, 14, 12, 18, 16, 18, 12, 14, 14, 0}, + {16, 20, 18, 24, 26, 24, 18, 20, 16, 0}, + {12, 12, 12, 18, 18, 18, 12, 12, 12, 0}, + {12, 18, 16, 22, 22, 22, 16, 18, 12, 0}, + {12, 14, 12, 18, 18, 18, 12, 14, 12, 0}, + {12, 16, 14, 20, 20, 20, 14, 16, 12, 0}, + {12, 12, 12, 18, 18, 18, 12, 12, 12, 0}, + {12, 18, 16, 22, 22, 22, 16, 18, 12, 0}, + {16, 20, 18, 24, 26, 24, 18, 20, 16, 0}, + {14, 14, 12, 18, 16, 18, 12, 14, 14, 0}, + }; + + public static final int[][] PST_CANNON = { + { 6, 4, 0, -10, -12, -10, 0, 4, 6, 0}, + { 2, 2, 0, -4, -14, -4, 0, 2, 2, 0}, + { 2, 6, 4, 0, -6, 0, 4, 6, 2, 0}, + { 0, 0, 0, 6, 10, 6, 0, 0, 0, 0}, + { 0, 2, 4, 6, 10, 6, 4, 2, 0, 0}, + { 0, 0, 4, 6, 10, 6, 4, 0, 0, 0}, + { 0, 2, 0, 4, 8, 4, 0, 2, 0, 0}, + {-2, -4, -2, 4, 8, 4, -2, -4, -2, 0}, + { 0, 0, 2, 4, 6, 4, 2, 0, 0, 0}, + { 0, 2, 4, 6, 6, 6, 4, 2, 0, 0}, + }; + + /** 兵(过河前后差别大) */ + public static final int[][] PST_SOLDIER = { + { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + { 8, 18, 28, 40, 40, 40, 28, 18, 8, 0}, + {14, 24, 38, 52, 60, 52, 38, 24, 14, 0}, + {22, 34, 50, 64, 76, 64, 50, 34, 22, 0}, + {34, 48, 62, 76, 86, 76, 62, 48, 34, 0}, + { 6, 14, 22, 32, 36, 32, 22, 14, 6, 0}, + { 4, 10, 14, 20, 24, 20, 14, 10, 4, 0}, + { 2, 6, 8, 10, 12, 10, 8, 6, 2, 0}, + }; + + /** 士/仕位置表:九宫中央(4,1/4,8)最活跃,底线次之 */ + public static final int[][] PST_ADVISOR = { + {0,0,0,10,0,10,0,0,0,0}, + {0,0,0,0,20,0,0,0,0,0}, + {0,0,0,0,10,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,0,0,0,0,0,0}, + {0,0,0,0,10,0,0,0,0,0}, + {0,0,0,0,20,0,0,0,0,0}, + {0,0,0,10,0,10,0,0,0,0}, + }; + + /** 象/相位置表:连接状态(双象同一侧)/ 中心控制 */ + public static final int[][] PST_ELEPHANT = { + {0,0,10,0,0,0,10,0,0,0}, + {0,0,0,0,0,0,0,0,0,0}, + {10,0,0,0,20,0,0,0,10,0}, + {0,0,10,0,0,0,10,0,0,0}, + {0,0,0,0,10,0,0,0,0,0}, + {0,0,0,0,10,0,0,0,0,0}, + {0,0,10,0,0,0,10,0,0,0}, + {0,0,0,0,0,0,0,0,0,0}, + {10,0,0,0,20,0,0,0,10,0}, + {0,0,10,0,0,0,10,0,0,0}, + }; + + /** 棋子位置额外得分(从该方视角)。PST 表按 行=rank(横排)/ 列=col 设计,r 已做红方行号镜像 */ + public static int pstBonus(int abs, int col, int row, boolean red) { + int r = red ? (9 - row) : row; + // ★ Bug修复:原先写成 PST_xxx[col][r],把列当行用(转置索引),位置分整体错位; + // 表尾第 10 列是填充 0,转置后 rank9 一律得 0 分 + return switch (abs) { + case HORSE -> PST_HORSE[r][col]; + case CHARIOT -> PST_CHARIOT[r][col]; + case CANNON -> PST_CANNON[r][col]; + case SOLDIER -> PST_SOLDIER[r][col]; + case ADVISOR -> PST_ADVISOR[r][col]; + case ELEPHANT -> PST_ELEPHANT[r][col]; + default -> 0; + }; + } + + /** 统计一方全部伪合法走法数量(用于子力活性评估) */ + public static int countPseudoMoves(int[][] b, boolean red) { + int count = 0; + for (int c = 0; c < COLS; c++) { + for (int r = 0; r < ROWS; r++) { + int p = b[c][r]; + if (p == 0) continue; + if ((red && p > 0) || (!red && p < 0)) { + count += pseudoMoves(b, c, r).size(); + } + } + } + return count; + } + + /** 静态局面估值(从红方视角:正=红优,负=黑优) */ + public static int evaluate(int[][] b) { + int score = 0; + for (int c = 0; c < COLS; c++) { + for (int r = 0; r < ROWS; r++) { + int p = b[c][r]; + if (p == 0) continue; + boolean red = p > 0; + int abs = Math.abs(p); + int val = PIECE_VAL[abs] + pstBonus(abs, c, r, red); + score += red ? val : -val; + } + } + return score; + } +} \ No newline at end of file diff --git a/src/main/java/com/wzz/game_console/client/screens/games/chess/ChessSimulationMain.java b/src/main/java/com/wzz/game_console/client/screens/games/chess/ChessSimulationMain.java new file mode 100644 index 0000000..d5aaec3 --- /dev/null +++ b/src/main/java/com/wzz/game_console/client/screens/games/chess/ChessSimulationMain.java @@ -0,0 +1,283 @@ +package com.wzz.game_console.client.screens.games.chess; + +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; + +/** + * 中国象棋自对弈模拟器(支持多引擎对杀)。 + *

+ * 用法:gradle.bat runChessSim -PchessSimArgs="局数 并行度 深度 时间ms 红方引擎 黑方引擎 [皮卡鱼路径]" + *

+ * 引擎:built-in(默认)| pikafish + * 皮卡鱼路径默认:{@link ChessAI#DEFAULT_PIKAFISH_PATH} + *

+ * 例:gradle.bat runChessSim -PchessSimArgs="10 4 3 200 built-in pikafish" + */ +public class ChessSimulationMain { + + private static final int MAX_MOVES = 300; + + public static final class GameResult { + final int id; + int totalMoves = 0; + long elapsedMs = 0; + String outcome = "UNKNOWN"; + String reason = ""; + int finalMaterial = 0; // 红方视角子力差 + + GameResult(int id) { this.id = id; } + } + + public static void main(String[] args) throws Exception { + int totalGames = args.length > 0 ? Integer.parseInt(args[0]) : 100; + int parallelism = args.length > 1 ? Integer.parseInt(args[1]) : 8; + int depth = args.length > 2 ? Integer.parseInt(args[2]) : 3; + long timeMs = args.length > 3 ? Long.parseLong(args[3]) : 200L; + String engRed = args.length > 4 ? args[4] : "built-in"; + String engBlack = args.length > 5 ? args[5] : "built-in"; + String pkPath = args.length > 6 ? args[6] : ""; + + // 如果皮卡鱼路径传了,设置到系统属性(让 ChessAI.create 能读到) + // 实际 ChessAI.create 的工厂方法读取 GameSettings —— 模拟环境可能获取不到。 + // 绕过:直接传 argv 给 playOneGame,由它创建对应引擎 + + System.out.println("╔══════════════════════════════════════════════════════════╗"); + System.out.println("║ 中国象棋对杀模拟 ║"); + System.out.println("╠══════════════════════════════════════════════════════════╣"); + System.out.printf("║ 局数: %d 并行度: %d ║%n", totalGames, parallelism); + System.out.printf("║ 搜索深度: %d 搜索时间: %d ms ║%n", depth, timeMs); + System.out.printf("║ 红方: %s 黑方: %s ║%n", engRed, engBlack); + if (!pkPath.isEmpty()) System.out.printf("║ 皮卡鱼路径: %s ║%n", pkPath); + System.out.println("╚══════════════════════════════════════════════════════════╝"); + System.out.println(); + + long startWall = System.currentTimeMillis(); + AtomicInteger completed = new AtomicInteger(0); + AtomicInteger redWins = new AtomicInteger(0); + AtomicInteger blackWins = new AtomicInteger(0); + AtomicInteger draws = new AtomicInteger(0); + AtomicInteger crashes = new AtomicInteger(0); + AtomicLong totalMovesSum = new AtomicLong(0); + AtomicLong totalTimeNs = new AtomicLong(0); + AtomicLong totalMaterial = new AtomicLong(0); + + ExecutorService executor = Executors.newFixedThreadPool(parallelism); + List> futures = new ArrayList<>(totalGames); + + for (int i = 0; i < totalGames; i++) { + final int id = i + 1; + final String fEngRed = engRed, fEngBlack = engBlack, fPkPath = pkPath; + final long fTimeMs = timeMs; + final int fDepth = depth; + futures.add(executor.submit(() -> { + long gStart = System.nanoTime(); + GameResult r = new GameResult(id); + try { + ChessAI redAI = createEngine(fEngRed, fPkPath); + ChessAI blackAI = createEngine(fEngBlack, fPkPath); + redAI.setSearchTime(fTimeMs); redAI.setMaxDepth(fDepth); + blackAI.setSearchTime(fTimeMs); blackAI.setMaxDepth(fDepth); + try { + playOneGame(redAI, blackAI, fDepth, fTimeMs, r); + } finally { + redAI.shutdown(); + blackAI.shutdown(); + } + r.elapsedMs = (System.nanoTime() - gStart) / 1_000_000; + totalMovesSum.addAndGet(r.totalMoves); + totalTimeNs.addAndGet(System.nanoTime() - gStart); + totalMaterial.addAndGet(r.finalMaterial); + return r; + } catch (Exception e) { + r.outcome = "CRASH"; + r.reason = e.getClass().getSimpleName() + ": " + e.getMessage(); + r.elapsedMs = (System.nanoTime() - gStart) / 1_000_000; + return r; + } + })); + } + + executor.shutdown(); + // ★ Bug修复:原版 f.get() 无 try/catch,任一 worker 抛 ExecutionException + // 会终止整个仿真循环;且 shutdown 后无 awaitTermination,Pikafish 子进程 + // 可能未完全关闭就退出 main。等待上限随局数缩放:单局可达数分钟,固定 + // 120s 会把长仿真整体 shutdownNow 吞掉结果 + try { + long waitSeconds = Math.max(120, totalGames * 600L); + if (!executor.awaitTermination(waitSeconds, TimeUnit.SECONDS)) { + System.err.println("[警告] 仿真 " + waitSeconds + "s 内未完成,强制 shutdownNow"); + executor.shutdownNow(); + } + } catch (InterruptedException ie) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } + for (Future f : futures) { + GameResult r; + try { + r = f.get(); + } catch (Exception ex) { + crashes.incrementAndGet(); + int done = completed.incrementAndGet(); + System.out.printf(" 第%2d局: 异常崩溃 %s%n", done, ex.getClass().getSimpleName()); + continue; + } + switch (r.outcome) { + case "RED_WIN" -> redWins.incrementAndGet(); + case "BLACK_WIN" -> blackWins.incrementAndGet(); + case "DRAW" -> draws.incrementAndGet(); + default -> crashes.incrementAndGet(); + } + int done = completed.incrementAndGet(); + System.out.printf(" 第%2d局: %s(%s) 步数=%d 子力差=%+d 耗时=%.1fs%n", + r.id, r.outcome, r.reason, r.totalMoves, r.finalMaterial, r.elapsedMs / 1000.0); + } + + long wallMs = System.currentTimeMillis() - startWall; + long total = totalGames; + System.out.println(); + System.out.println("═══════════════════════════════════════════════════════════"); + System.out.println(" 对杀统计"); + System.out.println("═══════════════════════════════════════════════════════════"); + System.out.printf(" %s(红) vs %s(黑) 共 %d 局%n", engRed, engBlack, totalGames); + System.out.printf(" 红方胜: %d (%.1f%%)%n", redWins.get(), redWins.get() * 100.0 / total); + System.out.printf(" 黑方胜: %d (%.1f%%)%n", blackWins.get(), blackWins.get() * 100.0 / total); + System.out.printf(" 平局: %d (%.1f%%)%n", draws.get(), draws.get() * 100.0 / total); + System.out.printf(" 崩溃: %d%n", crashes.get()); + System.out.printf(" 平均步数: %.1f%n", total > 0 ? (double) totalMovesSum.get() / total : 0); + System.out.printf(" 平均耗时: %.1f s/局%n", total > 0 ? (double) totalTimeNs.get() / total / 1_000_000_000 : 0); + System.out.printf(" 平均终局子力差: %+.1f(红方视角)%n", total > 0 ? (double) totalMaterial.get() / total : 0); + System.out.printf(" 总耗时: %.1f s (并行度 %d)%n", wallMs / 1000.0, parallelism); + System.out.println("═══════════════════════════════════════════════════════════"); + } + + private static ChessAI createEngine(String type, String pkPath) { + if ("pikafish".equalsIgnoreCase(type)) { + String path = pkPath.isEmpty() ? ChessAI.DEFAULT_PIKAFISH_PATH : pkPath; + try { + PikafishChessAI pk = new PikafishChessAI(path); + System.out.println(" [Pikafish 引擎启动成功: " + path + "]"); + return pk; + } catch (Exception e) { + System.out.println(" [Pikafish 启动失败,回退内置: " + e.getMessage() + "]"); + return new BuiltInChessAI(); + } + } + if ("classic".equalsIgnoreCase(type) || "built-in-classic".equalsIgnoreCase(type)) { + return new BuiltInChessAI(false); // 关闭 LMR + Delta + } + // 默认内置引擎(含 LMR 增强) + return new BuiltInChessAI(); + } + + static void playOneGame(ChessAI redAI, ChessAI blackAI, int depth, long timeMs, GameResult r) { + int[][] board = initialBoard(); + boolean redTurn = true; + int moves = 0; + Set posHashes = new HashSet<>(); + + while (moves < MAX_MOVES) { + List legal = ChessRules.legalMoves(board, redTurn); + if (legal.isEmpty()) { + if (ChessRules.inCheckOnBoard(board, redTurn)) { + r.outcome = redTurn ? "BLACK_WIN" : "RED_WIN"; + r.reason = redTurn ? "红方被将死" : "黑方被将死"; + } else { + r.outcome = redTurn ? "BLACK_WIN" : "RED_WIN"; + r.reason = redTurn ? "红方困毙" : "黑方困毙"; + } + r.totalMoves = moves; + r.finalMaterial = countMaterial(board); + return; + } + + long hash = boardHash(board, redTurn); + if (posHashes.contains(hash)) { + r.outcome = "DRAW"; + r.reason = "局面重复"; + r.totalMoves = moves; + r.finalMaterial = countMaterial(board); + return; + } + posHashes.add(hash); + + ChessAI ai = redTurn ? redAI : blackAI; + int[][] boardCopy = deepCopy(board); + int[] mv = ai.getBestMove(boardCopy, redTurn); + if (mv == null) { + r.outcome = "DRAW"; + r.reason = "引擎返回 null"; + r.totalMoves = moves; + r.finalMaterial = countMaterial(board); + return; + } + + // 引擎走法合法性兜底:Pikafish 超时/异常输出可能给出与当前局面 + // 不符的着法,直接落子会把空格当棋子搬走、永久损坏棋盘 + boolean legalMove = false; + for (int[] lm : legal) { + if (lm[0] == mv[0] && lm[1] == mv[1] && lm[2] == mv[2] && lm[3] == mv[3]) { legalMove = true; break; } + } + if (!legalMove) { + r.outcome = "DRAW"; + r.reason = "引擎返回非法走法 (" + mv[0] + "," + mv[1] + ")->(" + mv[2] + "," + mv[3] + ")"; + r.totalMoves = moves; + r.finalMaterial = countMaterial(board); + return; + } + + board[mv[2]][mv[3]] = board[mv[0]][mv[1]]; + board[mv[0]][mv[1]] = 0; + moves++; + redTurn = !redTurn; + } + + r.outcome = "DRAW"; + r.reason = "超过 " + MAX_MOVES + " 步上限"; + r.totalMoves = moves; + r.finalMaterial = countMaterial(board); + } + + private static int[][] initialBoard() { + int[][] b = new int[ChessRules.COLS][ChessRules.ROWS]; + int[] back = {ChessRules.CHARIOT, ChessRules.HORSE, ChessRules.ELEPHANT, + ChessRules.ADVISOR, ChessRules.GENERAL, ChessRules.ADVISOR, + ChessRules.ELEPHANT, ChessRules.HORSE, ChessRules.CHARIOT}; + for (int c = 0; c < 9; c++) b[c][0] = -back[c]; + b[1][2] = -ChessRules.CANNON; b[7][2] = -ChessRules.CANNON; + for (int c = 0; c < 9; c += 2) b[c][3] = -ChessRules.SOLDIER; + for (int c = 0; c < 9; c++) b[c][9] = back[c]; + b[1][7] = ChessRules.CANNON; b[7][7] = ChessRules.CANNON; + for (int c = 0; c < 9; c += 2) b[c][6] = ChessRules.SOLDIER; + return b; + } + + private static int countMaterial(int[][] b) { + int score = 0; + for (int c = 0; c < 9; c++) for (int r = 0; r < 10; r++) { + int p = b[c][r]; + if (p == 0) continue; + int val = ChessRules.PIECE_VAL[Math.abs(p)]; + score += (p > 0) ? val : -val; + } + return score; + } + + private static int[][] deepCopy(int[][] src) { + int[][] d = new int[ChessRules.COLS][ChessRules.ROWS]; + for (int i = 0; i < ChessRules.COLS; i++) d[i] = src[i].clone(); + return d; + } + + private static long boardHash(int[][] board, boolean redTurn) { + long h = 1; + for (int c = 0; c < 9; c++) for (int r = 0; r < 10; r++) { + int p = board[c][r]; + long v = p == 0 ? 0 : (p > 0 ? p : -p + 20L); + h = 31 * h + v; h ^= h >>> 33; + } + if (redTurn) h ^= 0xFEEDBACL; + return h; + } +} \ No newline at end of file diff --git a/src/main/java/com/wzz/game_console/client/screens/games/chess/PikafishChessAI.java b/src/main/java/com/wzz/game_console/client/screens/games/chess/PikafishChessAI.java new file mode 100644 index 0000000..bcc9fd4 --- /dev/null +++ b/src/main/java/com/wzz/game_console/client/screens/games/chess/PikafishChessAI.java @@ -0,0 +1,267 @@ +package com.wzz.game_console.client.screens.games.chess; + +import com.wzz.game_console.util.GameSettings; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +/** + * 外部 Pikafish(皮卡鱼)引擎封装,通过 UCI 协议通信。 + *

+ * Pikafish 是当前最强的开源中国象棋引擎,需要用户单独安装。 + * 配置 {@code data/game_settings.json} 中的 {@code chess.engine = "pikafish"} 启用: + *

+ * {
+ *   "chess": {
+ *     "engine": "pikafish",
+ *     "pikafishPath": "E:/皮卡鱼 20260131/pikafish-avx2.exe",
+ *     "pikafishThreads": 1,
+ *     "pikafishMovetime": 2000
+ *   }
+ * }
+ * 
+ *

+ * 若可执行文件不存在或初始化失败,将由 {@link ChessAI#create(String)} 自动回退到内置引擎。 + */ +public class PikafishChessAI implements ChessAI { + + private static final Logger LOGGER = LoggerFactory.getLogger("PikafishAI"); + + /** UCI 握手/单步命令超时(秒) */ + private static final int CMD_TIMEOUT_MS = 15_000; + /** ★ Bug修复:原版每个实例都 addShutdownHook,跑 100 局仿真 = 100 个 hook, + * 进程退出时每个 hook 都尝试 process.destroy,前 99 个空转。改为类级共享 + * Set 跟踪活动实例,只注册一次 hook 遍历关闭 */ + private static final java.util.Set LIVE_INSTANCES = + java.util.Collections.newSetFromMap(new java.util.concurrent.ConcurrentHashMap<>()); + private static volatile boolean SHUTDOWN_HOOK_REGISTERED = false; + + private final Process process; + private final BufferedWriter writer; + private final BufferedReader reader; + /** ★ Bug修复:原实现用单线程池提交阻塞 readLine,超时 cancel(true) 后管道读不响应 + * 中断,僵尸任务永久占住唯一线程并吞行。改为常驻读线程 + 行队列, + * readLine 只做带超时的 poll,超时/取消不再泄漏阻塞任务 */ + private final LinkedBlockingQueue lineQueue = new LinkedBlockingQueue<>(); + /** 流结束哨兵:读线程退出时入队并回填,让 poll 中的 readLine 立即感知 EOF 而非白等超时 */ + private static final String EOF_SENTINEL = "__PIKAFISH_EOF__"; + + private volatile long searchTimeMs = 2000; + private volatile boolean connected = false; + + /** + * 构造并初始化 Pikafish 进程。 + * + * @param exePath Pikafish 可执行文件路径 + */ + public PikafishChessAI(String exePath) throws IOException { + File exe = new File(exePath); + if (!exe.exists()) { + throw new FileNotFoundException("Pikafish 可执行文件不存在: " + exePath); + } + + int threads = GameSettings.getInt("chess", "pikafishThreads", 1); + searchTimeMs = GameSettings.getInt("chess", "pikafishMovetime", 2000); + + ProcessBuilder pb = new ProcessBuilder(exePath); + pb.redirectErrorStream(true); + this.process = pb.start(); + this.writer = new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8)); + this.reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)); + + // 常驻读线程:循环 readLine 塞入队列,EOF/流异常时退出; + // daemon 线程随进程退出,shutdown 销毁引擎后管道关闭自然结束 + Thread readerThread = new Thread(() -> { + try { + String line; + while ((line = reader.readLine()) != null) { + lineQueue.offer(line); + } + } catch (IOException ignored) { + // 进程退出/流关闭导致的读异常:读线程自然结束 + } finally { + lineQueue.offer(EOF_SENTINEL); // 唤醒正在 poll 的 readLine,立即感知流结束 + } + }, "Pikafish-Reader"); + readerThread.setDaemon(true); + readerThread.start(); + + // ★ Bug修复:原版每个实例都 addShutdownHook,跑 N 局仿真 = N 个 hook, + // 进程退出时 N 次空转 destroy。改为类级共享 LIVE_INSTANCES + 仅一次注册 + LIVE_INSTANCES.add(this); + if (!SHUTDOWN_HOOK_REGISTERED) { + SHUTDOWN_HOOK_REGISTERED = true; + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + for (PikafishChessAI ai : LIVE_INSTANCES) { + try { ai.shutdown(); } catch (Throwable ignored) {} + } + })); + } + + try { + initUci(threads); + connected = true; + LOGGER.info("[Pikafish] UCI 连接成功: {} (threads={})", exePath, threads); + } catch (Exception e) { + shutdown(); + throw new IOException("Pikafish UCI 初始化失败: " + e.getMessage(), e); + } + } + + /** + * UCI 握手:uci → 等待 uciok;设置线程数 → isready → 等待 readyok。 + */ + private void initUci(int threads) throws IOException { + writer.write("uci\n"); + writer.flush(); + String line; + boolean uciok = false; + while ((line = readLine(CMD_TIMEOUT_MS)) != null) { + if ("uciok".equals(line.trim())) { uciok = true; break; } + } + if (!uciok) throw new IOException("未收到 uciok"); + + writer.write("setoption name Threads value " + threads + "\n"); + writer.write("isready\n"); + writer.flush(); + + boolean ready = false; + while ((line = readLine(CMD_TIMEOUT_MS)) != null) { + if ("readyok".equals(line.trim())) { ready = true; break; } + } + if (!ready) throw new IOException("未收到 readyok"); + } + + @Override + public int[] getBestMove(int[][] board, boolean redTurn) { + if (!connected) { + LOGGER.warn("[Pikafish] 未连接,返回 null"); + return null; + } + try { + writer.write("position fen " + ChessRules.toFen(board, redTurn) + "\n"); + writer.write("go movetime " + searchTimeMs + "\n"); + writer.flush(); + + // 读 bestmove;+5s 缓冲多于 movetime 的收尾等待 + String line; + long deadline = System.nanoTime() + (searchTimeMs + 5000) * 1_000_000L; + while (true) { + long remainingMs = (deadline - System.nanoTime()) / 1_000_000L; + if (remainingMs <= 0) break; + line = readLine(remainingMs); + if (line == null) break; + String trimmed = line.trim(); + if (trimmed.startsWith("bestmove")) { + return acceptedMove(trimmed, board, redTurn); + } + } + // 超时:引擎仍在搜索本局面,必须发 stop 并把残留响应排空, + // 否则下一次 getBestMove 会读到本局面的 stale bestmove 当成新结果 + LOGGER.warn("[Pikafish] 等待 bestmove 超时,发送 stop 并排空残留响应"); + try { + writer.write("stop\n"); + writer.flush(); + long drainDeadline = System.nanoTime() + 5_000_000_000L; + while (System.nanoTime() < drainDeadline) { + long remainingMs = (drainDeadline - System.nanoTime()) / 1_000_000L; + line = readLine(Math.max(1, remainingMs)); + if (line == null) break; + if (line.trim().startsWith("bestmove")) break; + } + } catch (Exception ignored) {} + return null; + } catch (Exception e) { + LOGGER.warn("[Pikafish] 获取走法失败: {}", e.getMessage()); + return null; + } + } + + /** 解析 bestmove 并对照当前局面合法走法集校验,非法/无法解析一律丢弃(返回 null)。 */ + private int[] acceptedMove(String bestmoveLine, int[][] board, boolean redTurn) { + String[] parts = bestmoveLine.split("\\s+"); + if (parts.length < 2 || "none".equals(parts[1])) return null; + int[] mv = ChessRules.parseUciMove(parts[1]); + if (mv == null) { + LOGGER.warn("[Pikafish] 无法解析的走法: {}", parts[1]); + return null; + } + for (int[] legal : ChessRules.legalMoves(board, redTurn)) { + if (legal[0] == mv[0] && legal[1] == mv[1] && legal[2] == mv[2] && legal[3] == mv[3]) { + return mv; + } + } + LOGGER.warn("[Pikafish] 引擎返回与当前局面不符的走法 {},已丢弃", parts[1]); + return null; + } + + @Override + public void cancelSearch() { + connected = false; + if (process != null && process.isAlive()) process.destroy(); + } + + @Override + public void setSearchTime(long ms) { + this.searchTimeMs = ms; + } + + @Override + public void setMaxDepth(int depth) { + // Pikafish 以 movetime 控制思考,忽略深度上限 + } + + /** + * 读取一行:从常驻读线程的行队列带超时 poll。 + * 超时或流结束(EOF)返回 null,交由调用方按 null 分支处理 + * (握手阶段判定 uciok/readyok 失败;搜索阶段走 stop+排空残留响应), + * 不再像旧实现那样抛"读取超时"异常导致排空逻辑不可达。 + */ + private String readLine(long timeoutMillis) throws IOException { + try { + String line = lineQueue.poll(timeoutMillis, TimeUnit.MILLISECONDS); + if (line == null) return null; // 超时 + if (EOF_SENTINEL.equals(line)) { + lineQueue.offer(EOF_SENTINEL); // 哨兵回填,后续调用同样立即得到 EOF + return null; + } + return line; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("读取被中断"); + } + } + + @Override + public void shutdown() { + // ★ Bug修复:从共享 Set 移除自身,避免 hook 重复关闭已关闭实例 + LIVE_INSTANCES.remove(this); + connected = false; + try { + if (writer != null) { writer.write("quit\n"); writer.flush(); } + } catch (Exception ignored) {} + try { if (writer != null) writer.close(); } catch (IOException ignored) {} + try { if (reader != null) reader.close(); } catch (IOException ignored) {} + // 常驻读线程为 daemon:reader.close()/进程销毁使管道 EOF 后自动退出,无需显式取消 + if (process != null && process.isAlive()) { + process.destroy(); + try { + if (!process.waitFor(2, TimeUnit.SECONDS)) process.destroyForcibly(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + process.destroyForcibly(); + } + } + LOGGER.info("[Pikafish] 已关闭"); + } +} \ No newline at end of file diff --git a/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoAI.java b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoAI.java index e94737b..2d499f8 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoAI.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoAI.java @@ -1,556 +1,167 @@ package com.wzz.game_console.client.screens.games.gogame; -import java.util.*; +import com.wzz.game_console.util.GameSettings; /** - * 基于 MCTS(蒙特卡洛树搜索)+ 启发式局面评估的围棋 AI。 - * 算法思路参考 KataGo / Leela Zero 等现代围棋引擎: - * - MCTS 搜索树,UCB1 选择子节点 - * - 叶子节点用启发式评估函数代替随机模拟 - * - 综合位置价值、气、捕捉/防御、连接性等多维特征 + * 围棋 AI 抽象接口。 + *

+ * 实现类: + *

    + *
  • {@link MCTSGoAI} — 改进版 MCTS 蒙特卡洛树搜索(默认,纯 Java,开箱即用)
  • + *
  • {@link KataGoGoAI} — 外部 KataGo 引擎(GTP 协议,需用户安装 KataGo)
  • + *
+ *

+ * 引擎选择通过 {@code data/game_settings.json} 中的 {@code go.engine} 配置: + *

+ * {
+ *   "go": {
+ *     "engine": "mcts",        // "mcts" | "katago"
+ *     "searchTime": 3000,      // 搜索时间 ms
+ *     "mctsIterations": 2000,  // MCTS 迭代次数
+ *     "modelPath": "",         // MCTS 已训练 checkpoint(NEV2/NEV3);空 = 随机初始化
+ *     "katagoPath": "E:/katago/katago.exe",
+ *     "katagoModel": "E:/katago/model.bin.gz",
+ *     "katagoConfig": "E:/katago/analysis.cfg"
+ *   }
+ * }
+ * 
*/ -public class GoAI { - private static final int[][] DIRS = {{0,1}, {1,0}, {0,-1}, {-1,0}}; - private static final int BOARD_SIZE = 19; - /** 每次搜索的迭代次数上限 */ - private static final int MCTS_ITERATIONS = 400; - /** UCB1 探索常数 */ - private static final double UCB_C = 1.414; - - private Random random = new Random(); - +public interface GoAI { /** - * 获取最佳落子位置(MCTS + 启发式评估) + * Result of an AI turn. The typed result keeps pass, resignation, and + * transport/search errors distinct while retaining the legacy coordinate API. */ - public int[] getBestMove(GoGame game) { - GoPlayer[][] board = game.getBoardCopy(); - GoPlayer currentPlayer = game.getCurrentPlayer(); - - // 收集合法落子点 - List validMoves = getAllValidMoves(board, currentPlayer); - if (validMoves.isEmpty()) return null; - - // 如果只有一个候选点或棋盘较空,用启发式快速评估 - if (validMoves.size() <= 1 || countStones(board) < 8) { - return getBestHeuristicMove(board, currentPlayer, validMoves); - } + enum MoveType { MOVE, PASS, RESIGN, ERROR } - // MCTS 搜索 - return mctsSearch(board, currentPlayer, validMoves); - } - - /** - * MCTS 主搜索 - */ - private int[] mctsSearch(GoPlayer[][] board, GoPlayer player, List validMoves) { - MCTSNode root = new MCTSNode(board, player, null, null, validMoves); - - long deadline = System.currentTimeMillis() + 500; // 最多搜索 500ms - int iterations = 0; - - while (iterations < MCTS_ITERATIONS && System.currentTimeMillis() < deadline) { - iterations++; - // Selection - MCTSNode node = root; - GoPlayer[][] simBoard = deepCopyBoard(root.board); - - // Selection: 遍历树到叶子节点 - while (node.children != null && !node.children.isEmpty()) { - node = selectChild(node); - // 执行节点的落子(回溯时已经处理过,这里只需更新 simBoard) - } - - // 如果节点还有未扩展的走法,扩展一个 - if (node.untriedMoves != null && !node.untriedMoves.isEmpty()) { - int[] move = node.untriedMoves.remove(random.nextInt(node.untriedMoves.size())); - // 在 simBoard 上执行落子 - GoPlayer nextPlayer = node.player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; - simulatePlaceStone(simBoard, move[0], move[1], node.player); - - MCTSNode child = new MCTSNode(simBoard, nextPlayer, node, move, null); - // 生成子节点的合法走法 - child.untriedMoves = getAllValidMoves(simBoard, nextPlayer); - if (node.children == null) node.children = new ArrayList<>(); - node.children.add(child); - node = child; - } else { - // 节点已完全扩展,执行模拟落子(用当前 simBoard) - // 如果 node 是棋盘终局,不做模拟 - if (node.untriedMoves != null && node.untriedMoves.isEmpty() - && (node.children == null || node.children.isEmpty())) { - // 终局或没有走法,直接用局面评估 - } else { - // 在 simBoard 上的 node 局面执行一步随机走法(快速模拟) - simulateRandomMove(simBoard, node.player); - } + record MoveResult(MoveType type, int x, int y) { + public MoveResult { + if (type == null) throw new IllegalArgumentException("move type is required"); + if (type == MoveType.MOVE && (x < 0 || y < 0)) { + throw new IllegalArgumentException("a move requires coordinates"); } - - // Simulation: 用启发式评估结果 - double score = evaluateBoard(simBoard, player); - - // 如果当前走法导致自己被杀,降低分数 - GoPlayer[][] currentBoard = node.getBoardState(); - if (currentBoard != null) { - // 检查 node 对应的走法是否形成有利局面 - int[] lastMove = node.move; - if (lastMove != null) { - score += evaluateCapturePotential(simBoard, lastMove[0], lastMove[1], node.player); - score += evaluateDefensePotential(simBoard, lastMove[0], lastMove[1], node.player); - } - } - - // Backpropagation - backpropagate(node, score); } - // 选择访问次数最多的子节点 - return getBestMCTSMove(root); - } - - /** - * UCB1 选择最佳子节点 - */ - private MCTSNode selectChild(MCTSNode parent) { - MCTSNode best = null; - double bestValue = Double.NEGATIVE_INFINITY; - double logParentVisits = Math.log(parent.visits); + public static MoveResult move(int x, int y) { return new MoveResult(MoveType.MOVE, x, y); } + public static MoveResult pass() { return new MoveResult(MoveType.PASS, -1, -1); } + public static MoveResult resign() { return new MoveResult(MoveType.RESIGN, -1, -1); } + public static MoveResult error() { return new MoveResult(MoveType.ERROR, -1, -1); } - for (MCTSNode child : parent.children) { - if (child.visits == 0) return child; // 未访问的节点优先探索 - double ucb = child.totalScore / child.visits + UCB_C * Math.sqrt(logParentVisits / child.visits); - if (ucb > bestValue) { - bestValue = ucb; - best = child; - } + public int[] coordinates() { + return type == MoveType.MOVE ? new int[] {x, y} : null; } - return best; } /** - * 反向传播 + * 获取最佳落子位置。 + * + * @param game 当前对局状态 + * @return {x, y} 落子坐标,或 null 表示弃权 */ - private void backpropagate(MCTSNode node, double score) { - while (node != null) { - node.visits++; - node.totalScore += score; - node = node.parent; - } - } + int[] getBestMove(GoGame game); - /** - * 获取 MCTS 搜索后访问次数最多的走法 - */ - private int[] getBestMCTSMove(MCTSNode root) { - if (root.children == null || root.children.isEmpty()) { - return null; - } - MCTSNode best = null; - int maxVisits = -1; - for (MCTSNode child : root.children) { - if (child.visits > maxVisits) { - maxVisits = child.visits; - best = child; - } + /** Typed AI action. Existing implementations remain source-compatible. */ + default MoveResult getBestMoveResult(GoGame game) { + try { + int[] move = getBestMove(game); + return move == null ? MoveResult.pass() : MoveResult.move(move[0], move[1]); + } catch (RuntimeException ignored) { + return MoveResult.error(); } - return best != null ? best.move : null; } /** - * 在棋盘副本上模拟落子(简化版,不处理劫争) + * 获取 AI 的执棋颜色。 + *

+ * 默认返回白棋,子类(如 KataGoGoAI)可通过构造或配置改变。 + * + * @return AI 执棋颜色 */ - private boolean simulatePlaceStone(GoPlayer[][] board, int x, int y, GoPlayer player) { - if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE || board[x][y] != GoPlayer.NONE) { - return false; - } - - board[x][y] = player; - GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; - - // 检查并移除被吃的对方棋子 - int captured = 0; - for (int[] dir : DIRS) { - int nx = x + dir[0], ny = y + dir[1]; - if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == opponent) { - Set group = getGroup(board, nx, ny); - if (!hasLiberty(board, group)) { - for (int[] pos : group) { - board[pos[0]][pos[1]] = GoPlayer.NONE; - } - captured += group.size(); - } - } - } - - // 检查自杀 - if (captured == 0) { - Set myGroup = getGroup(board, x, y); - if (!hasLiberty(board, myGroup)) { - board[x][y] = GoPlayer.NONE; - return false; - } - } - - return true; - } - - /** - * 模拟一步随机走法 - */ - private void simulateRandomMove(GoPlayer[][] board, GoPlayer player) { - List moves = getAllValidMoves(board, player); - if (moves.isEmpty()) return; - int[] move = moves.get(random.nextInt(moves.size())); - simulatePlaceStone(board, move[0], move[1], player); - } - - /** - * 启发式评估:选择最佳走法(无 MCTS 时的回退) - */ - private int[] getBestHeuristicMove(GoPlayer[][] board, GoPlayer player, List validMoves) { - int[] bestMove = null; - int bestScore = Integer.MIN_VALUE; - - for (int[] move : validMoves) { - int score = evaluateMove(board, player, move[0], move[1]); - if (score > bestScore) { - bestScore = score; - bestMove = move; - } - } - - return bestMove != null ? bestMove : validMoves.get(random.nextInt(validMoves.size())); + default GoPlayer getAIColor() { + return GoPlayer.WHITE; } - // ══════════════════════════════════════════ - // 局面评估(启发式,用于 MCTS 叶节点和回退) - // ══════════════════════════════════════════ - /** - * 综合评估棋盘局面(从当前玩家视角) - * 使用 visited 标记已计分的棋群,避免重复计数 + * 释放 AI 引擎占用的资源(如外部进程)。 + * 默认空实现,子类按需覆盖。 */ - private double evaluateBoard(GoPlayer[][] board, GoPlayer player) { - double score = 0; - GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; - boolean[][] visited = new boolean[BOARD_SIZE][BOARD_SIZE]; - - for (int x = 0; x < BOARD_SIZE; x++) { - for (int y = 0; y < BOARD_SIZE; y++) { - if (board[x][y] == GoPlayer.NONE || visited[x][y]) continue; + default void shutdown() {} - Set group = getGroup(board, x, y); - boolean isMine = board[x][y] == player; - int libs = countGroupLiberties(board, group); - int groupScore = 0; - - // 气数评估 - if (isMine) { - groupScore += libs * 3; - if (libs >= 3) groupScore += 5; // 安定棋群 - else if (libs <= 1) groupScore -= 20; // 危险棋群 - } else { - if (libs <= 1) groupScore += 15; // 对方危险棋群,有利 - } - - // 标记整个棋群为已访问 - for (int[] pos : group) { - visited[pos[0]][pos[1]] = true; - } - - score += isMine ? groupScore : -groupScore; - } - } + /** 棋盘大小 */ + int BOARD_SIZE = 19; - // 棋子数量优势 - int myStones = countStones(board, player); - int oppStones = countStones(board, opponent); - score += (myStones - oppStones) * 1.5; + /** 四个方向偏移 */ + int[][] DIRS = {{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; - // 势力范围评估 - score += evaluateInfluence(board, player, opponent); + // ── 工厂方法 ───────────────────────────────────────────────── - return score; + /** 懒加载的日志记录器(避免静态初始化时 slf4j 不可用) */ + private static org.slf4j.Logger getFactoryLogger() { + return org.slf4j.LoggerFactory.getLogger("GoAI"); } - /** - * 简易势力范围评估 - */ - private double evaluateInfluence(GoPlayer[][] board, GoPlayer player, GoPlayer opponent) { - double influence = 0; - int radius = 4; - - for (int x = 0; x < BOARD_SIZE; x++) { - for (int y = 0; y < BOARD_SIZE; y++) { - if (board[x][y] != GoPlayer.NONE) continue; - - double myInf = 0, oppInf = 0; - for (int dx = -radius; dx <= radius; dx++) { - for (int dy = -radius; dy <= radius; dy++) { - int nx = x + dx, ny = y + dy; - if (nx < 0 || nx >= BOARD_SIZE || ny < 0 || ny >= BOARD_SIZE) continue; - if (board[nx][ny] == player) { - double dist = Math.sqrt(dx * dx + dy * dy); - if (dist > 0) myInf += 1.0 / dist; - } else if (board[nx][ny] == opponent) { - double dist = Math.sqrt(dx * dx + dy * dy); - if (dist > 0) oppInf += 1.0 / dist; - } - } - } - if (myInf > oppInf) influence += 0.5; - else if (oppInf > myInf) influence -= 0.5; - } - } - return influence; + /** Normalizes unsupported or missing engine names to the actual fallback engine. */ + static String normalizeEngine(String engine) { + return engine != null && "katago".equalsIgnoreCase(engine.trim()) ? "katago" : "mcts"; } - /** - * 单个走法的启发式评估 - */ - private int evaluateMove(GoPlayer[][] board, GoPlayer player, int x, int y) { - int score = 0; - - // 位置价值 - score += getPositionValue(x, y, BOARD_SIZE); - - // 检查是否能吃掉对方棋子 - GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; - for (int[] dir : DIRS) { - int nx = x + dir[0], ny = y + dir[1]; - if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == opponent) { - Set group = getGroup(board, nx, ny); - if (countGroupLiberties(board, group) <= 1) { - score += 50; - } - } - } - - // 检查是否能救援己方棋子 - for (int[] dir : DIRS) { - int nx = x + dir[0], ny = y + dir[1]; - if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == player) { - Set group = getGroup(board, nx, ny); - if (countGroupLiberties(board, group) <= 1) { - score += 30; - } - } - } - - // 周围己方棋子连接 - int friendly = 0; - for (int[] dir : DIRS) { - int nx = x + dir[0], ny = y + dir[1]; - if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == player) { - friendly++; - } - } - score += friendly * 8; - - // 周围气数 - int liberties = 0; - for (int[] dir : DIRS) { - int nx = x + dir[0], ny = y + dir[1]; - if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == GoPlayer.NONE) { - liberties++; - } - } - score += liberties * 5; - - return score; + static String runtimeEngineLabel(Class engineType) { + if (engineType == null) return null; + if (KataGoGoAI.class.isAssignableFrom(engineType)) return "KataGo"; + if (MCTSGoAI.class.isAssignableFrom(engineType)) return "MCTS"; + return engineType.getSimpleName(); } /** - * 捕捉潜力评估 + * 根据 GameSettings 创建 AI 引擎实例。 + * 配置键:go.engine = "mcts"(默认)或 "katago"。 */ - private int evaluateCapturePotential(GoPlayer[][] board, int x, int y, GoPlayer player) { - GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; - int score = 0; - for (int[] dir : DIRS) { - int nx = x + dir[0], ny = y + dir[1]; - if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == opponent) { - Set group = getGroup(board, nx, ny); - if (countGroupLiberties(board, group) <= 2) { - score += 20; - } - } + static GoAI create() { + String engine; + try { + engine = GameSettings.getString("go", "engine", "mcts"); + } catch (Throwable t) { + // GameSettings 不可用时使用默认 MCTS + return MCTSGoAI.createFromSettings(); } - return score; + return create(engine); } /** - * 防御潜力评估 + * 根据引擎名称创建 AI 实例。 + * + * @param engine "mcts" 或 "katago" */ - private int evaluateDefensePotential(GoPlayer[][] board, int x, int y, GoPlayer player) { - int score = 0; - for (int[] dir : DIRS) { - int nx = x + dir[0], ny = y + dir[1]; - if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == player) { - Set group = getGroup(board, nx, ny); - if (countGroupLiberties(board, group) <= 2) { - score += 15; - } - } - } - return score; - } - - // ══════════════════════════════════════════ - // 位置价值 - // ══════════════════════════════════════════ - - private int getPositionValue(int x, int y, int boardSize) { - int score = 0; - int center = boardSize / 2; - - if ((x == 0 || x == boardSize - 1) && (y == 0 || y == boardSize - 1)) { - score += 15; - } else if (x == 0 || x == boardSize - 1 || y == 0 || y == boardSize - 1) { - score += 8; - } else if (Math.abs(x - center) <= 3 && Math.abs(y - center) <= 3) { - score += 12; - } - - if (isStarPoint(x, y, boardSize)) { - score += 10; - } - - return score; - } - - private boolean isStarPoint(int x, int y, int boardSize) { - if (boardSize == 19) { - int[] starPositions = {3, 9, 15}; - for (int sx : starPositions) { - for (int sy : starPositions) { - if (x == sx && y == sy) return true; - } - } - } - return false; - } - - // ══════════════════════════════════════════ - // 工具方法 - // ══════════════════════════════════════════ - - private List getAllValidMoves(GoPlayer[][] board, GoPlayer player) { - List moves = new ArrayList<>(); - for (int x = 0; x < BOARD_SIZE; x++) { - for (int y = 0; y < BOARD_SIZE; y++) { - if (board[x][y] == GoPlayer.NONE) { - // 快速排除明显的自杀走法:在棋盘副本上测试落子 - GoPlayer[][] testBoard = deepCopyBoard(board); - if (simulatePlaceStone(testBoard, x, y, player)) { - moves.add(new int[]{x, y}); - } - } - } - } - return moves; - } - - private int countStones(GoPlayer[][] board) { - int count = 0; - for (int x = 0; x < BOARD_SIZE; x++) - for (int y = 0; y < BOARD_SIZE; y++) - if (board[x][y] != GoPlayer.NONE) count++; - return count; - } - - private int countStones(GoPlayer[][] board, GoPlayer player) { - int count = 0; - for (int x = 0; x < BOARD_SIZE; x++) - for (int y = 0; y < BOARD_SIZE; y++) - if (board[x][y] == player) count++; - return count; - } - - private Set getGroup(GoPlayer[][] board, int x, int y) { - Set group = new HashSet<>(); - GoPlayer color = board[x][y]; - if (color == GoPlayer.NONE) return group; - - Stack stack = new Stack<>(); - boolean[][] visited = new boolean[BOARD_SIZE][BOARD_SIZE]; - stack.push(new int[]{x, y}); - - while (!stack.isEmpty()) { - int[] pos = stack.pop(); - int px = pos[0], py = pos[1]; - if (visited[px][py]) continue; - visited[px][py] = true; - group.add(new int[]{px, py}); - - for (int[] dir : DIRS) { - int nx = px + dir[0], ny = py + dir[1]; - if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE - && !visited[nx][ny] && board[nx][ny] == color) { - stack.push(new int[]{nx, ny}); - } - } - } - return group; - } - - private boolean hasLiberty(GoPlayer[][] board, Set group) { - for (int[] pos : group) { - for (int[] dir : DIRS) { - int nx = pos[0] + dir[0], ny = pos[1] + dir[1]; - if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == GoPlayer.NONE) { - return true; - } - } - } - return false; - } - - private int countGroupLiberties(GoPlayer[][] board, Set group) { - Set libertySet = new HashSet<>(); - for (int[] pos : group) { - for (int[] dir : DIRS) { - int nx = pos[0] + dir[0], ny = pos[1] + dir[1]; - if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == GoPlayer.NONE) { - libertySet.add((long)nx * BOARD_SIZE + ny); - } - } - } - return libertySet.size(); - } - - private GoPlayer[][] deepCopyBoard(GoPlayer[][] board) { - GoPlayer[][] copy = new GoPlayer[BOARD_SIZE][BOARD_SIZE]; - for (int x = 0; x < BOARD_SIZE; x++) { - copy[x] = board[x].clone(); - } - return copy; - } - - // ══════════════════════════════════════════ - // MCTS 节点 - // ══════════════════════════════════════════ - - private static class MCTSNode { - GoPlayer[][] board; // 该节点的棋盘状态 - GoPlayer player; // 该节点轮到谁走 - MCTSNode parent; - int[] move; // 从父节点到达该节点的走法 - List children; - List untriedMoves; - - int visits = 0; - double totalScore = 0; - - MCTSNode(GoPlayer[][] board, GoPlayer player, MCTSNode parent, int[] move, - List untriedMoves) { - this.board = board; - this.player = player; - this.parent = parent; - this.move = move; - this.untriedMoves = untriedMoves != null ? new ArrayList<>(untriedMoves) : null; - } - - GoPlayer[][] getBoardState() { - return board; - } + static GoAI create(String engine) { + org.slf4j.Logger logger; + try { + logger = getFactoryLogger(); + } catch (Throwable t) { + // slf4j 不可用时使用默认 MCTS + return MCTSGoAI.createFromSettings(); + } + if ("katago".equals(normalizeEngine(engine))) { + String katagoPath; + try { + katagoPath = GameSettings.getString("go", "katagoPath", ""); + } catch (Throwable t) { + logger.warn("[围棋AI] KataGo 路径读取失败,回退到 MCTS"); + return MCTSGoAI.createFromSettings(); + } + if (katagoPath.isEmpty()) { + logger.warn("[围棋AI] KataGo 路径未配置,回退到 MCTS"); + return MCTSGoAI.createFromSettings(); + } + try { + KataGoGoAI katago = new KataGoGoAI(katagoPath); + logger.info("[围棋AI] 使用 KataGo 引擎: {}", katagoPath); + return katago; + } catch (Exception e) { + logger.warn("[围棋AI] KataGo 启动失败,回退到 MCTS: {}", e.getMessage()); + return MCTSGoAI.createFromSettings(); + } + } + // 默认 MCTS + logger.info("[围棋AI] 使用改进版 MCTS 引擎"); + return MCTSGoAI.createFromSettings(); } } \ No newline at end of file diff --git a/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoAdversarialMain.java b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoAdversarialMain.java new file mode 100644 index 0000000..5cd15de --- /dev/null +++ b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoAdversarialMain.java @@ -0,0 +1,110 @@ +package com.wzz.game_console.client.screens.games.gogame; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +/** + * 对抗训练 CLI 入口:己方 MCTS AI vs 外部 KataGo 引擎。 + * + * 参数: + * --games 对局数(默认 10) + * --parallelism 并行数(默认 5) + * --generations 训练轮次(默认 1) + * --epochs 每代训练轮次(默认 1) + * --learningRate 学习率(默认 0.001) + * --weights 权重文件路径(必填) + * --katago KataGo 可执行文件路径(必填) + * --katagoModel KataGo 模型文件路径(可选) + * --katagoConfig KataGo 配置文件(可选,默认 default_gtp.cfg) + * --maxReplaySamples replay buffer 上限(默认 20000) + */ +public final class GoAdversarialMain { + private GoAdversarialMain() {} + + public static void main(String[] args) throws Exception { + Map options = parse(args); + + GoAdversarialTrainer.Config config = new GoAdversarialTrainer.Config(); + config.searchTimeMillis = intOption(options, "searchTime", config.searchTimeMillis); + config.maxIterations = intOption(options, "iterations", config.maxIterations); + config.maxMoves = intOption(options, "maxMoves", config.maxMoves); + config.maxReplaySamples = intOption(options, "maxReplaySamples", config.maxReplaySamples); + config.katagoPath = required(options, "katago"); + config.katagoModel = stringOption(options, "katagoModel", ""); + config.katagoConfig = stringOption(options, "katagoConfig", "default_gtp.cfg"); + + int games = intOption(options, "games", 10); + int parallelism = intOption(options, "parallelism", 5); + int generations = intOption(options, "generations", 1); + int epochs = intOption(options, "epochs", 1); + double learningRate = doubleOption(options, "learningRate", 0.001); + long seed = longOption(options, "seed", 0x5EEDL); + Path weights = Path.of(required(options, "weights")); + + NeuralEvaluator evaluator = new NeuralEvaluator(); + if (Files.exists(weights)) { + evaluator.load(weights); + System.out.println("loaded=" + weights.toAbsolutePath()); + } + + GoAdversarialTrainer trainer = new GoAdversarialTrainer(config, evaluator); + + try { + for (int gen = 0; gen < generations; gen++) { + double frac = generations > 1 ? (double) gen / (generations - 1) : 0.0; + double currentLR = Math.max(learningRate * 0.5 * (1.0 + Math.cos(Math.PI * frac)), 1e-6); + GoAdversarialTrainer.Result result = trainer.runGeneration( + games, parallelism, epochs, currentLR, seed + gen); + System.out.printf("generation=%d lr=%.6f games=%d completed=%d samples=%d ourWins=%d replay=%d meanLoss=%.8f%n", + gen + 1, currentLR, result.games, result.completedGames, + result.samples, result.ourWins, trainer.getReplayBufferSize(), result.meanLoss); + } + } catch (RuntimeException | Error t) { + // ★ 崩溃保存:多代训练中途崩(OOM/GTP 异常/引擎崩溃)时, + // 已训练完的各代权重不能随进程一起丢掉 + try { + evaluator.save(weights); + System.out.println("saved-on-crash=" + weights.toAbsolutePath()); + } catch (Exception saveErr) { + System.err.println("save-on-crash failed: " + saveErr); + } + throw t; + } + + evaluator.save(weights); + System.out.println("saved=" + weights.toAbsolutePath()); + } + + private static Map parse(String[] args) { + Map result = new HashMap<>(); + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + if (!arg.startsWith("--")) throw new IllegalArgumentException("Unexpected: " + arg); + String name = arg.substring(2); + if (name.isEmpty() || i + 1 >= args.length || args[i + 1].startsWith("--")) + throw new IllegalArgumentException("Missing value for --" + name); + result.put(name, args[++i]); + } + return result; + } + + private static String required(Map opts, String key) { + String v = opts.get(key); + if (v == null || v.isBlank()) throw new IllegalArgumentException("Missing required --" + key); + return v; + } + private static String stringOption(Map opts, String key, String fallback) { + return opts.containsKey(key) ? opts.get(key) : fallback; + } + private static int intOption(Map opts, String key, int fallback) { + return opts.containsKey(key) ? Integer.parseInt(opts.get(key)) : fallback; + } + private static long longOption(Map opts, String key, long fallback) { + return opts.containsKey(key) ? Long.parseLong(opts.get(key)) : fallback; + } + private static double doubleOption(Map opts, String key, double fallback) { + return opts.containsKey(key) ? Double.parseDouble(opts.get(key)) : fallback; + } +} \ No newline at end of file diff --git a/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoAdversarialTrainer.java b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoAdversarialTrainer.java new file mode 100644 index 0000000..168ff7e --- /dev/null +++ b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoAdversarialTrainer.java @@ -0,0 +1,579 @@ +package com.wzz.game_console.client.screens.games.gogame; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; +import java.io.IOException; + +/** + * 对抗训练器:己方 MCTS AI vs 外部 KataGo 引擎。 + *

+ * 每局以随机先后手对弈,收集己方 AI 每步的局面样本, + * 以终局胜负作为标签,训练神经网络。 + */ +public final class GoAdversarialTrainer { + public static final class Config { + public int searchTimeMillis = 300; + public int maxIterations = 500; + public int maxMoves = 300; + public int batchSize = 128; + public double l2 = 1.0e-5; + public double gradientClip = 5.0; + public double momentum = 0.9; + public int maxReplaySamples = 20_000; + /** KataGo 可执行文件路径 */ + public String katagoPath = ""; + /** KataGo 模型文件路径(可选,不填则用 KataGo 默认) */ + public String katagoModel = ""; + /** KataGo GTP 配置文件路径(可选) */ + public String katagoConfig = "default_gtp.cfg"; + } + + public static final class Result { + public final int games; + public final int samples; + public final int completedGames; + public final double meanLoss; + public final NeuralEvaluator evaluator; + public final int ourWins; + + private Result(int games, int samples, int completedGames, double meanLoss, + NeuralEvaluator evaluator, int ourWins) { + this.games = games; + this.samples = samples; + this.completedGames = completedGames; + this.meanLoss = meanLoss; + this.evaluator = evaluator; + this.ourWins = ourWins; + } + } + + private static final class Sample { + final GoPlayer[][] board; + final GoPlayer player; + final int[] lastMove; // 上一手位置(plane 3,与推理对齐) + final double[] policyTarget; + double valueTarget; + Sample(GoPlayer[][] board, GoPlayer player, int[] lastMove, double[] policyTarget) { + this.board = board; + this.player = player; + this.lastMove = lastMove; + this.policyTarget = policyTarget; + } + } + + private final Config config; + private NeuralEvaluator evaluator; + private final List replayBuffer = new ArrayList<>(); + /** Serializes generation, replay-buffer, and evaluator mutation per trainer instance. */ + private final Object generationLock = new Object(); + + public GoAdversarialTrainer(Config config, NeuralEvaluator evaluator) { + this.config = config == null ? new Config() : config; + this.evaluator = evaluator == null ? new NeuralEvaluator() : evaluator; + } + + public NeuralEvaluator getEvaluator() { return evaluator; } + public int getReplayBufferSize() { + synchronized (generationLock) { return replayBuffer.size(); } + } + public void clearReplayBuffer() { + synchronized (generationLock) { replayBuffer.clear(); } + } + + /** + * 运行一代对抗训练。 + * @param games 对局数 + * @param parallelism 并行数 + * @param epochs 训练轮次 + * @param learningRate 学习率 + * @param seed 随机种子 + * @return 训练结果 + */ + public Result runGeneration(int games, int parallelism, int epochs, double learningRate, long seed) { + if (games < 0 || epochs < 0 || learningRate <= 0) + throw new IllegalArgumentException("Invalid generation parameters"); + synchronized (generationLock) { + return runGenerationLocked(games, parallelism, epochs, learningRate, seed); + } + } + + private Result runGenerationLocked(int games, int parallelism, int epochs, double learningRate, long seed) { + if (games == 0 || Thread.currentThread().isInterrupted()) return new Result(games, 0, 0, 0, evaluator, 0); + + int workers = Math.max(1, Math.min(parallelism <= 0 ? 1 : parallelism, games)); + final NeuralEvaluator.ModelWeights snapshot = evaluator.snapshot(); + ExecutorService pool = Executors.newFixedThreadPool(workers); + List> futures = new ArrayList<>(workers); + try { + List newSamples = new ArrayList<>(); + int nextGame = 0; + for (; nextGame < workers; nextGame++) { + final int gameIndex = nextGame; + futures.add(pool.submit(() -> + playAdversarialGame(snapshot, seed + 0x9E3779B97F4A7C15L * gameIndex))); + } + int completed = 0; + int ourWins = 0; + // 单局上限:避免外部引擎不响应时整代无限等待。 + long perGameTimeoutSec = (long) (Math.max(1, config.maxMoves) * 11L * 60 * 1.5); + while (!futures.isEmpty()) { + Future future = futures.remove(0); + try { + GameResult gr = future.get(perGameTimeoutSec, TimeUnit.SECONDS); + if (gr.completed) { + appendReplaySamples(newSamples, gr.samples); + completed++; + if (gr.ourWin) ourWins++; + } else { + System.err.println("[Adversarial] game did not complete; discarding samples"); + } + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + future.cancel(true); + for (Future pending : futures) pending.cancel(true); + return new Result(games, 0, 0, 0, evaluator, 0); + } catch (java.util.concurrent.CancellationException ce) { + for (Future pending : futures) pending.cancel(true); + return new Result(games, 0, 0, 0, evaluator, 0); + } catch (java.util.concurrent.ExecutionException ee) { + Throwable cause = ee.getCause(); + if (cause instanceof java.util.concurrent.CancellationException + || cause instanceof InterruptedException) { + if (cause instanceof InterruptedException) Thread.currentThread().interrupt(); + for (Future pending : futures) pending.cancel(true); + return new Result(games, 0, 0, 0, evaluator, 0); + } + System.err.println("[Adversarial] game failed: " + cause); + } catch (java.util.concurrent.TimeoutException te) { + future.cancel(true); + System.err.println("[Adversarial] 对局超时(" + perGameTimeoutSec + "s),已取消"); + } + if (nextGame < games) { + final int gameIndex = nextGame++; + futures.add(pool.submit(() -> + playAdversarialGame(snapshot, seed + 0x9E3779B97F4A7C15L * gameIndex))); + } + } + + if (Thread.currentThread().isInterrupted()) return new Result(games, 0, 0, 0, evaluator, 0); + replayBuffer.addAll(newSamples); + trimReplayBuffer(); + double loss = train(replayBuffer, epochs, learningRate, seed ^ 0xD1B54A32D192ED03L); + return new Result(games, newSamples.size(), completed, loss, evaluator, ourWins); + } finally { + pool.shutdownNow(); + // ★ Bug修复:等待 worker 释放 native 资源,见 GoSelfPlayTrainer 同改 + try { + if (!pool.awaitTermination(5, TimeUnit.SECONDS)) { + System.err.println("[对抗训练] 训练线程池 5s 内未关闭,放弃等待"); + } + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + } + + private void appendReplaySamples(List samples, List gameSamples) { + samples.addAll(gameSamples); + int max = Math.max(1, config.maxReplaySamples); + if (samples.size() > max) { + samples.subList(0, samples.size() - max).clear(); + } + } + + private void trimReplayBuffer() { + int max = Math.max(1, config.maxReplaySamples); + int overflow = replayBuffer.size() - max; + if (overflow > 0) { + // ★ 修复:原版 while(remove(0)) 逐条前移,O(n²);subList 批量清除一次完成 + replayBuffer.subList(0, overflow).clear(); + } + } + + private static final class GameResult { + final List samples; + final boolean ourWin; + final boolean completed; + GameResult(List samples, boolean ourWin, boolean completed) { + this.samples = samples; + this.ourWin = ourWin; + this.completed = completed; + } + } + + /** + * 一局对抗:MCTS AI vs KataGo,随机先后手。 + */ + private GameResult playAdversarialGame(NeuralEvaluator.ModelWeights model, long seed) { + List samples = new ArrayList<>(); + Random rnd = new Random(seed); + boolean ourIsBlack = rnd.nextBoolean(); + + // 创建己方 AI + MCTSGoAI ourAI = new MCTSGoAI(config.searchTimeMillis, config.maxIterations, 1, model); + ourAI.setRandomSeed(seed ^ 0x12345678); + + Process process = null; + try { + String katagoPath = config.katagoPath; + if (katagoPath.isEmpty()) { + System.err.println("[Adversarial] 未配置 katagoPath,跳过"); + return new GameResult(java.util.Collections.emptyList(), false, false); + } + // 检查文件是否存在 + java.io.File exeFile = new java.io.File(katagoPath); + if (!exeFile.exists()) { + System.err.println("[Adversarial] KataGo 不存在: " + katagoPath); + return new GameResult(java.util.Collections.emptyList(), false, false); + } + + // 构建命令行参数 + java.util.List cmd = new java.util.ArrayList<>(); + cmd.add(exeFile.getAbsolutePath()); + cmd.add("gtp"); + if (!config.katagoModel.isEmpty()) { + cmd.add("-model"); + cmd.add(new java.io.File(config.katagoModel).getAbsolutePath()); + } + // 配置文件路径 — 相对于 KataGo 目录或绝对路径 + String configPath = config.katagoConfig; + if (!configPath.isEmpty()) { + java.io.File cfgFile = new java.io.File(configPath); + if (!cfgFile.isAbsolute()) { + // 相对于 KataGo 可执行文件目录 + cfgFile = new java.io.File(exeFile.getParentFile(), configPath); + } + cmd.add("-config"); + cmd.add(cfgFile.getAbsolutePath()); + } + + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.directory(exeFile.getParentFile()); // 设置工作目录为 KataGo 目录(找到 DLL 和调优缓存) + // ★ 修复:不再 redirectErrorStream——stdout 必须保持纯 GTP 流, + // 引擎日志混入 stdout 会被当作响应解析,导致 GTP 解析错位分叉; + // stderr 也不能完全不消费——管道缓冲写满会挂死引擎,继承到本进程 stderr + pb.redirectError(ProcessBuilder.Redirect.INHERIT); + process = pb.start(); + java.io.BufferedWriter writer = new java.io.BufferedWriter( + new java.io.OutputStreamWriter(process.getOutputStream(), java.nio.charset.StandardCharsets.UTF_8)); + java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader(process.getInputStream(), java.nio.charset.StandardCharsets.UTF_8)); + + // 初始化 GTP,使用与训练标签相同的固定贴目快照。 + double roundKomi = GoGame.getConfiguredKomi(); + sendGTP(process, writer, reader, "boardsize 19"); + sendGTP(process, writer, reader, "komi " + GoScoringProtocol.formatKomi(roundKomi)); + sendGTP(process, writer, reader, "clear_board"); + + // 对弈 + GoGame game = GoGame.rulesOnly(); + boolean kataResigned = false; + + try { + int moves = 0; + int[] lastMoveOnBoard = null; // 上一手(plane 3),追踪双方落子 + while (!game.isGameOver() && moves < Math.max(1, config.maxMoves)) { + if (Thread.currentThread().isInterrupted()) { + throw new java.util.concurrent.CancellationException("adversarial generation cancelled"); + } + GoPlayer currentPlayer = game.getCurrentPlayer(); + boolean ourTurn = (currentPlayer == GoPlayer.BLACK) == ourIsBlack; + + if (ourTurn) { + // 己方 AI 走棋 + GoPlayer[][] boardCopy = game.getBoardCopy(); + int[] previousLastMove = lastMoveOnBoard == null ? null : lastMoveOnBoard.clone(); + int[] move = ourAI.getBestMove(game); + if (Thread.currentThread().isInterrupted()) { + throw new java.util.concurrent.CancellationException("adversarial generation cancelled"); + } + double[] policyTarget = ourAI.getVisitDistribution(); + GoTrainingMove.Applied applied = GoTrainingMove.apply(game, move, policyTarget); + int[] actuallyPlayed = applied.coordinates(); + lastMoveOnBoard = actuallyPlayed; + policyTarget = applied.policy(); + samples.add(new Sample(boardCopy, currentPlayer, previousLastMove, policyTarget)); + + // 同步到 KataGo(用实际落子,避免 fallback 时两盘棋分叉) + if (actuallyPlayed != null) { + String color = ourIsBlack ? "black" : "white"; + sendGTP(process, writer, reader, "play " + color + " " + formatMove(actuallyPlayed[0], actuallyPlayed[1])); + } else { + String color = ourIsBlack ? "black" : "white"; + sendGTP(process, writer, reader, "play " + color + " pass"); + } + } else { + // KataGo 走棋 + String color = ourIsBlack ? "white" : "black"; + String response = sendGTP(process, writer, reader, "genmove " + color); + // resign ends the game; pass is a real pass. Any other malformed + // or illegal response fails the game instead of desynchronizing boards. + GoAI.MoveResult action = parseGTPAction(response); + if (action.type() == GoAI.MoveType.RESIGN) { + kataResigned = true; + break; + } + if (action.type() == GoAI.MoveType.PASS) { + game.pass(); + lastMoveOnBoard = null; + } else if (action.type() == GoAI.MoveType.MOVE && game.placeStone(action.x(), action.y())) { + lastMoveOnBoard = action.coordinates(); + } else { + throw new IOException("Invalid KataGo genmove response: " + response); + } + } + moves++; + } + if (!game.isGameOver() && !kataResigned) { + // maxMoves is a truncation guard, not an implicit second pass. + return new GameResult(java.util.Collections.emptyList(), false, false); + } + + // 计算胜负:认输直接记确定值 ±1(残盘点目对中盘认输无意义) + double margin = game.getScoreMargin(GoPlayer.BLACK, java.util.Collections.emptySet(), roundKomi); + boolean ourWin = kataResigned + ? true + : (ourIsBlack && margin > 0) || (!ourIsBlack && margin < 0); + // ★ 修复:resign 是"黑方(KataGo 或我方)认输"——我方执白时黑(对手)实际输了, + // blackValue 应为 -1 而非 +1,否则训练标签方向完全颠倒 + double blackValue = kataResigned ? (ourIsBlack ? 1.0 : -1.0) : clamp(margin / 100.0); + + for (Sample s : samples) { + s.valueTarget = (s.player == GoPlayer.BLACK) ? blackValue : -blackValue; + } + + // 关闭 KataGo(优先优雅退出,外层 finally 兜底强杀,覆盖所有异常路径) + sendGTP(process, writer, reader, "quit"); + writer.close(); + reader.close(); + process.waitFor(2, java.util.concurrent.TimeUnit.SECONDS); + + return new GameResult(samples, ourWin, true); + } finally { + game.close(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new java.util.concurrent.CancellationException("adversarial generation cancelled"); + } catch (java.util.concurrent.CancellationException e) { + throw e; + } catch (Exception e) { + System.err.println("[Adversarial] 对局异常: " + e.getMessage()); + return new GameResult(java.util.Collections.emptyList(), false, false); + } finally { + // Every early-return and initialization failure must release the private evaluator. + ourAI.shutdown(); + // ★ Bug修复:此前 kataGo 变量从未真正赋值,异常路径下真正持有子进程的 process + // 完全没被清理——GTP 通信异常/超时会让 KataGo 残留为僵尸进程占用显存。 + // 无论正常返回还是任意异常路径,这里保证子进程被强杀。 + if (process != null && process.isAlive()) { + process.destroyForcibly(); + } + } + } + + private String sendGTP(java.io.Writer writer, java.io.Reader reader, String cmd) throws Exception { + return sendGTP(null, writer, reader, cmd, 600_000L); + } + + private String sendGTP(Process process, java.io.Writer writer, java.io.Reader reader, + String cmd) throws Exception { + return sendGTP(process, writer, reader, cmd, 600_000L); + } + + /** Test hook without a process handle: timeout abandons the reader instead of closing the stream. */ + private String sendGTP(java.io.Writer writer, java.io.Reader reader, String cmd, + long timeoutMillis) throws Exception { + return sendGTP(null, writer, reader, cmd, timeoutMillis); + } + + /** + * 发送 GTP 命令并等待响应终止行("=..."/"?...")。 + *

+ * 超时/中断路径绝不在主线程 close() reader:reader 线程可能仍持有 BufferedReader + * 内部锁阻塞在管道读上,同步 close 会永久死锁(Windows 实测复现)。此处改为 + * destroyForcibly 引擎使管道 EOF,reader 线程自行退出;进程清理由对局 finally 兜底。 + * + * @param process 所属引擎进程;仅用于超时/中断时强制解除管道阻塞,可为 null(测试钩子) + */ + private String sendGTP(Process process, java.io.Writer writer, java.io.Reader reader, String cmd, + long timeoutMillis) throws Exception { + writer.write(cmd + "\n"); + writer.flush(); + java.io.BufferedReader br = (java.io.BufferedReader) reader; + long timeoutNanos = TimeUnit.MILLISECONDS.toNanos(Math.max(1L, timeoutMillis)); + CountDownLatch done = new CountDownLatch(1); + AtomicReference responseRef = new AtomicReference<>(); + AtomicReference errorRef = new AtomicReference<>(); + Thread readerThread = new Thread(() -> { + StringBuilder noise = new StringBuilder(); + try { + String line; + while ((line = br.readLine()) != null) { + String trimmed = line.stripLeading(); + if (trimmed.startsWith("=") || trimmed.startsWith("?")) { + responseRef.set(trimmed); + return; + } + // 响应前的引擎日志/横幅行不属于 GTP 响应,丢弃 + noise.append(line).append('\n'); + } + // EOF:若此前还有未终止的内容(半行响应),不得当作成功返回 + if (noise.length() > 0) { + errorRef.set(new IOException("KataGo 进程已退出(响应不完整)")); + } + // 注意:readLine 会把"无换行即 EOF"的尾行当作完整行返回(如 "=1"), + // 这类响应会作为成功文本交由 parseGTPAction 判定;裸 id/非法动作会被 + // 判为 ERROR 使对局安全失败,且引擎已死时下一条命令必然 EOF。 + } catch (Throwable t) { + errorRef.set(t); + } finally { + done.countDown(); + } + }, "gtp-response-reader"); + readerThread.setDaemon(true); + readerThread.start(); + try { + long deadline = System.nanoTime() + timeoutNanos; + while (!done.await(100L, TimeUnit.MILLISECONDS)) { + if (System.nanoTime() >= deadline) { + throw new java.util.concurrent.TimeoutException("GTP 命令超时: " + cmd); + } + } + } catch (java.util.concurrent.TimeoutException | InterruptedException e) { + if (process != null) process.destroyForcibly(); + throw e; + } + Throwable error = errorRef.get(); + if (error instanceof IOException io) throw io; + if (error instanceof RuntimeException re) throw re; + if (error != null) throw new IOException("KataGo GTP read failed", error); + String response = responseRef.get(); + if (response == null) throw new IOException("KataGo 进程已退出"); + if (response.startsWith("?")) { + throw new IOException("KataGo rejected GTP command " + cmd + ": " + response); + } + return response + "\n"; + } + + static GoAI.MoveResult parseGTPAction(String response) { + if (response == null) return GoAI.MoveResult.error(); + String line = response.trim(); + if (!line.startsWith("=")) return GoAI.MoveResult.error(); + String body = line.substring(1).trim(); + int idEnd = 0; + while (idEnd < body.length() && Character.isDigit(body.charAt(idEnd))) idEnd++; + if (idEnd > 0) { + if (idEnd == body.length() || !Character.isWhitespace(body.charAt(idEnd))) { + return GoAI.MoveResult.error(); + } + body = body.substring(idEnd).trim(); + } + if (body.isEmpty() || body.chars().anyMatch(Character::isWhitespace)) { + return GoAI.MoveResult.error(); + } + return KataGoGoAI.parseMoveResult(body); + } + + private String formatMove(int x, int y) { + int gtpCol = x + (x >= 8 ? 1 : 0); + return String.valueOf((char) ('a' + gtpCol)) + (y + 1); + } + + // ── 训练(与 GoSelfPlayTrainer 相同) ────────────────────────── + + private static final int BOARD_SIZE = 19; + private static final int BOARD_FEATURES = BOARD_SIZE * BOARD_SIZE; + private static final int AUX_FEATURES = 24; + private static final int[][] SYMM_PERMS = buildSymmetryPerms(); + + private static int[][] buildSymmetryPerms() { + int n = BOARD_SIZE; + int[][] perms = new int[8][BOARD_FEATURES]; + for (int x = 0; x < n; x++) for (int y = 0; y < n; y++) { + int idx = x * n + y; + perms[0][idx] = idx; + perms[1][idx] = y * n + (n - 1 - x); + perms[2][idx] = (n - 1 - x) * n + (n - 1 - y); + perms[3][idx] = (n - 1 - y) * n + x; + perms[4][idx] = (n - 1 - x) * n + y; + perms[5][idx] = x * n + (n - 1 - y); + perms[6][idx] = y * n + x; + perms[7][idx] = (n - 1 - y) * n + (n - 1 - x); + } + return perms; + } + + private double train(List samples, int epochs, double learningRate, long seed) { + if (samples.isEmpty() || epochs == 0) return 0; + Random random = new Random(seed); + double total = 0; + int batches = 0; + int symCount = SYMM_PERMS.length; + + for (int epoch = 0; epoch < epochs; epoch++) { + java.util.Collections.shuffle(samples, random); + int batchLimit = Math.max(1, config.batchSize); + for (int start = 0; start < samples.size(); start += batchLimit) { + if (Thread.currentThread().isInterrupted()) return batches == 0 ? 0 : total / batches; + int end = Math.min(samples.size(), start + batchLimit); + int baseCount = end - start; + double[][][][] planes = new double[baseCount * symCount][4][BOARD_SIZE][BOARD_SIZE]; + double[][] aux = new double[baseCount * symCount][AUX_FEATURES]; + double[] values = new double[baseCount * symCount]; + double[][] policies = new double[baseCount * symCount][362]; + + int n = 0; + for (int k = start; k < end; k++) { + Sample s = samples.get(k); + for (int t = 0; t < symCount; t++) { + GoPlayer[][] tb = applySymmetry(s.board, SYMM_PERMS[t]); + // 上一手随对称变换(plane 3 与推理对齐) + int[] tLastMove = null; + if (s.lastMove != null && s.lastMove.length >= 2) { + int dstIdx = SYMM_PERMS[t][s.lastMove[0] * BOARD_SIZE + s.lastMove[1]]; + tLastMove = new int[]{dstIdx / BOARD_SIZE, dstIdx % BOARD_SIZE}; + } + planes[n] = evaluator.buildInputPlanes(tb, s.player, tLastMove); + aux[n] = evaluator.extractAuxFeatures(tb, s.player); + values[n] = s.valueTarget; + if (t == 0) { + policies[n] = s.policyTarget.clone(); + } else { + double[] pt = new double[362]; + for (int i = 0; i < BOARD_FEATURES; i++) + pt[SYMM_PERMS[t][i]] = s.policyTarget[i]; + pt[361] = s.policyTarget[361]; + policies[n] = pt; + } + n++; + } + } + if (Thread.currentThread().isInterrupted()) return batches == 0 ? 0 : total / batches; + total += evaluator.trainMiniBatch(planes, aux, values, policies, + learningRate, config.l2, config.gradientClip, config.momentum); + batches++; + } + } + return batches == 0 ? 0 : total / batches; + } + + private static GoPlayer[][] applySymmetry(GoPlayer[][] board, int[] perm) { + int n = BOARD_SIZE; + GoPlayer[][] result = new GoPlayer[n][n]; + for (int x = 0; x < n; x++) for (int y = 0; y < n; y++) { + int dstIdx = perm[x * n + y]; + result[dstIdx / n][dstIdx % n] = board[x][y]; + } + return result; + } + + private static double clamp(double value) { return Math.max(-1.0, Math.min(1.0, value)); } +} \ No newline at end of file diff --git a/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoGame.java b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoGame.java index 10a5940..4df6e02 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoGame.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoGame.java @@ -2,20 +2,54 @@ import java.util.*; -public class GoGame { +public class GoGame implements AutoCloseable { private static final int BOARD_SIZE = 19; + /** Shared komi configuration used by scoring and external engines. */ + public static final double DEFAULT_KOMI = 7.5; + + public static double getConfiguredKomi() { + try { + return normalizeKomi(com.wzz.game_console.util.GameSettings.getDouble( + "go", "komi", DEFAULT_KOMI)); + } catch (Throwable ignored) { + return DEFAULT_KOMI; + } + } + + /** Returns the canonical finite komi accepted by Go scoring and network messages. */ + public static double normalizeKomi(double komi) { + if (!Double.isFinite(komi)) return DEFAULT_KOMI; + if (komi == 0.0d) return 0.0d; + return Math.max(-100.0d, Math.min(100.0d, komi)); + } private static final int[][] DIRS = {{0,1}, {1,0}, {0,-1}, {-1,0}}; private GoPlayer[][] board; private GoPlayer currentPlayer; private boolean gameOver; + private GoPlayer resignedPlayer = GoPlayer.NONE; private int blackCaptured; private int whiteCaptured; private boolean aiMode; private int consecutivePasses; private List moveHistory; private GoAI ai; + private boolean closed; + /** Whether reset() should create the configured AI engine. */ + private final boolean initializeAi; + /** Serializes engine creation and replacement without blocking board resets. */ + private final Object aiLifecycleLock = new Object(); + /** Incremented whenever the board lifecycle changes, invalidating in-flight engine creation. */ + private long aiLifecycleGeneration; + /** Incremented after every committed board-state mutation. */ + private long positionRevision; + /** Prevents duplicate lazy engine creation while allowing reset/close to proceed. */ + private boolean aiCreationInProgress; + /** Generation for the currently reserved engine creation, or -1 when idle. */ + private long aiCreationGeneration = -1L; /** 历史局面哈希:劫争判定(禁止全局同型,中国规则),新局面不得与任何历史局面重复 */ private final Set positionHistory = new HashSet<>(); + /** 对局状态锁:placeStone/pass 写、getMoveHistory/getPositionHistory 读,跨线程同步 */ + private final Object stateLock = new Object(); /** 调试开关:为 true 时输出劫争判定的详细追踪信息(默认关闭,正常对局不刷屏) */ private static final boolean DEBUG_KO = false; @@ -33,37 +67,261 @@ public class GoGame { } public GoGame() { + this(true); + } + + /** + * Creates a game and optionally initializes its AI engine. + * + *

Passing {@code false} creates a rules-only game. This path does not + * read {@code GameSettings} and does not start an external engine, making + * it suitable for trainers and rule/evaluation code.

+ * + * @param initializeAi whether to create the configured AI engine + */ + public GoGame(boolean initializeAi) { + this.initializeAi = initializeAi; this.board = new GoPlayer[BOARD_SIZE][BOARD_SIZE]; this.moveHistory = new ArrayList<>(); - this.ai = new GoAI(); // reset() 已声明为 final,避免构造器调用可覆写方法的 this-escape 风险 reset(); } - + + /** Creates a game containing only the Go rules and board state. */ + public static GoGame rulesOnly() { + return new GoGame(false); + } + public final void reset() { - // 初始化棋盘 - for (int x = 0; x < BOARD_SIZE; x++) { - for (int y = 0; y < BOARD_SIZE; y++) { - board[x][y] = GoPlayer.NONE; + GoAI oldAi; + synchronized (stateLock) { + aiLifecycleGeneration++; + positionRevision++; + aiCreationInProgress = false; + aiCreationGeneration = -1L; + // 初始化棋盘 + for (int x = 0; x < BOARD_SIZE; x++) { + for (int y = 0; y < BOARD_SIZE; y++) { + board[x][y] = GoPlayer.NONE; + } } + + currentPlayer = GoPlayer.BLACK; + gameOver = false; + resignedPlayer = GoPlayer.NONE; + blackCaptured = 0; + whiteCaptured = 0; + consecutivePasses = 0; + moveHistory.clear(); + positionHistory.clear(); + positionHistory.add(boardHash()); + oldAi = ai; + ai = null; + } + if (oldAi != null) { + try { oldAi.shutdown(); } catch (Throwable ignored) {} } + // Engine creation is lazy in GoGameScreen's worker. Starting KataGo here + // would run process creation and GTP handshakes on the client thread. + } - currentPlayer = GoPlayer.BLACK; - gameOver = false; - blackCaptured = 0; - whiteCaptured = 0; - consecutivePasses = 0; - moveHistory.clear(); - // 空棋盘作为初始历史局面(用于劫争的同型判定) - positionHistory.clear(); - positionHistory.add(boardHash()); + /** + * 根据 GameSettings 初始化 AI 引擎。 + * 支持在游戏中途切换引擎(重开时生效)。 + * 注意:如果 GameSettings 或其他依赖不可用,会回退到默认 MCTS。 + */ + public void initAi() { + if (!initializeAi) return; + replaceAiFromSettings(); + } + + /** 后台搜索线程使用:仅在尚无引擎时创建,避免客户端线程执行外部引擎握手。 */ + public void initAiIfAbsent() { + if (!initializeAi) return; + long generation; + synchronized (aiLifecycleLock) { + synchronized (stateLock) { + if (ai != null || gameOver || closed || aiCreationInProgress) return; + generation = aiLifecycleGeneration; + aiCreationInProgress = true; + aiCreationGeneration = generation; + } + } + publishCreatedAi(generation, createConfiguredAi()); + } + + private void replaceAiFromSettings() { + if (!initializeAi) return; + GoAI oldAi; + long generation; + synchronized (aiLifecycleLock) { + synchronized (stateLock) { + aiLifecycleGeneration++; + oldAi = ai; + ai = null; + generation = aiLifecycleGeneration; + aiCreationInProgress = true; + aiCreationGeneration = generation; + } + } + if (oldAi != null) { + try { oldAi.shutdown(); } catch (Throwable ignored) {} + } + publishCreatedAi(generation, createConfiguredAi()); + } + + private GoAI createConfiguredAi() { + try { + return GoAI.create(); + } catch (Throwable t) { + try { + return MCTSGoAI.createFromSettings(); + } catch (Throwable ignored) { + return null; + } + } + } + + private void publishCreatedAi(long generation, GoAI newAi) { + boolean publish; + synchronized (stateLock) { + publish = newAi != null && generation == aiLifecycleGeneration && ai == null && !gameOver && !closed; + if (publish) ai = newAi; + if (generation == aiCreationGeneration) { + aiCreationInProgress = false; + aiCreationGeneration = -1L; + } + } + if (!publish && newAi != null) { + try { newAi.shutdown(); } catch (Throwable ignored) {} + } + } + + public String getRuntimeAiEngineLabel() { + synchronized (stateLock) { + return GoAI.runtimeEngineLabel(ai == null ? null : ai.getClass()); + } + } + + /** + * 设置自定义 AI 引擎(覆盖 GameSettings 配置)。 + */ + public void setAiEngine(GoAI aiEngine) { + GoAI oldAi; + synchronized (stateLock) { + aiLifecycleGeneration++; + aiCreationInProgress = false; + if (closed) { + if (aiEngine != null) { + try { aiEngine.shutdown(); } catch (Throwable ignored) {} + } + return; + } + oldAi = this.ai; + this.ai = aiEngine; + } + if (oldAi != null && oldAi != aiEngine) { + try { oldAi.shutdown(); } catch (Throwable ignored) {} + } + } + + /** Releases the configured AI engine, if this game owns one. */ + @Override + public void close() { + GoAI oldAi; + synchronized (stateLock) { + closed = true; + aiLifecycleGeneration++; + aiCreationInProgress = false; + oldAi = this.ai; + this.ai = null; + } + if (oldAi != null) { + try { oldAi.shutdown(); } catch (Throwable ignored) {} + } + } + + /** Alias for callers that use explicit resource lifecycle naming. */ + public void shutdown() { + close(); + } + + /** + * 获取历史走法列表(供 KataGo 等外部引擎同步棋盘用)。 + */ + public List getMoveHistory() { + synchronized (stateLock) { + return Collections.unmodifiableList(new ArrayList<>(moveHistory)); + } + } + + /** + * 获取全部历史局面哈希集合(含当前局面),供 MCTS 搜索做 super-ko 检查。 + * 返回副本,避免外部修改影响内部状态。 + */ + public Set getPositionHistory() { + synchronized (stateLock) { + return new HashSet<>(positionHistory); + } + } + + /** Immutable, single-revision view used to initialize one AI search. */ + record PositionSnapshot(GoPlayer[][] board, GoPlayer currentPlayer, int moveCount, + Set positionHistory, int consecutivePasses, + int[] lastMove, long currentHash, long revision) {} + + PositionSnapshot positionSnapshot() { + synchronized (stateLock) { + int[] lastMove = null; + if (!moveHistory.isEmpty()) { + GoMove last = moveHistory.get(moveHistory.size() - 1); + if (last.x >= 0 && last.y >= 0) lastMove = new int[]{last.x, last.y}; + } + return new PositionSnapshot(copyBoardInternal(), currentPlayer, moveHistory.size(), + Set.copyOf(positionHistory), consecutivePasses, lastMove, + boardHash(), positionRevision); + } + } + + /** + * 当前局面的 Zobrist 哈希(供 MCTS 根节点初始化 super-ko 检查用)。 + */ + public long getCurrentHash() { + synchronized (stateLock) { + return boardHash(); + } + } + + /** + * 返回上一手落子位置 {x,y};无上一手或上一手是弃权时返回 null。 + * 供 MCTS 根节点构造 plane 3(上一手位置)时使用,保证 train/serve 一致。 + */ + public int[] getLastMove() { + synchronized (stateLock) { + if (moveHistory.isEmpty()) return null; + GoMove last = moveHistory.get(moveHistory.size() - 1); + if (last.x < 0 || last.y < 0) return null; // 弃权 + return new int[]{last.x, last.y}; + } + } + + /** + * 获取当前回合数(落子数 / 2 + 1)。 + */ + public int moveHistorySize() { + synchronized (stateLock) { + return moveHistory.size(); + } } public boolean placeStone(int x, int y) { - if (!canPlaceStone(x, y)) { + // 落子→提子→劫争/自杀判定→记录→换手整段持锁:后台 AI 线程经 + // getBoardCopy/getCurrentHash 读棋盘时不得看到"已落子未提完"的撕裂快照, + // positionHistory 的 contains/add 也不再有 TOCTOU 窗口 + synchronized (stateLock) { + if (gameOver || !isValidPosition(x, y) || board[x][y] != GoPlayer.NONE) { return false; } - // 备份当前局面:自杀或劫争判定失败时整体回滚 GoPlayer[][] backup = copyBoardInternal(); @@ -125,19 +383,23 @@ public boolean placeStone(int x, int y) { blackCaptured += capturedStones; } - // 切换玩家 - switchPlayer(); - - // 记录移动与局面(记录切换后的玩家状态) + // 记录移动与局面(记录当前玩家——落子者,与 pass() 一致;外层已持 stateLock) moveHistory.add(new GoMove(x, y, currentPlayer, capturedStones)); positionHistory.add(newHash); consecutivePasses = 0; + positionRevision++; + + // 切换玩家 + switchPlayer(); return true; + } } - /** 当前局面的 Zobrist 哈希(64 位,碰撞概率极低) */ - private long boardHash() { + /** + * 根据棋盘状态计算 Zobrist 哈希(静态方法,供 NeuralEvaluator 复用)。 + */ + public static long boardHash(GoPlayer[][] board) { long hash = 0; for (int x = 0; x < BOARD_SIZE; x++) for (int y = 0; y < BOARD_SIZE; y++) @@ -146,6 +408,20 @@ private long boardHash() { return hash; } + /** 当前局面的 Zobrist 哈希(64 位,碰撞概率极低) */ + private long boardHash() { + return boardHash(this.board); + } + + /** + * 增量 Zobrist:在 baseHash 基础上 XOR 进/出一颗子的哈希分量。 + * XOR 顺序无关,落子/提子序列的增量结果与 {@link #boardHash(GoPlayer[][])} + * 全盘重算逐位一致;供 AI 候选生成避免逐点深拷贝+全盘哈希。 + */ + static long xorStone(long baseHash, int x, int y, GoPlayer p) { + return p == GoPlayer.NONE ? baseHash : baseHash ^ ZOBRIST_TABLE[x][y][p.ordinal()]; + } + private GoPlayer[][] copyBoardInternal() { GoPlayer[][] copy = new GoPlayer[BOARD_SIZE][BOARD_SIZE]; for (int i = 0; i < BOARD_SIZE; i++) copy[i] = board[i].clone(); @@ -153,52 +429,181 @@ private GoPlayer[][] copyBoardInternal() { } private void restoreBoard(GoPlayer[][] backup) { - for (int i = 0; i < BOARD_SIZE; i++) board[i] = backup[i].clone(); + // backup 是刚刚 copyBoardInternal() 深拷贝的独立副本,直接整行赋回即可,无需再 clone + System.arraycopy(backup, 0, board, 0, BOARD_SIZE); } public boolean canPlaceStone(int x, int y) { - if (gameOver || !isValidPosition(x, y) || board[x][y] != GoPlayer.NONE) { - return false; + synchronized (stateLock) { + if (gameOver || !isValidPosition(x, y) || board[x][y] != GoPlayer.NONE) return false; + GoPlayer[][] backup = copyBoardInternal(); + GoPlayer player = currentPlayer; + int oldBlackCaptured = blackCaptured; + int oldWhiteCaptured = whiteCaptured; + int oldConsecutivePasses = consecutivePasses; + boolean oldGameOver = gameOver; + long oldPositionRevision = positionRevision; + List oldHistory = new ArrayList<>(moveHistory); + Set oldPositions = new HashSet<>(positionHistory); + try { + return placeStone(x, y); + } finally { + restoreBoard(backup); + currentPlayer = player; + blackCaptured = oldBlackCaptured; + whiteCaptured = oldWhiteCaptured; + consecutivePasses = oldConsecutivePasses; + gameOver = oldGameOver; + positionRevision = oldPositionRevision; + moveHistory.clear(); + moveHistory.addAll(oldHistory); + positionHistory.clear(); + positionHistory.addAll(oldPositions); + } } - - // 简单检查 - 实际实现中应该检查自杀规则和劫争规则 - return true; } public void pass() { - if (gameOver) return; + // ★ 修复:consecutivePasses/switchPlayer/endGame 原先在 stateLock 之外修改, + // 与 placeStone 的持锁协议不一致——并发读端可能看到"已记弃权未换手"的 + // 撕裂状态。整段状态变更持锁;这里只有内存操作,无 IO 重活,不会长期占锁 + synchronized (stateLock) { + if (gameOver) return; - // 先按当前玩家记录弃权(原先在switchPlayer之后记录,会把弃权记到对手名下) - moveHistory.add(new GoMove(-1, -1, currentPlayer, 0)); // -1,-1表示弃权 + // 先按当前玩家记录弃权(原先在switchPlayer之后记录,会把弃权记到对手名下) + moveHistory.add(new GoMove(-1, -1, currentPlayer, 0)); // -1,-1表示弃权 + positionRevision++; - consecutivePasses++; - if (consecutivePasses >= 2) { - endGame(); - } else { - switchPlayer(); + consecutivePasses++; + if (consecutivePasses >= 2) { + endGame(); + } else { + switchPlayer(); + } } } - + public void resign() { - gameOver = true; - // 可以记录谁认输了 + synchronized (stateLock) { + if (!gameOver) { + resignedPlayer = currentPlayer; + gameOver = true; + positionRevision++; + } + } + } + + /** Ends the game with the supplied player as the resigning side. */ + public void resign(GoPlayer player) { + synchronized (stateLock) { + if (!gameOver && (player == GoPlayer.BLACK || player == GoPlayer.WHITE)) { + resignedPlayer = player; + gameOver = true; + positionRevision++; + } + } + } + + public GoPlayer getResignedPlayer() { + synchronized (stateLock) { return resignedPlayer; } } + record AiMoveComputation(GoAI.MoveResult result, GoAI engine, long lifecycleGeneration, + long positionRevision, GoPlayer expectedPlayer) {} + public void makeAiMove() { - if (!aiMode || gameOver || currentPlayer == GoPlayer.BLACK) { - return; + applyAiMoveComputation(computeAiMoveComputation()); + } + + /** Computes a typed AI action and the state token required to apply it safely. */ + AiMoveComputation computeAiMoveComputation() { + GoAI engine; + long lifecycleGeneration; + long revision; + GoPlayer player; + synchronized (stateLock) { + if (!aiMode || ai == null || gameOver || closed) { + return new AiMoveComputation(GoAI.MoveResult.error(), null, -1L, -1L, GoPlayer.NONE); + } + engine = ai; + lifecycleGeneration = aiLifecycleGeneration; + revision = positionRevision; + player = currentPlayer; + } + GoAI.MoveResult result; + try { + result = engine.getBestMoveResult(this); + if (result == null) result = GoAI.MoveResult.error(); + } catch (RuntimeException ignored) { + result = GoAI.MoveResult.error(); + } + return new AiMoveComputation(result, engine, lifecycleGeneration, revision, player); + } + + /** Applies a computed action only if its engine and source position are still current. */ + boolean applyAiMoveComputation(AiMoveComputation computation) { + if (computation == null || computation.result() == null) return false; + synchronized (stateLock) { + if (closed || gameOver || !aiMode || ai != computation.engine() + || aiLifecycleGeneration != computation.lifecycleGeneration() + || positionRevision != computation.positionRevision() + || currentPlayer != computation.expectedPlayer()) { + return false; + } + applyAiMoveResult(computation.result()); + return true; } + } + + /** Computes a typed AI action without applying it. */ + public GoAI.MoveResult computeAiMoveResult() { + return computeAiMoveComputation().result(); + } - int[] move = ai.getBestMove(this); - // 最优落子非法(劫争/自杀)时,扫描棋盘找第一个合法点,避免直接弃权 - if (move != null && placeStone(move[0], move[1])) { + /** 计算 AI 走法(不落子),返回 {x,y} 或 null(表示建议弃权)。供后台线程计算使用。 */ + public int[] computeAiMove() { + GoAI.MoveResult result = computeAiMoveResult(); + return result.type() == GoAI.MoveType.MOVE ? result.coordinates() : null; + } + + /** 将 AI 走法应用到棋盘(含非法回退扫描),应在客户端线程调用。 */ + public void applyAiMove(int[] move) { + if (move == null) { + applyAiMoveResult(GoAI.MoveResult.pass()); + } else if (move.length >= 2 && isValidPosition(move[0], move[1])) { + applyAiMoveResult(GoAI.MoveResult.move(move[0], move[1])); + } else { + applyAiMoveResult(GoAI.MoveResult.error()); + } + } + + /** Applies a typed AI action. Errors leave the position unchanged. */ + public void applyAiMoveResult(GoAI.MoveResult result) { + if (result == null || result.type() == GoAI.MoveType.ERROR) return; + if (result.type() == GoAI.MoveType.RESIGN) { + resign(); return; } - for (int x = 0; x < BOARD_SIZE; x++) { - for (int y = 0; y < BOARD_SIZE; y++) { - if (placeStone(x, y)) return; + if (result.type() == GoAI.MoveType.PASS) { + pass(); + return; + } + int[] move = result.coordinates(); + // 校验 AI 坐标后再尝试落子,避免畸形引擎响应触发越界。 + if (move != null && move.length >= 2 && isValidPosition(move[0], move[1]) && placeStone(move[0], move[1])) { + return; + } + // 最优落子非法时从中心向外扫描,优先选择自然的中腹点。 + int center = BOARD_SIZE / 2; + for (int radius = 0; radius < BOARD_SIZE; radius++) { + for (int x = center - radius; x <= center + radius; x++) { + for (int y = center - radius; y <= center + radius; y++) { + if (Math.max(Math.abs(x - center), Math.abs(y - center)) == radius + && isValidPosition(x, y) && placeStone(x, y)) return; + } } } + // 全盘无合法落子则弃权 pass(); } @@ -264,28 +669,227 @@ private boolean hasLiberty(Set group) { return false; } - // Getter方法 + // Getter方法(读棋盘/终态字段,统一持 stateLock 防撕裂读) public GoPlayer getStone(int x, int y) { if (!isValidPosition(x, y)) return GoPlayer.NONE; - return board[x][y]; + synchronized (stateLock) { + return board[x][y]; + } + } + + public GoPlayer getCurrentPlayer() { synchronized (stateLock) { return currentPlayer; } } + public boolean isGameOver() { synchronized (stateLock) { return gameOver; } } + public int getConsecutivePasses() { synchronized (stateLock) { return consecutivePasses; } } + public int getBlackCaptured() { synchronized (stateLock) { return blackCaptured; } } + public int getWhiteCaptured() { synchronized (stateLock) { return whiteCaptured; } } + public boolean isAiMode() { synchronized (stateLock) { return aiMode; } } + + /** Whether this game was created with an AI lifecycle enabled. */ + public boolean isAiEnabled() { return initializeAi; } + + public void setAiMode(boolean aiMode) { + synchronized (stateLock) { + if (this.aiMode != aiMode) { + this.aiMode = aiMode; + aiLifecycleGeneration++; + } + } } - - public GoPlayer getCurrentPlayer() { return currentPlayer; } - public boolean isGameOver() { return gameOver; } - public int getBlackCaptured() { return blackCaptured; } - public int getWhiteCaptured() { return whiteCaptured; } - public boolean isAiMode() { return aiMode; } - public void setAiMode(boolean aiMode) { this.aiMode = aiMode; } public int getBoardSize() { return BOARD_SIZE; } // 获取棋盘副本供AI使用 public GoPlayer[][] getBoardCopy() { - GoPlayer[][] copy = new GoPlayer[BOARD_SIZE][BOARD_SIZE]; + synchronized (stateLock) { + return copyBoardInternal(); + } + } + + // ═══════════════════════════════════════════════════════════════ + // 计分(中国规则数子法) + // ═══════════════════════════════════════════════════════════════ + + /** + * 数子法计算领地(flood-fill 无子区域,判断属于哪方)。 + * 得分 = 棋盘活子数 + 单独围空(不含提子数,避免双重计分)。 + * @return [黑领地, 白领地] + */ + public int[] calcTerritory() { + return calcTerritory(Collections.emptySet()); + } + + /** + * Calculates Chinese area score after removing only explicitly marked dead groups. + * Coordinates may name any stone in a group; the complete connected group is removed + * from a detached scoring copy, so the live board and move history remain unchanged. + */ + public int[] calcTerritory(Set markedDead) { + synchronized (stateLock) { + GoPlayer[][] scoringBoard = copyBoardInternal(); + removeMarkedGroups(scoringBoard, markedDead); + return calcTerritoryInternal(scoringBoard); + } + } + + /** Calculates Chinese area totals from a detached board without mutating it. */ + static int[] calcTerritory(GoPlayer[][] scoringBoard) { + if (scoringBoard == null || scoringBoard.length != BOARD_SIZE) { + throw new IllegalArgumentException("棋盘尺寸必须为 19x19"); + } + for (GoPlayer[] column : scoringBoard) { + if (column == null || column.length != BOARD_SIZE) { + throw new IllegalArgumentException("棋盘尺寸必须为 19x19"); + } + } + return calcTerritoryInternal(scoringBoard); + } + + /** 无锁内部实现:仅读取传入棋盘(棋盘活子数 + 围空 flood-fill)。 */ + private static int[] calcTerritoryInternal(GoPlayer[][] scoringBoard) { + boolean[][] visited = new boolean[BOARD_SIZE][BOARD_SIZE]; + int blackT = 0, whiteT = 0; + + // 先统计棋盘上的活子数(死子不计入得分;但活子数只需数黑/白总数, + // 领地统计用空点相邻判定,对死子已通过 visited 排除) + for (int x = 0; x < BOARD_SIZE; x++) + for (int y = 0; y < BOARD_SIZE; y++) { + GoPlayer s = scoringBoard[x][y]; + if (s == GoPlayer.BLACK) blackT++; + else if (s == GoPlayer.WHITE) whiteT++; + } + + // 再统计空点领地 for (int x = 0; x < BOARD_SIZE; x++) { for (int y = 0; y < BOARD_SIZE; y++) { - copy[x][y] = board[x][y]; + if (visited[x][y] || scoringBoard[x][y] != GoPlayer.NONE) continue; + // BFS 只遍历空点;边界棋子仅记录颜色,不能共享空区 visited 标记。 + java.util.List region = new java.util.ArrayList<>(); + java.util.Queue queue = new java.util.ArrayDeque<>(); + queue.add(new int[]{x, y}); + visited[x][y] = true; + boolean touchBlack = false, touchWhite = false; + while (!queue.isEmpty()) { + int[] pos = queue.remove(); + int px = pos[0], py = pos[1]; + region.add(pos); + for (int[] d : DIRS) { + int nx = px + d[0], ny = py + d[1]; + if (nx < 0 || nx >= BOARD_SIZE || ny < 0 || ny >= BOARD_SIZE) continue; + GoPlayer st = scoringBoard[nx][ny]; + if (st == GoPlayer.BLACK) touchBlack = true; + else if (st == GoPlayer.WHITE) touchWhite = true; + else if (!visited[nx][ny]) { + visited[nx][ny] = true; + queue.add(new int[]{nx, ny}); + } + } + } + int pts = region.size(); + if (touchBlack && !touchWhite) blackT += pts; + else if (touchWhite && !touchBlack) whiteT += pts; + // 争议地带不计 } } - return copy; + return new int[]{blackT, whiteT}; + } + + /** Remove only explicitly marked groups from a detached scoring copy. */ + private void removeMarkedGroups(GoPlayer[][] scoringBoard, Set markedDead) { + if (markedDead == null || markedDead.isEmpty()) return; + List anchors = new ArrayList<>(markedDead.size()); + for (Long encodedValue : markedDead) { + if (encodedValue == null) throw new IllegalArgumentException("死棋标记不能为空"); + long encoded = encodedValue; + int x = (int) (encoded >> 32); + int y = (int) encoded; + if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE + || board[x][y] == GoPlayer.NONE) { + throw new IllegalArgumentException("死棋标记必须指向棋盘上的棋子"); + } + anchors.add(new int[]{x, y}); + } + boolean[][] seen = new boolean[BOARD_SIZE][BOARD_SIZE]; + for (int[] anchor : anchors) { + int x = anchor[0], y = anchor[1]; + if (scoringBoard[x][y] == GoPlayer.NONE) continue; + List group = collectGroup(scoringBoard, x, y, seen); + for (int[] stone : group) scoringBoard[stone[0]][stone[1]] = GoPlayer.NONE; + } + } + + private static List collectGroup(GoPlayer[][] position, int x, int y, boolean[][] seen) { + List group = new ArrayList<>(); + ArrayDeque queue = new ArrayDeque<>(); + queue.add(new int[]{x, y}); + seen[x][y] = true; + GoPlayer color = position[x][y]; + while (!queue.isEmpty()) { + int[] point = queue.remove(); + group.add(point); + for (int[] dir : DIRS) { + int nx = point[0] + dir[0], ny = point[1] + dir[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE + && !seen[nx][ny] && position[nx][ny] == color) { + seen[nx][ny] = true; + queue.add(new int[]{nx, ny}); + } + } + } + return group; + } + + private static long key(int x, int y) { return ((long) x << 32) | (y & 0xffffffffL); } + + /** + * 计算某一方的最终得分(中国规则数子法,白方加贴目)。 + * @param player 视角 + * @return 该方的得分 + */ + public double getScore(GoPlayer player) { + return getScore(player, Collections.emptySet(), getConfiguredKomi()); + } + + /** Calculates a player's score after removing explicitly marked dead groups. */ + public double getScore(GoPlayer player, Set markedDead) { + return getScore(player, markedDead, getConfiguredKomi()); + } + + public record Score(double black, double white) { + public GoPlayer winner() { + if (black > white) return GoPlayer.BLACK; + if (white > black) return GoPlayer.WHITE; + return GoPlayer.NONE; + } + } + + public Score getScores(Set markedDead, double komi) { + int[] territory = calcTerritory(markedDead); + return new Score(territory[0], territory[1] + normalizeKomi(komi)); + } + + /** Calculates a player's score using a fixed komi snapshot. */ + public double getScore(GoPlayer player, Set markedDead, double komi) { + if (player != GoPlayer.BLACK && player != GoPlayer.WHITE) { + throw new IllegalArgumentException("计分方必须是黑棋或白棋"); + } + Score score = getScores(markedDead, komi); + return player == GoPlayer.BLACK ? score.black() : score.white(); + } + + /** + * 计算两方的分差(从指定视角看,正数表示该方领先)。 + * @param perspective 视角方 + * @return 分差(视角方 - 对方) + */ + public double getScoreMargin(GoPlayer perspective) { + return getScoreMargin(perspective, Collections.emptySet(), getConfiguredKomi()); + } + + /** Calculates score margin using one fixed komi snapshot. */ + public double getScoreMargin(GoPlayer perspective, Set markedDead, double komi) { + if (perspective != GoPlayer.BLACK && perspective != GoPlayer.WHITE) { + throw new IllegalArgumentException("计分方必须是黑棋或白棋"); + } + GoPlayer opponent = perspective == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + return getScore(perspective, markedDead, komi) - getScore(opponent, markedDead, komi); } } \ No newline at end of file diff --git a/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoGameScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoGameScreen.java index 67f8be6..eb1d20f 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoGameScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoGameScreen.java @@ -3,8 +3,10 @@ import com.wzz.game_console.client.screens.GameSelectorScreen; import com.wzz.game_console.client.screens.games.LanMultiplayerScreen; import com.wzz.game_console.util.GameRenderHelper; +import com.wzz.game_console.util.GameSettings; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.gui.screens.Screen; import net.minecraft.network.chat.Component; import net.minecraft.util.Mth; @@ -14,14 +16,34 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + @OnlyIn(Dist.CLIENT) public class GoGameScreen extends Screen implements LanMultiplayerScreen { private static final Logger LOGGER = LoggerFactory.getLogger(GoGameScreen.class); boolean showExitConfirm = false; private static final int BOARD_SIZE = 19; - private static final int[][] DIRS = {{1,0},{-1,0},{0,1},{0,-1}}; - private enum State { MENU, PLAYING, GAME_OVER } + private enum State { MENU, SETTINGS, PLAYING, SCORING, GAME_OVER } + private final Set markedDead = new HashSet<>(); + private boolean scoringConfirmed; + private long scoringRevision; + private UUID scoringEpoch; + private String scoringDigest = ""; + private boolean remoteScoringConfirmed; + private long remoteConfirmedRevision = -1L; + private String remoteConfirmedDigest = ""; + /** Canonical komi captured for this round's final scoring. */ + private double roundKomi = GoGame.DEFAULT_KOMI; + private boolean roundKomiSet; + private long scoringLastBroadcastTick = -40L; + private GoScoringProtocol.Snapshot finalScoringSnapshot; + /** Round identity and accepted move index prevent delayed packets crossing a restart. */ + private UUID gameEpoch = UUID.randomUUID(); + private long networkPly; private State state = State.MENU; private final GoGame game; private long tickCount = 0; @@ -42,18 +64,51 @@ private enum State { MENU, PLAYING, GAME_OVER } /** 防重复发送 LEAVE_GAME 标志 */ private boolean lanLeaveSent = false; + // ── AI 引擎设置 ───────────────────────────────────────── + /** 设置界面中当前选中的引擎 */ + private String settingsEngine = GoAI.normalizeEngine(GameSettings.getString("go", "engine", "mcts")); + /** 设置界面中当前的搜索时间(ms) */ + private int settingsSearchTime = GameSettings.getInt("go", "searchTime", 3000); + /** 设置界面中当前的 KataGo 路径 */ + private String settingsKatagoPath = GameSettings.getString("go", "katagoPath", ""); + private EditBox katagoPathEditBox; + + /** AI 后台思考标记(防止重复启动线程) */ + private volatile boolean aiThinking = false; + /** A failed AI turn remains frozen until the round is restarted. */ + private boolean aiFailed = false; + /** Versioned result preserves action semantics and its source position across the worker boundary. */ + private volatile GoGame.AiMoveComputation aiPendingComputation = null; + /** Serializes AI worker start, completion publication, and client-side consumption. */ + private final Object aiWorkerLock = new Object(); + /** AI 后台是否已完成思考(待客户端线程消费) */ + private volatile boolean aiComputed = false; + /** Generation associated with the published result; access under aiWorkerLock. */ + private int aiComputedGeneration = -1; + /** + * AI 搜索代际:resetGame 时递增,worker 落地前比对。 + * 修复:worker 的 finally 无条件 aiComputed=true,重开对局后迟到的落地会让 + * tick 对新 game 调 applyAiMove(null) 强制空过一手。代际守卫使旧 worker 的 + * 任何落地(含异常路径)全部失效,与 interrupt 的时序无关。 + */ + private volatile int aiGeneration = 0; + /** 单机 / AI 构造 */ public GoGameScreen(GoGame game) { + this(game, game.isAiEnabled()); + } + + /** 单机构造,可选择 AI 对战或本地双人模式。 */ + public GoGameScreen(GoGame game, boolean aiMode) { super(Component.literal("围棋")); this.game = game; - this.game.setAiMode(true); + this.game.setAiMode(aiMode); } /** LAN 联机构造 */ public GoGameScreen(boolean isHost, java.util.UUID remote) { super(Component.literal("围棋")); - this.game = new GoGame(); - this.game.setAiMode(false); + this.game = new GoGame(false); this.lanMode = isHost ? LAN_HOST : LAN_CLIENT; this.remotePeer = remote; this.myTurn = isHost; // HOST(黑)先手 @@ -83,76 +138,325 @@ private void sendLeaveGameOnce() { sendLeaveGame(); } + /** 后台 AI 计算线程(cleanup 时 interrupt 并等待其退出) */ + private volatile Thread aiWorker = null; + private boolean cleanedUp = false; + + /** 统一、幂等地释放本屏幕拥有的 worker 和棋局资源。 */ + private synchronized void cleanup() { + if (cleanedUp) return; + cleanedUp = true; + Thread t; + synchronized (aiWorkerLock) { + aiGeneration++; + t = aiWorker; + aiWorker = null; + aiThinking = false; + aiComputed = false; + aiComputedGeneration = -1; + aiPendingComputation = null; + } + if (t != null && t != Thread.currentThread()) { + t.interrupt(); + try { t.join(2000L); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); } + } + try { game.close(); } catch (Exception ignored) {} + } + + @Override + public void removed() { + sendLeaveGameOnce(); + cleanup(); + super.removed(); + } + @Override public void onClose() { sendLeaveGameOnce(); + cleanup(); super.onClose(); } @Override public void onRemoteMove(String data) { - if ("RESTART".equals(data)) { resetGame(); return; } - if ("PASS".equals(data)) { - game.pass(); - myTurn = true; - if (game.isGameOver()) finishGame(); + if (lanMode == LAN_NONE || data == null) return; + String[] message = data.split("\\|", -1); + if (message.length != 4 || !"GO_MOVE1".equals(message[0])) return; + UUID epoch = GoScoringProtocol.parseEpoch(message[1]); + Long ply = GoScoringProtocol.parseRevision(message[2]); + if (epoch == null || ply == null) return; + String action = message[3]; + if ("START".equals(action) || "RESTART".equals(action)) { + // START/RESTART is idempotent per epoch; a duplicate or delayed packet from + // the current round must not erase moves already accepted on this client. + if (lanMode != LAN_CLIENT || ply != 0 || epoch.equals(gameEpoch)) return; + resetGame(); + gameEpoch = epoch; + networkPly = 0; + state = State.PLAYING; return; } - if (data.startsWith("RESIGN:")) { - // 对方认输,我赢 - myWin = true; + if (!epoch.equals(gameEpoch) || ply != networkPly) return; + // 认输不受回合状态限制;否则对方在本地回合认输时会被 guard 丢弃。 + if ("RESIGN".equals(action)) { + if (state != State.PLAYING || game.isGameOver()) return; + networkPly++; + myWin = true; resultMsg = "对方认输,你赢了!"; - state = State.GAME_OVER; + state = State.GAME_OVER; return; } + if (state != State.PLAYING || game.isGameOver() || myTurn) return; + if ("PASS".equals(action)) { + game.pass(); + networkPly++; + myTurn = true; + // 只有 HOST 建立评分 epoch。CLIENT 等待 HOST 的 BEGIN, + // 避免先生成随机 epoch 后拒绝主机的 canonical 状态。 + if (game.isGameOver() && lanMode == LAN_HOST) enterScoring(); + return; + } + String[] coordinates = action.split(",", -1); + if (coordinates.length != 2) return; try { - String[] p = data.split(","); - int x = Integer.parseInt(p[0]), y = Integer.parseInt(p[1]); - game.placeStone(x, y); + int x = Integer.parseInt(coordinates[0]); + int y = Integer.parseInt(coordinates[1]); + if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE) return; + if (!game.placeStone(x, y)) return; + networkPly++; myTurn = true; - if (game.isGameOver()) finishGame(); - } catch (Exception ignored) {} + if (game.isGameOver() && lanMode == LAN_HOST) enterScoring(); + } catch (NumberFormatException ignored) {} + } + + + @Override public void onRemoteState(UUID senderUuid, String data) { + if (remotePeer == null || !remotePeer.equals(senderUuid) || data == null) return; + String[] parts = data.split("\\|", -1); + if (parts.length < 2 || !GoScoringProtocol.PREFIX.equals(parts[0])) return; + String action = parts[1]; + try { + if ("BEGIN".equals(action) || "STATE".equals(action) || "FINAL".equals(action)) { + GoScoringProtocol.Phase phase = switch (state) { + case PLAYING -> GoScoringProtocol.Phase.PLAYING; + case SCORING -> GoScoringProtocol.Phase.SCORING; + case GAME_OVER -> GoScoringProtocol.Phase.FINISHED; + default -> GoScoringProtocol.Phase.OTHER; + }; + GoScoringProtocol.Snapshot current = currentScoringSnapshot(); + GoScoringProtocol.Snapshot incoming = GoScoringProtocol.receiveSnapshot(game, + new GoScoringProtocol.Receiver(lanMode == LAN_CLIENT, remotePeer, gameEpoch, phase, current), + senderUuid, parts); + if (incoming == null) return; + if ("FINAL".equals(action)) { + finishGame(incoming.marks()); + return; + } + if (!incoming.equals(current)) { + scoringConfirmed = false; + remoteScoringConfirmed = false; + remoteConfirmedRevision = -1L; + remoteConfirmedDigest = ""; + } + scoringEpoch = incoming.epoch(); + scoringRevision = incoming.revision(); + markedDead.clear(); + markedDead.addAll(incoming.marks()); + scoringDigest = incoming.digest(); + roundKomi = incoming.komi(); + roundKomiSet = true; + state = State.SCORING; + } else if ("CLEAR".equals(action) && lanMode == LAN_HOST && state == State.SCORING && parts.length == 4) { + UUID epoch = GoScoringProtocol.parseEpoch(parts[2]); + Long baseRevision = GoScoringProtocol.parseRevision(parts[3]); + if (epoch == null || baseRevision == null || !epoch.equals(scoringEpoch) + || baseRevision != scoringRevision) return; + markedDead.clear(); + scoringRevision++; + scoringConfirmed = false; + remoteScoringConfirmed = false; + remoteConfirmedRevision = -1L; + remoteConfirmedDigest = ""; + sendScoringState(); + } else if ("TOGGLE".equals(action) && lanMode == LAN_HOST && state == State.SCORING && parts.length == 5) { + UUID epoch = GoScoringProtocol.parseEpoch(parts[2]); + Long baseRevision = GoScoringProtocol.parseRevision(parts[3]); + if (epoch == null || baseRevision == null || !epoch.equals(scoringEpoch) + || baseRevision != scoringRevision) return; + String[] xy = parts[4].split(",", -1); + if (xy.length != 2) return; + int x = Integer.parseInt(xy[0]); + int y = Integer.parseInt(xy[1]); + if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE + || game.getStone(x, y) == GoPlayer.NONE) return; + toggleMarkedGroupLocal(x, y); + sendScoringState(); + } else if ("CONFIRM".equals(action) && lanMode == LAN_HOST && state == State.SCORING + && parts.length == 7) { + UUID epoch = GoScoringProtocol.parseEpoch(parts[2]); + Long revision = GoScoringProtocol.parseRevision(parts[3]); + Double komi = GoScoringProtocol.parseKomi(parts[6]); + if (epoch == null || revision == null || komi == null || !epoch.equals(scoringEpoch) + || revision != scoringRevision || !sameKomi(komi, roundKomi)) return; + Set confirmedMarks = GoScoringProtocol.parseMarks(parts[4]); + validateMarks(confirmedMarks); + String digest = GoScoringProtocol.digest(confirmedMarks); + if (!digest.equals(parts[5]) || !confirmedMarks.equals(markedDead)) return; + remoteScoringConfirmed = true; + remoteConfirmedRevision = revision; + remoteConfirmedDigest = digest; + if (scoringConfirmed && remoteConfirmedRevision == scoringRevision + && remoteConfirmedDigest.equals(scoringDigest)) { + finishConfirmedScoring(); + } + } + } catch (IllegalArgumentException ignored) { + LOGGER.warn("[围棋] 丢弃畸形结算消息"); + } } + @Override public void onRemoteState(String data) { } + @Override public void onRemoteGameOver(String data) { } - @Override public void onRemoteState(String data) { /* 围棋走法驱动 */ } - @Override public void onRemoteGameOver(String data) { /* 由 finishGame 本地处理 */ } + private void sendLanMove(String action) { + if (lanMode == LAN_NONE || gameEpoch == null) return; + sendMoveEnvelope("GO_MOVE1|" + gameEpoch + "|" + networkPly + "|" + action); + } + + private void sendLanStart(String action) { + if (lanMode != LAN_HOST || gameEpoch == null) return; + sendMoveEnvelope("GO_MOVE1|" + gameEpoch + "|0|" + action); + } - private void sendLanMove(String moveData) { - if (lanMode == LAN_NONE) return; - sendMove(moveData); + /** 单一入口判断本地玩家当前是否可落子/虚着/显示预览。 */ + private boolean canLocalPlayerMove() { + if (state != State.PLAYING || game.isGameOver()) return false; + if (lanMode != LAN_NONE) return myTurn; + return !game.isAiMode() || game.getCurrentPlayer() == GoPlayer.BLACK; } // ── 游戏逻辑 ────────────────────────────────────────────── private void resetGame() { + // ★ Bug修复:玩家在 AI 思考中按 N 重开,旧 AI 线程仍持有旧 game 引用, + // 写入的 aiPendingComputation 可能是新 game 还没准备好的状态,后续落子错乱。 + // 这里中断旧 AI 线程并清空 pending 状态 + Thread old; + synchronized (aiWorkerLock) { + old = aiWorker; + aiGeneration++; // 旧 worker 的迟到落地一律作废 + aiWorker = null; + aiThinking = false; + aiPendingComputation = null; + aiFailed = false; + aiComputed = false; + aiComputedGeneration = -1; + } + if (old != null && old != Thread.currentThread()) { + old.interrupt(); + try { old.join(1000L); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); } + } + game.reset(); myTurn = (lanMode != LAN_CLIENT); resultMsg = ""; myWin = false; + markedDead.clear(); + scoringConfirmed = false; + remoteScoringConfirmed = false; + remoteConfirmedRevision = -1L; + remoteConfirmedDigest = ""; + scoringRevision = 0; + scoringEpoch = null; + scoringDigest = ""; + finalScoringSnapshot = null; + roundKomi = GoGame.DEFAULT_KOMI; + roundKomiSet = false; + scoringLastBroadcastTick = tickCount - 40L; state = State.PLAYING; } /** - * 游戏结束时计算胜负(中国规则:数子法,黑子贴目3.75目)。 + * 游戏结束时计算胜负(中国规则数子法,黑棋贴目 7.5)。 * 修复 Bug:原版 endGame() 不计算胜者,导致局域网双方都显示"你赢了"。 */ - private void finishGame() { - // 计算双方领地 + 提子数 - int[] territory = calcTerritory(); - int blackTerritory = territory[0]; - int whiteTerritory = territory[1]; + private void enterScoring() { + markedDead.clear(); + scoringRevision = 0; + // A scoring session belongs to exactly one game round; reuse the round epoch + // so delayed scoring packets from a previous restart cannot enter this game. + scoringEpoch = gameEpoch; + roundKomi = GoGame.normalizeKomi(GoGame.getConfiguredKomi()); + roundKomiSet = true; + scoringDigest = GoScoringProtocol.digest(markedDead); + scoringConfirmed = false; + remoteScoringConfirmed = false; + remoteConfirmedRevision = -1L; + remoteConfirmedDigest = ""; + state = State.SCORING; + if (lanMode == LAN_HOST) { + // Bootstrap is self-contained so a reordered follow-up STATE cannot strand the client. + sendStateEnvelope("GO_SCORE1|BEGIN|" + scoringEpoch + "|" + scoringRevision + "|" + + encodeMarks() + "|" + scoringDigest + "|" + formatRoundKomi()); + sendScoringState(); + } + } + + private String formatRoundKomi() { + return GoScoringProtocol.formatKomi(roundKomiSet ? roundKomi : GoGame.DEFAULT_KOMI); + } - // 中国规则数子法(区域计分):得分 = 棋盘活子数 + 单独围空。 - // 修复:不再把提子数加进总分——提掉的子已从对方区域中消失, - // 区域计分天然包含了提子收益,再加一次属于双重计分。 - // 黑棋贴3.75子(等价于贴目7.5)。 - double blackScore = blackTerritory; - double whiteScore = whiteTerritory + 3.75; + private static boolean sameKomi(double first, double second) { + return Double.doubleToLongBits(first) == Double.doubleToLongBits(second); + } + + private GoScoringProtocol.Snapshot currentScoringSnapshot() { + if (scoringEpoch == null || !roundKomiSet) return null; + return new GoScoringProtocol.Snapshot(scoringEpoch, scoringRevision, markedDead, scoringDigest, roundKomi); + } + + private void finishConfirmedScoring() { + finalScoringSnapshot = currentScoringSnapshot(); + if (finalScoringSnapshot == null) return; + sendStateEnvelope(finalScoringSnapshot.encode("FINAL")); + scoringLastBroadcastTick = tickCount; + finishGame(finalScoringSnapshot.marks()); + } + + private void sendScoringState() { + if (lanMode != LAN_HOST || scoringEpoch == null) return; + scoringDigest = GoScoringProtocol.digest(markedDead); + sendStateEnvelope("GO_SCORE1|STATE|" + scoringEpoch + "|" + scoringRevision + + "|" + GoScoringProtocol.encodeMarks(markedDead) + "|" + scoringDigest + + "|" + formatRoundKomi()); + } + + private void validateMarks(Set marks) { + for (long point : marks) { + if (game.getStone(GoScoringProtocol.x(point), GoScoringProtocol.y(point)) == GoPlayer.NONE) { + throw new IllegalArgumentException("marked point is empty"); + } + } + } - boolean blackWins = blackScore > whiteScore; + private void finishGame() { finishGame(Collections.emptySet()); } + + private void finishGame(Set deadGroups) { + // 统一使用 GoGame 的显式死棋计分实现,避免自动猜测死活。 + double komi = roundKomiSet ? roundKomi : GoGame.getConfiguredKomi(); + GoGame.Score score = game.getScores(deadGroups, komi); + double blackScore = score.black(); + double whiteScore = score.white(); + GoPlayer winner = score.winner(); + boolean blackWins = winner == GoPlayer.BLACK; + boolean tied = winner == GoPlayer.NONE; if (lanMode == LAN_NONE) { // 单机/AI 模式 - if (game.isAiMode()) { + if (tied) { + myWin = false; + resultMsg = String.format("平局!黑%.1f 白%.1f", blackScore, whiteScore); + } else if (game.isAiMode()) { // AI 执白,玩家执黑 myWin = blackWins; resultMsg = String.format("%s 胜!黑%.1f 白%.1f", @@ -166,94 +470,162 @@ private void finishGame() { } else { // LAN 模式:HOST=黑,CLIENT=白 boolean iAmBlack = (lanMode == LAN_HOST); - myWin = iAmBlack == blackWins; - resultMsg = String.format("%s 胜!黑%.1f 白%.1f", + myWin = !tied && iAmBlack == blackWins; + resultMsg = tied + ? String.format("平局!黑%.1f 白%.1f", blackScore, whiteScore) + : String.format("%s 胜!黑%.1f 白%.1f", myWin ? "你" : "对方", blackScore, whiteScore); } state = State.GAME_OVER; } - /** - * 数子法计算领地(flood-fill 无子区域,判断属于哪方)。 - * @return [黑领地, 白领地](包含己方活子数) - */ - private int[] calcTerritory() { - int size = game.getBoardSize(); - boolean[][] visited = new boolean[size][size]; - int blackT = 0, whiteT = 0; - - // 先统计棋盘上的活子数 - for (int x = 0; x < size; x++) - for (int y = 0; y < size; y++) { - GoPlayer s = game.getStone(x, y); - if (s == GoPlayer.BLACK) blackT++; - else if (s == GoPlayer.WHITE) whiteT++; - } - - // 再统计空点领地 - for (int x = 0; x < size; x++) { - for (int y = 0; y < size; y++) { - if (visited[x][y] || game.getStone(x, y) != GoPlayer.NONE) continue; - // BFS 找连通空区 - java.util.List region = new java.util.ArrayList<>(); - java.util.Queue queue = new java.util.LinkedList<>(); - queue.add(new int[]{x, y}); - boolean touchBlack = false, touchWhite = false; - while (!queue.isEmpty()) { - int[] pos = queue.poll(); - int px = pos[0], py = pos[1]; - if (px < 0 || px >= size || py < 0 || py >= size) continue; - if (visited[px][py]) continue; - visited[px][py] = true; - GoPlayer st = game.getStone(px, py); - if (st == GoPlayer.BLACK) { touchBlack = true; continue; } - if (st == GoPlayer.WHITE) { touchWhite = true; continue; } - region.add(new int[]{px, py}); - for (int[] d : DIRS) - queue.add(new int[]{px+d[0], py+d[1]}); - } - int pts = region.size(); - if (touchBlack && !touchWhite) blackT += pts; - else if (touchWhite && !touchBlack) whiteT += pts; - // 争议地带不计 - } - } - return new int[]{blackT, whiteT}; + @Override protected void init() { + super.init(); + int cx = width / 2, cy = height / 2; + int px = cx - 160, py = cy - 130; + int pathY = py + 45 + 45 + 45; + katagoPathEditBox = new EditBox(font, px + 90, pathY - 4, 215, 22, Component.literal("KataGo 路径")); + katagoPathEditBox.setValue(settingsKatagoPath == null ? "" : settingsKatagoPath); + katagoPathEditBox.setMaxLength(512); + katagoPathEditBox.setResponder(value -> settingsKatagoPath = value); + katagoPathEditBox.setVisible(state == State.SETTINGS && "katago".equals(settingsEngine)); + addRenderableWidget(katagoPathEditBox); } @Override public void tick() { tickCount++; + if (lanMode == LAN_HOST && tickCount - scoringLastBroadcastTick >= 40L) { + if (state == State.SCORING) { + sendScoringState(); + scoringLastBroadcastTick = tickCount; + } else if (state == State.GAME_OVER && finalScoringSnapshot != null) { + sendStateEnvelope(finalScoringSnapshot.encode("FINAL")); + scoringLastBroadcastTick = tickCount; + } + } + if (showExitConfirm) return; // AI 模式:AI 执白,黑棋下完后触发 if (state == State.PLAYING && lanMode == LAN_NONE && game.isAiMode() - && !game.isGameOver() && game.getCurrentPlayer() == GoPlayer.WHITE) { - game.makeAiMove(); - if (game.isGameOver()) finishGame(); + && !aiFailed && !game.isGameOver() && game.getCurrentPlayer() == GoPlayer.WHITE) { + // 后台线程计算 AI 走法,避免阻塞客户端线程(MCTS 搜索 1~12 秒) + synchronized (aiWorkerLock) { + if (!aiThinking && !aiComputed) { + aiThinking = true; + final int gen = aiGeneration; + Thread t = new Thread(() -> { + GoGame.AiMoveComputation computation = null; + try { + // 外部引擎创建和握手也在 worker 中,绝不阻塞客户端线程。 + if (Thread.currentThread().isInterrupted()) return; + game.initAiIfAbsent(); + if (Thread.currentThread().isInterrupted() || gen != aiGeneration) return; + computation = game.computeAiMoveComputation(); + } catch (Exception e) { + LOGGER.warn("[KataGo] AI turn failed: {}", e.getMessage()); + } finally { + synchronized (aiWorkerLock) { + if (gen == aiGeneration && !cleanedUp) { + aiPendingComputation = computation; + aiComputedGeneration = gen; + aiThinking = false; + aiComputed = true; + } + } + } + }, "go-ai-worker"); + aiWorker = t; + t.setDaemon(true); + t.start(); + } + } + } + // 客户端线程消费 AI 结果并落子 + GoGame.AiMoveComputation computation = null; + boolean consumeAiMove = false; + synchronized (aiWorkerLock) { + if (aiComputed && aiComputedGeneration == aiGeneration) { + aiComputed = false; + aiComputedGeneration = -1; + computation = aiPendingComputation; + aiPendingComputation = null; + consumeAiMove = true; + } else if (aiComputed && aiComputedGeneration != aiGeneration) { + aiComputed = false; + aiComputedGeneration = -1; + aiPendingComputation = null; + } + } + if (consumeAiMove && computation != null && state == State.PLAYING && !game.isGameOver() + && lanMode == LAN_NONE && game.isAiMode() + && game.getCurrentPlayer() == GoPlayer.WHITE) { + GoAI.MoveResult result = computation.result(); + if (!game.applyAiMoveComputation(computation)) return; + if (result.type() == GoAI.MoveType.ERROR) { + aiFailed = true; + return; + } + if (game.isGameOver()) { + if (result.type() == GoAI.MoveType.RESIGN) { + myWin = true; + resultMsg = "AI认输,你赢了!"; + state = State.GAME_OVER; + } else { + enterScoring(); + } + } } } @Override public boolean keyPressed(int key, int scan, int mods) { if (key == GLFW.GLFW_KEY_ESCAPE) { if (showExitConfirm) { showExitConfirm = false; return true; } + if (state == State.SETTINGS) { + state = State.MENU; + if (katagoPathEditBox != null) { + katagoPathEditBox.setFocused(false); + katagoPathEditBox.setVisible(false); + } + return true; + } if (state != State.MENU) { showExitConfirm = true; return true; } Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } if (showExitConfirm) return true; + if (katagoPathEditBox != null && katagoPathEditBox.visible + && katagoPathEditBox.isFocused() && super.keyPressed(key, scan, mods)) { + return true; + } if (key == GLFW.GLFW_KEY_N) { // LAN:CLIENT 不能单方面重开;HOST 重开需广播 RESTART 同步对端,否则双方棋盘永久分叉 if (lanMode == LAN_CLIENT) return true; - resetGame(); state = State.PLAYING; - if (lanMode == LAN_HOST) sendLanMove("RESTART"); + resetGame(); + gameEpoch = UUID.randomUUID(); + networkPly = 0; + state = State.PLAYING; + if (lanMode == LAN_HOST) sendLanStart("RESTART"); return true; } if (key == GLFW.GLFW_KEY_P && state == State.PLAYING && !game.isGameOver()) { + if (!canLocalPlayerMove()) return true; if (lanMode == LAN_NONE) { game.pass(); - if (game.isGameOver()) finishGame(); + if (game.isGameOver()) enterScoring(); } else if (myTurn) { game.pass(); - myTurn = false; sendLanMove("PASS"); - if (game.isGameOver()) finishGame(); + networkPly++; + myTurn = false; + if (game.isGameOver() && lanMode != LAN_CLIENT) enterScoring(); + } + return true; + } + if (key == GLFW.GLFW_KEY_R && state == State.PLAYING && !game.isGameOver()) { + // 认输:仅联机有效(单机没有结算对手)。对端在 onRemoteMove 收到 RESIGN: 判胜。 + if (lanMode != LAN_NONE) { + sendLanMove("RESIGN"); + myWin = false; + resultMsg = "你认输了"; + state = State.GAME_OVER; } return true; } @@ -269,9 +641,14 @@ private int[] calcTerritory() { GameRenderHelper.fillDarkBackground(g, width, height); switch (state) { case MENU -> renderMenu(g, mx, my); + case SETTINGS -> renderSettings(g, mx, my); case PLAYING -> renderPlaying(g, mx, my); + case SCORING -> { renderPlaying(g, mx, my); renderScoring(g, mx, my); } case GAME_OVER -> { renderPlaying(g, mx, my); renderGameOver(g, mx, my); } } + if (katagoPathEditBox != null && katagoPathEditBox.visible) { + katagoPathEditBox.render(g, mx, my, pt); + } if (showExitConfirm) GameRenderHelper.drawExitConfirmOverlay(g, font, width, height, mx, my); } @@ -281,9 +658,86 @@ private void renderMenu(GuiGraphics g, int mx, int my) { GameRenderHelper.drawShadowedCenteredText(g, font, "围 棋", cx, cy - 60, 0xFFFFFF, 2); g.drawCenteredString(font, "Go Game", cx, cy - 42, 0x555555); GameRenderHelper.drawDivider(g, cx - 80, cy - 32, 160, 0xFFD2B48C, 0xFF8B7355); - g.drawCenteredString(font, "鼠标点击落子 N新游戏 P弃权", cx, cy - 10, 0xAAAAAA); + g.drawCenteredString(font, "鼠标点击落子 N新游戏 P虚着 R认输", cx, cy - 10, 0xAAAAAA); g.drawCenteredString(font, "围地为王,黑白博弈的艺术", cx, cy + 5, 0xCCCCCC); GameRenderHelper.drawPrimaryButton(g, font, "开始游戏", cx - 60, cy + 30, 120, 22, mx, my); + // 设置按钮 + GameRenderHelper.drawPrimaryButton(g, font, "⚙ AI设置", cx - 60, cy + 58, 120, 22, mx, my); + } + + private void renderSettings(GuiGraphics g, int mx, int my) { + int cx = width / 2, cy = height / 2; + GameRenderHelper.renderDecorativeLines(g, width, height, tickCount, 0x112200); + + // 设置面板背景 + int pw = 320, ph = 260; + int px = cx - pw / 2, py = cy - ph / 2; + g.fill(px, py, px + pw, py + ph, 0xCC0A1520); + g.fill(px - 2, py - 2, px + pw + 2, py + 2, 0xFF3A5A8A); + g.fill(px - 2, py + ph, px + pw + 2, py + ph + 2, 0xFF3A5A8A); + g.fill(px - 2, py, px + 2, py + ph, 0xFF3A5A8A); + g.fill(px + pw, py, px + pw + 2, py + ph, 0xFF3A5A8A); + + // 标题 + GameRenderHelper.drawShadowedCenteredText(g, font, "AI 引擎设置", cx, py + 12, 0xFFFFFF, 1); + + // 引擎选择 + int optionY = py + 45; + g.drawString(font, "引擎类型:", px + 15, optionY, 0xCCCCCC); + + // MCTS 按钮 + boolean mctsSelected = "mcts".equals(settingsEngine); + int mctsColor = mctsSelected ? 0xFF44AA44 : 0xFF666666; + int mctsBg = mctsSelected ? 0xFF1A3A1A : 0xFF2A2A2A; + g.fill(px + 90, optionY - 4, px + 200, optionY + 18, mctsBg); + g.drawString(font, "改进版MCTS", px + 95, optionY, mctsColor); + + // KataGo 按钮 + boolean kataSelected = "katago".equals(settingsEngine); + int kataColor = kataSelected ? 0xFF44AA44 : 0xFF666666; + int kataBg = kataSelected ? 0xFF1A3A1A : 0xFF2A2A2A; + g.fill(px + 205, optionY - 4, px + 305, optionY + 18, kataBg); + g.drawString(font, "KataGo", px + 215, optionY, kataColor); + + // 搜索时间 + int timeY = optionY + 45; + g.drawString(font, "搜索时间: " + (settingsSearchTime / 1000.0) + "s", px + 15, timeY, 0xCCCCCC); + + // 时间滑块背景 + int sliderX = px + 90, sliderW = 200; + g.fill(sliderX, timeY + 8, sliderX + sliderW, timeY + 18, 0xFF3A3A3A); + // 滑块填充 + int searchRange = GameSettings.GO_SEARCH_TIME_MAX - GameSettings.GO_SEARCH_TIME_MIN; + int fillW = (settingsSearchTime - GameSettings.GO_SEARCH_TIME_MIN) * sliderW / searchRange; + g.fill(sliderX, timeY + 8, sliderX + fillW, timeY + 18, 0xFF4A6A8A); + // 滑块位置 + int thumbX = sliderX + fillW - 5; + g.fill(thumbX, timeY + 3, thumbX + 10, timeY + 23, 0xFF88AACC); + + // KataGo 路径(仅当选择 KataGo 时显示) + int pathY = timeY + 45; + if ("katago".equals(settingsEngine)) { + g.drawString(font, "KataGo 路径:", px + 15, pathY, 0xCCCCCC); + // 路径显示(截断过长路径) + String displayPath = settingsKatagoPath.isEmpty() ? "(未配置)" : + (settingsKatagoPath.length() > 30 ? + "..." + settingsKatagoPath.substring(settingsKatagoPath.length() - 30) : + settingsKatagoPath); + int pathColor = settingsKatagoPath.isEmpty() ? 0xFF666666 : 0xFFAAAAAA; + g.fill(px + 90, pathY - 4, px + 305, pathY + 18, 0xFF2A2A2A); + g.drawString(font, displayPath, px + 95, pathY, pathColor); + pathY += 45; + } + + // 当前状态 + int statusY = pathY + 10; + String configuredEngine = GoAI.normalizeEngine(GameSettings.getString("go", "engine", "mcts")); + String status = "配置引擎: " + ("mcts".equals(configuredEngine) ? "改进版MCTS" : "KataGo"); + g.drawString(font, status, px + 15, statusY, 0xFF888888); + + // 保存并返回按钮 + int btnY = py + ph - 50; + GameRenderHelper.drawPrimaryButton(g, font, "保存并返回", cx - 60, btnY, 120, 22, mx, my); } private void renderPlaying(GuiGraphics g, int mx, int my) { @@ -314,20 +768,20 @@ private void renderPlaying(GuiGraphics g, int mx, int my) { int color = stone == GoPlayer.BLACK ? 0xFF111111 : 0xFFEEEEEE; GameRenderHelper.drawCircle(g, scx, scy, stoneR, color); if (stone == GoPlayer.WHITE) GameRenderHelper.drawCircle(g, scx-stoneR/3, scy-stoneR/3, stoneR/4, 0x44FFFFFF); + if (state == State.SCORING && markedDead.contains(key(x, y))) { + GameRenderHelper.drawCircle(g, scx, scy, Math.max(2, stoneR / 2), 0x99CC3333); + } } } // 悬停预览 - if (state == State.PLAYING && !game.isGameOver()) { - boolean canPlay = (lanMode == LAN_NONE) || myTurn; - if (canPlay) { - int[] pos = getBoardPos(mx, my); - if (pos != null && game.canPlaceStone(pos[0], pos[1])) { - int scx = boardStartX + pos[0] * cellSize + cellSize/2; - int scy = boardStartY + pos[1] * cellSize + cellSize/2; - int color = game.getCurrentPlayer() == GoPlayer.BLACK ? 0x66111111 : 0x66EEEEEE; - GameRenderHelper.drawCircle(g, scx, scy, stoneR, color); - } + if (canLocalPlayerMove()) { + int[] pos = getBoardPos(mx, my); + if (pos != null && game.canPlaceStone(pos[0], pos[1])) { + int scx = boardStartX + pos[0] * cellSize + cellSize/2; + int scy = boardStartY + pos[1] * cellSize + cellSize/2; + int color = game.getCurrentPlayer() == GoPlayer.BLACK ? 0x66111111 : 0x66EEEEEE; + GameRenderHelper.drawCircle(g, scx, scy, stoneR, color); } } @@ -341,7 +795,8 @@ private void renderPlaying(GuiGraphics g, int mx, int my) { String turnHint; if (lanMode == LAN_NONE) { - turnHint = game.isAiMode() ? (game.getCurrentPlayer()==GoPlayer.BLACK ? "你的回合" : "AI思考中") : "进行中"; + turnHint = aiFailed ? "AI错误,请重开" : game.isAiMode() + ? (game.getCurrentPlayer()==GoPlayer.BLACK ? "你的回合" : "AI思考中") : "进行中"; } else { turnHint = myTurn ? "你的回合" : "等待对方"; } @@ -353,11 +808,72 @@ private void renderPlaying(GuiGraphics g, int mx, int my) { : (game.isAiMode() ? "AI模式" : "双人模式"); g.drawString(font, modeStr, infoX + 5, infoY + 78, 0xCCCCCC); + // AI 引擎状态 + String engineLabel = game.getRuntimeAiEngineLabel(); + if (engineLabel == null) { + engineLabel = aiFailed ? "不可用" : aiThinking ? "启动中" : "未启动"; + } + g.drawString(font, "AI: " + engineLabel, infoX + 5, infoY + 92, 0x666666); + GameRenderHelper.drawTopHUD(g, width, height); g.drawString(font, "⚫⚪ 围棋", 8, 7, 0xFFFFFF); GameRenderHelper.drawBottomBar(g, font, width, height, "ESC 菜单 N 新游戏 P 弃权"); } + private void renderScoring(GuiGraphics g, int mx, int my) { + int cx = width / 2; + g.fill(cx - 145, boardStartY + BOARD_SIZE * cellSize + 10, cx + 145, + boardStartY + BOARD_SIZE * cellSize + 62, 0xCC0A1520); + g.drawCenteredString(font, "结算:点击棋块标记死棋", cx, boardStartY + BOARD_SIZE * cellSize + 16, 0xFFFFFF); + GameRenderHelper.drawPrimaryButton(g, font, "清除标记", cx - 140, boardStartY + BOARD_SIZE * cellSize + 32, 90, 22, mx, my); + GameRenderHelper.drawPrimaryButton(g, font, "确认结果", cx + 50, boardStartY + BOARD_SIZE * cellSize + 32, 90, 22, mx, my); + } + + private static long key(int x, int y) { return ((long) x << 32) | (y & 0xffffffffL); } + + private Set collectGroup(int sx, int sy) { + GoPlayer color = game.getStone(sx, sy); + if (color == GoPlayer.NONE) return Collections.emptySet(); + Set group = new HashSet<>(); + java.util.ArrayDeque queue = new java.util.ArrayDeque<>(); + queue.add(new int[]{sx, sy}); group.add(key(sx, sy)); + int[][] dirs = {{1,0},{-1,0},{0,1},{0,-1}}; + while (!queue.isEmpty()) { + int[] p = queue.remove(); + for (int[] d : dirs) { + int x = p[0] + d[0], y = p[1] + d[1]; + if (x >= 0 && x < BOARD_SIZE && y >= 0 && y < BOARD_SIZE + && game.getStone(x, y) == color && group.add(key(x, y))) queue.add(new int[]{x, y}); + } + } + return group; + } + + private void toggleMarkedGroupLocal(int x, int y) { + Set group = collectGroup(x, y); + if (group.isEmpty()) return; + if (markedDead.contains(key(x, y))) markedDead.removeAll(group); else markedDead.addAll(group); + scoringConfirmed = false; + scoringRevision++; + remoteScoringConfirmed = false; + remoteConfirmedRevision = -1L; + remoteConfirmedDigest = ""; + } + + private void toggleMarkedGroup(int x, int y) { + if (lanMode == LAN_CLIENT) { + if (scoringEpoch == null) return; + sendStateEnvelope("GO_SCORE1|TOGGLE|" + scoringEpoch + "|" + scoringRevision + "|" + x + "," + y); + return; + } + toggleMarkedGroupLocal(x, y); + if (lanMode == LAN_HOST) sendScoringState(); + } + + private String encodeMarks() { + return GoScoringProtocol.encodeMarks(markedDead); + } + private void renderGameOver(GuiGraphics g, int mx, int my) { int cx = width / 2, cy = height / 2; g.flush(); // 防止先绘制的棋盘/HUD文字盖住遮罩背景(批量渲染text批次后置) @@ -375,32 +891,125 @@ private void renderGameOver(GuiGraphics g, int mx, int my) { } private int[] getBoardPos(int mx, int my) { - int bx = Mth.floor((mx - boardStartX + cellSize/2) / (float)cellSize); - int by = Mth.floor((my - boardStartY + cellSize/2) / (float)cellSize); + int bx = Mth.floor((mx - boardStartX) / (float)cellSize); + int by = Mth.floor((my - boardStartY) / (float)cellSize); if (bx >= 0 && bx < BOARD_SIZE && by >= 0 && by < BOARD_SIZE) return new int[]{bx, by}; return null; } @Override public boolean mouseClicked(double mx, double my, int btn) { if (showExitConfirm) { int click = GameRenderHelper.getExitConfirmClick(mx, my, width, height); if (click == 1) { showExitConfirm = false; sendLeaveGameOnce(); Minecraft.getInstance().setScreen(new GameSelectorScreen()); return true; } if (click == 2) { showExitConfirm = false; return true; } return true; } + + // ── 设置界面 ── + if (state == State.SETTINGS) { + if (katagoPathEditBox != null && katagoPathEditBox.visible + && katagoPathEditBox.mouseClicked(mx, my, btn)) { + return true; + } + if (katagoPathEditBox != null) katagoPathEditBox.setFocused(false); + int cx = width / 2, cy = height / 2; + int pw = 320, ph = 260; + int px = cx - pw / 2, py = cy - ph / 2; + + // 引擎选择 + int optionY = py + 45; + if (mx >= px + 90 && mx <= px + 200 && my >= optionY - 4 && my <= optionY + 18) { + settingsEngine = "mcts"; + if (katagoPathEditBox != null) katagoPathEditBox.setVisible(false); + return true; + } + if (mx >= px + 205 && mx <= px + 305 && my >= optionY - 4 && my <= optionY + 18) { + settingsEngine = "katago"; + if (katagoPathEditBox != null) katagoPathEditBox.setVisible(true); + return true; + } + + // 时间滑块 + int timeY = optionY + 45; + int sliderX = px + 90, sliderW = 200; + if (mx >= sliderX && mx <= sliderX + sliderW && my >= timeY + 3 && my <= timeY + 23) { + double ratio = (mx - sliderX) / sliderW; + int range = GameSettings.GO_SEARCH_TIME_MAX - GameSettings.GO_SEARCH_TIME_MIN; + settingsSearchTime = GameSettings.GO_SEARCH_TIME_MIN + (int) Math.round(ratio * range); + return true; + } + + // 保存并返回按钮 + int btnY = py + ph - 50; + if (mx >= cx - 60 && mx <= cx + 60 && my >= btnY && my <= btnY + 22) { + // 保存设置到 GameSettings + saveSettings(); + state = State.MENU; + if (katagoPathEditBox != null) { + katagoPathEditBox.setFocused(false); + katagoPathEditBox.setVisible(false); + } + return true; + } + return true; + } + if (state == State.MENU) { int cx = width/2, cy = height/2; if (mx >= cx-60 && mx <= cx+60 && my >= cy+30 && my <= cy+52) { - resetGame(); state = State.PLAYING; return true; + resetGame(); + gameEpoch = UUID.randomUUID(); + networkPly = 0; + state = State.PLAYING; + if (lanMode == LAN_HOST) sendLanStart("START"); + return true; + } + // 设置按钮 + if (mx >= cx-60 && mx <= cx+60 && my >= cy+58 && my <= cy+80) { + state = State.SETTINGS; + if (katagoPathEditBox != null) katagoPathEditBox.setVisible("katago".equals(settingsEngine)); + return true; } } + if (state == State.SCORING && btn == 0) { + int[] pos = getBoardPos((int) mx, (int) my); + if (pos != null) { toggleMarkedGroup(pos[0], pos[1]); return true; } + int cx = width / 2; + int by = boardStartY + BOARD_SIZE * cellSize + 32; + if (mx >= cx - 140 && mx <= cx - 50 && my >= by && my <= by + 22) { + if (lanMode == LAN_CLIENT && scoringEpoch != null) { + sendStateEnvelope("GO_SCORE1|CLEAR|" + scoringEpoch + "|" + scoringRevision); + } else { + markedDead.clear(); scoringConfirmed = false; + scoringRevision++; + remoteScoringConfirmed = false; remoteConfirmedRevision = -1L; remoteConfirmedDigest = ""; + if (lanMode == LAN_HOST) sendScoringState(); + } + return true; + } + if (mx >= cx + 50 && mx <= cx + 140 && my >= by && my <= by + 22) { + scoringConfirmed = true; + if (lanMode == LAN_NONE) finishGame(markedDead); + else if (lanMode == LAN_HOST) { + if (remoteScoringConfirmed && remoteConfirmedRevision == scoringRevision + && remoteConfirmedDigest.equals(scoringDigest)) { + finishConfirmedScoring(); + } else sendScoringState(); + } else if (scoringEpoch != null && roundKomiSet) { + sendStateEnvelope("GO_SCORE1|CONFIRM|" + scoringEpoch + "|" + scoringRevision + "|" + + encodeMarks() + "|" + scoringDigest + "|" + formatRoundKomi()); + } + return true; + } + return true; + } if (state == State.PLAYING && btn == 0 && !game.isGameOver()) { - boolean canPlay = (lanMode == LAN_NONE) || myTurn; - if (!canPlay) return true; + if (!canLocalPlayerMove()) return true; int[] pos = getBoardPos((int)mx, (int)my); if (pos != null && game.canPlaceStone(pos[0], pos[1])) { if (game.placeStone(pos[0], pos[1])) { if (lanMode != LAN_NONE) { sendLanMove(pos[0] + "," + pos[1]); + networkPly++; myTurn = false; } - if (game.isGameOver()) finishGame(); + if (game.isGameOver() && lanMode != LAN_CLIENT) enterScoring(); else if (lanMode == LAN_NONE && game.isAiMode()) { // AI 在 tick 里触发 } @@ -411,6 +1020,75 @@ else if (lanMode == LAN_NONE && game.isAiMode()) { return super.mouseClicked(mx, my, btn); } + /** + * 保存 AI 设置到 data/game_settings.json + */ + private void saveSettings() { + try { + // 使用 GameSettings 的内部机制保存 + // 这里通过反射或直接修改 settings map 来保存 + // 由于 GameSettings 没有提供保存单个键的方法,我们使用 importFromFile 的变通方式 + java.nio.file.Path dataDir = com.wzz.game_console.util.ExternalFileManager.getDataDir(); + java.nio.file.Path settingsPath = dataDir.resolve("game_settings.json"); + + // 读取现有设置 + java.util.Map> allSettings = new java.util.HashMap<>(); + if (java.nio.file.Files.exists(settingsPath)) { + // ★ Bug修复:原版 Files.readString 整文件读入,无大小限制,settings 文件 + // 被外部异常增长时瞬时占大块堆。改为 BufferedReader + try-with-resources, + // 并通过 size 预检拒绝 > 1MB 的文件 + long size = java.nio.file.Files.size(settingsPath); + if (size > 1024 * 1024) { + // 配置文件超 1MB,视为异常,直接跳过读取 + } else { + StringBuilder sb = new StringBuilder(); + try (java.io.BufferedReader br = java.nio.file.Files.newBufferedReader( + settingsPath, java.nio.charset.StandardCharsets.UTF_8)) { + char[] buf = new char[4096]; + int n; + while ((n = br.read(buf)) > 0) sb.append(buf, 0, n); + } + String content = sb.toString(); + com.google.gson.Gson gson = new com.google.gson.Gson(); + java.lang.reflect.Type type = new com.google.gson.reflect.TypeToken>>(){}.getType(); + java.util.Map> loaded = gson.fromJson(content, type); + if (loaded != null) allSettings = loaded; + } + } + + // 更新围棋设置 + java.util.Map goSettings = allSettings.getOrDefault("go", new java.util.HashMap<>()); + // ★ Bug修复:settingsEngine/KatagoPath 可能为 null(null 进 GSON 序列化为 + // "engine": null,后续 getString 虽 instanceof 兜底不崩,但下游分支 + // 可能因 null 走错路径。改为只 put 非空字段 + if (settingsEngine != null && !settingsEngine.isBlank()) { + goSettings.put("engine", settingsEngine); + } + goSettings.put("searchTime", settingsSearchTime); + if (settingsKatagoPath != null && !settingsKatagoPath.isBlank()) { + goSettings.put("katagoPath", settingsKatagoPath); + } else { + // 允许在 EditBox 中清空路径,并从配置中移除旧值。 + goSettings.remove("katagoPath"); + } + allSettings.put("go", goSettings); + + // 保存 — 走 ExternalFileManager 原子写(tmp+atomic move),防 JVM 崩溃 + // 时截断 settings.json 导致玩家全部游戏配置丢失 + com.google.gson.Gson gson = new com.google.gson.GsonBuilder().setPrettyPrinting().create(); + String json = gson.toJson(allSettings); + if (!com.wzz.game_console.util.ExternalFileManager.writeTextFile("data", "game_settings.json", json)) { + throw new java.io.IOException("写入 game_settings.json 失败"); + } + // 刷新 GameSettings 的内存快照,让下一局初始化 AI/Komi 时立即使用新配置。 + GameSettings.importFromFile(settingsPath); + + LOGGER.info("[围棋] AI 设置已保存: engine={}, searchTime={}ms", settingsEngine, settingsSearchTime); + } catch (Exception e) { + LOGGER.error("[围棋] 保存 AI 设置失败: {}", e.getMessage()); + } + } + @Override public boolean isPauseScreen() { return false; } } diff --git a/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoGpuProbe.java b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoGpuProbe.java new file mode 100644 index 0000000..e8b9f15 --- /dev/null +++ b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoGpuProbe.java @@ -0,0 +1,19 @@ +package com.wzz.game_console.client.screens.games.gogame; + +/** OpenCL GPU 探针:检测 GPU 加速是否可用。 */ +public final class GoGpuProbe { + public static void main(String[] args) { + System.out.println("=== OpenCL GPU 探针 ==="); + try (OpenCLBackend backend = new OpenCLBackend()) { + if (backend.isAvailable()) { + System.out.println("SUCCESS: OpenCL GPU 加速可用"); + System.out.println("设备: " + backend.getDeviceName()); + } else { + System.out.println("FAILED: OpenCL 不可用,将回退 CPU"); + } + } catch (Throwable t) { + // 探针本身不能因缺少 JNA、OpenCL 或本地驱动而使进程崩溃。 + System.out.println("FAILED: GPU 探测异常,将回退 CPU (" + t + ")"); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoScoringProtocol.java b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoScoringProtocol.java new file mode 100644 index 0000000..34e3e02 --- /dev/null +++ b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoScoringProtocol.java @@ -0,0 +1,168 @@ +package com.wzz.game_console.client.screens.games.gogame; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +final class GoScoringProtocol { + static final String PREFIX = "GO_SCORE1"; + static final int MAX_MARKS = 19 * 19; + + private GoScoringProtocol() {} + + enum Phase { PLAYING, SCORING, FINISHED, OTHER } + + record Snapshot(UUID epoch, long revision, Set marks, String digest, double komi) { + Snapshot { + marks = Set.copyOf(marks); + } + + String encode(String action) { + return PREFIX + "|" + action + "|" + epoch + "|" + revision + "|" + + encodeMarks(marks) + "|" + digest + "|" + formatKomi(komi); + } + } + + record Receiver(boolean client, UUID peer, UUID gameEpoch, Phase phase, Snapshot current) {} + + static Snapshot receiveSnapshot(GoGame game, Receiver receiver, UUID sender, String[] parts) { + if (!receiver.client() || receiver.peer() == null || !receiver.peer().equals(sender) + || !game.isGameOver() || parts.length != 7 || !PREFIX.equals(parts[0])) return null; + String action = parts[1]; + if (!"BEGIN".equals(action) && !"STATE".equals(action) && !"FINAL".equals(action)) return null; + if (receiver.phase() != Phase.PLAYING && receiver.phase() != Phase.SCORING) return null; + UUID epoch = parseEpoch(parts[2]); + Long revision = parseRevision(parts[3]); + Double komi = parseKomi(parts[6]); + if (epoch == null || revision == null || komi == null || !epoch.equals(receiver.gameEpoch())) return null; + Snapshot current = receiver.current(); + if ("BEGIN".equals(action)) { + if (current != null && epoch.equals(current.epoch())) return null; + } else if (receiver.phase() == Phase.SCORING) { + if (current == null || !epoch.equals(current.epoch()) || revision < current.revision() + || Double.compare(komi, current.komi()) != 0) return null; + } + if ("FINAL".equals(action) && receiver.phase() != Phase.SCORING) return null; + Set marks = parseMarks(parts[4]); + for (long point : marks) { + if (game.getStone(x(point), y(point)) == GoPlayer.NONE) return null; + } + if (!hasValidDigest(marks, parts[5])) return null; + Snapshot incoming = new Snapshot(epoch, revision, marks, parts[5], komi); + if ("FINAL".equals(action) && !incoming.equals(current)) return null; + return incoming; + } + + static UUID parseEpoch(String value) { + try { + return UUID.fromString(value); + } catch (RuntimeException ignored) { + return null; + } + } + + static Long parseRevision(String value) { + try { + long revision = Long.parseLong(value); + return revision >= 0 ? revision : null; + } catch (NumberFormatException ignored) { + return null; + } + } + + static Double parseKomi(String value) { + try { + double komi = Double.parseDouble(value); + if (!Double.isFinite(komi) || komi < -100.0d || komi > 100.0d) return null; + double normalized = GoGame.normalizeKomi(komi); + return Double.toString(normalized).equals(value) ? normalized : null; + } catch (RuntimeException ignored) { + return null; + } + } + + static String formatKomi(double komi) { + return Double.toString(GoGame.normalizeKomi(komi)); + } + + static Set parseMarks(String encoded) { + Set marks = new LinkedHashSet<>(); + if (encoded == null || encoded.isEmpty()) return marks; + String[] points = encoded.split(";", -1); + if (points.length > MAX_MARKS) throw new IllegalArgumentException("too many marked stones"); + for (String point : points) { + String[] xy = point.split(",", -1); + if (xy.length != 2) throw new IllegalArgumentException("malformed marked stone"); + int x; + int y; + try { + x = Integer.parseInt(xy[0]); + y = Integer.parseInt(xy[1]); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("malformed marked stone", e); + } + if (x < 0 || x >= 19 || y < 0 || y >= 19) { + throw new IllegalArgumentException("marked stone is outside the board"); + } + if (!marks.add(key(x, y))) throw new IllegalArgumentException("duplicate marked stone"); + } + return marks; + } + + static String encodeMarks(Set marks) { + if (marks == null || marks.isEmpty()) return ""; + if (marks.size() > MAX_MARKS) throw new IllegalArgumentException("too many marked stones"); + List sorted = new ArrayList<>(marks); + sorted.sort(Long::compare); + StringBuilder encoded = new StringBuilder(sorted.size() * 6); + for (long point : sorted) { + int x = x(point); + int y = y(point); + if (x < 0 || x >= 19 || y < 0 || y >= 19) { + throw new IllegalArgumentException("marked stone is outside the board"); + } + if (encoded.length() > 0) encoded.append(';'); + encoded.append(x).append(',').append(y); + } + return encoded.toString(); + } + + static String digest(Set marks) { + return digestEncoded(encodeMarks(marks)); + } + + static boolean hasValidDigest(Set marks, String expected) { + return expected != null && MessageDigest.isEqual( + digest(marks).getBytes(StandardCharsets.US_ASCII), + expected.getBytes(StandardCharsets.US_ASCII)); + } + + static long key(int x, int y) { + return ((long) x << 32) | (y & 0xffffffffL); + } + + static int x(long point) { + return (int) (point >> 32); + } + + static int y(long point) { + return (int) point; + } + + private static String digestEncoded(String encoded) { + try { + byte[] bytes = MessageDigest.getInstance("SHA-256") + .digest(encoded.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(bytes.length * 2); + for (byte value : bytes) hex.append(String.format("%02x", value & 0xff)); + return hex.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } +} diff --git a/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoSelfPlayTrainer.java b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoSelfPlayTrainer.java new file mode 100644 index 0000000..668fbea --- /dev/null +++ b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoSelfPlayTrainer.java @@ -0,0 +1,369 @@ +package com.wzz.game_console.client.screens.games.gogame; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +/** Pure-Java self-play trainer for the local Go evaluator. */ +public final class GoSelfPlayTrainer { + public static final class Config { + public int searchTimeMillis = 300; + public int maxIterations = 500; + public int parallelism = 30; + public int maxMoves = 300; + public int batchSize = 128; + public double l2 = 1.0e-5; + public double gradientClip = 5.0; + public double momentum = 0.9; + public int maxReplaySamples = 20_000; + } + + // ── 8-fold 对称增强 ────────────────────────────────────────── + private static final int BOARD_SIZE = 19; + private static final int BOARD_FEATURES = BOARD_SIZE * BOARD_SIZE; + private static final int AUX_FEATURES = 24; + private static final int[][] SYMM_PERMS = buildSymmetryPerms(); + + private static int[][] buildSymmetryPerms() { + int n = BOARD_SIZE; + int[][] perms = new int[8][BOARD_FEATURES]; + for (int x = 0; x < n; x++) { + for (int y = 0; y < n; y++) { + int idx = x * n + y; + int[] targets = new int[]{ + idx, + y * n + (n - 1 - x), + (n - 1 - x) * n + (n - 1 - y), + (n - 1 - y) * n + x, + (n - 1 - x) * n + y, + x * n + (n - 1 - y), + y * n + x, + (n - 1 - y) * n + (n - 1 - x), + }; + for (int t = 0; t < 8; t++) perms[t][idx] = targets[t]; + } + } + return perms; + } + + public static final class Result { + public final int games; + public final int samples; + public final int completedGames; + public final double meanLoss; + public final NeuralEvaluator evaluator; + + private Result(int games, int samples, int completedGames, double meanLoss, + NeuralEvaluator evaluator) { + this.games = games; + this.samples = samples; + this.completedGames = completedGames; + this.meanLoss = meanLoss; + this.evaluator = evaluator; + } + } + + private static final class Sample { + final GoPlayer[][] board; // 存储棋盘供训练时重建平面 + final GoPlayer player; + final int[] lastMove; // 上一手位置(plane 3,推理时也使用,保证 train/serve 一致) + final double[] policyTarget; // 362 维 MCTS 访问分布 + double valueTarget; + + Sample(GoPlayer[][] board, GoPlayer player, int[] lastMove, double[] policyTarget) { + this.board = board; + this.player = player; + this.lastMove = lastMove; + this.policyTarget = policyTarget; + } + } + + private final Config config; + private NeuralEvaluator evaluator; + private final List replayBuffer = new ArrayList<>(); + /** Serializes generation, replay-buffer, and evaluator mutation per trainer instance. */ + private final Object generationLock = new Object(); + /** 当前代次数(用于探索衰减等训练策略) */ + private int generation = 0; + + public GoSelfPlayTrainer() { + this(new Config(), new NeuralEvaluator()); + } + + public GoSelfPlayTrainer(Config config) { + this(config, new NeuralEvaluator()); + } + + public GoSelfPlayTrainer(Config config, NeuralEvaluator evaluator) { + this.config = config == null ? new Config() : config; + this.evaluator = evaluator == null ? new NeuralEvaluator() : evaluator; + } + + public NeuralEvaluator getEvaluator() { return evaluator; } + public int getReplayBufferSize() { + synchronized (generationLock) { return replayBuffer.size(); } + } + public void clearReplayBuffer() { + synchronized (generationLock) { replayBuffer.clear(); } + } + + /** Runs one generation, then trains the shared model on the collected positions. */ + public Result runGeneration(int games, int parallelism, int epochs, double learningRate, long seed) { + if (games < 0 || epochs < 0 || learningRate <= 0) throw new IllegalArgumentException("Invalid generation parameters"); + synchronized (generationLock) { + return runGenerationLocked(games, parallelism, epochs, learningRate, seed); + } + } + + private Result runGenerationLocked(int games, int parallelism, int epochs, double learningRate, long seed) { + if (games == 0 || Thread.currentThread().isInterrupted()) return new Result(games, 0, 0, 0, evaluator); + generation++; // 递增代次,供探索衰减使用 + // 探索强度随训练代次衰减:gen 1→1.0, gen 41→0.2(下限 0.2) + final double expScale = Math.max(0.2, 1.0 - 0.02 * (generation - 1)); + int workers = Math.max(1, Math.min(parallelism <= 0 ? config.parallelism : parallelism, games)); + final NeuralEvaluator.ModelWeights snapshot = evaluator.snapshot(); + ExecutorService pool = Executors.newFixedThreadPool(workers); + List> futures = new ArrayList<>(workers); + try { + List newSamples = new ArrayList<>(); + int nextGame = 0; + for (; nextGame < workers; nextGame++) { + final int gameIndex = nextGame; + futures.add(pool.submit(() -> playGame(snapshot, seed + 0x9E3779B97F4A7C15L * gameIndex, expScale))); + } + int completed = 0; + while (!futures.isEmpty()) { + Future future = futures.remove(0); + try { + GameSamples game = future.get(); + if (game.completed) { + appendReplaySamples(newSamples, game.samples); + completed++; + } else { + System.err.println("[SelfPlay] game truncated; discarding samples"); + } + if (nextGame < games) { + final int gameIndex = nextGame++; + futures.add(pool.submit(() -> playGame(snapshot, seed + 0x9E3779B97F4A7C15L * gameIndex, expScale))); + } + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + for (Future pending : futures) pending.cancel(true); + future.cancel(true); + return new Result(games, 0, 0, 0, evaluator); + } catch (java.util.concurrent.CancellationException ce) { + for (Future pending : futures) pending.cancel(true); + return new Result(games, 0, 0, 0, evaluator); + } catch (java.util.concurrent.ExecutionException ee) { + Throwable cause = ee.getCause(); + if (cause instanceof java.util.concurrent.CancellationException + || cause instanceof InterruptedException) { + if (cause instanceof InterruptedException) Thread.currentThread().interrupt(); + for (Future pending : futures) pending.cancel(true); + return new Result(games, 0, 0, 0, evaluator); + } + System.err.println("[SelfPlay] game failed: " + cause); + if (nextGame < games) { + final int gameIndex = nextGame++; + futures.add(pool.submit(() -> playGame(snapshot, seed + 0x9E3779B97F4A7C15L * gameIndex, expScale))); + } + } + } + if (Thread.currentThread().isInterrupted()) return new Result(games, 0, 0, 0, evaluator); + replayBuffer.addAll(newSamples); + trimReplayBuffer(); + double loss = train(replayBuffer, epochs, learningRate, seed ^ 0xD1B54A32D192ED03L); + return new Result(games, newSamples.size(), completed, loss, evaluator); + } finally { + pool.shutdownNow(); + // ★ Bug修复:原版 shutdownNow 后立即返回,持有 JNI/native 资源 + // (NeuralEvaluator+OpenCLBackend) 的 worker 可能未释放完毕, + // 下次训练启动会拿半初始化状态 + try { + if (!pool.awaitTermination(5, TimeUnit.SECONDS)) { + System.err.println("[自对弈] 训练线程池 5s 内未关闭,放弃等待"); + } + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + } + + private void appendReplaySamples(List samples, List gameSamples) { + samples.addAll(gameSamples); + int max = Math.max(1, config.maxReplaySamples); + if (samples.size() > max) { + samples.subList(0, samples.size() - max).clear(); + } + } + + private void trimReplayBuffer() { + int max = Math.max(1, config.maxReplaySamples); + if (replayBuffer.size() > max) { + // O(n) 批量移除,避免逐条 remove(0) 的 O(n²) + replayBuffer.subList(0, replayBuffer.size() - max).clear(); + } + } + + /** + * 余弦退火学习率。 + * @param initialLr 初始学习率 + * @param currentStep 当前步数(0-based) + * @param totalSteps 总步数 + * @return 当前学习率 + */ + public static double cosineLearningRate(double initialLr, int currentStep, int totalSteps) { + if (totalSteps <= 0) return initialLr; + double ratio = (double) currentStep / totalSteps; + return initialLr * 0.5 * (1.0 + Math.cos(Math.PI * ratio)); + } + + private GameSamples playGame(NeuralEvaluator.ModelWeights model, long seed, double explorationScale) { + List samples = new ArrayList<>(); + GoGame game = GoGame.rulesOnly(); + MCTSGoAI ai = new MCTSGoAI(config.searchTimeMillis, config.maxIterations, 1, model); + ai.setRandomSeed(seed); + double roundKomi = GoGame.getConfiguredKomi(); + // 自对弈模式:开启根节点 Dirichlet 噪声 + 访问分布温度采样(增强探索) + ai.setSelfPlayMode(true); + // 探索强度随训练代次衰减 + ai.setExplorationScale(explorationScale); + try { + int moves = 0; + int[] lastMoveOnBoard = null; // 上一手(构建 plane 3,与推理的 node.move 对齐) + while (!game.isGameOver() && moves < Math.max(1, config.maxMoves)) { + if (Thread.currentThread().isInterrupted()) { + throw new java.util.concurrent.CancellationException("self-play generation cancelled"); + } + GoPlayer player = game.getCurrentPlayer(); + int[] previousLastMove = lastMoveOnBoard == null ? null : lastMoveOnBoard.clone(); + // 获取当前棋盘副本 + GoPlayer[][] boardCopy = game.getBoardCopy(); + + // 获取 MCTS 走法(并记录访问分布作为策略目标) + int[] move = ai.getBestMove(game); + if (Thread.currentThread().isInterrupted()) { + throw new java.util.concurrent.CancellationException("self-play generation cancelled"); + } + // 从 MCTS 获取访问分布(访问数 / 总访问数) + double[] policyTarget = ai.getVisitDistribution(); + + GoTrainingMove.Applied applied = GoTrainingMove.apply(game, move, policyTarget); + lastMoveOnBoard = applied.coordinates(); + policyTarget = applied.policy(); + samples.add(new Sample(boardCopy, player, previousLastMove, policyTarget)); + moves++; + } + if (!game.isGameOver()) { + // maxMoves is a truncation guard, not an implicit second pass. + return new GameSamples(Collections.emptyList(), false); + } + + // 设置价值目标(中国规则数子法) + double margin = game.getScoreMargin(GoPlayer.BLACK, Collections.emptySet(), roundKomi); + for (Sample s : samples) { + s.valueTarget = clamp((s.player == GoPlayer.BLACK ? margin : -margin) / 100.0); + } + return new GameSamples(samples, true); + } finally { + ai.shutdown(); + game.close(); + } + } + + /** + * 训练模型。对每个样本重建输入平面和辅助特征,8 倍对称展开后训练。 + */ + private double train(List samples, int epochs, double learningRate, long seed) { + if (samples.isEmpty() || epochs == 0) return 0; + Random random = new Random(seed); + double total = 0; + int batches = 0; + int symCount = SYMM_PERMS.length; + + for (int epoch = 0; epoch < epochs; epoch++) { + Collections.shuffle(samples, random); + int batchLimit = Math.max(1, config.batchSize); + for (int start = 0; start < samples.size(); start += batchLimit) { + if (Thread.currentThread().isInterrupted()) return batches == 0 ? 0 : total / batches; + int end = Math.min(samples.size(), start + batchLimit); + int baseCount = end - start; + double[][][][] planes = new double[baseCount * symCount][][][]; + double[][] aux = new double[baseCount * symCount][AUX_FEATURES]; + double[] values = new double[baseCount * symCount]; + double[][] policies = new double[baseCount * symCount][362]; + + int n = 0; + for (int k = start; k < end; k++) { + Sample s = samples.get(k); + // 辅助特征在 D4 对称下不变(气数直方图/眼形/全局特征均对称),只需计算一次 + double[] baseAux = evaluator.extractAuxFeatures(s.board, s.player); + for (int t = 0; t < symCount; t++) { + // 对棋盘应用对称变换 + GoPlayer[][] transformedBoard = applySymmetry(s.board, SYMM_PERMS[t]); + // 上一手坐标随对称变换同步(plane 3 与推理的 node.move 对齐) + int[] tLastMove = null; + if (s.lastMove != null && s.lastMove.length >= 2) { + int srcIdx = s.lastMove[0] * BOARD_SIZE + s.lastMove[1]; + int dstIdx = SYMM_PERMS[t][srcIdx]; + tLastMove = new int[]{dstIdx / BOARD_SIZE, dstIdx % BOARD_SIZE}; + } + // 构建输入平面 + planes[n] = evaluator.buildInputPlanes(transformedBoard, s.player, tLastMove); + // 辅助特征(对称不变,复用) + aux[n] = baseAux; + // 价值目标 + values[n] = s.valueTarget; + // 策略目标:前 361 维随棋盘变换,pass 维不变 + if (t == 0) { + policies[n] = s.policyTarget.clone(); + } else { + double[] pt = new double[362]; + for (int i = 0; i < BOARD_FEATURES; i++) + pt[SYMM_PERMS[t][i]] = s.policyTarget[i]; + pt[361] = s.policyTarget[361]; // pass 不变 + policies[n] = pt; + } + n++; + } + } + if (Thread.currentThread().isInterrupted()) return batches == 0 ? 0 : total / batches; + total += evaluator.trainMiniBatch(planes, aux, values, policies, + learningRate, config.l2, config.gradientClip, config.momentum); + batches++; + } + } + return batches == 0 ? 0 : total / batches; + } + + /** 对棋盘应用 D4 对称变换,返回新棋盘 */ + private static GoPlayer[][] applySymmetry(GoPlayer[][] board, int[] perm) { + int n = BOARD_SIZE; + GoPlayer[][] result = new GoPlayer[n][n]; + for (int x = 0; x < n; x++) { + for (int y = 0; y < n; y++) { + int srcIdx = x * n + y; + int dstIdx = perm[srcIdx]; + int dx = dstIdx / n, dy = dstIdx % n; + result[dx][dy] = board[x][y]; + } + } + return result; + } + + private static double clamp(double value) { return Math.max(-1.0, Math.min(1.0, value)); } + + private static final class GameSamples { + final List samples; + final boolean completed; + GameSamples(List samples, boolean completed) { + this.samples = samples; + this.completed = completed; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoTrainingMain.java b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoTrainingMain.java new file mode 100644 index 0000000..d2b62f0 --- /dev/null +++ b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoTrainingMain.java @@ -0,0 +1,102 @@ +package com.wzz.game_console.client.screens.games.gogame; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +/** Command-line entry point for pure-Java Go self-play training. */ +public final class GoTrainingMain { + private GoTrainingMain() {} + + public static void main(String[] args) throws Exception { + Map options = parse(args); + // GPU 可选:--gpu true/false(默认 true),通过系统属性传递给 NeuralEvaluator + String gpuVal = options.get("gpu"); + if (gpuVal != null) { + System.setProperty("go.gpu", gpuVal); + } + GoSelfPlayTrainer.Config config = new GoSelfPlayTrainer.Config(); + config.searchTimeMillis = intOption(options, "searchTime", config.searchTimeMillis); + config.maxIterations = intOption(options, "iterations", config.maxIterations); + config.maxMoves = intOption(options, "maxMoves", config.maxMoves); + config.maxReplaySamples = intOption(options, "maxReplaySamples", config.maxReplaySamples); + int games = intOption(options, "games", 30); + int parallelism = intOption(options, "parallelism", intOption(options, "threads", 30)); + int generations = intOption(options, "generations", 1); + int epochs = intOption(options, "epochs", 1); + double learningRate = doubleOption(options, "learningRate", 0.001); + int warmup = intOption(options, "warmup", 0); + long seed = longOption(options, "seed", 0x5EEDL); + Path weights = Path.of(required(options, "weights")); + int checkpointInterval = intOption(options, "checkpoint", 10); + + NeuralEvaluator evaluator = new NeuralEvaluator(); + if (Files.exists(weights)) evaluator.load(weights); + GoSelfPlayTrainer trainer = new GoSelfPlayTrainer(config, evaluator); + GoSelfPlayTrainer.Result result = null; + try { + for (int generation = 0; generation < generations; generation++) { + double currentLR; + if (warmup > 0 && generation < warmup) { + // 预热阶段:LR 从 LR/warmup 线性升到目标值,配合 Momentum 稳定起步 + currentLR = learningRate * (generation + 1.0) / warmup; + } else { + // 余弦退火学习率调度:从初始 LR 平滑衰减 + int remain = Math.max(1, generations - warmup); + double frac = (double) (generation - warmup) / remain; + currentLR = Math.max(learningRate * 0.5 * (1.0 + Math.cos(Math.PI * frac)), 1e-6); + } + result = trainer.runGeneration(games, parallelism, epochs, currentLR, seed + generation); + System.out.printf("generation=%d lr=%.6f games=%d completed=%d samples=%d replay=%d meanLoss=%.8f%n", + generation + 1, currentLR, result.games, result.completedGames, + result.samples, trainer.getReplayBufferSize(), result.meanLoss); + // 定期 checkpoint 保存,支持断点续训 + if (checkpointInterval > 0 && (generation + 1) % checkpointInterval == 0) { + evaluator.save(weights); + System.out.println("checkpoint saved at generation " + (generation + 1)); + } + } + evaluator.save(weights); + System.out.println("saved=" + weights.toAbsolutePath()); + } catch (Throwable t) { + // 崩溃时保存当前权重,防止训练白跑 + try { + evaluator.save(weights); + System.out.println("crash-saved=" + weights.toAbsolutePath()); + } catch (Exception ignored) {} + t.printStackTrace(); + System.exit(1); + } + } + + private static Map parse(String[] args) { + Map result = new HashMap<>(); + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + if (!arg.startsWith("--")) throw new IllegalArgumentException("Unexpected argument: " + arg); + String name = arg.substring(2); + if (name.isEmpty() || i + 1 >= args.length || args[i + 1].startsWith("--")) { + throw new IllegalArgumentException("Missing value for --" + name); + } + result.put(name, args[++i]); + } + return result; + } + + private static String required(Map options, String key) { + String value = options.get(key); + if (value == null || value.isBlank()) throw new IllegalArgumentException("Missing required --" + key); + return value; + } + + private static int intOption(Map options, String key, int fallback) { + return options.containsKey(key) ? Integer.parseInt(options.get(key)) : fallback; + } + private static long longOption(Map options, String key, long fallback) { + return options.containsKey(key) ? Long.parseLong(options.get(key)) : fallback; + } + private static double doubleOption(Map options, String key, double fallback) { + return options.containsKey(key) ? Double.parseDouble(options.get(key)) : fallback; + } +} \ No newline at end of file diff --git a/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoTrainingMove.java b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoTrainingMove.java new file mode 100644 index 0000000..57d9cf2 --- /dev/null +++ b/src/main/java/com/wzz/game_console/client/screens/games/gogame/GoTrainingMove.java @@ -0,0 +1,29 @@ +package com.wzz.game_console.client.screens.games.gogame; + +final class GoTrainingMove { + private GoTrainingMove() {} + + record Applied(int[] coordinates, double[] policy) {} + + static Applied apply(GoGame game, int[] suggested, double[] policy) { + if (suggested == null) { + game.pass(); + return new Applied(null, policy); + } + if (suggested.length >= 2 && game.placeStone(suggested[0], suggested[1])) { + return new Applied(new int[]{suggested[0], suggested[1]}, policy); + } + double[] fallbackPolicy = new double[362]; + for (int x = 0; x < game.getBoardSize(); x++) { + for (int y = 0; y < game.getBoardSize(); y++) { + if (game.placeStone(x, y)) { + fallbackPolicy[x * game.getBoardSize() + y] = 1.0; + return new Applied(new int[]{x, y}, fallbackPolicy); + } + } + } + game.pass(); + fallbackPolicy[361] = 1.0; + return new Applied(null, fallbackPolicy); + } +} diff --git a/src/main/java/com/wzz/game_console/client/screens/games/gogame/KataGoGoAI.java b/src/main/java/com/wzz/game_console/client/screens/games/gogame/KataGoGoAI.java new file mode 100644 index 0000000..b62638d --- /dev/null +++ b/src/main/java/com/wzz/game_console/client/screens/games/gogame/KataGoGoAI.java @@ -0,0 +1,434 @@ +package com.wzz.game_console.client.screens.games.gogame; + +import com.wzz.game_console.util.GameSettings; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 外部 KataGo 引擎封装(通过 GTP 协议通信)。 + *

+ * KataGo 是一款高性能围棋 AI,需要用户单独安装: + *

    + *
  1. 下载 KataGo 可执行文件:KataGo Releases
  2. + *
  3. 下载 KataGo 权重模型:model.bin.gz
  4. + *
  5. 配置 data/game_settings.json 中的 go.katagoPath 路径
  6. + *
+ *

+ * 配置示例: + *

+ * {
+ *   "go": {
+ *     "engine": "katago",
+ *     "katagoPath": "E:/katago/katago.exe",
+ *     "katagoModel": "E:/katago/model.bin.gz",
+ *     "katagoConfig": "E:/katago/analysis.cfg"
+ *   }
+ * }
+ * 
+ *

+ * 如果 KataGo 不可用或启动失败,将自动回退到 MCTSGoAI。 + */ +public class KataGoGoAI implements GoAI { + private static final Logger LOGGER = LoggerFactory.getLogger("KataGoAI"); + + /** 默认 GTP 超时(秒) */ + private static final int DEFAULT_GTP_TIMEOUT = 30; + + private final Process process; + private final BufferedWriter writer; + private final BufferedReader reader; + private final int timeout; + private volatile boolean connected = false; + private final AtomicInteger commandId = new AtomicInteger(0); + /** ★ Bug修复:原版每个实例都 addShutdownHook,多次创建引擎会注册多个 hook, + * 进程退出时每个 hook 都尝试 process.destroy,前面的空转。改为类级共享 */ + private static final java.util.Set LIVE_INSTANCES = + java.util.Collections.newSetFromMap(new java.util.concurrent.ConcurrentHashMap<>()); + private static volatile boolean SHUTDOWN_HOOK_REGISTERED = false; + + /** AI 执棋颜色,默认白棋 */ + private GoPlayer aiColor = GoPlayer.WHITE; + + private final BoardSync boardSync = new BoardSync(this::sendCommand); + + /** 驻留读线程 + 队列:读线程只负责把 GTP 行推入队列, + * 响应等待方用 poll(剩余时间) 实现超时,超时不会遗留阻塞在 readLine 上的任务 */ + /** Bounded so a buggy engine spamming stdout fails fast instead of leaking memory. */ + private static final int RESPONSE_QUEUE_CAPACITY = 4_096; + private final BlockingQueue responseQueue = new LinkedBlockingDeque<>(RESPONSE_QUEUE_CAPACITY); + private volatile boolean running = true; + /** 引擎退出/流关闭时入队的哨兵(空行已在读线程过滤,队列中出现 "" 仅表示 EOF) */ + private static final String EOF_SENTINEL = ""; + + /** + * 构造 KataGo AI。 + * + * @param katagoExePath KataGo 可执行文件路径 + */ + public KataGoGoAI(String katagoExePath) throws IOException { + this(katagoExePath, DEFAULT_GTP_TIMEOUT); + } + + /** + * 构造 KataGo AI(指定超时)。 + */ + public KataGoGoAI(String katagoExePath, int timeoutSeconds) throws IOException { + this.timeout = timeoutSeconds; + + // 检查文件是否存在 + File exeFile = new File(katagoExePath); + if (!exeFile.exists()) { + throw new FileNotFoundException("KataGo 可执行文件不存在: " + katagoExePath); + } + // ★ Bug修复:Mac/Linux 文件存在但无执行位时,ProcessBuilder 报 "permission denied" + // 错误信息玩家看不懂。提前 canExecute 检查并提示 chmod +x + if (!exeFile.canExecute()) { + throw new IOException("KataGo 文件无执行权限: " + katagoExePath + + " (Mac/Linux 请运行: chmod +x " + exeFile.getName() + ")"); + } + + // 读取配置 + String modelPath = GameSettings.getString("go", "katagoModel", ""); + String configPath = GameSettings.getString("go", "katagoConfig", ""); + + // 构建命令行参数 + java.util.List cmd = new java.util.ArrayList<>(); + cmd.add(katagoExePath); + cmd.add("gtp"); + + if (!modelPath.isEmpty()) { + cmd.add("-model"); + cmd.add(modelPath); + } + if (!configPath.isEmpty()) { + cmd.add("-config"); + cmd.add(configPath); + } + + LOGGER.info("[KataGo] 启动进程: {}", String.join(" ", cmd)); + + ProcessBuilder pb = new ProcessBuilder(cmd); + // ★ Bug修复:stderr 不并入 stdout——stdout 必须保持纯 GTP 流, + // 引擎日志混入后会被响应解析吞掉/错位;日志改走本进程 stderr。 + // stderr 也不能完全不消费——管道缓冲写满会挂死引擎,继承到本进程 stderr + pb.redirectError(ProcessBuilder.Redirect.INHERIT); + this.process = pb.start(); + + this.writer = new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8)); + this.reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)); + + // 驻留读线程必须先于 initGTP 启动,否则首条命令的响应无人消费 + Thread rt = new Thread(this::readLoop, "KataGo-Reader"); + rt.setDaemon(true); + rt.start(); + + // 注册关闭钩子 + // ★ Bug修复:见 PikafishChessAI 同样处理 + LIVE_INSTANCES.add(this); + if (!SHUTDOWN_HOOK_REGISTERED) { + SHUTDOWN_HOOK_REGISTERED = true; + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + for (KataGoGoAI ai : LIVE_INSTANCES) { + try { ai.shutdown(); } catch (Throwable ignored) {} + } + })); + } + + // 初始化 GTP 连接 + try { + initGTP(); + connected = true; + LOGGER.info("[KataGo] GTP 连接成功"); + } catch (Exception e) { + shutdown(); + throw new IOException("KataGo GTP 初始化失败: " + e.getMessage(), e); + } + } + + /** + * 初始化 GTP 连接(sendCommand 对 "?" 错误响应直接抛 IOException) + */ + private void initGTP() throws IOException { + // 设置棋盘大小 + sendCommand("boardsize " + BOARD_SIZE); + // 设置贴目,与 GoGame 的计分配置保持一致。 + sendCommand("komi " + GoGame.getConfiguredKomi()); + // 清空棋盘 + sendCommand("clear_board"); + } + + /** + * 设置 AI 的执棋颜色。 + *

+ * KataGoGoAI 默认执白棋,调用此方法可更改为执黑。 + * + * @param color AI 执棋颜色 + */ + public void setAIColor(GoPlayer color) { + this.aiColor = (color == GoPlayer.BLACK) ? GoPlayer.BLACK : GoPlayer.WHITE; + } + + /** + * 获取 AI 的执棋颜色。 + * + * @return AI 执棋颜色 + */ + @Override + public GoPlayer getAIColor() { + return aiColor; + } + + @Override + public int[] getBestMove(GoGame game) { + return getBestMoveResult(game).coordinates(); + } + + @Override + public MoveResult getBestMoveResult(GoGame game) { + if (!connected) { + LOGGER.warn("[KataGo] 未连接,返回错误结果"); + return MoveResult.error(); + } + + try { + return boardSync.generate(game.getMoveHistory(), aiColor); + } catch (Exception e) { + LOGGER.error("[KataGo] 获取走法失败: {}", e.getMessage()); + return MoveResult.error(); + } + } + + @FunctionalInterface + interface CommandTransport { + String send(String command) throws IOException; + } + + /** Production replay logic, independent of the child process for transport tests. */ + static final class BoardSync { + private final CommandTransport transport; + private final java.util.ArrayList engineHistory = new java.util.ArrayList<>(); + private boolean dirty = true; + + BoardSync(CommandTransport transport) { + this.transport = transport; + } + + synchronized MoveResult generate(List history, GoPlayer color) throws IOException { + try { + syncBoard(history); + MoveResult result = parseMoveResult(transport.send("genmove " + colorName(color))); + // genmove applies its own move. The next local history must acknowledge it. + if (result.type() == MoveType.MOVE) { + engineHistory.add(new GoMove(result.x(), result.y(), color, 0)); + } else if (result.type() == MoveType.PASS) { + engineHistory.add(new GoMove(-1, -1, color, 0)); + } else { + dirty = true; + } + return result; + } catch (IOException | RuntimeException e) { + dirty = true; + throw e; + } + } + + private void syncBoard(List history) throws IOException { + boolean prefixMatches = history.size() >= engineHistory.size(); + if (prefixMatches) { + for (int i = 0; i < engineHistory.size(); i++) { + GoMove local = history.get(i), engine = engineHistory.get(i); + if (local.x != engine.x || local.y != engine.y || local.player != engine.player) { + prefixMatches = false; + break; + } + } + } + if (dirty || !prefixMatches) { + transport.send("clear_board"); + engineHistory.clear(); + } + for (int i = engineHistory.size(); i < history.size(); i++) { + GoMove move = history.get(i); + String coordinate = move.x < 0 || move.y < 0 ? "pass" : formatMove(move.x, move.y); + transport.send("play " + colorName(move.player) + " " + coordinate); + engineHistory.add(move); + } + dirty = false; + } + + private static String colorName(GoPlayer color) { + return color == GoPlayer.BLACK ? "black" : "white"; + } + } + + /** + * 发送 GTP 命令并返回该命令的响应正文。 + * ★ Bug修复:原版 sendCommand 内部读一次响应、调用方 expectSuccess/readResponse 再读一次, + * 每条命令的响应被双重消费——第二条读取只能等到下一条命令的响应或超时, + * genmove 必然超时失败,KataGo 引擎 100% 不可用。 + * 现在发送+读取严格一一对应:成功返回正文,"?" 错误响应抛 IOException。 + */ + private String sendCommand(String cmd) throws IOException { + int id = commandId.incrementAndGet(); + String fullCmd = id + " " + cmd; + LOGGER.debug("[KataGo] >>> {}", fullCmd); + try { + writer.write(fullCmd); + writer.newLine(); + writer.flush(); + } catch (IOException e) { + connected = false; + throw e; + } + try { + return readResponse(); + } catch (TimeoutException e) { + connected = false; + throw new IOException("KataGo 命令超时: " + cmd, e); + } + } + + /** + * 驻留读线程:把引擎 stdout 的 GTP 行推入队列。 + * 空行(GTP 响应块终止符)与引擎日志行全部入队,由消费方按前缀过滤。 + */ + private void readLoop() { + try { + String line; + while (running && (line = reader.readLine()) != null) { + if (line.isBlank()) continue; // GTP 响应以"=..."行为准,空行终止符无需入队 + if (!responseQueue.offer(line)) { + LOGGER.error("[KataGo] 响应队列溢出({} 条未消费),判定引擎输出异常并断开", + RESPONSE_QUEUE_CAPACITY); + connected = false; + running = false; + responseQueue.clear(); + break; + } + } + } catch (IOException ignored) { + // shutdown 通过 closeProcess 关闭流使 readLine 抛出并落到 finally 哨兵 + } finally { + if (!responseQueue.offer(EOF_SENTINEL)) { + responseQueue.clear(); + responseQueue.offer(EOF_SENTINEL); + } + } + } + + /** + * 等待并返回当前命令的响应正文,带整体超时。 + * 非 GTP 行(引擎日志/横幅)跳过;"=xxx" 剥离前缀与回显的命令 id 后返回; + * "?xxx" 视为 GTP 错误抛 IOException。 + */ + private String readResponse() throws IOException, TimeoutException { + long deadline = System.nanoTime() + timeout * 1_000_000_000L; + while (true) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) { + connected = false; + throw new TimeoutException("KataGo 响应超时"); + } + String line; + try { + line = responseQueue.poll(remaining, TimeUnit.NANOSECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("KataGo 读取被中断", e); + } + if (line == null) continue; // 单次 poll 到时未到整体 deadline,继续等 + if (line.isEmpty()) { // EOF 哨兵 + connected = false; + throw new IOException("KataGo 引擎已退出"); + } + LOGGER.debug("[KataGo] <<< {}", line); + if (line.startsWith("=") || line.startsWith("?")) { + String body = line.substring(1).stripLeading(); + // GTP 会在响应中回显命令 id(如 "=5 D4"),剥掉纯数字 id 前缀 + int sp = body.indexOf(' '); + String head = sp >= 0 ? body.substring(0, sp) : body; + if (!head.isEmpty() && head.chars().allMatch(Character::isDigit)) { + body = sp >= 0 ? body.substring(sp + 1) : ""; + } + if (line.startsWith("?")) { + throw new IOException("GTP 命令失败: " + body); + } + return body; + } + // 非 GTP 输出(引擎启动日志/横幅),跳过继续等 + } + } + + /** + * 解析 GTP 坐标为 {x, y}(GTP 使用 A-T 跳过 I 列) + */ + static MoveResult parseMoveResult(String coordinate) { + if (coordinate == null) return MoveResult.error(); + String coord = coordinate.toLowerCase().trim(); + if (coord.isEmpty()) return MoveResult.error(); + if ("pass".equals(coord)) return MoveResult.pass(); + if ("resign".equals(coord)) return MoveResult.resign(); + try { + char colChar = coord.charAt(0); + int row = Integer.parseInt(coord.substring(1)); + if (colChar == 'i' || colChar < 'a' || colChar > 't') return MoveResult.error(); + int col = colChar < 'i' ? colChar - 'a' : colChar - 'a' - 1; + int boardRow = row - 1; + if (col >= 0 && col < BOARD_SIZE && boardRow >= 0 && boardRow < BOARD_SIZE) { + return MoveResult.move(col, boardRow); + } + } catch (NumberFormatException ignored) { + LOGGER.warn("[KataGo] 无法解析坐标: {}", coordinate); + } + return MoveResult.error(); + } + + /** + * 格式化坐标为 GTP 格式 + */ + private static String formatMove(int x, int y) { + // GTP 列坐标跳过 I:棋盘第 8 列对应 J,而不是 I。 + char col = (char) ('a' + x + (x >= 8 ? 1 : 0)); + return col + String.valueOf(y + 1); + } + + @Override + public void shutdown() { + // ★ Bug修复:从共享 Set 移除自身,避免 hook 重复关闭已关闭实例 + LIVE_INSTANCES.remove(this); + connected = false; + running = false; + responseQueue.clear(); + responseQueue.offer(EOF_SENTINEL); // 唤醒可能仍在等待的消费者 + + closeProcess(process, writer, reader); + LOGGER.info("[KataGo] 已关闭"); + } + + static void closeProcess(Process process, Closeable writer, Closeable reader) { + // Terminate the child first. Closing a reader before the child exits can + // block on platform pipes while KataGo is still writing its response. + if (process != null && process.isAlive()) { + process.destroy(); + try { + if (!process.waitFor(2, TimeUnit.SECONDS)) process.destroyForcibly(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + process.destroyForcibly(); + } + } + + try { + if (writer != null) writer.close(); + } catch (IOException ignored) {} + try { + if (reader != null) reader.close(); + } catch (IOException ignored) {} + } +} diff --git a/src/main/java/com/wzz/game_console/client/screens/games/gogame/MCTSGoAI.java b/src/main/java/com/wzz/game_console/client/screens/games/gogame/MCTSGoAI.java new file mode 100644 index 0000000..1781cb4 --- /dev/null +++ b/src/main/java/com/wzz/game_console/client/screens/games/gogame/MCTSGoAI.java @@ -0,0 +1,2566 @@ +package com.wzz.game_console.client.screens.games.gogame; + +import com.wzz.game_console.util.GameSettings; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.ReentrantLock; + +/** + * 全面升级版 MCTS 围棋 AI。 + *

+ * 核心优化: + *

    + *
  • 并行多线程 MCTS 搜索(每线程独立树,模拟结束后合并)
  • + *
  • 增强评估函数:死活判断、真假眼、劫材价值、区域控制
  • + *
  • 杀棋检测:Ladder、Atari 识别
  • + *
  • 扩展开局定式库
  • + *
  • 终局判断与收官策略
  • + *
  • MCTS 树重用 + RAVE + 共享树并行搜索
  • + *
+ */ +public class MCTSGoAI implements GoAI { + private static final Logger LOGGER = LoggerFactory.getLogger(MCTSGoAI.class); + /** 默认搜索时间(毫秒) */ + private static final int DEFAULT_SEARCH_TIME = 3000; + private static final int PASS_INDEX = BOARD_SIZE * BOARD_SIZE; + private static final int POLICY_SIZE = PASS_INDEX + 1; + private static final int[] PASS_MOVE = {-1, -1, 0}; + /** 默认迭代次数上限 */ + private static final int DEFAULT_ITERATIONS = 5000; + /** 并行搜索线程数;高核心数机器也限制 worker 峰值,避免多 AI 实例制造数百线程。 */ + private static final int PARALLEL_THREADS = Math.max(1, + Math.min(32, Runtime.getRuntime().availableProcessors() - 1)); + + // ══════════════════════════════════════════════════════════════════ + // 动态时间控制参数 + // ══════════════════════════════════════════════════════════════════ + /** 开局阶段阈值(手数) */ + private static final int OPENING_THRESHOLD = 30; + /** 终局阶段阈值(棋子数) */ + private static final int ENDGAME_STONES = 120; + /** 必胜/必败检测阈值 */ + private static final double WIN_THRESHOLD = 0.95; + private static final double LOSS_THRESHOLD = -0.95; + /** 置信区间内提前终止的最小访问次数 */ + private static final int MIN_VISITS_FOR_TERMINATION = 50; + /** 早停门槛:最高胜率分支自身至少要被访问这么多次,其胜率才可信(防 2 连胜假信号截断搜索) */ + private static final int MIN_EARLY_STOP_BEST_VISITS = 32; + + // ══════════════════════════════════════════════════════════════════ + // Dirichlet 噪声参数(根节点探索增强,仅用于自对弈训练) + // ══════════════════════════════════════════════════════════════════ + /** Dirichlet 浓度参数;越大越分散,越小越集中 */ + private static final double DIRICHLET_ALPHA = 0.03; + /** 噪声与均匀先验的混合比例:final = (1-eps) * prior + eps * noise */ + private static final double DIRICHLET_EPS = 0.25; + + private final int baseSearchTime; + private final int maxIterations; + private final int parallelThreads; + + /** 神经网络评估器 */ + private final NeuralEvaluator neuralEvaluator; + /** Serializes searches with shutdown so evaluator native resources remain live while used. */ + private final ReentrantLock lifecycleLock = new ReentrantLock(); + private volatile boolean shutdown; + private boolean evaluatorReleased; + + /** 自对弈训练模式:启用根节点 Dirichlet 噪声增强探索(对局模式禁用) */ + private volatile boolean selfPlayMode = false; + + /** 当前对局的全部历史局面哈希(super-ko 全局同型检测),从 GoGame 同步 */ + private volatile Set koHistory = null; + /** Fixed komi snapshot for terminal nodes in the current search. */ + private volatile double searchKomi = GoGame.DEFAULT_KOMI; + + /** 上一手评估值(用于趋势判断) */ + private double lastEvaluation = 0; + /** 连续优势/劣势回合数 */ + private int advantageStreak = 0; + /** 劣势连续回合数 */ + private int disadvantageStreak = 0; + + private Random random; + private MCTSNode lastRoot = null; + private int[] lastMove = null; + private MCTSNode currentRoot; + + /** 并行搜索时用于追踪总迭代次数的原子变量 */ + private final AtomicLong totalIterations = new AtomicLong(0); + /** 使超时或关闭后的迟到 worker 无法继续修改已结束的搜索树。 */ + private final AtomicLong searchGeneration = new AtomicLong(); + + // ══════════════════════════════════════════════════════════════════ + // 构造函数 + // ══════════════════════════════════════════════════════════════════ + + public MCTSGoAI() { + this(DEFAULT_SEARCH_TIME, DEFAULT_ITERATIONS); + } + + public MCTSGoAI(int searchTime, int maxIterations) { + this(searchTime, maxIterations, Math.max(1, PARALLEL_THREADS - 1)); + } + + public MCTSGoAI(int searchTime, int maxIterations, int parallelThreads) { + this(searchTime, maxIterations, parallelThreads, null); + } + + /** Creates an AI using a private evaluator initialized from a model snapshot. */ + public MCTSGoAI(int searchTime, int maxIterations, int parallelThreads, + NeuralEvaluator.ModelWeights model) { + if (searchTime < 0 || maxIterations < 0 || parallelThreads < 1) { + throw new IllegalArgumentException("Invalid MCTS parameters"); + } + this.baseSearchTime = searchTime; + this.maxIterations = maxIterations; + this.parallelThreads = parallelThreads; + this.random = new Random(); + // 用 fromWeights 跳过随机 init(省去 init+apply 双重开销) + if (model != null) { + this.neuralEvaluator = NeuralEvaluator.fromWeights(model); + } else { + this.neuralEvaluator = new NeuralEvaluator(); + } + } + + /** Sets the random seed for a self-play game. */ + public void setRandomSeed(long seed) { + this.random.setSeed(seed); + } + + /** 切换自对弈训练模式(启用根节点 Dirichlet 探索噪声)。对局模式(默认)关闭噪声以保证棋力。 */ + public void setSelfPlayMode(boolean selfPlayMode) { + this.selfPlayMode = selfPlayMode; + } + + /** 当前探索强度倍率(自对弈训练用,随代次衰减:1.0 → ~0.2) */ + private volatile double explorationScale = 1.0; + + /** 设置探索强度倍率(影响 Dirichlet 噪声比例和采样温度)。范围 [0,1]。 */ + public void setExplorationScale(double scale) { + this.explorationScale = Math.max(0, Math.min(1, scale)); + } + + /** + * 从 GameSettings 创建 AI;若配置了 {@code go.modelPath} 且文件有效, + * 对局使用训练入口保存的 checkpoint 权重,否则使用内置随机初始化。 + * 任何加载失败都只降级为随机初始化,绝不阻断对局创建。 + */ + public static MCTSGoAI createFromSettings() { + try { + int searchTime = GameSettings.getInt("go", "searchTime", DEFAULT_SEARCH_TIME); + int iterations = GameSettings.getInt("go", "mctsIterations", DEFAULT_ITERATIONS); + NeuralEvaluator trained = loadConfiguredEvaluator(); + if (trained != null) { + return new MCTSGoAI(searchTime, iterations, + Math.max(1, PARALLEL_THREADS - 1), trained.snapshot()); + } + return new MCTSGoAI(searchTime, iterations); + } catch (Throwable t) { + return new MCTSGoAI(); + } + } + + /** + * 运行时加载 {@code go.modelPath} 指向的 checkpoint(NEV2/NEV3 均支持, + * 与 {@link NeuralEvaluator#load} 契约一致)。未配置、文件缺失或损坏时返回 + * null,由调用方回退到随机初始化。 + */ + private static NeuralEvaluator loadConfiguredEvaluator() { + String modelPath; + try { + modelPath = GameSettings.getString("go", "modelPath", ""); + } catch (Throwable t) { + return null; + } + if (modelPath == null || modelPath.isBlank()) return null; + Path path = Path.of(modelPath); + if (!Files.isRegularFile(path)) { + LOGGER.warn("[围棋AI] 配置的模型文件不存在,使用随机初始化权重: {}", modelPath); + return null; + } + try { + NeuralEvaluator evaluator = new NeuralEvaluator(); + evaluator.load(path); + LOGGER.info("[围棋AI] 已加载训练模型 (version={}): {}", + evaluator.getModelVersion(), modelPath); + return evaluator; + } catch (IOException | RuntimeException e) { + LOGGER.warn("[围棋AI] 模型加载失败,使用随机初始化权重: {} ({})", + modelPath, e.getMessage()); + return null; + } + } + + // ══════════════════════════════════════════════════════════════════ + // 动态时间控制 + // ══════════════════════════════════════════════════════════════════ + + /** + * 根据对局阶段计算搜索时间 + */ + private int calculateDynamicSearchTime(int moveCount, int validMoveCount) { + int time = baseSearchTime; + + // 开局阶段:快速落子 + if (moveCount < OPENING_THRESHOLD) { + time = (int)(baseSearchTime * 0.4); // 40% 时间 + } + // 中盘阶段:完整搜索 + else if (moveCount < 80) { + time = baseSearchTime; + } + // 终局阶段:快速收官 + else { + time = (int)(baseSearchTime * 0.6); // 60% 时间 + } + + // 根据候选点数量调整 + if (validMoveCount > 50) { + time = (int)(time * 1.2); // 更多候选点需要更多时间 + } else if (validMoveCount < 10) { + time = (int)(time * 0.7); // 少候选点可以更快 + } + + // 根据局势紧张程度调整 + if (advantageStreak >= 3) { + time = (int)(time * 1.3); // 我方连续占优,延长时间确保 + } else if (disadvantageStreak >= 2) { + time = (int)(time * 1.5); // 我方连续劣势,延长思考 + } + + return Math.max(500, Math.min(time, baseSearchTime * 2)); // 500ms ~ 2x base + } + + /** + * 检查是否应该提前终止搜索 + */ + private boolean shouldTerminateEarly(long iterations) { + if (iterations < MIN_VISITS_FOR_TERMINATION) { + return false; + } + + // 快照 currentRoot.children(共享树下其他线程可能并发写入,需同步) + MCTSNode root = currentRoot; + if (root == null) return false; + List children; + synchronized (root) { + if (root.children == null || root.children.isEmpty()) return false; + children = new ArrayList<>(root.children); + } + + double bestWinRate = Double.NEGATIVE_INFINITY; + double secondWinRate = Double.NEGATIVE_INFINITY; + double bestWinRateVisits = 0; + + for (MCTSNode child : children) { + double visits; + double totalScore; + synchronized (child) { + visits = child.visits; + totalScore = child.totalScore; + } + if (visits > 0) { + // 子节点是"对手行棋方"视角,父节点视角需取反(节点存自身行棋方视角) + double winRate = -totalScore / visits; + if (winRate > bestWinRate) { + secondWinRate = bestWinRate; + bestWinRate = winRate; + bestWinRateVisits = visits; + } else if (winRate > secondWinRate) { + secondWinRate = winRate; + } + } + } + + // 无任何子节点有访问时,bestWinRate 保持 -INF,不能当作必败触发提前终止 + if (bestWinRate == Double.NEGATIVE_INFINITY) { + return false; + } + // ★ 最访问数门槛:胜率均值在极少访问下方差极大(2 连胜即 100%), + // 必胜/必败/置信区间判定都必须建立在最高胜率分支被充分搜索的基础上, + // 否则开局几手就可能被低访问高方差的假信号提前截断搜索 + if (bestWinRateVisits < MIN_EARLY_STOP_BEST_VISITS) { + return false; + } + // 必胜/必败检测 + if (bestWinRate > WIN_THRESHOLD) { + return true; + } + if (bestWinRate < LOSS_THRESHOLD) { + return true; + } + + // 置信区间判断:仅当至少两个子节点被访问(secondWinRate 有限)时才启用, + // 否则 bestWinRate - (-INF) = +INF 会导致搜索在第 1 个子节点被访问后立即提前终止 + if (secondWinRate != Double.NEGATIVE_INFINITY) { + double margin = bestWinRate - secondWinRate; + if (margin > 0.3 && iterations > MIN_VISITS_FOR_TERMINATION * 2) { + return true; + } + } + + return false; + } + + /** + * 更新局势评估 + */ + private void updateGameAssessment() { + if (lastEvaluation > 0.3) { + advantageStreak++; + disadvantageStreak = 0; + } else if (lastEvaluation < -0.3) { + disadvantageStreak++; + advantageStreak = 0; + } else { + advantageStreak = 0; + disadvantageStreak = 0; + } + } + + // ══════════════════════════════════════════════════════════════════ + // MCTS 搜索 + // ══════════════════════════════════════════════════════════════════ + + @Override + public int[] getBestMove(GoGame game) { + MoveResult result = getBestMoveResult(game); + return result.type() == MoveType.MOVE ? result.coordinates() : null; + } + + @Override + public MoveResult getBestMoveResult(GoGame game) { + try { + lifecycleLock.lockInterruptibly(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return MoveResult.error(); + } + try { + if (shutdown || Thread.currentThread().isInterrupted()) return MoveResult.error(); + int[] move = getBestMoveLocked(game); + if (shutdown || Thread.currentThread().isInterrupted()) return MoveResult.error(); + return move == null ? MoveResult.pass() : MoveResult.move(move[0], move[1]); + } finally { + if (shutdown) releaseEvaluatorLocked(); + lifecycleLock.unlock(); + } + } + + private int[] getBestMoveLocked(GoGame game) { + GoGame.PositionSnapshot snapshot = game.positionSnapshot(); + GoPlayer[][] board = snapshot.board(); + GoPlayer currentPlayer = snapshot.currentPlayer(); + int moveCount = snapshot.moveCount(); + + // super-ko 历史:从同一局面修订的快照同步全部历史局面哈希(含当前局面), + // 必须在 getAllValidMoves 之前设置,使根节点走法也经过 super-ko 过滤 + this.koHistory = snapshot.positionHistory(); + this.searchKomi = GoGame.getConfiguredKomi(); + + List validMoves = getAllValidMoves(snapshot.currentHash(), board, currentPlayer); + int consecutivePasses = snapshot.consecutivePasses(); + int stoneCount = countStones(board); + boolean passCandidate = consecutivePasses > 0 || stoneCount >= ENDGAME_STONES || validMoves.isEmpty(); + if (!passCandidate) { + // 杀棋、定式与局部战术只处理棋盘落子;进入收官或已有一手 PASS 后, + // 必须交给包含 PASS 的树搜索比较继续落子和结束对局。 + int[] killerMove = findKillerMove(board, currentPlayer, validMoves); + if (killerMove != null) { this.lastMove = killerMove; this.currentRoot = null; return killerMove; } + + int[] bookMove = getOpeningBookMove(board, currentPlayer, validMoves, moveCount); + if (bookMove != null) { this.lastMove = bookMove; this.currentRoot = null; return bookMove; } + + int tacticalBudget = Math.max(200, Math.min(800, baseSearchTime / 4)); + int[] tacticalMove = tacticalReading(board, currentPlayer, System.currentTimeMillis() + tacticalBudget); + if (tacticalMove != null && isLegalMove(board, tacticalMove[0], tacticalMove[1], currentPlayer) + && !isKoIllegal(board, tacticalMove[0], tacticalMove[1], currentPlayer)) { + this.lastMove = tacticalMove; + this.currentRoot = null; + return tacticalMove; + } + } + + if (validMoves.size() == 1 && !passCandidate) { + int[] m = validMoves.get(0); + this.lastMove = new int[]{m[0], m[1]}; + this.currentRoot = null; + return new int[]{m[0], m[1]}; + } + + // maxIterations 为 0 时不启动空搜索;直接使用合法走法作为安全兜底, + // 避免并行 worker 全部拿到 0 次迭代后 getBestMCTSMove 返回 null。 + if (maxIterations == 0) { + if (validMoves.isEmpty() || consecutivePasses > 0) { + this.currentRoot = null; + this.lastRoot = null; + this.lastMove = null; + return null; + } + int[] fallback = validMoves.get(validMoves.size() - 1); + this.lastMove = new int[]{fallback[0], fallback[1]}; + this.currentRoot = null; + return new int[]{fallback[0], fallback[1]}; + } + + List searchMoves = new ArrayList<>(validMoves); + if (passCandidate) searchMoves.add(PASS_MOVE.clone()); + + // 动态计算搜索时间 + int searchTime = calculateDynamicSearchTime(moveCount, validMoves.size()); + + // 树重用 + MCTSNode reusedRoot = tryReuseTree(board, currentPlayer); + // 上一手位置(plane 3):新鲜根节点需要设置 move 以保证与训练分布一致 + int[] gameLastMove = snapshot.lastMove(); + if (reusedRoot != null) { + this.currentRoot = reusedRoot; + this.currentRoot.player = currentPlayer; + this.currentRoot.parent = null; + } else { + // 启发式排序候选点(getAllValidMoves 已按价值升序排好) + this.currentRoot = new MCTSNode(board, currentPlayer, null, gameLastMove, searchMoves); + } + this.currentRoot.consecutivePasses = consecutivePasses; + this.currentRoot.terminal = consecutivePasses >= 2; + this.currentRoot.hash = snapshot.currentHash(); + + // 根节点 Dirichlet 噪声(仅在自对弈训练时启用,对局模式关闭以保证棋力) + if (selfPlayMode) { + applyRootNoise(this.currentRoot); + } + + long deadline = System.currentTimeMillis() + searchTime; + long generation = searchGeneration.incrementAndGet(); + totalIterations.set(0); + + // 并行 MCTS 搜索(带提前终止) + if (parallelThreads > 1) { + parallelSearchWithEarlyTerminate(currentRoot, deadline, generation); + } else { + sequentialSearchWithEarlyTerminate(deadline); + } + + // 更新局势评估 + this.lastEvaluation = getCurrentWinRate(); + updateGameAssessment(); + + this.lastRoot = this.currentRoot; + int[] best; + if (selfPlayMode) { + // 自对弈:按访问分布采样(含温度控制),增强探索多样性 + best = sampleMCTSMove(currentRoot, moveCount); + } else { + best = getBestMCTSMove(currentRoot); // 对局:贪心选最高胜率 + } + // 搜索可能因极短时间预算、线程异常或所有候选扩展被过滤而没有访问节点。 + if (best == null) { + if (validMoves.isEmpty()) { + best = PASS_MOVE.clone(); + } else { + int[] fallback = validMoves.get(validMoves.size() - 1); + best = new int[]{fallback[0], fallback[1]}; + } + this.currentRoot = null; + } + this.lastMove = new int[]{best[0], best[1]}; + return isPass(best) ? null : new int[]{best[0], best[1]}; + } + + /** + * 返回上一次搜索的 MCTS 访问分布(362 维),作为策略训练目标。 + * 索引 0~360 为棋盘 361 个位置,索引 361 为弃权 pass。 + */ + public double[] getVisitDistribution() { + double[] dist = new double[POLICY_SIZE]; + MCTSNode root = currentRoot; + List children = null; + if (root != null) { + synchronized (root) { + if (root.children != null && !root.children.isEmpty()) { + children = new ArrayList<>(root.children); + } + } + } + if (children == null) { + // 无 MCTS 分布(早退路径:杀棋/定式/终局/战术/唯一走法)。 + // 返回 lastMove 的 one-hot,避免全 pass 污染策略目标。 + if (lastMove != null && lastMove.length >= 2) { + dist[policyIndex(lastMove)] = 1.0; + return dist; + } + // 连 lastMove 都没有(理论上不会走到),退化为全 pass + dist[PASS_INDEX] = 1.0; + return dist; + } + double total = 0; + for (MCTSNode child : children) { + double visits; + synchronized (child) { + visits = child.visits; + } + if (visits > 0 && child.move != null) { + total += visits; + dist[policyIndex(child.move)] += visits; + } + } + + if (total > 0) { + for (int i = 0; i < POLICY_SIZE; i++) dist[i] /= total; + } else if (lastMove != null && lastMove.length >= 2) { + dist[policyIndex(lastMove)] = 1.0; + } else { + dist[PASS_INDEX] = 1.0; + } + return dist; + } + + /** + * 对根节点应用 Dirichlet 噪声,增强 MCTS 搜索的探索多样性。 + * 噪声仅对当前根节点的子节点生效,非根节点无影响。 + */ + private void applyRootNoise(MCTSNode root) { + if (root == null) return; + // 统计候选走法总数:已展开的子节点 + 未展开的候选 + int n = (root.children == null ? 0 : root.children.size()) + + (root.untriedMoves == null ? 0 : root.untriedMoves.size()); + if (n < 2) return; + double[] noise = dirichletSample(n, DIRICHLET_ALPHA, this.random); + // 探索衰减:噪声混入比例随训练代次降低 + double eps = DIRICHLET_EPS * explorationScale; + Map noiseMap = new HashMap<>(n * 2); + int i = 0; + // 对已展开的子节点,直接设置 prior(噪声按策略先验的 ×361 缩放对齐) + if (root.children != null) { + for (MCTSNode child : root.children) { + if (child.move == null) { i++; continue; } + double v = noise[i++]; + noiseMap.put(actionKey(child.move), v); + child.prior = (1.0 - eps) * child.prior + eps * (v * PASS_INDEX); + } + } + // 对未展开的候选,仅记录噪声,expand 时读取 + if (root.untriedMoves != null) { + for (int[] m : root.untriedMoves) { + double v = noise[i++]; + noiseMap.put(actionKey(m), v); + } + } + root.rootNoise = noiseMap; + } + + /** + * 从 Dirichlet(alpha) 分布采样 n 个值。 + * 使用 Ahrens 算法对 shape < 1 的 Gamma 采样,再归一化。 + */ + private static double[] dirichletSample(int n, double alpha, Random rnd) { + double[] z = new double[n]; + double sum = 0; + for (int i = 0; i < n; i++) { + z[i] = gammaSample(alpha, rnd); + sum += z[i]; + } + if (sum <= 0) { + // 退化情况:全部为 0,回退到均匀分布 + for (int i = 0; i < n; i++) z[i] = 1.0 / n; + return z; + } + for (int i = 0; i < n; i++) z[i] /= sum; + return z; + } + + /** + * Gamma(shape, 1) 采样,支持 shape < 1(Ahrens-Dieter 算法)。 + */ + private static double gammaSample(double shape, Random rnd) { + if (shape < 1e-9) return 0.0; + if (shape >= 1.0) { + // Marsaglia-Tsang 算法对于 shape >= 1 + double d = shape - 1.0 / 3.0; + double c = 1.0 / Math.sqrt(9.0 * d); + while (true) { + double x, v; + do { + x = rnd.nextGaussian(); + v = 1.0 + c * x; + } while (v <= 0); + v = v * v * v; + double u = rnd.nextDouble(); + double rsq = x * x; + if (u < 1.0 - 0.0331 * rsq * rsq) return d * v; + if (Math.log(u) < 0.5 * rsq + d * (1.0 - v + Math.log(v))) return d * v; + } + } else { + // Ahrens-Dieter 算法 for shape < 1 + double e = Math.E + shape; + while (true) { + double u = rnd.nextDouble(); + double p = e * u; + if (p > 1.0) { + double x = -Math.log((e - p) / shape); + if (rnd.nextDouble() < Math.pow(x, shape - 1.0)) return x; + } else { + double x = Math.pow(p, 1.0 / shape); + if (rnd.nextDouble() < Math.exp(-x)) return x; + } + } + } + } + + /** + * 获取当前最佳走法的胜率 + */ + private double getCurrentWinRate() { + double bestWinRate = Double.NEGATIVE_INFINITY; + List children; + synchronized (currentRoot) { + if (currentRoot.children == null || currentRoot.children.isEmpty()) return 0; + children = new ArrayList<>(currentRoot.children); + } + for (MCTSNode child : children) { + double visits; + double totalScore; + synchronized (child) { + visits = child.visits; + totalScore = child.totalScore; + } + if (visits > 0) { + // 子节点是"对手行棋方"视角,根视角需取反 + double winRate = -totalScore / visits; + if (winRate > bestWinRate) { + bestWinRate = winRate; + } + } + } + // 无任何子节点有访问时返回 0,避免 NEGATIVE_INFINITY 污染局势评估 + return bestWinRate == Double.NEGATIVE_INFINITY ? 0 : bestWinRate; + } + + /** + * 并行 MCTS 搜索(共享树)。 + *

+ * 所有线程共享同一棵搜索树;节点扩展和回传分别在节点锁内完成, + * 无需树克隆和合并。 + */ + private void parallelSearchWithEarlyTerminate(MCTSNode searchRoot, long deadline, long generation) { + // 只启动有迭代预算的 worker,并将余数平均分配,确保 maxIterations < + // parallelThreads 时仍至少有一个 worker 执行一次迭代,同时不超出总预算。 + int workerCount = Math.min(parallelThreads, maxIterations); + int baseIterations = maxIterations / workerCount; + int remainder = maxIterations % workerCount; + CompletionService completions = new ExecutorCompletionService<>(SHARED_POOL); + List> futures = new ArrayList<>(workerCount); + AtomicLong activeWorkers = new AtomicLong(); + Object workerMonitor = new Object(); + + for (int i = 0; i < workerCount; i++) { + final int workerIterations = baseIterations + (i < remainder ? 1 : 0); + futures.add(completions.submit(() -> { + synchronized (workerMonitor) { + if (shutdown || searchGeneration.get() != generation + || Thread.currentThread().isInterrupted()) return null; + activeWorkers.incrementAndGet(); + } + try { + int iters = 0; + while (iters < workerIterations && System.currentTimeMillis() < deadline + && !shutdown && searchGeneration.get() == generation + && !Thread.currentThread().isInterrupted()) { + if (shouldTerminateEarly(totalIterations.get())) break; + iters++; + totalIterations.incrementAndGet(); + + MCTSNode node = selectNode(searchRoot); + MCTSNode leaf = node; + MCTSNode expanded = expand(node); + if (expanded != null) leaf = expanded; + double score = simulate(leaf); + + // Native/自定义 evaluator 可能在 deadline 后才返回;迟到结果不得回传。 + if (shutdown || searchGeneration.get() != generation + || Thread.currentThread().isInterrupted()) break; + backpropagate(leaf, score); + } + return null; + } finally { + if (activeWorkers.decrementAndGet() == 0L) { + synchronized (workerMonitor) { + workerMonitor.notifyAll(); + } + } + } + })); + } + + // 所有 Future 共享一次搜索截止时间。额外 1 秒只用于正常 worker 收尾, + // 不再按 worker 数量叠加每个 Future 的等待上限。 + long remainingMillis = Math.max(0L, deadline - System.currentTimeMillis()); + long waitDeadline = System.nanoTime() + + TimeUnit.MILLISECONDS.toNanos(remainingMillis + 1_000L); + int completed = 0; + boolean cancelled = false; + try { + while (completed < workerCount && !shutdown + && !Thread.currentThread().isInterrupted()) { + long remainingNanos = waitDeadline - System.nanoTime(); + if (remainingNanos <= 0L) { + cancelled = true; + break; + } + Future finished = completions.poll( + Math.min(remainingNanos, TimeUnit.MILLISECONDS.toNanos(100L)), + TimeUnit.NANOSECONDS); + if (finished == null) continue; + completed++; + try { + finished.get(); + } catch (CancellationException | ExecutionException ignored) { + } + } + if (completed < workerCount) cancelled = true; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + cancelled = true; + } finally { + if (cancelled || shutdown || Thread.currentThread().isInterrupted()) { + searchGeneration.compareAndSet(generation, generation + 1L); + for (Future future : futures) future.cancel(true); + } + boolean restoreInterrupt = Thread.interrupted(); + synchronized (workerMonitor) { + while (activeWorkers.get() > 0L) { + try { + workerMonitor.wait(); + } catch (InterruptedException e) { + restoreInterrupt = true; + searchGeneration.compareAndSet(generation, generation + 1L); + for (Future future : futures) future.cancel(true); + } + } + } + if (restoreInterrupt) Thread.currentThread().interrupt(); + } + // 共享线程池不关闭(线程设为 daemon,随进程退出) + } + + /** + * 顺序 MCTS 搜索(带提前终止) + */ + private void sequentialSearchWithEarlyTerminate(long deadline) { + int iterations = 0; + while (iterations < maxIterations && System.currentTimeMillis() < deadline + && !shutdown && !Thread.currentThread().isInterrupted()) { + iterations++; + totalIterations.set(iterations); + // 提前终止检查 + if (shouldTerminateEarly(iterations)) { + break; + } + MCTSNode node = selectNode(currentRoot); + MCTSNode leaf = node; + MCTSNode expanded = expand(node); + if (expanded != null) leaf = expanded; + double score = simulate(leaf); + backpropagate(leaf, score); + } + } + + /** + * Expands one action and returns the new leaf that must be evaluated this iteration. + */ + private MCTSNode expand(MCTSNode node) { + int[] moveFull; + synchronized (node) { + if (node.terminal || node.untriedMoves.isEmpty()) return null; + moveFull = node.untriedMoves.remove(node.untriedMoves.size() - 1); + } + int[] move = new int[]{moveFull[0], moveFull[1]}; + + boolean needsForward; + synchronized (node) { + needsForward = node.policyCache == null && !node.forwardInFlight; + if (needsForward) node.forwardInFlight = true; + } + if (needsForward) { + NeuralEvaluator.ForwardResult fr; + try { + fr = neuralEvaluator.forward( + neuralEvaluator.buildInputPlanes(node.board, node.player, node.move), + neuralEvaluator.extractAuxFeatures(node.board, node.player)); + } catch (RuntimeException | Error e) { + synchronized (node) { + node.forwardInFlight = false; + node.notifyAll(); + } + throw e; + } + + synchronized (node) { + node.policyCache = fr.policy; + node.valueCache = fr.value; + node.valueCached = true; + if (node.untriedMoves.size() > 1) { + double[] policy = node.policyCache; + node.untriedMoves.sort((a, b) -> Double.compare( + policy[policyIndex(a)], policy[policyIndex(b)])); + double total = 0; + for (int[] candidate : node.untriedMoves) total += policy[policyIndex(candidate)]; + if (total > 0) { + double cumulative = 0; + List kept = new ArrayList<>(node.untriedMoves.size()); + for (int i = node.untriedMoves.size() - 1; i >= 0; i--) { + int[] candidate = node.untriedMoves.get(i); + kept.add(candidate); + cumulative += policy[policyIndex(candidate)]; + if (cumulative >= 0.95 * total && kept.size() >= 3) break; + } + Collections.reverse(kept); + node.untriedMoves = kept; + } + } + node.forwardInFlight = false; + node.notifyAll(); + } + } + + GoPlayer[][] childBoard = deepCopyBoard(node.board); + GoPlayer nextPlayer = opposite(node.player); + int childPasses; + long childHash; + boolean terminal; + if (isPass(move)) { + childPasses = node.consecutivePasses + 1; + childHash = node.hash != 0 ? node.hash : GoGame.boardHash(childBoard); + terminal = childPasses >= 2; + } else { + if (!simulatePlaceStone(childBoard, move[0], move[1], node.player)) return null; + childHash = GoGame.boardHash(childBoard); + if (koHistory != null && (koHistory.contains(childHash) || isAncestorKoRepeat(node, childHash))) { + return null; + } + childPasses = 0; + terminal = false; + } + + List childMoves = terminal + ? Collections.emptyList() + : getSearchMoves(childHash, childBoard, nextPlayer, childPasses); + MCTSNode child = new MCTSNode(childBoard, nextPlayer, node, move, childMoves); + child.hash = childHash; + child.consecutivePasses = childPasses; + child.terminal = terminal; + + synchronized (node) { + while (node.policyCache == null && node.forwardInFlight + && !shutdown && !Thread.currentThread().isInterrupted()) { + try { + node.wait(100L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } + } + if (node.policyCache == null || shutdown || Thread.currentThread().isInterrupted()) { + return null; + } + int moveIdx = policyIndex(move); + double prior = moveIdx < node.policyCache.length + ? Math.max(node.policyCache[moveIdx], 1e-10) * PASS_INDEX + : 1.0; + if (node.rootNoise != null) { + Double noise = node.rootNoise.get(actionKey(move)); + if (noise != null) { + double eps = DIRICHLET_EPS * explorationScale; + prior = (1.0 - eps) * prior + eps * (noise * PASS_INDEX); + } + } + child.prior = prior; + } + + synchronized (node) { + if (node.children == null) node.children = new ArrayList<>(); + node.children.add(child); + } + node.linkedMove = move; + return child; + } + + /** 每线程零分配缓冲:热路径(候选生成/打分)的棋群扫描、去重、BFS 全部复用。 + * 并行搜索多 worker 共享同一 AI 实例,故必须 ThreadLocal。 */ + private static final ThreadLocal SCRATCH_CELLS_A = ThreadLocal.withInitial(() -> new int[BOARD_SIZE * BOARD_SIZE]); + private static final ThreadLocal SCRATCH_CELLS_B = ThreadLocal.withInitial(() -> new int[BOARD_SIZE * BOARD_SIZE]); + private static final ThreadLocal SCRATCH_STACK = ThreadLocal.withInitial(() -> new int[BOARD_SIZE * BOARD_SIZE]); + private static final ThreadLocal SCRATCH_CAPT = ThreadLocal.withInitial(() -> new int[BOARD_SIZE * BOARD_SIZE]); + private static final ThreadLocal SCRATCH_BFS_Q = ThreadLocal.withInitial(() -> new int[BOARD_SIZE * BOARD_SIZE]); + private static final ThreadLocal SCRATCH_VIS_GROUP = ThreadLocal.withInitial(() -> new boolean[BOARD_SIZE * BOARD_SIZE]); + private static final ThreadLocal SCRATCH_VIS_BFS = ThreadLocal.withInitial(() -> new boolean[BOARD_SIZE * BOARD_SIZE]); + private static final ThreadLocal SCRATCH_SEEN = ThreadLocal.withInitial(() -> new boolean[BOARD_SIZE * BOARD_SIZE]); + + /** 原语版 {@link #getGroup}:把 (sx,sy) 处 color 棋群的格点写入 cells,返回数量。 + * 要求 board[sx][sy]==color。输出为同一格点集合(getGroup 的集合语义与遍历顺序无关)。 */ + private int scanGroup(GoPlayer[][] board, int sx, int sy, GoPlayer color, int[] cells) { + boolean[] visited = SCRATCH_VIS_GROUP.get(); + java.util.Arrays.fill(visited, false); + int[] stack = SCRATCH_STACK.get(); + int top = 0, n = 0; + int start = sx * BOARD_SIZE + sy; + visited[start] = true; + stack[top++] = start; + cells[n++] = start; + while (top > 0) { + int p = stack[--top]; + int px = p / BOARD_SIZE, py = p % BOARD_SIZE; + for (int[] dir : DIRS) { + int nx = px + dir[0], ny = py + dir[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE) { + int idx = nx * BOARD_SIZE + ny; + if (!visited[idx] && board[nx][ny] == color) { + visited[idx] = true; + stack[top++] = idx; + cells[n++] = idx; + } + } + } + } + return n; + } + + /** cells 中的 n 个格点构成的棋群在当前盘面上是否有气。 */ + private boolean cellsHaveLiberty(GoPlayer[][] board, int[] cells, int n) { + for (int i = 0; i < n; i++) { + int p = cells[i]; + int px = p / BOARD_SIZE, py = p % BOARD_SIZE; + for (int[] dir : DIRS) { + int nx = px + dir[0], ny = py + dir[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE + && board[nx][ny] == GoPlayer.NONE) return true; + } + } + return false; + } + + /** 原语版 {@link #countGroupLiberties}:cells 中 n 个格点棋群的不同空点邻数。 */ + private int countGroupLibertiesPrim(GoPlayer[][] board, int[] cells, int n) { + boolean[] seen = SCRATCH_SEEN.get(); + java.util.Arrays.fill(seen, false); + int count = 0; + for (int i = 0; i < n; i++) { + int p = cells[i]; + int px = p / BOARD_SIZE, py = p % BOARD_SIZE; + for (int[] dir : DIRS) { + int nx = px + dir[0], ny = py + dir[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE + && board[nx][ny] == GoPlayer.NONE) { + int idx = nx * BOARD_SIZE + ny; + if (!seen[idx]) { + seen[idx] = true; + count++; + } + } + } + } + return count; + } + + /** 原语版 {@link #countCaptures}:语义逐位一致(含"标记不移除"去重), + * 仅把 HashSet 换成线程局部原语缓冲。契约同样要求调用时 (x,y) 为空。 */ + private int countCapturesPrim(GoPlayer[][] board, int x, int y, GoPlayer player) { + board[x][y] = player; + GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + int[] cells = SCRATCH_CELLS_B.get(); + boolean[] seen = SCRATCH_SEEN.get(); + java.util.Arrays.fill(seen, false); + int captures = 0; + for (int[] dir : DIRS) { + int nx = x + dir[0], ny = y + dir[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE + && board[nx][ny] == opponent) { + int idx = nx * BOARD_SIZE + ny; + if (seen[idx]) continue; // 该棋群已被计入 + int n = scanGroup(board, nx, ny, opponent, cells); + if (!cellsHaveLiberty(board, cells, n)) { + captures += n; + for (int i = 0; i < n; i++) seen[cells[i]] = true; + } + } + } + board[x][y] = GoPlayer.NONE; + return captures; + } + + /** 原语版 {@link #evaluateMoveLiberties}:FIFO 出队顺序与 DIRS 扫描顺序逐位复刻, + * 软上限 10 的过冲语义因此与旧实现一致。 */ + private int evaluateMoveLibertiesPrim(GoPlayer[][] board, int x, int y, GoPlayer player) { + boolean[] visited = SCRATCH_VIS_BFS.get(); + java.util.Arrays.fill(visited, false); + int[] queue = SCRATCH_BFS_Q.get(); + int head = 0, tail = 0, liberties = 0; + int start = x * BOARD_SIZE + y; + queue[tail++] = start; + visited[start] = true; + while (head < tail && liberties < 10) { + int p = queue[head++]; + int px = p / BOARD_SIZE, py = p % BOARD_SIZE; + for (int[] dir : DIRS) { + int nx = px + dir[0], ny = py + dir[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE) { + int idx = nx * BOARD_SIZE + ny; + if (!visited[idx]) { + visited[idx] = true; + if (board[nx][ny] == GoPlayer.NONE) { + liberties++; + } else if (board[nx][ny] == player) { + queue[tail++] = idx; + } + } + } + } + } + return liberties; + } + + /** + * 融合打分:合并剪枝判定和排序分,且复用候选模拟阶段已算出的自身棋群, + * 省去 wouldBeInAtari 的冗余棋群遍历。输出与原 {@code evaluateMoveScore} + * 逐位一致(行为快照门禁保证): + *

    + *
  • captures/oppCaptures 仍走 countCaptures 语义(其"标记不移除"去重 + * 与模拟阶段的即时移除在"提二甩一"等边角上可能不同,不能直接用 + * 提子数替代);原语版 countCapturesPrim 与之逐位一致。
  • + *
  • 打吃判定:精确气数==1 ⟺ wouldBeInAtari(同在落子态下计数,(x,y) + * 自身不计入气)。
  • + *
  • 气数:evaluateMoveLiberties 的"上限 10"是软上限——BFS 在轮询间隔检查 + * liberties<10,单个棋格的邻居扫描可一次过冲到 10–13,故精确气数 + * ≥10 时必须调 BFS 复刻过冲语义,<10 时精确气数逐位等于其返回值。
  • + *
+ * + * @param cells 落子点所属棋群(含 (x,y),落子态下计算);提子复位不改变 + * 该棋群的组成,可直接复用 + * @param selfCount cells 中的棋群格点数;<0 表示尚未扫描,在落子态下现算 + * @return 走法分值(负值表示应剪枝跳过) + */ + private int scoreFusedMove(GoPlayer[][] board, int x, int y, GoPlayer player, + int[] cells, int selfCount) { + GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + + // ── 一次性计算所有昂贵原语(棋群遍历仅一次)── + int captures = countCapturesPrim(board, x, y, player); + int oppCaptures = countCapturesPrim(board, x, y, opponent); + board[x][y] = player; + if (selfCount < 0) selfCount = scanGroup(board, x, y, player, cells); + int groupLibs = countGroupLibertiesPrim(board, cells, selfCount); + board[x][y] = GoPlayer.NONE; + int libs = groupLibs < 10 ? groupLibs + : evaluateMoveLibertiesPrim(board, x, y, player); // 复刻 BFS 软上限过冲 + int friendly = countFriendlyNeighbors(board, x, y, player); + int posBonus = getPositionBonus(x, y); + int edgeDist = Math.min(Math.min(x, y), Math.min(BOARD_SIZE - 1 - x, BOARD_SIZE - 1 - y)); + + // 对手邻居数 + int oppNbrs = 0; + for (int[] dir : DIRS) { + int nx = x + dir[0], ny = y + dir[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == opponent) + oppNbrs++; + } + + // ── 剪枝判定 ── + int pruneScore = libs * 3 + friendly * 5 + captures * 20 + posBonus; + if (edgeDist == 0) pruneScore -= 5; + if (edgeDist == 0 && friendly == 0) pruneScore -= 20; + if (friendly == 0 && oppNbrs >= 3) pruneScore -= 15; + if (libs <= 1 && captures == 0) pruneScore -= 30; + + // 明显差的走法直接跳过(被剪掉的点不再付出排序分的开销) + if (pruneScore < -10) return pruneScore; + + // ── 完整排序分 ── + int score = captures * 50 - oppCaptures * 40 + libs * 10 + posBonus + friendly * 15; + if (groupLibs == 1) score -= 30; // ⟺ wouldBeInAtari + return score; + } + + /** + * 计算落子能吃的棋子数 + */ + private int countCaptures(GoPlayer[][] board, int x, int y, GoPlayer player) { + board[x][y] = player; + GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + int captures = 0; + // 用已计数集合去重:同一对手棋群若环绕 (x,y) 从两个方向相邻,只计一次 + Set counted = new HashSet<>(); + for (int[] dir : DIRS) { + int nx = x + dir[0], ny = y + dir[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == opponent) { + long key = (long) nx * BOARD_SIZE + ny; + if (counted.contains(key)) continue; // 该棋群已被计入 + Set group = getGroup(board, nx, ny); + if (!hasLiberty(board, group)) { + captures += group.size(); + for (int[] pos : group) counted.add((long) pos[0] * BOARD_SIZE + pos[1]); + } + } + } + board[x][y] = GoPlayer.NONE; + return captures; + } + + /** + * 评估走子的气数 + */ + private int evaluateMoveLiberties(GoPlayer[][] board, int x, int y, GoPlayer player) { + int liberties = 0; + boolean[][] visited = new boolean[BOARD_SIZE][BOARD_SIZE]; + Queue queue = new LinkedList<>(); + queue.offer(new int[]{x, y}); + visited[x][y] = true; + + while (!queue.isEmpty() && liberties < 10) { + int[] pos = queue.poll(); + for (int[] dir : DIRS) { + int nx = pos[0] + dir[0], ny = pos[1] + dir[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && !visited[nx][ny]) { + visited[nx][ny] = true; + if (board[nx][ny] == GoPlayer.NONE) { + liberties++; + } else if (board[nx][ny] == player) { + queue.offer(new int[]{nx, ny}); + } + } + } + } + return liberties; + } + + /** + * 落子后是否会被打吃 + */ + private boolean wouldBeInAtari(GoPlayer[][] board, int x, int y, GoPlayer player) { + board[x][y] = player; + Set group = getGroup(board, x, y); + boolean inAtari = countGroupLiberties(board, group) == 1; + board[x][y] = GoPlayer.NONE; + return inAtari; + } + + /** + * 统计相邻己方棋子数 + */ + private int countFriendlyNeighbors(GoPlayer[][] board, int x, int y, GoPlayer player) { + int count = 0; + for (int[] dir : DIRS) { + int nx = x + dir[0], ny = y + dir[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == player) { + count++; + } + } + return count; + } + + /** + * 位置加成(优先中心、避开边角) + */ + private int getPositionBonus(int x, int y) { + int center = BOARD_SIZE / 2; + int distToCenter = Math.max(Math.abs(x - center), Math.abs(y - center)); + int distToEdge = Math.min(Math.min(x, y), Math.min(BOARD_SIZE - 1 - x, BOARD_SIZE - 1 - y)); + + int bonus = 0; + if (distToCenter <= 2) bonus += 8; // 中心区域 + else if (distToCenter <= 4) bonus += 4; // 中腹 + if (distToEdge <= 1) bonus -= 5; // 边角惩罚 + return bonus; + } + + // ══════════════════════════════════════════════════════════════════ + // MCTS 选择与模拟 + // ══════════════════════════════════════════════════════════════════ + + private static final double UCB_C = 1.414; + /** FPU(First Play Urgency):未访问子节点的默认价值,0 表示假设均势 */ + private static final double FPU_VALUE = 0.0; + + /** 共享有界线程池;跨 AI 复用,同时限制异常高并发下的系统线程峰值。 */ + private static final ExecutorService SHARED_POOL = Executors.newFixedThreadPool( + Math.max(2, PARALLEL_THREADS), + r -> { Thread t = new Thread(r, "mcts-worker"); t.setDaemon(true); return t; }); + + /** + * 释放本 AI 持有的 native 资源(OpenCL 后端)。 + * 修复:GoAI.shutdown 原为 default 空实现且本类未覆写,GoGame.close() 对 + * MCTS 路径是 no-op,GPU 句柄在屏显重开时反复堆积。SHARED_POOL 是跨实例 + * 共享的静态池,此处不关闭。 + */ + @Override + public void shutdown() { + shutdown = true; + searchGeneration.incrementAndGet(); + if (!lifecycleLock.tryLock()) return; + try { + releaseEvaluatorLocked(); + } finally { + lifecycleLock.unlock(); + } + } + + private void releaseEvaluatorLocked() { + if (evaluatorReleased) return; + evaluatorReleased = true; + neuralEvaluator.release(); + } + + private MCTSNode selectNode(MCTSNode node) { + while (true) { + synchronized (node) { + if (!node.untriedMoves.isEmpty() + || node.children == null || node.children.isEmpty()) { + return node; + } + } + MCTSNode child = selectBestChild(node); + if (child == null) return node; + node = child; + } + } + + private MCTSNode selectBestChild(MCTSNode parent) { + MCTSNode best = null; + double bestValue = Double.NEGATIVE_INFINITY; + double parentVisits; + + // 快照 children 避免并发修改异常(expand 在加锁状态下添加子节点) + List children; + synchronized (parent) { + if (parent.children == null || parent.children.isEmpty()) return null; + parentVisits = parent.visits; + children = new ArrayList<>(parent.children); + } + double sqrtParentVisits = Math.sqrt(Math.max(parentVisits, 1)); + + for (MCTSNode child : children) { + double visits; + double totalScore; + synchronized (child) { + visits = child.visits; + totalScore = child.totalScore; + } + // 子节点是"对手行棋方"视角,父节点视角需取反(Q 项为 -child.Q) + double winRate; + if (visits == 0) { + // FPU:未访问子节点用 FPU_VALUE(默认 0 表示均势),替代强制展开。 + // 避免宽局面(100+ 合法走法)下前 100 次迭代全花在"点一遍每个点", + // 已访问高胜率走法可被立即重访,搜索深度显著提升。 + winRate = FPU_VALUE; + } else { + winRate = -totalScore / visits; + } + // PUCT(标准 AlphaGo Zero 形式): Q + c_puct * P(s,a) * sqrt(N_parent) / (1 + N_child) + // ★ 修正:原版探索项用 √log(N),随搜索增长过慢,先验高但少访问的 + // 走法迟迟得不到试探;标准式随 √N 增长,prior 引导的探索更充分 + double ucb = winRate + + UCB_C * child.prior * sqrtParentVisits / (1.0 + visits); + + if (ucb > bestValue) { + bestValue = ucb; + best = child; + } + } + return best; + } + + /** + * 纯神经网络模拟评估(缓存感知)。 + *

+ * 若节点已有 valueCache(来自 expand 的同一次前向),直接返回; + * 否则(首次选中且 untried 为空时)做一次完整前向,同时缓存策略+价值。 + */ + private double simulate(MCTSNode node) { + if (node.terminal) return terminalScore(node); + if (node.valueCached) return node.valueCache; + // 首次遇此节点:一次前向同时拿到策略+价值,避免后续重复前向 + NeuralEvaluator.ForwardResult fr = neuralEvaluator.forward( + neuralEvaluator.buildInputPlanes(node.board, node.player, node.move), + neuralEvaluator.extractAuxFeatures(node.board, node.player)); + node.valueCache = fr.value; + node.valueCached = true; + if (node.policyCache == null) { + node.policyCache = fr.policy; + } + return fr.value; + } + + private double terminalScore(MCTSNode node) { + int[] area = GoGame.calcTerritory(node.board); + double blackMargin = area[0] - (area[1] + searchKomi); + double perspectiveMargin = node.player == GoPlayer.BLACK ? blackMargin : -blackMargin; + return Math.max(-1.0, Math.min(1.0, perspectiveMargin / 100.0)); + } + + private void backpropagate(MCTSNode node, double score) { + // score 是 node(叶子)行棋方视角的价值。 + // 每往上一层,行棋方交替,价值取反 —— 这样每个节点存的是"该节点行棋方视角"。 + // (价值头输出为当前行棋方视角,见 valueTarget = (s.player==BLACK ? margin : -margin)) + // ★ Bug修复:神经网络在极端输入下可能输出 NaN/Infinity(如全 0 plane、batch + // 越界等),一旦回传将沿 parent 链把所有 totalScore 污染为 NaN,导致 + // shouldTerminateEarly 与 winrate 计算全部失效 → 中盘胜率坍塌。 + // 这里加一道 finite 守卫:若 score 非有限值则跳过累加,但 visits 仍 +1, + // 避免一个坏叶子把整棵树打分全部"毒化"。 + if (!Double.isFinite(score)) { + while (node != null) { + synchronized (node) { node.visits++; } + node = node.parent; + } + return; + } + while (node != null) { + synchronized (node) { + node.visits++; + node.totalScore += score; + } + score = -score; + node = node.parent; + } + } + + /** + * 尝试重用上一回合的搜索树。 + *

+ * 找到匹配的子节点后,先对该子树执行深拷贝(cloneNodeTree),再递归更新棋盘。 + * 使用 deepCopyNode 递归复制子树,避免破坏兄弟节点间的棋盘独立性。 + * + * @param board 当前棋盘 + * @param currentPlayer 当前玩家 + * @return 可复用的子树根节点,若无匹配则返回 null + */ + private MCTSNode tryReuseTree(GoPlayer[][] board, GoPlayer currentPlayer) { + if (lastRoot == null || lastMove == null || lastRoot.children == null) return null; + + for (MCTSNode child : lastRoot.children) { + if (child.move != null && child.move[0] == lastMove[0] && child.move[1] == lastMove[1]) { + // 校验棋盘一致:重用的子节点必须是"当前局面恰好是上一步之后"。 + // 自对弈(AI 每步都走)时匹配;对抗/人机对局中对手插了一手, + // 子节点棋盘与当前棋盘不同,跳过重用避免旧位置子树污染搜索。 + if (child.player != currentPlayer || !boardsEqual(child.board, board)) continue; + // 使用 deepCopyNode 递归复制子树,避免破坏兄弟节点的棋盘引用 + MCTSNode newRoot = deepCopyNode(child, board); + newRoot.parent = null; + return newRoot; + } + } + return null; + } + + /** + * 递归深拷贝节点及其子树,并用新棋盘状态替换根节点的棋盘 + */ + private MCTSNode deepCopyNode(MCTSNode node, GoPlayer[][] newBoard) { + GoPlayer[][] boardCopy = deepCopyBoard(newBoard); + MCTSNode copy = new MCTSNode( + boardCopy, + node.player, + null, // parent 稍后设置 + node.move, + node.untriedMoves // 复用未展开走法(棋盘已按走法重建,合法走法集合一致) + ); + copy.visits = node.visits; + copy.totalScore = node.totalScore; + copy.linkedMove = node.linkedMove; + // prior belongs to the incoming parent edge. A node's own policyCache describes + // its outgoing edges and cannot reconstruct this value during tree reuse. + copy.prior = node.prior; + copy.policyCache = node.policyCache; // 只读共享,线程安全(行为复用) + copy.valueCache = node.valueCache; + copy.valueCached = node.valueCached; + // 复制局面哈希(deepCopyNode 递归应用走法重建棋盘,哈希需从子节点棋盘重新计算) + copy.hash = GoGame.boardHash(boardCopy); + copy.consecutivePasses = node.consecutivePasses; + copy.terminal = node.terminal; + + if (node.children != null) { + copy.children = new ArrayList<>(); + for (MCTSNode child : node.children) { + // 为每个子节点创建正确的棋盘:在父棋盘基础上应用子走法 + GoPlayer[][] childBoard = deepCopyBoard(boardCopy); + if (child.move != null && !isPass(child.move)) { + simulatePlaceStone(childBoard, child.move[0], child.move[1], node.player); + } + MCTSNode childCopy = deepCopyNode(child, childBoard); + childCopy.parent = copy; + copy.children.add(childCopy); + } + } + + return copy; + } + + private int[] getBestMCTSMove(MCTSNode root) { + List children; + synchronized (root) { + if (root.children == null || root.children.isEmpty()) return null; + children = new ArrayList<>(root.children); + } + + // AlphaZero 标准终局选着:按访问数最大。 + // 访问数对评估噪声更鲁棒:胜率均值在低访问分支方差大, + // 原版按胜率选会偶尔选进"2 连胜假信号"的冷门分支 + MCTSNode best = null; + double bestVisits = -1; + + for (MCTSNode child : children) { + double visits; + synchronized (child) { + visits = child.visits; + } + if (visits > bestVisits) { + bestVisits = visits; + best = child; + } + } + return best != null ? best.move : null; + } + + /** + * 自对弈模式走法选择:按访问分布采样(AlphaZero 风格温度控制)。 + * 开局 temp=1.0(按比例采样,探索充分),30 手后 temp=0.1(趋近贪心收敛)。 + * 与存盘的策略目标(访问分布)保持一致,保证训练样本分布合理。 + */ + private int[] sampleMCTSMove(MCTSNode root, int moveCount) { + List children; + synchronized (root) { + if (root.children == null || root.children.isEmpty()) return null; + children = new ArrayList<>(root.children); + } + // 温度随探索强度衰减(早期高探索→高温,后期低探索→低温更贪心) + // ★ 修正:默认探索强度(1.0)下开局温度应为 1.0(与上方注释一致)。 + // 原公式 1.5*x+0.1 在默认档得 1.6,开局采样过散,策略训练目标噪声过大 + double earlyTemp = 0.9 * explorationScale + 0.1; + double lateTemp = 0.3 * explorationScale + 0.05; + double temp = moveCount < 30 ? earlyTemp : lateTemp; + + List candidates = new ArrayList<>(); + List weights = new ArrayList<>(); + double sum = 0; + for (MCTSNode child : children) { + double visits; + synchronized (child) { + visits = child.visits; + } + if (visits > 0) { + double w = (temp <= 0.01) ? visits : Math.pow(visits, 1.0 / temp); + weights.add(w); + candidates.add(child); + sum += w; + } + } + if (candidates.isEmpty() || sum <= 0) return getBestMCTSMove(root); + + double r = random.nextDouble() * sum; + double cum = 0; + for (int i = 0; i < candidates.size(); i++) { + cum += weights.get(i); + if (cum >= r) return candidates.get(i).move; + } + return candidates.get(candidates.size() - 1).move; + } + + private boolean movesEqual(int[] a, int[] b) { + return a != null && b != null && a[0] == b[0] && a[1] == b[1]; + } + + private static boolean isPass(int[] move) { + return move != null && move.length >= 2 && move[0] < 0 && move[1] < 0; + } + + private static int policyIndex(int[] move) { + if (isPass(move)) return PASS_INDEX; + if (move == null || move.length < 2 || move[0] < 0 || move[0] >= BOARD_SIZE + || move[1] < 0 || move[1] >= BOARD_SIZE) { + throw new IllegalArgumentException("Invalid MCTS action"); + } + return move[0] * BOARD_SIZE + move[1]; + } + + private static String actionKey(int[] move) { + return isPass(move) ? "pass" : move[0] + "," + move[1]; + } + + private static GoPlayer opposite(GoPlayer player) { + return player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + } + + // ══════════════════════════════════════════════════════════════════ + // 战术阅读器(深度优先 α-β 搜索) + // ══════════════════════════════════════════════════════════════════ + + // ══════════════════════════════════════════════════════════════════ + // 局部战术搜索(受限 α-β + 神经网络叶子评估) + // ══════════════════════════════════════════════════════════════════ + + /** 局部搜索最大深度 */ + private static final int TACTICAL_DEPTH = 5; + + /** + * 战术阅读:对对手危险棋群做受限 α-β 深度搜索,用神经网络评估叶子。 + * 气数≤2 的棋群用深度 5 搜索,气数=3 用深度 3。 + * 搜索范围限定在目标棋群周围 2 格内,避免全盘扫描。 + * + * @param deadline 时间预算截止时间戳,超时立即返回 + * @return 必胜走法 {x,y},找不到则返回 null + */ + private int[] tacticalReading(GoPlayer[][] board, GoPlayer player, long deadline) { + GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + // ★ Bug修复(中盘算炸防御):原版对每个 (x,y) 都重新取棋群并搜索, + // 中盘 200+ 步时濒死棋群密集,多个同色连体子被反复扫到,导致 + // O(361 × α-β深度5) 的组合爆炸 → 内存/时间耗尽。 + // 1) 棋群去重:Set 没有原生 hash,把 group 序列化为 "x,y" 串后 + // 放入 visited 集合,已访问过的棋群整体跳过。 + // 2) 候选点过载保护:collectLocalRegion 返回超过 30 个候选时直接跳过, + // 避免在松散棋形上启动深度 5 α-β。 + Set visited = new HashSet<>(); + + for (int x = 0; x < BOARD_SIZE; x++) { + for (int y = 0; y < BOARD_SIZE; y++) { + if (board[x][y] != opponent) continue; + Set group = getGroup(board, x, y); + int libs = countGroupLiberties(board, group); + if (libs <= 3 && group.size() >= 2) { + // 棋群去重(用最小 x,y 作为代表 key;group 自身按 x 升序排) + String key = groupKey(group); + if (visited.contains(key)) continue; + visited.add(key); + // 收集局部区域 + Set region = collectLocalRegion(board, group); + if (region.isEmpty()) continue; + if (region.size() > 30) continue; // 候选过载,跳过 + int[] best = localAlphaBetaSearch(board, group, player, opponent, + libs <= 2 ? TACTICAL_DEPTH : 3, region, deadline); + if (best != null) return best; + } + } + } + return null; + } + + /** 把棋群序列化为唯一字符串 key,用于 visited 集合去重。 + * 使用相对坐标(锚点 = 棋群最左上的子)+ 字典序排序, + * 使同形状但位置不同的棋群产生不同 key,避免被错误地合并; + * 同位置同形状的棋群(递归扫描时的重复)产生相同 key,被正确去重。 */ + private static String groupKey(Set group) { + int minX = Integer.MAX_VALUE, minY = Integer.MAX_VALUE; + for (int[] p : group) { + if (p[0] < minX || (p[0] == minX && p[1] < minY)) { + minX = p[0]; minY = p[1]; + } + } + StringBuilder sb = new StringBuilder(); + List sorted = new ArrayList<>(group); + sorted.sort((a, b) -> { + int dx = a[0] - b[0], dy = a[1] - b[1]; + return dx != 0 ? dx : dy; + }); + for (int[] p : sorted) { + if (sb.length() > 0) sb.append(';'); + sb.append(p[0] - minX).append(',').append(p[1] - minY); + } + return sb.toString(); + } + + /** + * 收集目标棋群周围 2 格内的所有空点(局部搜索区域)。 + */ + private Set collectLocalRegion(GoPlayer[][] board, Set group) { + Set region = new HashSet<>(); + for (int[] pos : group) { + for (int[] dir : DIRS) { + int nx = pos[0] + dir[0], ny = pos[1] + dir[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE + && board[nx][ny] == GoPlayer.NONE) { + region.add(nx + "," + ny); + // 扩展一圈到 2 格半径 + for (int[] d2 : DIRS) { + int nx2 = nx + d2[0], ny2 = ny + d2[1]; + if (nx2 >= 0 && nx2 < BOARD_SIZE && ny2 >= 0 && ny2 < BOARD_SIZE + && board[nx2][ny2] == GoPlayer.NONE) + region.add(nx2 + "," + ny2); + } + } + } + } + return region; + } + + /** + * 对目标棋群做局部 α-β 搜索,返回最佳杀棋走法。 + * 只有找到明确优势(评估值 > 0.3)的走法才返回。 + */ + private int[] localAlphaBetaSearch(GoPlayer[][] board, Set target, + GoPlayer attacker, GoPlayer defender, + int maxDepth, Set region, long deadline) { + // 候选点排序(吃子优先) + List candidates = new ArrayList<>(); + for (String s : region) { + // ★ Bug修复:region 里的字符串可能为 "x,"(缺 y)或 ","(空),split 后 + // p.length<2 会抛 AIOOBE;NumberFormatException 也可能。 + // 防御性跳过,AI 线程不应因此崩 + try { + String[] p = s.split(","); + if (p.length != 2) continue; + int x = Integer.parseInt(p[0]); + int y = Integer.parseInt(p[1]); + if (!isLegalMove(board, x, y, attacker)) continue; + int priority = countCaptures(board, x, y, attacker) * 20 + + countFriendlyNeighbors(board, x, y, attacker) * 5; + candidates.add(new int[]{x, y, priority}); + } catch (NumberFormatException nfe) { + // 畸形坐标字符串,跳过 + } + } + candidates.sort((a, b) -> b[2] - a[2]); + + int[] bestMove = null; + double bestScore = Double.NEGATIVE_INFINITY; + + for (int[] move : candidates) { + if (System.currentTimeMillis() > deadline) break; + GoPlayer[][] next = deepCopyBoard(board); + if (!simulatePlaceStone(next, move[0], move[1], attacker)) continue; + + double score = -localAlphaBeta(next, target, defender, attacker, maxDepth - 1, + Double.NEGATIVE_INFINITY, -bestScore, region, deadline); + + if (score > bestScore) { + bestScore = score; + bestMove = new int[]{move[0], move[1]}; + } + // 必胜走法,提前停止 + if (score > 0.8) break; + } + + return bestScore > 0.3 ? bestMove : null; + } + + /** + * 递归 α-β 搜索(限深、限局部区域)。 + * 叶子节点用神经网络价值头评估。 + * 通过 negamax 负号翻转处理交替行棋方。 + */ + private double localAlphaBeta(GoPlayer[][] board, Set target, + GoPlayer player, GoPlayer attacker, int depth, + double alpha, double beta, Set region, long deadline) { + if (System.currentTimeMillis() > deadline) return 0; + + // 终局:目标棋群被完全提掉 + if (targetIsCaptured(board, target)) { + // negamax 约定:返回当前行棋方视角。 + // 攻击方(player==attacker)成功提掉目标 → +1.0;防守方(player!=attacker)→ -1.0 + return player == attacker ? 1.0 : -1.0; + } + + // 达到深度或目标活了(多气+安全):神经网络评估 + if (depth <= 0) { + return neuralEvaluator.forwardValue(board, player, null); + } + + // 生成局部合法走法 + List moves = legalMovesInRegion(board, player, region); + if (moves.isEmpty()) return 0; + + // 按吃子数排序提升剪枝效率 + GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + moves.sort((a, b) -> { + int ca = countCaptures(board, a[0], a[1], player); + int cb = countCaptures(board, b[0], b[1], player); + return cb - ca; + }); + + for (int[] move : moves) { + GoPlayer[][] next = deepCopyBoard(board); + if (!simulatePlaceStone(next, move[0], move[1], player)) continue; + double v = -localAlphaBeta(next, target, opponent, attacker, depth - 1, -beta, -alpha, region, deadline); + if (v > alpha) alpha = v; + if (alpha >= beta) break; + } + return alpha; + } + + /** 检查目标棋群是否已被完全提掉 */ + private boolean targetIsCaptured(GoPlayer[][] board, Set target) { + for (int[] pos : target) { + if (board[pos[0]][pos[1]] != GoPlayer.NONE) return false; + } + return true; + } + + /** 生成局部区域内的合法走法 */ + private List legalMovesInRegion(GoPlayer[][] board, GoPlayer player, Set region) { + List moves = new ArrayList<>(); + for (String s : region) { + try { + String[] p = s.split(","); + if (p.length != 2) continue; + int x = Integer.parseInt(p[0]), y = Integer.parseInt(p[1]); + // isLegalMove temporarily writes the candidate square. Calling it on + // an occupied point would overwrite the board and then reset it. + if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE + || board[x][y] != GoPlayer.NONE) continue; + if (isLegalMove(board, x, y, player)) moves.add(new int[]{x, y}); + } catch (NumberFormatException ignored) { + // Ignore malformed tactical-region coordinates. + } + } + return moves; + } + + // ══════════════════════════════════════════════════════════════════ + // 杀棋检测 + // ══════════════════════════════════════════════════════════════════ + + /** + * 查找杀棋走法(围棋特有战术检测) + */ + private int[] findKillerMove(GoPlayer[][] board, GoPlayer player, List validMoves) { + // 1. 提大龙:落子能提掉对手3+子的大龙 + int[] captureMove = findBigCapture(board, player, validMoves); + if (captureMove != null) return captureMove; + + // 2. 救己方大龙:防己方被打吃 + int[] saveMove = findSaveOwnGroup(board, player, validMoves); + if (saveMove != null) return saveMove; + + // 3. 征子检测:追捕逃子 + int[] ladderMove = findLadderCapture(board, player, validMoves); + if (ladderMove != null) return ladderMove; + + // 4. 劫材价值:落子后成为劫材 + int[] koMove = findKoThreat(board, player, validMoves); + if (koMove != null) return koMove; + + // 5. 防守对手征子 + int[] defendLadder = findDefendLadder(board, player, validMoves); + if (defendLadder != null) return defendLadder; + + // 6. 杀对手大龙(气数<=2的对手棋群) + int[] killMove = findKillMove(board, player, validMoves); + if (killMove != null) return killMove; + + // 7. 防守打吃 + for (int[] move : validMoves) { + if (wouldPreventAtari(board, move[0], move[1], player)) { + return move; + } + } + + return null; + } + + /** + * 找能提大龙的走法(至少提3子) + */ + private int[] findBigCapture(GoPlayer[][] board, GoPlayer player, List validMoves) { + int[] best = null; + int bestSize = 0; + for (int[] move : validMoves) { + int captures = countCaptures(board, move[0], move[1], player); + if (captures > bestSize) { + bestSize = captures; + best = move; + } + } + return bestSize >= 3 ? best : null; + } + + /** + * 救己方被打吃的棋群(找其气点落子) + */ + private int[] findSaveOwnGroup(GoPlayer[][] board, GoPlayer player, List validMoves) { + for (int x = 0; x < BOARD_SIZE; x++) { + for (int y = 0; y < BOARD_SIZE; y++) { + if (board[x][y] == player) { + Set group = getGroup(board, x, y); + if (countGroupLiberties(board, group) == 1) { + for (int[] pos : group) { + for (int[] dir : DIRS) { + int nx = pos[0] + dir[0], ny = pos[1] + dir[1]; + if (isValid(validMoves, nx, ny)) { + return new int[]{nx, ny}; + } + } + } + } + } + } + } + return null; + } + + /** + * 征子追击:在对手逃路上落子 + */ + private int[] findLadderCapture(GoPlayer[][] board, GoPlayer player, List validMoves) { + GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + for (int x = 0; x < BOARD_SIZE; x++) { + for (int y = 0; y < BOARD_SIZE; y++) { + if (board[x][y] == opponent) { + Set group = getGroup(board, x, y); + if (countGroupLiberties(board, group) == 1) { + int[] escape = getEscapeDirection(board, group, opponent); + if (escape != null && isValid(validMoves, escape[0], escape[1])) return escape; + } + } + } + } + return null; + } + + /** + * 找棋群最靠近边角的逃生点 + */ + private int[] getEscapeDirection(GoPlayer[][] board, Set group, GoPlayer player) { + Set liberties = new HashSet<>(); + for (int[] pos : group) { + for (int[] dir : DIRS) { + int nx = pos[0] + dir[0], ny = pos[1] + dir[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == GoPlayer.NONE) { + liberties.add(nx + "," + ny); + } + } + } + if (liberties.isEmpty()) return null; + int[] best = null; + int bestScore = Integer.MAX_VALUE; + for (String lib : liberties) { + String[] parts = lib.split(","); + int lx = Integer.parseInt(parts[0]); + int ly = Integer.parseInt(parts[1]); + int score = Math.min(Math.min(lx, ly), Math.min(BOARD_SIZE - 1 - lx, BOARD_SIZE - 1 - ly)); + if (score < bestScore) { + bestScore = score; + best = new int[]{lx, ly}; + } + } + return best; + } + + /** + * 找能成为劫材的走法:在对手紧气点附近落子 + */ + private int[] findKoThreat(GoPlayer[][] board, GoPlayer player, List validMoves) { + GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + for (int x = 0; x < BOARD_SIZE; x++) { + for (int y = 0; y < BOARD_SIZE; y++) { + if (board[x][y] == opponent) { + Set group = getGroup(board, x, y); + if (countGroupLiberties(board, group) == 1) { + for (int[] pos : group) { + for (int[] dir : DIRS) { + int nx = pos[0] + dir[0], ny = pos[1] + dir[1]; + if (isValid(validMoves, nx, ny) && !wouldBeInAtari(board, nx, ny, player)) { + return new int[]{nx, ny}; + } + } + } + } + } + } + } + return null; + } + + /** + * 防守对手征子:己方棋群被打吃时找逃生方向 + */ + private int[] findDefendLadder(GoPlayer[][] board, GoPlayer player, List validMoves) { + for (int x = 0; x < BOARD_SIZE; x++) { + for (int y = 0; y < BOARD_SIZE; y++) { + if (board[x][y] == player) { + Set group = getGroup(board, x, y); + if (countGroupLiberties(board, group) == 1) { + int[] escape = getEscapeDirection(board, group, player); + if (escape != null && isValid(validMoves, escape[0], escape[1])) return escape; + } + } + } + } + return null; + } + + /** + * 杀气数<=2的对手大龙 + */ + private int[] findKillMove(GoPlayer[][] board, GoPlayer player, List validMoves) { + GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + for (int x = 0; x < BOARD_SIZE; x++) { + for (int y = 0; y < BOARD_SIZE; y++) { + if (board[x][y] == opponent) { + Set group = getGroup(board, x, y); + if (group.size() >= 3 && countGroupLiberties(board, group) <= 2) { + for (int[] pos : group) { + for (int[] dir : DIRS) { + int nx = pos[0] + dir[0], ny = pos[1] + dir[1]; + if (isValid(validMoves, nx, ny)) { + return new int[]{nx, ny}; + } + } + } + } + } + } + } + return null; + } + + /** + * 防止己方被打吃 + */ + private boolean wouldPreventAtari(GoPlayer[][] board, int x, int y, GoPlayer player) { + // 检查周围己方棋群 + for (int[] dir : DIRS) { + int nx = x + dir[0], ny = y + dir[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == player) { + Set group = getGroup(board, nx, ny); + if (countGroupLiberties(board, group) == 1) { + // 这个走法能救活己方被打吃的棋 + return true; + } + } + } + return false; + } + + // ══════════════════════════════════════════════════════════════════ + // 终局策略 + // ══════════════════════════════════════════════════════════════════ + + /** + * 终局走法(收官) + */ + private int[] getEndgameMove(GoPlayer[][] board, GoPlayer player, List validMoves) { + int[] bestMove = null; + double bestScore = Double.NEGATIVE_INFINITY; + + for (int[] move : validMoves) { + double score = evaluateEndgameMove(board, player, move); + if (score > bestScore) { + bestScore = score; + bestMove = move; + } + } + return bestMove; + } + + /** + * 终局走法评估 + */ + private double evaluateEndgameMove(GoPlayer[][] board, GoPlayer player, int[] move) { + double score = 0; + int x = move[0], y = move[1]; + + // 1. 围空评估 + score += evaluateTerritoryGain(board, player, x, y) * 2.0; + + // 2. 自身安全 + board[x][y] = player; + Set group = getGroup(board, x, y); + int libs = countGroupLiberties(board, group); + score += libs * 0.5; + board[x][y] = GoPlayer.NONE; + + // 3. 阻止对手围空 + GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + score -= evaluateTerritoryGain(board, opponent, x, y) * 1.5; + + // 4. 位置价值 + score += getPositionBonus(x, y) * 0.3; + + return score; + } + + /** + * 评估落子后围空增益 + */ + private double evaluateTerritoryGain(GoPlayer[][] board, GoPlayer player, int x, int y) { + double gain = 0; + int radius = 3; + + for (int dx = -radius; dx <= radius; dx++) { + for (int dy = -radius; dy <= radius; dy++) { + int nx = x + dx, ny = y + dy; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE) { + if (board[nx][ny] == GoPlayer.NONE) { + // 检查这个空点被谁控制 + if (isNearPlayer(board, nx, ny, player)) { + gain += 1.0 / (Math.abs(dx) + Math.abs(dy) + 1); + } + } + } + } + } + return gain; + } + + private boolean isNearPlayer(GoPlayer[][] board, int x, int y, GoPlayer player) { + for (int[] dir : DIRS) { + int nx = x + dir[0], ny = y + dir[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == player) { + return true; + } + } + return false; + } + + // ══════════════════════════════════════════════════════════════════ + // 开局定式库(覆盖前30手) + // ══════════════════════════════════════════════════════════════════ + + /** + * 围棋开局定式库。 + * 覆盖:星位、三三、小目、高目、目外等多种开局变化。 + * 格式:moveHistorySize -> [x, y] 或 null(继续 MCTS 搜索) + */ + private int[] getOpeningBookMove(GoPlayer[][] board, GoPlayer player, List validMoves, int moveCount) { + if (moveCount > 30) return null; // 开局库覆盖前30手 + + int center = BOARD_SIZE / 2; + GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + + // 找对手棋子位置(判断对手开局的类型) + int[] oppFirst = findFirstStone(board, opponent); + int[] oppSecond = findSecondStone(board, opponent); + + // === 第1手:黑棋首选 === + if (moveCount == 0) { + // 经典开局选择:星位(3,3)、三三(3,4)、天元(center,center) + int[][] options = {{3, 3}, {3, 4}, {center, center}}; + return pickValid(options, validMoves); + } + + // === 第2手:白棋应对 === + if (moveCount == 1) { + if (oppFirst != null) { + int dx = oppFirst[0] - center, dy = oppFirst[1] - center; // 相对于中心 + + // 应对星位(3,3)或类似位置:各种挂角 + if (Math.abs(dx) <= 2 && Math.abs(dy) <= 2) { + int[][] responses = { + {oppFirst[0] - 1, oppFirst[1]}, // 小飞挂 + {oppFirst[0] + 1, oppFirst[1]}, // 另一侧小飞 + {oppFirst[0], oppFirst[1] - 1}, // 垂直小飞 + {oppFirst[0], oppFirst[1] + 1}, // 垂直小飞另一侧 + {oppFirst[0] - 1, oppFirst[1] - 1}, // 一间高挂 + {oppFirst[0] + 1, oppFirst[1] + 1}, + }; + int[] r = pickValid(responses, validMoves); + if (r != null) return r; + } + + // 应对三三:肩冲、托退、飞压 + if (Math.abs(dx) <= 3 && Math.abs(dy) <= 3) { + int[][] responses = { + {oppFirst[0] - 1, oppFirst[1] - 1}, // 肩冲 + {oppFirst[0] + 1, oppFirst[1] + 1}, // 另一侧肩冲 + }; + int[] r = pickValid(responses, validMoves); + if (r != null) return r; + } + } + return null; + } + + // === 第3-10手:定式继续 === + if (moveCount >= 2 && moveCount <= 10) { + return getBookFollowUp(board, player, validMoves, oppFirst, oppSecond); + } + + // === 中盘开局(11-30手):扩张阵型 === + if (moveCount > 10 && moveCount <= 30) { + return getOpeningExpansion(board, player, validMoves); + } + + return null; + } + + private int[] findFirstStone(GoPlayer[][] board, GoPlayer player) { + for (int x = 0; x < BOARD_SIZE; x++) { + for (int y = 0; y < BOARD_SIZE; y++) { + if (board[x][y] == player) { + return new int[]{x, y}; + } + } + } + return null; + } + + private int[] findSecondStone(GoPlayer[][] board, GoPlayer player) { + int count = 0; + for (int x = 0; x < BOARD_SIZE; x++) { + for (int y = 0; y < BOARD_SIZE; y++) { + if (board[x][y] == player) { + count++; + if (count == 2) return new int[]{x, y}; + } + } + } + return null; + } + + private int[] pickValid(int[][] options, List validMoves) { + for (int[] opt : options) { + if (isValid(validMoves, opt[0], opt[1])) { + return opt; + } + } + return null; + } + + /** + * 定式后续应对:找对手棋子附近的价值点 + */ + private int[] getBookFollowUp(GoPlayer[][] board, GoPlayer player, List validMoves, + int[] oppFirst, int[] oppSecond) { + // 在对手棋子附近寻找价值点 + List candidates = new ArrayList<>(); + Set targets = new HashSet<>(); + + if (oppFirst != null) targets.add(oppFirst[0] + "," + oppFirst[1]); + if (oppSecond != null) targets.add(oppSecond[0] + "," + oppSecond[1]); + + for (String target : targets) { + String[] parts = target.split(","); + int tx = Integer.parseInt(parts[0]); + int ty = Integer.parseInt(parts[1]); + + // 周围3格范围内的空点 + for (int dx = -3; dx <= 3; dx++) { + for (int dy = -3; dy <= 3; dy++) { + if (dx == 0 && dy == 0) continue; + int nx = tx + dx, ny = ty + dy; + if (isValid(validMoves, nx, ny)) { + // 评估该点的价值 + int value = evaluateApproachMove(board, nx, ny, player, tx, ty); + candidates.add(new int[]{nx, ny, value}); + } + } + } + } + + if (candidates.isEmpty()) return null; + + // 按价值排序,取最高分 + candidates.sort((a, b) -> b[2] - a[2]); + int[] best = candidates.get(0); + return new int[]{best[0], best[1]}; + } + + /** + * 评估接近点的价值(用于定式后续) + */ + private int evaluateApproachMove(GoPlayer[][] board, int x, int y, GoPlayer player, int targetX, int targetY) { + int value = 0; + int dist = Math.abs(x - targetX) + Math.abs(y - targetY); + + // 距离越近价值越高 + value += Math.max(0, 10 - dist) * 5; + + // 在对手棋子周围(1-2格)很有价值 + if (dist == 1) value += 30; + if (dist == 2) value += 15; + + // 连接己方棋子 + value += countFriendlyNeighbors(board, x, y, player) * 10; + + // 避免被对手包围 + GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + board[x][y] = player; + Set group = getGroup(board, x, y); + int libs = countGroupLiberties(board, group); + board[x][y] = GoPlayer.NONE; + value += libs * 3; + + // 位置价值 + value += getPositionBonus(x, y); + + return value; + } + + /** + * 中盘开局扩张:在己方棋子周围找最优点 + */ + private int[] getOpeningExpansion(GoPlayer[][] board, GoPlayer player, List validMoves) { + // 找己方棋子周围最近的空点 + List candidates = new ArrayList<>(); + + for (int x = 0; x < BOARD_SIZE; x++) { + for (int y = 0; y < BOARD_SIZE; y++) { + if (board[x][y] == player) { + // 在己方棋子周围2-4格找空点 + for (int d = 2; d <= 4; d++) { + for (int[] dir : DIRS) { + int nx = x + dir[0] * d, ny = y + dir[1] * d; + if (isValid(validMoves, nx, ny)) { + int value = evaluateMoveLiberties(board, nx, ny, player) * 5 + + countFriendlyNeighbors(board, nx, ny, player) * 8 + + getPositionBonus(nx, ny); + // 避免距离对手太近(被攻击风险) + GoPlayer opp = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + for (int dx = -2; dx <= 2; dx++) { + for (int dy = -2; dy <= 2; dy++) { + int ox = nx + dx, oy = ny + dy; + if (ox >= 0 && ox < BOARD_SIZE && oy >= 0 && oy < BOARD_SIZE + && board[ox][oy] == opp) { + value -= (3 - Math.abs(dx) - Math.abs(dy)) * 5; + } + } + } + candidates.add(new int[]{nx, ny, value}); + } + } + } + } + } + } + + if (candidates.isEmpty()) return null; + + candidates.sort((a, b) -> b[2] - a[2]); + int[] best = candidates.get(0); + return new int[]{best[0], best[1]}; + } + + private boolean isValid(List moves, int x, int y) { + if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE) return false; + for (int[] m : moves) { + if (m[0] == x && m[1] == y) return true; + } + return false; + } + + // ══════════════════════════════════════════════════════════════════ + // 棋盘操作 + // ══════════════════════════════════════════════════════════════════ + + private boolean simulatePlaceStone(GoPlayer[][] board, int x, int y, GoPlayer player) { + if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE || board[x][y] != GoPlayer.NONE) { + return false; + } + + board[x][y] = player; + GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + + int captured = 0; + for (int[] dir : DIRS) { + int nx = x + dir[0], ny = y + dir[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == opponent) { + Set group = getGroup(board, nx, ny); + if (!hasLiberty(board, group)) { + for (int[] pos : group) board[pos[0]][pos[1]] = GoPlayer.NONE; + captured += group.size(); + } + } + } + + if (captured == 0) { + Set myGroup = getGroup(board, x, y); + if (!hasLiberty(board, myGroup)) { + board[x][y] = GoPlayer.NONE; + return false; + } + } + return true; + } + + /** + * 获取所有合法走法(带知识剪枝 + super-ko 过滤)。 + * 返回 3 元素数组 [x, y, value],value 用于剪枝和排序。 + * 去掉:角部/边部过于深入、无意义的尖、离对手太远的孤立点等。 + * 并过滤会触发 super-ko(全局同型)的走法。 + * + *

性能关键:合法性、提子与 super-ko 哈希在真实棋盘上一次模拟完成, + * 哈希用 Zobrist 增量更新(XOR 顺序无关,与全盘 boardHash 逐位一致), + * 替代原先每个候选点的整盘深拷贝 + 361 点重哈希。 + */ + private List getAllValidMoves(long baseHash, GoPlayer[][] board, GoPlayer player) { + List moves = new ArrayList<>(); + collectValidMoves(baseHash, board, player, moves, true); + + if (moves.isEmpty()) { + // 回退到全量搜索(不剪枝,但仍过滤 super-ko) + collectValidMoves(baseHash, board, player, moves, false); + } + + // 按价值排序(升序,让 expand 的 remove(size-1) 取出最高分走法优先展开) + moves.sort((a, b) -> a[2] - b[2]); + + return moves; + } + + /** 融合的候选收集:落子→收集提子→自杀判定→增量哈希→super-ko 过滤→(可选)知识剪枝。 + * 每个候选点模拟后在原棋盘上完全恢复,与旧 isLegalMove+isKoIllegal 组合逐位等价。 + * 热路径全程使用线程局部原语缓冲,不产生 HashSet/int[] 分配。 */ + private void collectValidMoves(long baseHash, GoPlayer[][] board, GoPlayer player, + List out, boolean prune) { + GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + int[] cells = SCRATCH_CELLS_A.get(); + int[] captured = SCRATCH_CAPT.get(); + for (int x = 0; x < BOARD_SIZE; x++) { + for (int y = 0; y < BOARD_SIZE; y++) { + if (board[x][y] != GoPlayer.NONE) continue; + board[x][y] = player; + long hashAfter = GoGame.xorStone(baseHash, x, y, player); + int capturedCount = 0; + boolean anyCaptured = false; + for (int[] dir : DIRS) { + int nx = x + dir[0], ny = y + dir[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE + && board[nx][ny] == opponent) { + int n = scanGroup(board, nx, ny, opponent, cells); + if (!cellsHaveLiberty(board, cells, n)) { + for (int i = 0; i < n; i++) { + int p = cells[i]; + board[p / BOARD_SIZE][p % BOARD_SIZE] = GoPlayer.NONE; + hashAfter = GoGame.xorStone(hashAfter, + p / BOARD_SIZE, p % BOARD_SIZE, opponent); + captured[capturedCount++] = p; + } + anyCaptured = true; + } + } + } + // 无提子时才需检查自身气;有提子必活。棋群在落子态下记录, + // 供 scoreFusedMove 复用(提子复位不改变其组成) + int selfCount = -1; + boolean legal = anyCaptured; + if (!legal) { + selfCount = scanGroup(board, x, y, player, cells); + legal = cellsHaveLiberty(board, cells, selfCount); + } + if (legal && koHistory != null && koHistory.contains(hashAfter)) { + legal = false; // super-ko 过滤 + } + // 恢复棋盘:先复位提子,再清空落子点(两组点位不相交)。 + // ★ scoreFusedMove/countCaptures 的契约要求 (x,y) 为空、棋盘为原盘, + // 必须先恢复再打分,否则打分失真且污染后续候选点 + for (int i = 0; i < capturedCount; i++) { + int p = captured[i]; + board[p / BOARD_SIZE][p % BOARD_SIZE] = opponent; + } + board[x][y] = GoPlayer.NONE; + int value = 0; + boolean keep = false; + if (legal) { + if (prune) { + value = scoreFusedMove(board, x, y, player, cells, selfCount); + keep = value >= -10; + } else { + keep = true; + } + } + if (keep) out.add(new int[]{x, y, value}); + } + } + } + + private List getSearchMoves(long baseHash, GoPlayer[][] board, GoPlayer player, int consecutivePasses) { + List moves = getAllValidMoves(baseHash, board, player); + if (consecutivePasses > 0 || countStones(board) >= ENDGAME_STONES || moves.isEmpty()) { + moves.add(PASS_MOVE.clone()); + } + return moves; + } + + /** + * 评估走法是否值得搜索(负分表示应剪枝) + */ + private boolean isLegalMove(GoPlayer[][] board, int x, int y, GoPlayer player) { + if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE + || board[x][y] != GoPlayer.NONE || player == GoPlayer.NONE) return false; + board[x][y] = player; + GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + List captured = new ArrayList<>(); + + for (int[] dir : DIRS) { + int nx = x + dir[0], ny = y + dir[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == opponent) { + Set group = getGroup(board, nx, ny); + if (!hasLiberty(board, group)) { + for (int[] pos : group) { + board[pos[0]][pos[1]] = GoPlayer.NONE; + captured.add(pos); + } + } + } + } + + boolean legal = !captured.isEmpty() || hasLiberty(board, getGroup(board, x, y)); + + board[x][y] = GoPlayer.NONE; + for (int[] pos : captured) board[pos[0]][pos[1]] = opponent; + return legal; + } + + /** + * super-ko 检查:落子后的局面若与全局历史(koHistory)中任何局面重复则非法。 + * 注意:在棋盘副本上模拟(不修改传入棋盘),并通过祖先链(isAncestorKoRepeat)覆盖树内重复。 + */ + private boolean isKoIllegal(GoPlayer[][] board, int x, int y, GoPlayer player) { + if (koHistory == null) return false; + GoPlayer[][] test = deepCopyBoard(board); + if (!simulatePlaceStone(test, x, y, player)) return true; // 落子本身不合法(自杀等) + long h = GoGame.boardHash(test); + return koHistory.contains(h); + } + + private int countStones(GoPlayer[][] board) { + int count = 0; + for (int x = 0; x < BOARD_SIZE; x++) { + for (int y = 0; y < BOARD_SIZE; y++) { + if (board[x][y] != GoPlayer.NONE) count++; + } + } + return count; + } + + private int countStones(GoPlayer[][] board, GoPlayer player) { + int count = 0; + for (int x = 0; x < BOARD_SIZE; x++) { + for (int y = 0; y < BOARD_SIZE; y++) { + if (board[x][y] == player) count++; + } + } + return count; + } + + private Set getGroup(GoPlayer[][] board, int x, int y) { + Set group = new HashSet<>(); + GoPlayer color = board[x][y]; + if (color == GoPlayer.NONE) return group; + + Stack stack = new Stack<>(); + boolean[][] visited = new boolean[BOARD_SIZE][BOARD_SIZE]; + stack.push(new int[]{x, y}); + + while (!stack.isEmpty()) { + int[] pos = stack.pop(); + int px = pos[0], py = pos[1]; + if (visited[px][py]) continue; + visited[px][py] = true; + group.add(new int[]{px, py}); + + for (int[] dir : DIRS) { + int nx = px + dir[0], ny = py + dir[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE + && !visited[nx][ny] && board[nx][ny] == color) { + stack.push(new int[]{nx, ny}); + } + } + } + return group; + } + + private boolean hasLiberty(GoPlayer[][] board, Set group) { + for (int[] pos : group) { + for (int[] dir : DIRS) { + int nx = pos[0] + dir[0], ny = pos[1] + dir[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == GoPlayer.NONE) { + return true; + } + } + } + return false; + } + + private int countGroupLiberties(GoPlayer[][] board, Set group) { + Set libertySet = new HashSet<>(); + for (int[] pos : group) { + for (int[] dir : DIRS) { + int nx = pos[0] + dir[0], ny = pos[1] + dir[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == GoPlayer.NONE) { + libertySet.add((long) nx * BOARD_SIZE + ny); + } + } + } + return libertySet.size(); + } + + private GoPlayer[][] deepCopyBoard(GoPlayer[][] board) { + GoPlayer[][] copy = new GoPlayer[BOARD_SIZE][BOARD_SIZE]; + for (int x = 0; x < BOARD_SIZE; x++) { + copy[x] = board[x].clone(); + } + return copy; + } + + /** 判断两块棋盘是否完全一致(用于树重用前的棋盘匹配校验) */ + private static boolean boardsEqual(GoPlayer[][] a, GoPlayer[][] b) { + for (int x = 0; x < BOARD_SIZE; x++) { + for (int y = 0; y < BOARD_SIZE; y++) { + if (a[x][y] != b[x][y]) return false; + } + } + return true; + } + + /** + * 沿父链检查目标哈希是否与任一祖先局面重复(super-ko 全局同型)。 + * 根节点哈希由 getBestMove 初始化,子节点哈希在 expand 时设置。 + */ + private boolean isAncestorKoRepeat(MCTSNode node, long targetHash) { + MCTSNode ancestor = node; + while (ancestor != null) { + if (ancestor.hash != 0 && ancestor.hash == targetHash) return true; + ancestor = ancestor.parent; + } + return false; + } + + // ══════════════════════════════════════════════════════════════════ + // MCTS 节点 + // ══════════════════════════════════════════════════════════════════ + + private static class MCTSNode { + GoPlayer[][] board; + GoPlayer player; + MCTSNode parent; + int[] move; + int[] linkedMove; + List children; + List untriedMoves; + + /** 本节点局面的 Zobrist 哈希(用于 super-ko 全局同型检测) */ + long hash; + int consecutivePasses; + boolean terminal; + + double visits = 0; + double totalScore = 0; + /** 先验偏置(默认 1.0),根节点的子节点由 Dirichlet 噪声调制 */ + double prior = 1.0; + /** 仅根节点持有:move "x,y" -> Dirichlet 噪声值(只读) */ + Map rootNoise = null; + /** 该节点的策略缓存(362 维,网络输出的走法先验),首次展开时填充 */ + double[] policyCache = null; + /** 该节点的价值缓存(-1~1,与 policyCache 同一次前向计算),避免 MCTS 重复前向 */ + double valueCache = 0; + /** valueCache 是否已填充(volatile 保证写入顺序,防止双检锁失效) */ + volatile boolean valueCached = false; + /** 是否已有线程正在为该节点做首次前向,配合 policyCache 判空实现原子占位, + * 防止并发展开同一节点时被重复 Top-K 剪枝(见 expand() 注释) */ + volatile boolean forwardInFlight = false; + + MCTSNode(GoPlayer[][] board, GoPlayer player, MCTSNode parent, int[] move, List untriedMoves) { + this.board = board; + this.player = player; + this.parent = parent; + this.move = move; + this.untriedMoves = untriedMoves != null ? new ArrayList<>(untriedMoves) : new ArrayList<>(); + } + } +} diff --git a/src/main/java/com/wzz/game_console/client/screens/games/gogame/NeuralEvaluator.java b/src/main/java/com/wzz/game_console/client/screens/games/gogame/NeuralEvaluator.java new file mode 100644 index 0000000..b1efa8a --- /dev/null +++ b/src/main/java/com/wzz/game_console/client/screens/games/gogame/NeuralEvaluator.java @@ -0,0 +1,1569 @@ +package com.wzz.game_console.client.screens.games.gogame; + +import java.util.*; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.concurrent.locks.ReentrantLock; +import com.wzz.game_console.util.GameSettings; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; + +/** + * 三级分块隔离神经网络评估器。 + *

+ * 架构: + *

+ * 输入: 4×19×19 平面
+ *   → 二级子块(81个, 3×3×4→FC(36→16)→ReLU) × 9套独立权重
+ *   → 一级字块(9个, 9×16→FC(144→64)→ReLU) × 9套独立权重
+ *   → 顶级(9×64+24→FC(600→256)→ReLU) → 共享256维
+ *     → 策略头: FC(256→362) + softmax
+ *     → 价值头: FC(256→128) + ReLU → FC(128→1) + tanh
+ * 
+ */ +public class NeuralEvaluator { + + // ══════════════════════════════════════════════════════════════════════ + // 常量 + // ══════════════════════════════════════════════════════════════════════ + + private static final int BOARD_SIZE = 19; + private static final int PLANES = 4; + + // 分块网格 + private static final int BLOCK_SIZE = 7; // 一级字块尺寸 + private static final int BLOCK_OVERLAP = 1; // 一级字块重叠 + private static final int BLOCK_STRIDE = BLOCK_SIZE - BLOCK_OVERLAP; // 6 + private static final int BLOCKS_PER_DIM = 3; // 每维块数 + private static final int NUM_BLOCKS = 9; // 总块数 + + private static final int SUB_SIZE = 3; // 二级子块尺寸 + private static final int SUB_OVERLAP = 1; // 二级子块重叠 + private static final int SUB_STRIDE = SUB_SIZE - SUB_OVERLAP; // 2 + private static final int SUBS_PER_BLOCK = 9; // 每大块子块数 + + // 网络维度 + private static final int SUB_INPUT = SUB_SIZE * SUB_SIZE * PLANES; // 36 + private static final int SUB_HIDDEN = 16; + private static final int BLOCK_INPUT = SUBS_PER_BLOCK * SUB_HIDDEN; // 144 + private static final int BLOCK_HIDDEN = 64; + private static final int TOP_INPUT = NUM_BLOCKS * BLOCK_HIDDEN + 24; // 576+24=600 + private static final int TOP_HIDDEN = 256; + private static final int POLICY_SIZE = 362; // 361 moves + pass + private static final int VALUE_HIDDEN = 128; + + // 辅助特征维度(沿用旧版) + private static final int LIBERTY_HIST_BINS = 8; + private static final int EYE_FEATURE_SIZE = 8; + private static final int GLOBAL_FEATURE_SIZE = 8; + private static final int AUX_SIZE = LIBERTY_HIST_BINS + EYE_FEATURE_SIZE + GLOBAL_FEATURE_SIZE; // 24 + + // 持久化 + private static final int MODEL_MAGIC = 0x4E455633; // NEV3 + private static final int MODEL_FORMAT = 3; + private static final int LEGACY_MODEL_MAGIC = 0x4E455632; // NEV2 + private static final int LEGACY_MODEL_FORMAT = 2; + private static final int MAX_CACHE_SIZE = 10000; + + // 四方向 + private static final int[][] DIRS = {{0, 1}, {1, 0}, {0, -1}, {-1, 0}}; + + // ══════════════════════════════════════════════════════════════════════ + // 分块索引(静态计算) + // ══════════════════════════════════════════════════════════════════════ + + /** 9 个一级字块的左上角 (bx, by) */ + private static final int[][] BLOCK_STARTS = new int[9][2]; + /** 每个一级字块内 9 个二级子块的左上角偏移 (sx, sy) 相对于大块起点 */ + private static final int[][] SUB_OFFSETS = new int[9][2]; + + static { + // 一级字块起始位置 + for (int bx = 0; bx < BLOCKS_PER_DIM; bx++) { + for (int by = 0; by < BLOCKS_PER_DIM; by++) { + int idx = bx * BLOCKS_PER_DIM + by; + BLOCK_STARTS[idx][0] = bx * BLOCK_STRIDE; + BLOCK_STARTS[idx][1] = by * BLOCK_STRIDE; + } + } + // 二级子块偏移 + for (int sx = 0; sx < BLOCKS_PER_DIM; sx++) { + for (int sy = 0; sy < BLOCKS_PER_DIM; sy++) { + int idx = sx * BLOCKS_PER_DIM + sy; + SUB_OFFSETS[idx][0] = sx * SUB_STRIDE; + SUB_OFFSETS[idx][1] = sy * SUB_STRIDE; + } + } + } + + // ══════════════════════════════════════════════════════════════════════ + // 权重矩阵 + // ══════════════════════════════════════════════════════════════════════ + + // 第 1 级:二级子块(9 套独立权重) + private final double[][][] subW1; // [block][input=36][hidden=16] + private final double[][] subB1; // [block][hidden=16] + + // 第 2 级:一级字块(9 套独立权重) + private final double[][][] blockW1; // [block][input=144][hidden=64] + private final double[][] blockB1; // [block][hidden=64] + + // 第 3 级:顶级(共享权重) + private final double[][] topW1; // [600][256] + private final double[] topB1; // [256] + + // 策略头 + private final double[][] policyW; // [256][362] + private final double[] policyB; // [362] + + // 价值头 + private final double[][] valueW1; // [256][128] + private final double[] valueB1; // [128] + private final double[] valueW2; // [128] + private double valueB2; + + private final ReentrantReadWriteLock modelLock = new ReentrantReadWriteLock(); + /** Serializes forward/train with release so the native backend cannot be closed in use. */ + private final ReentrantLock lifecycleLock = new ReentrantLock(); + private volatile boolean released; + private volatile long modelVersion; + + // 动量缓冲(惰性分配,首次 momentum > 0 训练时创建) + private double[][][] vSubW1; + private double[][] vSubB1; + private double[][][] vBlockW1; + private double[][] vBlockB1; + private double[][] vTopW1; + private double[] vTopB1; + private double[][] vPolicyW; + private double[] vPolicyB; + private double[][] vValueW1; + private double[] vValueB1; + private double[] vValueW2; + private double vValueB2; + + // 评估缓存(Zobrist 哈希 -> 评估值,仅用于旧版 evaluate 接口) + private final Map evaluationCache = new HashMap<>(); + + /** OpenCL GPU 加速后端(懒初始化,失败自动回退 CPU) */ + private volatile OpenCLBackend opencl; + /** OpenCL 初始化失败标记:置位后不再反复尝试加载(每次 forward 都调 ensureOpenCL) */ + private volatile boolean openclDisabled; + + /** + * 释放 OpenCL native 资源(kernel/program/queue/context)。 + * 修复:此前 OpenCLBackend.close() 全项目无调用点,屏显重开反复创建 + * MCTSGoAI 会堆积 native 句柄只能靠 GC 兜底。重复调用安全(幂等)。 + */ + public void release() { + lifecycleLock.lock(); + try { + if (released) return; + released = true; + OpenCLBackend b = opencl; + opencl = null; + if (b != null) { + try { + b.close(); + } catch (Throwable ignored) { + } + } + } finally { + lifecycleLock.unlock(); + } + } + + // ══════════════════════════════════════════════════════════════════════ + // 构造 + // ══════════════════════════════════════════════════════════════════════ + + public NeuralEvaluator() { + this(false); + } + + /** 私有构造:skipInit=true 时跳过随机初始化(默认构造调用 initWeights) */ + private NeuralEvaluator(boolean skipInit) { + this.subW1 = new double[NUM_BLOCKS][SUB_INPUT][SUB_HIDDEN]; + this.subB1 = new double[NUM_BLOCKS][SUB_HIDDEN]; + this.blockW1 = new double[NUM_BLOCKS][BLOCK_INPUT][BLOCK_HIDDEN]; + this.blockB1 = new double[NUM_BLOCKS][BLOCK_HIDDEN]; + this.topW1 = new double[TOP_INPUT][TOP_HIDDEN]; + this.topB1 = new double[TOP_HIDDEN]; + this.policyW = new double[TOP_HIDDEN][POLICY_SIZE]; + this.policyB = new double[POLICY_SIZE]; + this.valueW1 = new double[TOP_HIDDEN][VALUE_HIDDEN]; + this.valueB1 = new double[VALUE_HIDDEN]; + this.valueW2 = new double[VALUE_HIDDEN]; + this.valueB2 = 0.0; + this.modelVersion = 0L; + if (!skipInit) initWeights(); + } + + /** + * 直接从模型快照构造评估器(跳过随机初始化,省去一倍的 init+apply 开销)。 + * 用于并行自对弈的每局 worker,避免频繁创建。 + */ + public static NeuralEvaluator fromWeights(ModelWeights m) { + // 跳过随机 init(省去一倍的 init+apply 开销),直接 apply 模型快照 + NeuralEvaluator e = new NeuralEvaluator(true); + e.apply(m); + return e; + } + + /** 前向传播结果 */ + public static final class ForwardResult { + public final double value; + public final double[] policy; + public ForwardResult(double value, double[] policy) { + this.value = value; + this.policy = policy; + } + } + + // ══════════════════════════════════════════════════════════════════════ + // 权重初始化 + // ══════════════════════════════════════════════════════════════════════ + + private void initWeights() { + Random rnd = new Random(42); + // 二级子块(9 套) + for (int b = 0; b < NUM_BLOCKS; b++) { + for (int i = 0; i < SUB_INPUT; i++) + for (int j = 0; j < SUB_HIDDEN; j++) + subW1[b][i][j] = rnd.nextGaussian() * 0.5 / Math.sqrt(SUB_INPUT); + Arrays.fill(subB1[b], 0.01); + } + // 一级字块(9 套) + for (int b = 0; b < NUM_BLOCKS; b++) { + for (int i = 0; i < BLOCK_INPUT; i++) + for (int j = 0; j < BLOCK_HIDDEN; j++) + blockW1[b][i][j] = rnd.nextGaussian() * 0.5 / Math.sqrt(BLOCK_INPUT); + Arrays.fill(blockB1[b], 0.01); + } + // 顶级 + for (int i = 0; i < TOP_INPUT; i++) + for (int j = 0; j < TOP_HIDDEN; j++) + topW1[i][j] = rnd.nextGaussian() * 0.5 / Math.sqrt(TOP_INPUT); + Arrays.fill(topB1, 0.01); + // 策略头 + for (int i = 0; i < TOP_HIDDEN; i++) + for (int j = 0; j < POLICY_SIZE; j++) + policyW[i][j] = rnd.nextGaussian() * 0.5 / Math.sqrt(TOP_HIDDEN); + Arrays.fill(policyB, 0.0); + // 价值头 + for (int i = 0; i < TOP_HIDDEN; i++) + for (int j = 0; j < VALUE_HIDDEN; j++) + valueW1[i][j] = rnd.nextGaussian() * 0.5 / Math.sqrt(TOP_HIDDEN); + Arrays.fill(valueB1, 0.01); + for (int i = 0; i < VALUE_HIDDEN; i++) + valueW2[i] = rnd.nextGaussian() * 0.5 / Math.sqrt(VALUE_HIDDEN); + valueB2 = 0.0; + } + + // ══════════════════════════════════════════════════════════════════════ + // 输入平面构建 + // ══════════════════════════════════════════════════════════════════════ + + /** + * 构建 4×19×19 输入平面。 + * 平面0: 己方棋子 + * 平面1: 对方棋子 + * 平面2: 空点气数归一化 + * 平面3: 上一步落子位置 + */ + public double[][][] buildInputPlanes(GoPlayer[][] board, GoPlayer player, int[] lastMove) { + double[][][] planes = new double[PLANES][BOARD_SIZE][BOARD_SIZE]; + GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + + for (int x = 0; x < BOARD_SIZE; x++) { + for (int y = 0; y < BOARD_SIZE; y++) { + if (board[x][y] == player) { + planes[0][x][y] = 1.0; + } else if (board[x][y] == opponent) { + planes[1][x][y] = 1.0; + } else { + // 空点:计算气数 + int libs = countEmptyLiberties(board, x, y); + if (libs >= 3) planes[2][x][y] = 1.0; + else if (libs == 2) planes[2][x][y] = 0.5; + else if (libs == 1) planes[2][x][y] = 0.25; + } + // 上一步落子位置 + if (lastMove != null && lastMove.length >= 2 && lastMove[0] == x && lastMove[1] == y) { + planes[3][x][y] = 1.0; + } + } + } + return planes; + } + + /** 计算空点 x,y 周围的气数(仅看相邻空点+同色连通) */ + private int countEmptyLiberties(GoPlayer[][] board, int x, int y) { + int libs = 0; + for (int[] d : DIRS) { + int nx = x + d[0], ny = y + d[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE + && board[nx][ny] == GoPlayer.NONE) { + libs++; + } + } + return libs; + } + + // ══════════════════════════════════════════════════════════════════════ + // 辅助特征(24 维,沿用旧版计算逻辑) + // ══════════════════════════════════════════════════════════════════════ + + /** + * 提取 24 维全局辅助特征:气数直方图(8) + 眼形特征(8) + 全局特征(8)。 + */ + public double[] extractAuxFeatures(GoPlayer[][] board, GoPlayer player) { + double[] aux = new double[AUX_SIZE]; + GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + int BS = BOARD_SIZE; + + // ── 气数直方图 [0..7] ────────────────────────────────────────── + int[] libertyHist = new int[LIBERTY_HIST_BINS]; + boolean[][] visited = new boolean[BS][BS]; + for (int x = 0; x < BS; x++) { + for (int y = 0; y < BS; y++) { + if (board[x][y] != GoPlayer.NONE && !visited[x][y]) { + Set group = getGroup(board, x, y); + int libs = countGroupLiberties(board, group); + int bin = Math.min(libs, LIBERTY_HIST_BINS - 1); + libertyHist[bin]++; + for (int[] p : group) visited[p[0]][p[1]] = true; + } + } + } + int histSum = 0; + for (int v : libertyHist) histSum += v; + for (int i = 0; i < LIBERTY_HIST_BINS; i++) { + aux[i] = histSum > 0 ? (libertyHist[i] * 2.0 / histSum - 1.0) : 0.0; + } + + // ── 眼形特征 [8..15] ────────────────────────────────────────── + int myTrueEyes = 0, myFalseEyes = 0, oppTrueEyes = 0, oppFalseEyes = 0; + for (int x = 0; x < BS; x++) { + for (int y = 0; y < BS; y++) { + if (board[x][y] != GoPlayer.NONE) { + EyeInfo eye = analyzeEye(board, x, y); + if (board[x][y] == player) { + if (eye.isTrue) myTrueEyes++; else if (eye.isPotential) myFalseEyes++; + } else { + if (eye.isTrue) oppTrueEyes++; else if (eye.isPotential) oppFalseEyes++; + } + } + } + } + aux[8] = (myTrueEyes - myFalseEyes) / 5.0; + aux[9] = (oppTrueEyes - oppFalseEyes) / 5.0; + + // 眼大小分布 + int cornerEyesMy = 0, cornerEyesOpp = 0, centerEyesMy = 0, centerEyesOpp = 0; + int cornerZone = 3, centerZone = BS / 2 - 2; + for (int x = 0; x < BS; x++) { + for (int y = 0; y < BS; y++) { + if (board[x][y] == GoPlayer.NONE) { + int distToEdge = Math.min(Math.min(x, y), Math.min(BS - 1 - x, BS - 1 - y)); + boolean isCorner = distToEdge <= cornerZone; + boolean isCenter = x >= centerZone && x < BS - centerZone && y >= centerZone && y < BS - centerZone; + double surrounding = countSurrounding(board, x, y, player); + double oppSurrounding = countSurrounding(board, x, y, opponent); + if (surrounding > oppSurrounding + 0.5) { + if (isCorner) cornerEyesMy++; else if (isCenter) centerEyesMy++; + } else if (oppSurrounding > surrounding + 0.5) { + if (isCorner) cornerEyesOpp++; else if (isCenter) centerEyesOpp++; + } + } + } + } + aux[10] = (cornerEyesMy - cornerEyesOpp) / 20.0; + aux[11] = (centerEyesMy - centerEyesOpp) / 20.0; + + // 眼形空洞 + int eyeHolesMy = 0, eyeHolesOpp = 0; + for (int x = 0; x < BS; x++) { + for (int y = 0; y < BS; y++) { + if (board[x][y] == GoPlayer.NONE) { + int fn = countFriendlyNeighbors(board, x, y, player); + int on = countFriendlyNeighbors(board, x, y, opponent); + if (fn >= 4) eyeHolesMy++; else if (on >= 4) eyeHolesOpp++; + } + } + } + aux[12] = eyeHolesMy / 30.0; + aux[13] = eyeHolesOpp / 30.0; + + // ── 全局特征 [16..23] ────────────────────────────────────────── + int myStones = 0, oppStones = 0; + for (int x = 0; x < BS; x++) for (int y = 0; y < BS; y++) { + if (board[x][y] == player) myStones++; else if (board[x][y] == opponent) oppStones++; + } + aux[16] = (myStones - oppStones) / 100.0; + + double myControl = 0, oppControl = 0; + int radius = 3; + for (int x = 0; x < BS; x++) for (int y = 0; y < BS; y++) { + if (board[x][y] == GoPlayer.NONE) { + double myInf = 0, oppInf = 0; + for (int dx = -radius; dx <= radius; dx++) for (int dy = -radius; dy <= radius; dy++) { + int nx = x + dx, ny = y + dy; + if (nx >= 0 && nx < BS && ny >= 0 && ny < BS) { + double w = Math.sqrt(dx * dx + dy * dy); + w = w > 0 ? 1.0 / w : 1.0; + if (board[nx][ny] == player) myInf += w; + else if (board[nx][ny] == opponent) oppInf += w; + } + } + if (myInf > oppInf) myControl++; else if (oppInf > myInf) oppControl++; + } + } + aux[17] = (myControl - oppControl) / 100.0; + aux[18] = evaluateConnections(board, player) / 20.0; + aux[19] = evaluateSeparation(board, player) / 20.0; + aux[20] = evaluateStrategicPoints(board, player) / 10.0; + TerritoryResult tr = evaluateTerritory(board, player); + aux[21] = (tr.myTerritory - tr.oppTerritory) / 50.0; + + return aux; + } + + // ══════════════════════════════════════════════════════════════════════ + // 前向传播 + // ══════════════════════════════════════════════════════════════════════ + + /** GPU 加速是否启用(可选配置 go.gpu,默认开)。false 时一律走 CPU,不初始化 OpenCL。 */ + private static volatile Boolean gpuEnabledCache = null; + + private static boolean isGpuEnabled() { + Boolean v = gpuEnabledCache; + if (v != null) return v; + // 1) 系统属性 -Dgo.gpu=false(训练 CLI 用) + String prop = System.getProperty("go.gpu"); + if (prop != null) { + v = Boolean.parseBoolean(prop); + gpuEnabledCache = v; + return v; + } + // 2) GameSettings 配置文件(MC 对局用) + try { + v = GameSettings.getBoolean("go", "gpu", true); + } catch (Exception e) { + v = true; + } + gpuEnabledCache = v; + return v; + } + + /** + * 懒初始化 OpenCL 后端(GPU 启用且成功才使用,失败自动回退 CPU)。 + */ + private OpenCLBackend ensureOpenCL() { + if (openclDisabled || !isGpuEnabled()) return null; // GPU 可选/初始化失败:走 CPU + if (opencl == null) { + synchronized (this) { + if (opencl == null) { + try { + opencl = new OpenCLBackend(); + } catch (Throwable t) { + // ★ 兜底:JNA 缺失/UnsatisfiedLinkError 等属于 Error, + // 不能让 GPU 探测失败把整条推理路径炸掉,降级 CPU + System.err.println("[NeuralEvaluator] OpenCL 初始化失败,回退 CPU: " + t); + opencl = null; + openclDisabled = true; + } + } + } + } + return (opencl != null && opencl.isAvailable()) ? opencl : null; + } + + /** + * 完整前向传播:三级分块 → 双头。 + */ + public ForwardResult forward(double[][][] planes, double[] auxFeatures) { + try { + lifecycleLock.lockInterruptibly(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return new ForwardResult(0.0, new double[POLICY_SIZE]); + } + try { + if (released) return new ForwardResult(0.0, new double[POLICY_SIZE]); + modelLock.readLock().lock(); + try { + // ── 第 1 级:二级子块 ────────────────────────────────────── + // subOut[b][s][h] — 大块 b 的第 s 个子块的 16 维输出 + double[][][] subOut = new double[NUM_BLOCKS][SUBS_PER_BLOCK][SUB_HIDDEN]; + for (int b = 0; b < NUM_BLOCKS; b++) { + int bx = BLOCK_STARTS[b][0], by = BLOCK_STARTS[b][1]; + double[] w1 = null; // flatten subW1[b] for fast access + double[] b1 = subB1[b]; + for (int s = 0; s < SUBS_PER_BLOCK; s++) { + int sx = bx + SUB_OFFSETS[s][0], sy = by + SUB_OFFSETS[s][1]; + // 提取 3×3×4 = 36 个值 + double[] input = new double[SUB_INPUT]; + int idx = 0; + for (int p = 0; p < PLANES; p++) + for (int dx = 0; dx < SUB_SIZE; dx++) + for (int dy = 0; dy < SUB_SIZE; dy++) + input[idx++] = planes[p][sx + dx][sy + dy]; + // FC(36→16) + ReLU + for (int j = 0; j < SUB_HIDDEN; j++) { + double sum = b1[j]; + for (int i = 0; i < SUB_INPUT; i++) + sum += subW1[b][i][j] * input[i]; + subOut[b][s][j] = Math.max(0, sum); + } + } + } + + // ── 第 2 级:一级字块 ────────────────────────────────────── + double[][] blockOut = new double[NUM_BLOCKS][BLOCK_HIDDEN]; + for (int b = 0; b < NUM_BLOCKS; b++) { + // 拼接 9 个子块输出 → 144 维 + double[] input = new double[BLOCK_INPUT]; + int idx = 0; + for (int s = 0; s < SUBS_PER_BLOCK; s++) + for (int h = 0; h < SUB_HIDDEN; h++) + input[idx++] = subOut[b][s][h]; + // FC(144→64) + ReLU + for (int j = 0; j < BLOCK_HIDDEN; j++) { + double sum = blockB1[b][j]; + for (int i = 0; i < BLOCK_INPUT; i++) + sum += blockW1[b][i][j] * input[i]; + blockOut[b][j] = Math.max(0, sum); + } + } + + // ── 第 3 级:顶级 ────────────────────────────────────────── + double[] topInput = new double[TOP_INPUT]; + int idx = 0; + for (int b = 0; b < NUM_BLOCKS; b++) + for (int h = 0; h < BLOCK_HIDDEN; h++) + topInput[idx++] = blockOut[b][h]; + // 拼接辅助特征 + System.arraycopy(auxFeatures, 0, topInput, NUM_BLOCKS * BLOCK_HIDDEN, AUX_SIZE); + + // FC(600→256) + ReLU + double[] shared = new double[TOP_HIDDEN]; + for (int j = 0; j < TOP_HIDDEN; j++) { + double sum = topB1[j]; + for (int i = 0; i < TOP_INPUT; i++) + sum += topW1[i][j] * topInput[i]; + shared[j] = Math.max(0, sum); + } + + // ── 策略头 ────────────────────────────────────────────────── + double[] policy = new double[POLICY_SIZE]; + double maxLogit = Double.NEGATIVE_INFINITY; + for (int j = 0; j < POLICY_SIZE; j++) { + double sum = policyB[j]; + for (int i = 0; i < TOP_HIDDEN; i++) + sum += policyW[i][j] * shared[i]; + if (sum > maxLogit) maxLogit = sum; + policy[j] = sum; + } + // softmax(数值稳定版) + double sumExp = 0; + for (int j = 0; j < POLICY_SIZE; j++) { + policy[j] = Math.exp(policy[j] - maxLogit); + sumExp += policy[j]; + } + double invSum = 1.0 / Math.max(sumExp, 1e-30); + for (int j = 0; j < POLICY_SIZE; j++) policy[j] *= invSum; + + // ── 价值头 ────────────────────────────────────────────────── + double[] vh = new double[VALUE_HIDDEN]; + for (int j = 0; j < VALUE_HIDDEN; j++) { + double sum = valueB1[j]; + for (int i = 0; i < TOP_HIDDEN; i++) + sum += valueW1[i][j] * shared[i]; + vh[j] = Math.max(0, sum); + } + double valueSum = valueB2; + for (int i = 0; i < VALUE_HIDDEN; i++) + valueSum += valueW2[i] * vh[i]; + double value = Math.tanh(valueSum); + + return new ForwardResult(value, policy); + } finally { + modelLock.readLock().unlock(); + } + } finally { + lifecycleLock.unlock(); + } + } + + /** + * 纯神经网络价值评估(不混合启发式),用于 MCTS 搜索。 + * 直接返回价值头输出(-1~1),不携带启发式偏差。 + */ + public double forwardValue(GoPlayer[][] board, GoPlayer player, int[] lastMove) { + double[][][] planes = buildInputPlanes(board, player, lastMove); + double[] aux = extractAuxFeatures(board, player); + return forward(planes, aux).value; + } + + /** + * 仅计算策略头(用于 MCTS 节点扩展获取先验概率)。 + */ + public double[] forwardPolicy(double[][][] planes, double[] auxFeatures) { + return forward(planes, auxFeatures).policy; + } + + // ══════════════════════════════════════════════════════════════════════ + // 训练(反向传播) + // ══════════════════════════════════════════════════════════════════════ + + /** + * 训练一个 mini-batch(双头 loss:value MSE + policy cross-entropy)。 + * + * @param planes [batch][4][19][19] + * @param auxFeatures [batch][24] + * @param valueTargets [batch] + * @param policyTargets [batch][362] + * @param learningRate 学习率 + * @param l2 L2 正则系数 + * @param gradientClip 梯度裁剪阈值 + * @param momentum 动量系数(0 = 无动量,推荐 0.9) + * @return 平均 loss + */ + public double trainMiniBatch(double[][][][] planes, double[][] auxFeatures, + double[] valueTargets, double[][] policyTargets, + double learningRate, double l2, double gradientClip, + double momentum) { + int batchSize = planes.length; + if (batchSize == 0) return 0; + + try { + lifecycleLock.lockInterruptibly(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return 0; + } + try { + if (released) return 0; + modelLock.writeLock().lock(); + try { + // 确保动量缓冲就绪 + if (momentum > 0) ensureVelocities(); + // ── 梯度累加器 ────────────────────────────────────────────── + double[][][] gSubW = new double[NUM_BLOCKS][SUB_INPUT][SUB_HIDDEN]; + double[][] gSubB = new double[NUM_BLOCKS][SUB_HIDDEN]; + double[][][] gBlockW = new double[NUM_BLOCKS][BLOCK_INPUT][BLOCK_HIDDEN]; + double[][] gBlockB = new double[NUM_BLOCKS][BLOCK_HIDDEN]; + double[][] gTopW = new double[TOP_INPUT][TOP_HIDDEN]; + double[] gTopB = new double[TOP_HIDDEN]; + double[][] gPolicyW = new double[TOP_HIDDEN][POLICY_SIZE]; + double[] gPolicyB = new double[POLICY_SIZE]; + double[][] gValueW1 = new double[TOP_HIDDEN][VALUE_HIDDEN]; + double[] gValueB1 = new double[VALUE_HIDDEN]; + double[] gValueW2 = new double[VALUE_HIDDEN]; + double gValueB2 = 0; + double totalLoss = 0; + + // ── OpenCL GPU 路径:Pass 0 预计算子块/字块并批量运行顶级 FC ── + boolean useGpu = false; + OpenCLBackend oc = null; + double[][][][] bSubIn = null, bSubZ = null; + double[][][] bBlkIn = null, bBlkZ = null; + double[][] bTopIn = null, bShared = null, bShZ = null; + double[][] bPolicyOut = null; // [B][362] GPU softmax 输出 + double[] bValueOut = null; // [B] GPU tanh 输出 + try { oc = ensureOpenCL(); useGpu = (oc != null); } + catch (Throwable e) { useGpu = false; oc = null; } + if (useGpu) { + bSubIn = new double[batchSize][NUM_BLOCKS][SUBS_PER_BLOCK][SUB_INPUT]; + bSubZ = new double[batchSize][NUM_BLOCKS][SUBS_PER_BLOCK][SUB_HIDDEN]; + bBlkIn = new double[batchSize][NUM_BLOCKS][BLOCK_INPUT]; + bBlkZ = new double[batchSize][NUM_BLOCKS][BLOCK_HIDDEN]; + bTopIn = new double[batchSize][TOP_INPUT]; + bShared = new double[batchSize][TOP_HIDDEN]; + bShZ = new double[batchSize][TOP_HIDDEN]; + bPolicyOut = new double[batchSize][POLICY_SIZE]; + bValueOut = new double[batchSize]; + // 整个 batch 的前向在 GPU 上完成(子块→字块→顶级→策略头→价值头) + boolean ranGpu = oc.batchPass0Forward(planes, auxFeatures, batchSize, + subW1, subB1, blockW1, blockB1, topW1, topB1, + policyW, policyB, valueW1, valueB1, valueW2, valueB2, + bSubIn, bSubZ, bBlkIn, bBlkZ, bTopIn, bShared, bShZ, + bPolicyOut, bValueOut); + if (!ranGpu) { + // GPU 失败,回退 CPU 路径 + useGpu = false; + } + } + + for (int n = 0; n < batchSize; n++) { + double[][][] p = planes[n]; + double[] aux = auxFeatures[n]; + double vTgt = valueTargets[n]; + double[] pTgt = policyTargets[n]; + + // ═══════════════════════════════════════════════════════════ + // 前向(保存中间结果供反向使用) + // ═══════════════════════════════════════════════════════════ + + double[][][] subIn; double[][][] subZ; + double[][] blkIn; double[][] blkZ; + double[] topIn; double[] shZ; double[] shared; + if (useGpu) { + // GPU 路径:复用 Pass 0 预计算的中间结果与顶级 FC 输出 + subIn = bSubIn[n]; subZ = bSubZ[n]; + blkIn = bBlkIn[n]; blkZ = bBlkZ[n]; + topIn = bTopIn[n]; shZ = bShZ[n]; shared = bShared[n]; + } else { + // ── 第 1 级:二级子块 ── + subIn = new double[NUM_BLOCKS][SUBS_PER_BLOCK][SUB_INPUT]; + subZ = new double[NUM_BLOCKS][SUBS_PER_BLOCK][SUB_HIDDEN]; + double[][][] subOut = new double[NUM_BLOCKS][SUBS_PER_BLOCK][SUB_HIDDEN]; + for (int b = 0; b < NUM_BLOCKS; b++) { + int bx = BLOCK_STARTS[b][0], by = BLOCK_STARTS[b][1]; + for (int s = 0; s < SUBS_PER_BLOCK; s++) { + int sx = bx + SUB_OFFSETS[s][0], sy = by + SUB_OFFSETS[s][1]; + int idx = 0; + for (int pp = 0; pp < PLANES; pp++) + for (int dx = 0; dx < SUB_SIZE; dx++) + for (int dy = 0; dy < SUB_SIZE; dy++) + subIn[b][s][idx++] = p[pp][sx + dx][sy + dy]; + for (int j = 0; j < SUB_HIDDEN; j++) { + double sum = subB1[b][j]; + for (int i = 0; i < SUB_INPUT; i++) + sum += subW1[b][i][j] * subIn[b][s][i]; + subZ[b][s][j] = sum; + subOut[b][s][j] = Math.max(0, sum); + } + } + } + // ── 第 2 级:一级字块 ── + blkIn = new double[NUM_BLOCKS][BLOCK_INPUT]; + blkZ = new double[NUM_BLOCKS][BLOCK_HIDDEN]; + double[][] blkOut = new double[NUM_BLOCKS][BLOCK_HIDDEN]; + for (int b = 0; b < NUM_BLOCKS; b++) { + int idx = 0; + for (int s = 0; s < SUBS_PER_BLOCK; s++) + for (int h = 0; h < SUB_HIDDEN; h++) + blkIn[b][idx++] = subOut[b][s][h]; + for (int j = 0; j < BLOCK_HIDDEN; j++) { + double sum = blockB1[b][j]; + for (int i = 0; i < BLOCK_INPUT; i++) + sum += blockW1[b][i][j] * blkIn[b][i]; + blkZ[b][j] = sum; + blkOut[b][j] = Math.max(0, sum); + } + } + // ── 第 3 级:顶级 ── + topIn = new double[TOP_INPUT]; + int topIdx = 0; + for (int b = 0; b < NUM_BLOCKS; b++) + for (int h = 0; h < BLOCK_HIDDEN; h++) + topIn[topIdx++] = blkOut[b][h]; + System.arraycopy(aux, 0, topIn, NUM_BLOCKS * BLOCK_HIDDEN, AUX_SIZE); + shZ = new double[TOP_HIDDEN]; + shared = new double[TOP_HIDDEN]; + for (int j = 0; j < TOP_HIDDEN; j++) { + double sum = topB1[j]; + for (int i = 0; i < TOP_INPUT; i++) + sum += topW1[i][j] * topIn[i]; + shZ[j] = sum; + shared[j] = Math.max(0, sum); + } + } + + // 策略头(GPU 路径直接复用 GPU softmax 输出,CPU 路径自行前向) + double[] policy; + if (useGpu) { + policy = bPolicyOut[n]; + } else { + double[] logits = new double[POLICY_SIZE]; + double maxLog = Double.NEGATIVE_INFINITY; + for (int j = 0; j < POLICY_SIZE; j++) { + double sum = policyB[j]; + for (int i = 0; i < TOP_HIDDEN; i++) + sum += policyW[i][j] * shared[i]; + logits[j] = sum; + if (sum > maxLog) maxLog = sum; + } + policy = new double[POLICY_SIZE]; + double sumExp = 0; + for (int j = 0; j < POLICY_SIZE; j++) { + policy[j] = Math.exp(logits[j] - maxLog); + sumExp += policy[j]; + } + double invSum = 1.0 / Math.max(sumExp, 1e-30); + for (int j = 0; j < POLICY_SIZE; j++) policy[j] *= invSum; + } + + // 价值头(vh/vhZ 供反向使用;最终值 GPU 已算出则直接复用) + double[] vhZ = new double[VALUE_HIDDEN]; + double[] vh = new double[VALUE_HIDDEN]; + for (int j = 0; j < VALUE_HIDDEN; j++) { + double sum = valueB1[j]; + for (int i = 0; i < TOP_HIDDEN; i++) + sum += valueW1[i][j] * shared[i]; + vhZ[j] = sum; + vh[j] = Math.max(0, sum); + } + double value; + if (useGpu) { + value = bValueOut[n]; // GPU tanh 输出 + } else { + double valuePre = valueB2; + for (int i = 0; i < VALUE_HIDDEN; i++) + valuePre += valueW2[i] * vh[i]; + value = Math.tanh(valuePre); + } + + // ── Loss ────────────────────────────────────────────── + double vLoss = (value - vTgt) * (value - vTgt); + double pLoss = 0; + for (int j = 0; j < POLICY_SIZE; j++) { + if (pTgt[j] > 0) + pLoss -= pTgt[j] * Math.log(Math.max(policy[j], 1e-15)); + } + totalLoss += vLoss + pLoss; + + // ═══════════════════════════════════════════════════════════ + // 反向传播 + // ═══════════════════════════════════════════════════════════ + + // ── 价值头 ────────────────────────────────────────────── + double dValue = 2.0 * (value - vTgt) * (1.0 - value * value); + double[] dVh = new double[VALUE_HIDDEN]; + for (int i = 0; i < VALUE_HIDDEN; i++) { + gValueW2[i] += dValue * vh[i]; + dVh[i] = dValue * valueW2[i] * (vhZ[i] > 0 ? 1 : 0); + gValueB1[i] += dVh[i]; // 价值头隐藏层偏置梯度(此前遗漏,偏置永久冻结) + } + gValueB2 += dValue; + + // ── 策略头 ────────────────────────────────────────────── + double[] dLogit = new double[POLICY_SIZE]; + for (int j = 0; j < POLICY_SIZE; j++) { + dLogit[j] = policy[j] - pTgt[j]; + gPolicyB[j] += dLogit[j]; + } + + // ── 共享层梯度 ────────────────────────────────────────── + double[] dShared = new double[TOP_HIDDEN]; + for (int i = 0; i < TOP_HIDDEN; i++) { + double fromPolicy = 0; + for (int j = 0; j < POLICY_SIZE; j++) { + gPolicyW[i][j] += dLogit[j] * shared[i]; + fromPolicy += dLogit[j] * policyW[i][j]; + } + double fromValue = 0; + for (int j = 0; j < VALUE_HIDDEN; j++) { + gValueW1[i][j] += dVh[j] * shared[i]; + fromValue += dVh[j] * valueW1[i][j]; + } + dShared[i] = (fromPolicy + fromValue) * (shZ[i] > 0 ? 1 : 0); + gTopB[i] += dShared[i]; + } + // 顶级权重 + for (int k = 0; k < TOP_INPUT; k++) + for (int i = 0; i < TOP_HIDDEN; i++) + gTopW[k][i] += dShared[i] * topIn[k]; + + // ── 第 2 级:一级字块 ────────────────────────────────── + double[] dTopIn = new double[TOP_INPUT]; + for (int k = 0; k < TOP_INPUT; k++) + for (int i = 0; i < TOP_HIDDEN; i++) + dTopIn[k] += dShared[i] * topW1[k][i]; + + for (int b = 0; b < NUM_BLOCKS; b++) { + double[] dBlkOut = new double[BLOCK_HIDDEN]; + for (int h = 0; h < BLOCK_HIDDEN; h++) + dBlkOut[h] = dTopIn[b * BLOCK_HIDDEN + h]; + + double[] dBlkZ = new double[BLOCK_HIDDEN]; + for (int j = 0; j < BLOCK_HIDDEN; j++) { + dBlkZ[j] = dBlkOut[j] * (blkZ[b][j] > 0 ? 1 : 0); + gBlockB[b][j] += dBlkZ[j]; + } + for (int i = 0; i < BLOCK_INPUT; i++) + for (int j = 0; j < BLOCK_HIDDEN; j++) + gBlockW[b][i][j] += dBlkZ[j] * blkIn[b][i]; + + // ── 第 1 级:二级子块 ────────────────────────────── + double[] dBlkIn = new double[BLOCK_INPUT]; + for (int i = 0; i < BLOCK_INPUT; i++) + for (int j = 0; j < BLOCK_HIDDEN; j++) + dBlkIn[i] += dBlkZ[j] * blockW1[b][i][j]; + + for (int s = 0; s < SUBS_PER_BLOCK; s++) { + for (int h = 0; h < SUB_HIDDEN; h++) { + double dSub = dBlkIn[s * SUB_HIDDEN + h] * (subZ[b][s][h] > 0 ? 1 : 0); + gSubB[b][h] += dSub; + for (int i = 0; i < SUB_INPUT; i++) + gSubW[b][i][h] += dSub * subIn[b][s][i]; + } + } + } + } + + // ── 应用梯度(平均 + L2 + 裁剪) ────────────────────────── + double scale = 1.0 / batchSize; + double norm = gradientNorm(gSubW, gSubB, gBlockW, gBlockB, gTopW, gTopB, + gPolicyW, gPolicyB, gValueW1, gValueB1, gValueW2, gValueB2); + if (!Double.isFinite(norm)) { + // ★ Bug修复:NaN 与裁剪阈值比较恒为 false,NaN 梯度会绕过裁剪直接写入全部权重且无法回滚。 + // 范数非有限时直接跳过本次权重更新,保留上一步的有效权重。 + System.err.println("[NeuralEvaluator] 检测到非有限梯度范数(" + norm + "),跳过本次权重更新"); + return totalLoss / batchSize; + } + if (norm > gradientClip) scale *= gradientClip / norm; + + // 更新 all weights(momentum > 0 时使用动量更新) + double r = learningRate * scale; + boolean useMom = momentum > 0; + if (useMom) { + updateMMM(subW1, gSubW, vSubW1, r, l2, momentum); + updateMM(subB1, gSubB, vSubB1, r, 0, momentum); + updateMMM(blockW1, gBlockW, vBlockW1, r, l2, momentum); + updateMM(blockB1, gBlockB, vBlockB1, r, 0, momentum); + updateMM(topW1, gTopW, vTopW1, r, l2, momentum); + updateM(topB1, gTopB, vTopB1, r, 0, momentum); + updateMM(policyW, gPolicyW, vPolicyW, r, l2, momentum); + updateM(policyB, gPolicyB, vPolicyB, r, 0, momentum); + updateMM(valueW1, gValueW1, vValueW1, r, l2, momentum); + updateM(valueB1, gValueB1, vValueB1, r, 0, momentum); + updateM(valueW2, gValueW2, vValueW2, r, l2, momentum); + // bias 不做 L2 正则(与 valueB1 等其它 bias 一致,避免 valueB2 被额外收缩) + vValueB2 = momentum * vValueB2 + r * gValueB2; + valueB2 -= vValueB2; + } else { + updateMMM(subW1, gSubW, null, r, l2, 0); + updateMM(subB1, gSubB, null, r, 0, 0); + updateMMM(blockW1, gBlockW, null, r, l2, 0); + updateMM(blockB1, gBlockB, null, r, 0, 0); + updateMM(topW1, gTopW, null, r, l2, 0); + updateM(topB1, gTopB, null, r, 0, 0); + updateMM(policyW, gPolicyW, null, r, l2, 0); + updateM(policyB, gPolicyB, null, r, 0, 0); + updateMM(valueW1, gValueW1, null, r, l2, 0); + updateM(valueB1, gValueB1, null, r, 0, 0); + updateM(valueW2, gValueW2, null, r, l2, 0); + valueB2 -= r * gValueB2; // bias 不做 L2 正则(与其它 bias 一致) + } + + modelVersion++; + synchronized (evaluationCache) { evaluationCache.clear(); } + return totalLoss / batchSize; + } finally { + modelLock.writeLock().unlock(); + } + } finally { + lifecycleLock.unlock(); + } + } + + // ── 梯度更新辅助(支持动量)───────────────────────────────────── + /** + * 更新 3D 权重:w -= v, v = momentum * v + rate * (g + l2 * w) + * 当 v == null 或 momentum == 0 时退化为纯 SGD:w -= rate * (g + l2 * w) + */ + private static void updateMMM(double[][][] w, double[][][] g, double[][][] v, double rate, double l2, double momentum) { + if (v != null) { + for (int a = 0; a < w.length; a++) + for (int b = 0; b < w[a].length; b++) + for (int c = 0; c < w[a][b].length; c++) { + double grad = g[a][b][c] + l2 * w[a][b][c]; + v[a][b][c] = momentum * v[a][b][c] + rate * grad; + w[a][b][c] -= v[a][b][c]; + } + } else { + for (int a = 0; a < w.length; a++) + for (int b = 0; b < w[a].length; b++) + for (int c = 0; c < w[a][b].length; c++) + w[a][b][c] -= rate * (g[a][b][c] + l2 * w[a][b][c]); + } + } + private static void updateMM(double[][] w, double[][] g, double[][] v, double rate, double l2, double momentum) { + if (v != null) { + for (int a = 0; a < w.length; a++) + for (int b = 0; b < w[a].length; b++) { + double grad = g[a][b] + l2 * w[a][b]; + v[a][b] = momentum * v[a][b] + rate * grad; + w[a][b] -= v[a][b]; + } + } else { + for (int a = 0; a < w.length; a++) + for (int b = 0; b < w[a].length; b++) + w[a][b] -= rate * (g[a][b] + l2 * w[a][b]); + } + } + private static void updateM(double[] w, double[] g, double[] v, double rate, double l2, double momentum) { + if (v != null) { + for (int a = 0; a < w.length; a++) { + double grad = g[a] + l2 * w[a]; + v[a] = momentum * v[a] + rate * grad; + w[a] -= v[a]; + } + } else { + for (int a = 0; a < w.length; a++) + w[a] -= rate * (g[a] + l2 * w[a]); + } + } + + /** 惰性创建动量缓冲(首次 momentum > 0 训练时调用) */ + private void ensureVelocities() { + if (vSubW1 != null) return; + vSubW1 = new double[NUM_BLOCKS][SUB_INPUT][SUB_HIDDEN]; + vSubB1 = new double[NUM_BLOCKS][SUB_HIDDEN]; + vBlockW1 = new double[NUM_BLOCKS][BLOCK_INPUT][BLOCK_HIDDEN]; + vBlockB1 = new double[NUM_BLOCKS][BLOCK_HIDDEN]; + vTopW1 = new double[TOP_INPUT][TOP_HIDDEN]; + vTopB1 = new double[TOP_HIDDEN]; + vPolicyW = new double[TOP_HIDDEN][POLICY_SIZE]; + vPolicyB = new double[POLICY_SIZE]; + vValueW1 = new double[TOP_HIDDEN][VALUE_HIDDEN]; + vValueB1 = new double[VALUE_HIDDEN]; + vValueW2 = new double[VALUE_HIDDEN]; + vValueB2 = 0; + } + /** 重置动量缓冲为 0(加载新权重后调用) */ + private void resetVelocities() { + vSubW1 = null; vSubB1 = null; + vBlockW1 = null; vBlockB1 = null; + vTopW1 = null; vTopB1 = null; + vPolicyW = null; vPolicyB = null; + vValueW1 = null; vValueB1 = null; + vValueW2 = null; + vValueB2 = 0; + } + + private static double gradientNorm(double[][][] gSubW, double[][] gSubB, + double[][][] gBlockW, double[][] gBlockB, + double[][] gTopW, double[] gTopB, + double[][] gPolicyW, double[] gPolicyB, + double[][] gValueW1, double[] gValueB1, + double[] gValueW2, double gValueB2) { + double s = 0; + // gSubW [9][36][16] + for (double[][] mm : gSubW) for (double[] r : mm) for (double v : r) s += v * v; + // gBlockW [9][144][64] + for (double[][] mm : gBlockW) for (double[] r : mm) for (double v : r) s += v * v; + // 2D arrays + for (double[][] m : new double[][][]{gSubB, gBlockB, gTopW, gPolicyW, gValueW1}) + for (double[] r : m) for (double v : r) s += v * v; + // 1D arrays + for (double[] v : new double[][]{gTopB, gPolicyB, gValueB1, gValueW2}) + for (double x : v) s += x * x; + s += gValueB2 * gValueB2; + return Math.sqrt(s); + } + + // ══════════════════════════════════════════════════════════════════════ + // 辅助方法(眼形、连接、领地等,沿用旧版) + // ══════════════════════════════════════════════════════════════════════ + + private static class EyeInfo { boolean isTrue, isPotential; } + + private EyeInfo analyzeEye(GoPlayer[][] board, int x, int y) { + EyeInfo info = new EyeInfo(); + GoPlayer player = board[x][y]; + GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + int friendly = 0, enemy = 0, empty = 0; + for (int[] d : DIRS) { + int nx = x + d[0], ny = y + d[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE) { + if (board[nx][ny] == player) friendly++; + else if (board[nx][ny] == opponent) enemy++; + else if (board[nx][ny] == GoPlayer.NONE) empty++; + } + } + info.isTrue = (friendly >= 3 && empty >= 1) || (friendly == 4); + info.isPotential = friendly >= 2 && enemy > 0; + return info; + } + + private double evaluateConnections(GoPlayer[][] board, GoPlayer player) { + boolean[][] visited = new boolean[BOARD_SIZE][BOARD_SIZE]; + double bonus = 0; + for (int x = 0; x < BOARD_SIZE; x++) for (int y = 0; y < BOARD_SIZE; y++) { + if (board[x][y] == player && !visited[x][y]) { + Set group = getGroup(board, x, y); + int size = group.size(); + if (size >= 5) bonus += size * 0.3; + for (int[] p : group) { + for (int[] d : DIRS) { + int nx = p[0] + d[0], ny = p[1] + d[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == player) + bonus += 0.5; + } + } + int libs = countGroupLiberties(board, group); + if (libs >= 5) bonus += 2; + for (int[] p : group) visited[p[0]][p[1]] = true; + } + } + return bonus; + } + + private double evaluateSeparation(GoPlayer[][] board, GoPlayer player) { + GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + double threat = 0; + for (int x = 0; x < BOARD_SIZE; x++) for (int y = 0; y < BOARD_SIZE; y++) { + if (board[x][y] == GoPlayer.NONE) { + // getGroup 返回的 Set 中 int[] 是 identity 相等,同一棋群从多方向 + // 相邻会被 Set> 当成多个。用棋群最小坐标的 Long 键规范化去重。 + Set groupKeys = new HashSet<>(); + for (int[] d : DIRS) { + int nx = x + d[0], ny = y + d[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == opponent) { + Set g = getGroup(board, nx, ny); + long minKey = Long.MAX_VALUE; + for (int[] p : g) { + long key = (long) p[0] * BOARD_SIZE + p[1]; + if (key < minKey) minKey = key; + } + groupKeys.add(minKey); + } + } + if (groupKeys.size() >= 2) { + int minLibs = Integer.MAX_VALUE; + for (long key : groupKeys) { + int px = (int)(key / BOARD_SIZE), py = (int)(key % BOARD_SIZE); + int libs = countGroupLiberties(board, getGroup(board, px, py)); + minLibs = Math.min(minLibs, libs); + } + threat += minLibs <= 3 ? 3.0 : 1.0; + } + } + } + return threat; + } + + private double evaluateStrategicPoints(GoPlayer[][] board, GoPlayer player) { + GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + double score = 0; + int center = BOARD_SIZE / 2; + if (board[center][center] == player) score += 3; + else if (board[center][center] == opponent) score -= 3; + int[] star = {3, BOARD_SIZE - 4}; + for (int s1 : star) for (int s2 : star) { + if (board[s1][s2] == player) score += 2; + else if (board[s1][s2] == opponent) score -= 2; + } + int[] komoku = {6, BOARD_SIZE - 7}; + for (int k1 : komoku) for (int k2 : komoku) { + if (board[k1][k2] == player) score += 1.5; + else if (board[k1][k2] == opponent) score -= 1.5; + } + return score; + } + + private static class TerritoryResult { double myTerritory, oppTerritory; } + + private TerritoryResult evaluateTerritory(GoPlayer[][] board, GoPlayer player) { + GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + TerritoryResult result = new TerritoryResult(); + boolean[][] visited = new boolean[BOARD_SIZE][BOARD_SIZE]; + for (int x = 0; x < BOARD_SIZE; x++) for (int y = 0; y < BOARD_SIZE; y++) { + if (board[x][y] == GoPlayer.NONE && !visited[x][y]) { + List region = new ArrayList<>(); + Queue queue = new LinkedList<>(); + queue.add(new int[]{x, y}); visited[x][y] = true; + while (!queue.isEmpty()) { + int[] pos = queue.poll(); + region.add(pos); + for (int[] d : DIRS) { + int nx = pos[0] + d[0], ny = pos[1] + d[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE + && board[nx][ny] == GoPlayer.NONE && !visited[nx][ny]) { + visited[nx][ny] = true; + queue.add(new int[]{nx, ny}); + } + } + } + int myBorder = 0, oppBorder = 0; + for (int[] p : region) for (int[] d : DIRS) { + int nx = p[0] + d[0], ny = p[1] + d[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE) { + if (board[nx][ny] == player) myBorder++; + else if (board[nx][ny] == opponent) oppBorder++; + } + } + double tv = region.size(); + if (myBorder > oppBorder) result.myTerritory += tv; + else if (oppBorder > myBorder) result.oppTerritory += tv; + else { result.myTerritory += tv * 0.5; result.oppTerritory += tv * 0.5; } + } + } + return result; + } + + private double heuristicEvaluation(GoPlayer[][] board, GoPlayer player) { + GoPlayer opponent = player == GoPlayer.BLACK ? GoPlayer.WHITE : GoPlayer.BLACK; + double score = 0; + boolean[][] visited = new boolean[BOARD_SIZE][BOARD_SIZE]; + for (int x = 0; x < BOARD_SIZE; x++) for (int y = 0; y < BOARD_SIZE; y++) { + if (board[x][y] != GoPlayer.NONE && !visited[x][y]) { + Set group = getGroup(board, x, y); + int libs = countGroupLiberties(board, group); + boolean isOwn = board[x][y] == player; + if (libs >= 6) score += isOwn ? 15 : -15; + else if (libs == 5) score += isOwn ? 12 : -12; + else if (libs == 4) score += isOwn ? 8 : -8; + else if (libs == 3) score += isOwn ? 4 : -4; + else if (libs == 2) score += isOwn ? 1 : -3; + else if (libs == 1) score += isOwn ? -25 : 25; + else score += isOwn ? -40 : 40; + if (isOwn && group.size() >= 5) score += group.size() * 2; + if (!isOwn && libs <= 2) score += 20; + for (int[] p : group) visited[p[0]][p[1]] = true; + } + } + int myEyes = 0, oppEyes = 0; + for (int x = 0; x < BOARD_SIZE; x++) for (int y = 0; y < BOARD_SIZE; y++) { + if (board[x][y] != GoPlayer.NONE && isPotentialEye(board, x, y)) { + if (board[x][y] == player) myEyes++; else oppEyes++; + } + } + score += (myEyes - oppEyes) * 8; + int myControl = 0, oppControl = 0; + for (int x = 0; x < BOARD_SIZE; x++) for (int y = 0; y < BOARD_SIZE; y++) { + if (board[x][y] == GoPlayer.NONE) { + double myInf = 0, oppInf = 0; + for (int dx = -2; dx <= 2; dx++) for (int dy = -2; dy <= 2; dy++) { + int nx = x + dx, ny = y + dy; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE) { + double w = Math.sqrt(dx * dx + dy * dy); + w = w > 0 ? 1.0 / w : 1.0; + if (board[nx][ny] == player) myInf += w; + else if (board[nx][ny] == opponent) oppInf += w; + } + } + if (myInf > oppInf + 0.5) myControl++; + else if (oppInf > myInf + 0.5) oppControl++; + } + } + score += (myControl - oppControl) * 0.5; + int myStones = 0, oppStones = 0; + for (int x = 0; x < BOARD_SIZE; x++) for (int y = 0; y < BOARD_SIZE; y++) { + if (board[x][y] == player) myStones++; + else if (board[x][y] == opponent) oppStones++; + } + score += (myStones - oppStones) * 2; + return score; + } + + private boolean isPotentialEye(GoPlayer[][] board, int x, int y) { + int friendly = 0, empty = 0; + for (int[] d : DIRS) { + int nx = x + d[0], ny = y + d[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE) { + if (board[nx][ny] == board[x][y]) friendly++; + else if (board[nx][ny] == GoPlayer.NONE) empty++; + } + } + return friendly + empty >= 3; + } + + private double countSurrounding(GoPlayer[][] board, int x, int y, GoPlayer player) { + double influence = 0; + for (int[] d : DIRS) { + int nx = x + d[0], ny = y + d[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == player) + influence += 1.0; + } + return influence; + } + + private int countFriendlyNeighbors(GoPlayer[][] board, int x, int y, GoPlayer player) { + int count = 0; + for (int[] d : DIRS) { + int nx = x + d[0], ny = y + d[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE && board[nx][ny] == player) + count++; + } + return count; + } + + Set getGroup(GoPlayer[][] board, int x, int y) { + Set group = new HashSet<>(); + if (board[x][y] == GoPlayer.NONE) return group; + Stack stack = new Stack<>(); + boolean[][] visited = new boolean[BOARD_SIZE][BOARD_SIZE]; + stack.push(new int[]{x, y}); + while (!stack.isEmpty()) { + int[] pos = stack.pop(); + int px = pos[0], py = pos[1]; + if (visited[px][py]) continue; + visited[px][py] = true; + group.add(new int[]{px, py}); + for (int[] d : DIRS) { + int nx = px + d[0], ny = py + d[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE + && !visited[nx][ny] && board[nx][ny] == board[x][y]) + stack.push(new int[]{nx, ny}); + } + } + return group; + } + + int countGroupLiberties(GoPlayer[][] board, Set group) { + Set libertySet = new HashSet<>(); + for (int[] pos : group) { + for (int[] d : DIRS) { + int nx = pos[0] + d[0], ny = pos[1] + d[1]; + if (nx >= 0 && nx < BOARD_SIZE && ny >= 0 && ny < BOARD_SIZE + && board[nx][ny] == GoPlayer.NONE) + libertySet.add((long) nx * BOARD_SIZE + ny); + } + } + return libertySet.size(); + } + + // ══════════════════════════════════════════════════════════════════════ + // Zobrist 哈希(复用 GoGame 的表) + // ══════════════════════════════════════════════════════════════════════ + + private long computeZobristHash(GoPlayer[][] board, GoPlayer player) { + long hash = GoGame.boardHash(board); + if (player == GoPlayer.WHITE) hash ^= 0xFFFFFFFFL; + return hash; + } + + private static final class CacheKey { + final long hash, version; + CacheKey(long hash, long version) { this.hash = hash; this.version = version; } + @Override public int hashCode() { return Long.hashCode(hash * 31L + version); } + @Override public boolean equals(Object o) { + if (!(o instanceof CacheKey)) return false; + CacheKey k = (CacheKey) o; + return hash == k.hash && version == k.version; + } + } + + // ══════════════════════════════════════════════════════════════════════ + // 快照与持久化 + // ══════════════════════════════════════════════════════════════════════ + + public long getModelVersion() { return modelVersion; } + + public int getCacheSize() { synchronized (evaluationCache) { return evaluationCache.size(); } } + public void clearCache() { synchronized (evaluationCache) { evaluationCache.clear(); } } + + /** Immutable model snapshot. */ + public static final class ModelWeights { + public final double[][][] subW1, blockW1; + public final double[][] subB1, blockB1; + public final double[][] topW1; + public final double[] topB1; + public final double[][] policyW; + public final double[] policyB; + public final double[][] valueW1; + public final double[] valueB1; + public final double[] valueW2; + public final double valueB2; + public final long version; + + private ModelWeights(double[][][] subW1, double[][] subB1, + double[][][] blockW1, double[][] blockB1, + double[][] topW1, double[] topB1, + double[][] policyW, double[] policyB, + double[][] valueW1, double[] valueB1, + double[] valueW2, double valueB2, long version) { + this.subW1 = deepCopy(subW1); this.subB1 = deepCopy(subB1); + this.blockW1 = deepCopy(blockW1); this.blockB1 = deepCopy(blockB1); + this.topW1 = deepCopy(topW1); this.topB1 = topB1.clone(); + this.policyW = deepCopy(policyW); this.policyB = policyB.clone(); + this.valueW1 = deepCopy(valueW1); this.valueB1 = valueB1.clone(); + this.valueW2 = valueW2.clone(); this.valueB2 = valueB2; + this.version = version; + } + + private static double[][][] deepCopy(double[][][] src) { + double[][][] dst = new double[src.length][][]; + for (int i = 0; i < src.length; i++) dst[i] = deepCopy(src[i]); + return dst; + } + private static double[][] deepCopy(double[][] src) { + double[][] dst = new double[src.length][]; + for (int i = 0; i < src.length; i++) dst[i] = src[i].clone(); + return dst; + } + private static double[] deepCopy(double[] src) { return src.clone(); } + } + + public ModelWeights snapshot() { + modelLock.readLock().lock(); + try { + return new ModelWeights(subW1, subB1, blockW1, blockB1, + topW1, topB1, policyW, policyB, + valueW1, valueB1, valueW2, valueB2, modelVersion); + } finally { + modelLock.readLock().unlock(); + } + } + + public void apply(ModelWeights m) { + if (m == null) throw new IllegalArgumentException("Null model"); + modelLock.writeLock().lock(); + try { + copyInto(m.subW1, subW1); copyInto(m.subB1, subB1); + copyInto(m.blockW1, blockW1); copyInto(m.blockB1, blockB1); + copyInto(m.topW1, topW1); copyInto(m.topB1, topB1); + copyInto(m.policyW, policyW); copyInto(m.policyB, policyB); + copyInto(m.valueW1, valueW1); copyInto(m.valueB1, valueB1); + copyInto(m.valueW2, valueW2); this.valueB2 = m.valueB2; + // 恢复持久化版本号(避免加载 checkpoint 后版本被重置为 1, + // 导致模型版本与缓存键不一致) + modelVersion = m.version; + synchronized (evaluationCache) { evaluationCache.clear(); } + // 加载新权重后重置动量缓冲,避免旧动量污染新权重 + resetVelocities(); + } finally { + modelLock.writeLock().unlock(); + } + } + + private static void copyInto(double[][][] src, double[][][] dst) { + for (int i = 0; i < src.length; i++) copyInto(src[i], dst[i]); + } + private static void copyInto(double[][] src, double[][] dst) { + for (int i = 0; i < src.length; i++) System.arraycopy(src[i], 0, dst[i], 0, dst[i].length); + } + private static void copyInto(double[] src, double[] dst) { + System.arraycopy(src, 0, dst, 0, dst.length); + } + + public void save(Path path) throws IOException { + Path absolute = path.toAbsolutePath(); + Path parent = absolute.getParent(); + if (parent != null) Files.createDirectories(parent); + Path temp = Files.createTempFile(parent == null ? Path.of(".") : parent, + absolute.getFileName().toString() + ".", ".tmp"); + try { + ModelWeights m = snapshot(); + // 缓冲流:模型 ~37 万 float 逐值写出,无缓冲时每次 writeFloat 都是一次系统调用 + try (DataOutputStream out = new DataOutputStream(new java.io.BufferedOutputStream( + Files.newOutputStream(temp, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING), + 1 << 16))) { + out.writeInt(MODEL_MAGIC); + out.writeInt(MODEL_FORMAT); + out.writeLong(m.version); + // 9 套子块权重 + for (int b = 0; b < NUM_BLOCKS; b++) { + writeMatrix(out, m.subW1[b]); writeVector(out, m.subB1[b]); + } + // 9 套字块权重 + for (int b = 0; b < NUM_BLOCKS; b++) { + writeMatrix(out, m.blockW1[b]); writeVector(out, m.blockB1[b]); + } + writeMatrix(out, m.topW1); writeVector(out, m.topB1); + writeMatrix(out, m.policyW); writeVector(out, m.policyB); + writeMatrix(out, m.valueW1); writeVector(out, m.valueB1); + writeVector(out, m.valueW2); out.writeFloat((float)m.valueB2); + } + replaceFile(temp, absolute); + } finally { + Files.deleteIfExists(temp); + } + } + + /** + * 原子替换目标文件;Windows 上若另一线程/进程正持有目标文件的读句柄 + * (JVM 流不申请 FILE_SHARE_DELETE),replace 会抛 AccessDeniedException。 + * 读取窗口极短(缓冲流毫秒级),指数退避重试即可收敛。 + */ + private static void replaceFile(Path temp, Path absolute) throws IOException { + IOException last = null; + for (int attempt = 0; attempt < 6; attempt++) { + try { + try { + Files.move(temp, absolute, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (java.nio.file.AtomicMoveNotSupportedException e) { + Files.move(temp, absolute, StandardCopyOption.REPLACE_EXISTING); + } + return; + } catch (java.nio.file.AccessDeniedException e) { + last = e; + try { + Thread.sleep(20L << attempt); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new IOException("interrupted while replacing " + absolute, ie); + } + } + } + throw last; + } + + public void load(Path path) throws IOException { + // 缓冲流:与 save 对称,避免 ~37 万次逐 float 读取的系统调用开销 + try (DataInputStream in = new DataInputStream(new java.io.BufferedInputStream( + Files.newInputStream(path), 1 << 16))) { + int magic = in.readInt(); + int fmt = in.readInt(); + boolean currentFormat = magic == MODEL_MAGIC && fmt == MODEL_FORMAT; + boolean legacyFormat = magic == LEGACY_MODEL_MAGIC && fmt == LEGACY_MODEL_FORMAT; + if (!currentFormat && !legacyFormat) { + throw new IOException("Unsupported model format: magic=" + + Integer.toHexString(magic) + " fmt=" + fmt); + } + // 向后兼容:NEV2 用 double 8 字节,NEV3 用 float 4 字节。 + boolean isDouble = legacyFormat; + long ver = in.readLong(); + + double[][][] lSubW1 = new double[NUM_BLOCKS][SUB_INPUT][SUB_HIDDEN]; + double[][] lSubB1 = new double[NUM_BLOCKS][SUB_HIDDEN]; + double[][][] lBlockW1 = new double[NUM_BLOCKS][BLOCK_INPUT][BLOCK_HIDDEN]; + double[][] lBlockB1 = new double[NUM_BLOCKS][BLOCK_HIDDEN]; + for (int b = 0; b < NUM_BLOCKS; b++) { readMatrix(in, lSubW1[b], isDouble); readVector(in, lSubB1[b], isDouble); } + for (int b = 0; b < NUM_BLOCKS; b++) { readMatrix(in, lBlockW1[b], isDouble); readVector(in, lBlockB1[b], isDouble); } + double[][] lTopW1 = readMatrix(in, TOP_INPUT, TOP_HIDDEN, isDouble); + double[] lTopB1 = readVector(in, TOP_HIDDEN, isDouble); + double[][] lPolicyW = readMatrix(in, TOP_HIDDEN, POLICY_SIZE, isDouble); + double[] lPolicyB = readVector(in, POLICY_SIZE, isDouble); + double[][] lValueW1 = readMatrix(in, TOP_HIDDEN, VALUE_HIDDEN, isDouble); + double[] lValueB1 = readVector(in, VALUE_HIDDEN, isDouble); + double[] lValueW2 = readVector(in, VALUE_HIDDEN, isDouble); + double lValueB2 = readFinite(in, isDouble); + if (in.read() != -1) { + throw new IOException("Unexpected trailing data in model file"); + } + + ModelWeights m = new ModelWeights(lSubW1, lSubB1, lBlockW1, lBlockB1, + lTopW1, lTopB1, lPolicyW, lPolicyB, + lValueW1, lValueB1, lValueW2, lValueB2, ver); + apply(m); + } + } + + private static void writeMatrix(DataOutputStream o, double[][] m) throws IOException { + for (double[] r : m) for (double v : r) o.writeFloat((float)v); + } + private static void writeVector(DataOutputStream o, double[] v) throws IOException { + for (double x : v) o.writeFloat((float)x); + } + private static double[][] readMatrix(DataInputStream in, int r, int c, boolean isDouble) throws IOException { + double[][] m = new double[r][c]; + for (int i = 0; i < r; i++) for (int j = 0; j < c; j++) m[i][j] = readFinite(in, isDouble); + return m; + } + private static void readMatrix(DataInputStream in, double[][] target, boolean isDouble) throws IOException { + for (double[] r : target) for (int j = 0; j < r.length; j++) r[j] = readFinite(in, isDouble); + } + private static double[] readVector(DataInputStream in, int n, boolean isDouble) throws IOException { + double[] v = new double[n]; + for (int i = 0; i < n; i++) v[i] = readFinite(in, isDouble); + return v; + } + private static void readVector(DataInputStream in, double[] target, boolean isDouble) throws IOException { + for (int i = 0; i < target.length; i++) target[i] = readFinite(in, isDouble); + } + private static double readFinite(DataInputStream in, boolean isDouble) throws IOException { + double value = isDouble ? in.readDouble() : in.readFloat(); + if (!Double.isFinite(value)) throw new IOException("Non-finite model weight"); + return value; + } +} \ No newline at end of file diff --git a/src/main/java/com/wzz/game_console/client/screens/games/gogame/OpenCLBackend.java b/src/main/java/com/wzz/game_console/client/screens/games/gogame/OpenCLBackend.java new file mode 100644 index 0000000..7a9b801 --- /dev/null +++ b/src/main/java/com/wzz/game_console/client/screens/games/gogame/OpenCLBackend.java @@ -0,0 +1,527 @@ +package com.wzz.game_console.client.screens.games.gogame; + +import com.sun.jna.Memory; +import com.sun.jna.Native; +import com.sun.jna.NativeLibrary; +import com.sun.jna.Pointer; +import com.sun.jna.ptr.IntByReference; + +import java.util.concurrent.locks.ReentrantLock; + +/** + * OpenCL GPU 加速后端。CPU 负责构建输入,GPU 负责矩阵运算。 + */ +public class OpenCLBackend implements AutoCloseable { + private static final int CL_MEM_READ_WRITE = 1; + private static final int CL_DEVICE_EXTENSIONS = 0x1030; + private static final int CL_DEVICE_DOUBLE_FP_CONFIG = 0x1032; + private volatile boolean available = false; + private volatile boolean closed; + /** Serializes native use with close; close waits until the active batch has left. */ + private final ReentrantLock lifecycleLock = new ReentrantLock(); + private String deviceName = "CPU"; + private NativeLibrary cl; + private Pointer context, queue, device, program; + private final java.util.Map kernels = new java.util.concurrent.ConcurrentHashMap<>(); + + public OpenCLBackend() { + // ★ catch Throwable:JNA 缺失/UnsatisfiedLinkError 是 Error 不是 Exception, + // 原版 catch (Exception) 拦不住,GPU 探测失败会直接炸掉调用方 + try { init(); available = true; } + catch (Throwable t) { System.err.println("[OpenCL] 初始化失败: " + t); close(); } + } + public boolean isAvailable() { return available; } + public String getDeviceName() { return deviceName; } + + private void init() throws Exception { + cl = NativeLibrary.getInstance("OpenCL"); + IntByReference n = new IntByReference(); + checkCl("clGetPlatformIDs(count)", calli("clGetPlatformIDs", 0, null, n)); + if (n.getValue() == 0) throw new Exception("无 OpenCL 平台"); + Pointer[] platforms = new Pointer[n.getValue()]; + checkCl("clGetPlatformIDs(list)", calli("clGetPlatformIDs", n.getValue(), platforms, null)); + for (Pointer p : platforms) { + if (p == null) continue; + device = findDevice(p, 4L); + if (device != null) break; + } + if (device == null) throw new Exception("无 GPU"); + deviceName = getDeviceName(device); + Pointer[] devs = {device}; + IntByReference ec = new IntByReference(); + context = create("clCreateContext", ec, null, 1, devs, null, null, ec); + try { queue = create("clCreateCommandQueueWithProperties", ec, context, device, null, ec); } + catch (Throwable e) { queue = create("clCreateCommandQueue", ec, context, device, 0L, ec); } + + String source = fp64Pragma(device) + KERNEL_SOURCE; + byte[] src = source.getBytes(java.nio.charset.StandardCharsets.UTF_8); + try (Memory mem = new Memory(src.length + 1)) { + mem.write(0, src, 0, src.length); mem.setByte(src.length, (byte)0); + Pointer[] strings = {mem}; + long[] lens = {src.length}; + program = create("clCreateProgramWithSource", ec, context, 1, strings, lens, ec); + } + int be = calli("clBuildProgram", program, 0, null, null, null, null); + if (be != 0) { String log = getBuildLog(program); throw new Exception("编译失败: " + log); } + + for (String name : "sub_fwd,block_fwd,top_fwd,policy_fwd,value_fwd".split(",")) { + Pointer k = create("clCreateKernel(" + name + ")", "clCreateKernel", ec, program, name, ec); + kernels.put(name, k); + } + // 关键内核缺失(如老驱动编译失败被吞)时必须回退 CPU, + // 否则 batchPass0Forward 会拿到 null 结果被当作"成功" + if (kernels.size() < 5) throw new Exception("计算内核不足: 仅加载 " + kernels.keySet()); + System.out.println("[OpenCL] " + deviceName + " 内核: " + kernels.size()); + } + + // ── 完整 GPU Pass 0 前向:子块→字块→顶级,一次批量完成 ── + /** + * 在整个 batch 上 GPU 执行 子块/字块/顶级 三层前向(ReLU 中间结果), + * 并填充成 NeuralEvaluator.trainMiniBatch GPU 路径所需的全部中间量。 + * 若 GPU 不可用或失败返回 false,调用方回退 CPU 路径。 + * + * @param bSubIn [B][9][9][36] 子块输入 + * @param bSubZ [B][9][9][16] 子块 ReLU 输出(供反向的 ReLU 掩码使用) + * @param bBlkIn [B][9][144] 字块输入 + * @param bBlkZ [B][9][64] 字块 ReLU 输出 + * @param bTopIn [B][600] 顶级输入 + * @param bShared [B][256] 顶级 ReLU 输出 + * @param bShZ [B][256] 顶级 ReLU 输出(bShared 的副本,供反向掩码) + */ + public boolean batchPass0Forward(double[][][][] planes, double[][] aux, int B, + double[][][] subW, double[][] subB, + double[][][] blkW, double[][] blkB, + double[][] topW, double[] topB, + double[][] polW, double[] polB, + double[][] valW1, double[] valB1, double[] valW2, double valB2, + double[][][][] bSubIn, double[][][][] bSubZ, + double[][][] bBlkIn, double[][][] bBlkZ, + double[][] bTopIn, double[][] bShared, double[][] bShZ, + double[][] bPolicyOut, double[] bValueOut) { + if (!lifecycleLock.tryLock()) return false; + boolean failed = false; + try { + if (closed || !available || Thread.currentThread().isInterrupted()) return false; + // ── 关键:子块权重需从 [9][36][16] 复制为 GPU 内核期望的 [81][36][16] + // (每大块内 9 个子块共享该块的权重,内核按子块全局索引 s=0..80 取权) + double[][][] subWGpu = new double[81][36][16]; + double[][] subBGpu = new double[81][16]; + for (int si = 0; si < 81; si++) { + int b = si / 9; + subWGpu[si] = subW[b]; + subBGpu[si] = subB[b]; + } + // ── 子块前向 ── + double[][][] subIn = extractSubInputs(planes, B); + double[][][] subOut = gpuSubFwd(subIn, subWGpu, subBGpu, B); + double[][][] blkIn = buildBlkIn(subOut, B); + double[][][] blkOut = gpuBlockFwd(blkIn, blkW, blkB, B); + double[][] topIn = buildTopIn(blkOut, aux, B); + double[][] sharedOut = gpuTopFwd(topIn, topW, topB, B); + double[][] policyOut = gpuPolicyFwd(sharedOut, polW, polB, B); + double[] valueOut = gpuValueFwd(sharedOut, valW1, valB1, valW2, valB2, B); + if (policyOut == null || valueOut == null) throw new Exception("GPU 内核未返回完整结果"); + + for (int n = 0; n < B; n++) { + for (int b = 0; b < 9; b++) { + for (int s = 0; s < 9; s++) { + System.arraycopy(subIn[b * 9 + s][n], 0, bSubIn[n][b][s], 0, 36); + System.arraycopy(subOut[b * 9 + s][n], 0, bSubZ[n][b][s], 0, 16); + } + System.arraycopy(blkIn[b][n], 0, bBlkIn[n][b], 0, 144); + System.arraycopy(blkOut[b][n], 0, bBlkZ[n][b], 0, 64); + } + System.arraycopy(topIn[n], 0, bTopIn[n], 0, 600); + System.arraycopy(sharedOut[n], 0, bShared[n], 0, 256); + System.arraycopy(sharedOut[n], 0, bShZ[n], 0, 256); + System.arraycopy(policyOut[n], 0, bPolicyOut[n], 0, 362); + } + System.arraycopy(valueOut, 0, bValueOut, 0, B); + return true; + } catch (Throwable t) { + failed = true; + available = false; + System.err.println("[OpenCL] batchPass0Forward,已禁用 GPU: " + t); + return false; + } finally { + lifecycleLock.unlock(); + if (failed) close(); + } + } + + // ── GPU 前向方法 ── + private double[][][] gpuSubFwd(double[][][] in, double[][][] w, double[][] b, int B) throws Exception { + return run3D("sub_fwd", in, w, b, 81, 36, 16, B); + } + private double[][][] gpuBlockFwd(double[][][] in, double[][][] w, double[][] b, int B) throws Exception { + return run3D("block_fwd", in, w, b, 9, 144, 64, B); + } + private double[][] gpuTopFwd(double[][] in, double[][] w, double[] b, int B) throws Exception { + return run2D("top_fwd", in, w, b, 600, 256, B); + } + private double[][] gpuPolicyFwd(double[][] in, double[][] w, double[] b, int B) throws Exception { + // policy_fwd 内核只使用 get_global_id(0)(每行算全部 362 个输出), + // 必须 1D launch,避免 2D launch 的 y 维度产生 368 个冗余重复计算 + Pointer k = kernels.get("policy_fwd"); if (k == null) return null; + Pointer dInG = null, dWG = null, dBG = null, dOut = null; + Memory dIn = null, dW = null, dB = null; + try { + dIn = flatten2D(in); dInG = alloc(dIn.size()); writeG(dInG, dIn); + dW = flatten2D(w); dWG = alloc(dW.size()); writeG(dWG, dW); + dB = flatten1D(b); dBG = alloc(dB.size()); writeG(dBG, dB); + dOut = alloc((long)B * 362 * 8); + setPtr(k, 0, dInG); setPtr(k, 1, dWG); setPtr(k, 2, dBG); setPtr(k, 3, dOut); setInt(k, 4, B); + launch1D(k, B); + double[][] out = new double[B][362]; readBack2D(dOut, out, B, 362); + return out; + } finally { + free(dInG, dWG, dBG, dOut); + if (dIn != null) dIn.close(); if (dW != null) dW.close(); if (dB != null) dB.close(); + } + } + private double[] gpuValueFwd(double[][] in, double[][] w1, double[] b1, double[] w2, double b2, int B) throws Exception { + Pointer k = kernels.get("value_fwd"); if (k == null) return null; + Pointer dInG = null, dW1G = null, dB1G = null, dW2G = null, dOut = null; + Memory dIn = null, dW1 = null, dB1 = null, dW2 = null; + try { + dIn = flatten2D(in); dInG = alloc(dIn.size()); writeG(dInG, dIn); + dW1 = flatten2D(w1); dW1G = alloc(dW1.size()); writeG(dW1G, dW1); + dB1 = flatten1D(b1); dB1G = alloc(dB1.size()); writeG(dB1G, dB1); + dW2 = flatten1D(w2); dW2G = alloc(dW2.size()); writeG(dW2G, dW2); + dOut = alloc((long)B * 8); + setPtr(k, 0, dInG); setPtr(k, 1, dW1G); setPtr(k, 2, dB1G); + setPtr(k, 3, dW2G); setPtr(k, 4, dOut); setInt(k, 5, B); setF64(k, 6, b2); + // 内核只用 get_global_id(0),1D launch 避免 y 维度 16 倍冗余 + launch1D(k, B); + double[] out = new double[B]; readBack(dOut, out); + return out; + } finally { + free(dInG, dW1G, dB1G, dW2G, dOut); + if (dIn != null) dIn.close(); if (dW1 != null) dW1.close(); + if (dB1 != null) dB1.close(); if (dW2 != null) dW2.close(); + } + } + + // ── 数据提取 ── + private double[][][] extractSubInputs(double[][][][] planes, int B) { + double[][][] out = new double[81][B][36]; + for (int n = 0; n < B; n++) { + double[][][] p = planes[n]; + for (int b = 0; b < 9; b++) { + // 与 NeuralEvaluator.BLOCK_STARTS 一致:x=(b/3)*6, y=(b%3)*6 + int bx = (b / 3) * 6, by = (b % 3) * 6; + for (int s = 0; s < 9; s++) { + // 与 NeuralEvaluator.SUB_OFFSETS 一致:x=(s/3)*2, y=(s%3)*2 + int sx = bx + (s / 3) * 2, sy = by + (s % 3) * 2, si = b * 9 + s, idx = 0; + for (int pp = 0; pp < 4; pp++) + for (int dx = 0; dx < 3; dx++) + for (int dy = 0; dy < 3; dy++) + out[si][n][idx++] = p[pp][sx + dx][sy + dy]; + } + } + } + return out; + } + private double[][][] buildBlkIn(double[][][] subOut, int B) { + double[][][] out = new double[9][B][144]; + for (int b = 0; b < 9; b++) + for (int n = 0; n < B; n++) + for (int i = 0; i < 144; i++) + out[b][n][i] = subOut[b * 9 + i / 16][n][i % 16]; + return out; + } + private double[][] buildTopIn(double[][][] blkOut, double[][] aux, int B) { + double[][] out = new double[B][600]; + for (int n = 0; n < B; n++) { + int idx = 0; + for (int b = 0; b < 9; b++) { System.arraycopy(blkOut[b][n], 0, out[n], idx, 64); idx += 64; } + System.arraycopy(aux[n], 0, out[n], 576, 24); + } + return out; + } + + // ── GPU 前向辅助 ── + private double[][][] run3D(String kName, double[][][] in, double[][][] w, double[][] b, int S, int K, int N, int B) throws Exception { + Pointer k = kernels.get(kName); if (k == null) return null; + Pointer dInG = null, dWG = null, dBG = null, dOut = null; + Memory dIn = null, dW = null, dB = null; + try { + dIn = flatten3D(in); dInG = alloc(dIn.size()); writeG(dInG, dIn); + dW = flatten3D(w); dWG = alloc(dW.size()); writeG(dWG, dW); + dB = flatten2D(b); dBG = alloc(dB.size()); writeG(dBG, dB); + dOut = alloc((long)S * B * N * 8); + setPtr(k, 0, dInG); setPtr(k, 1, dWG); setPtr(k, 2, dBG); setPtr(k, 3, dOut); setInt(k, 4, B); + launch3D(k, S, B, N); + double[][][] out = new double[S][B][N]; readBack3D(dOut, out, S, B, N); + return out; + } finally { + free(dInG, dWG, dBG, dOut); + if (dIn != null) dIn.close(); if (dW != null) dW.close(); if (dB != null) dB.close(); + } + } + private double[][] run2D(String kName, double[][] in, double[][] w, double[] b, int K, int N, int B) throws Exception { + Pointer k = kernels.get(kName); if (k == null) return null; + Pointer dInG = null, dWG = null, dBG = null, dOut = null; + Memory dIn = null, dW = null, dB = null; + try { + dIn = flatten2D(in); dInG = alloc(dIn.size()); writeG(dInG, dIn); + dW = flatten2D(w); dWG = alloc(dW.size()); writeG(dWG, dW); + dB = flatten1D(b); dBG = alloc(dB.size()); writeG(dBG, dB); + dOut = alloc((long)B * N * 8); + setPtr(k, 0, dInG); setPtr(k, 1, dWG); setPtr(k, 2, dBG); setPtr(k, 3, dOut); setInt(k, 4, B); + launch(k, B, N); + double[][] out = new double[B][N]; readBack2D(dOut, out, B, N); + return out; + } finally { + free(dInG, dWG, dBG, dOut); + if (dIn != null) dIn.close(); if (dW != null) dW.close(); if (dB != null) dB.close(); + } + } + + // ── GPU 内存/执行 ── + private Pointer alloc(long bytes) throws Exception { + IntByReference error = new IntByReference(); + return create("clCreateBuffer", error, context, CL_MEM_READ_WRITE, bytes, null, error); + } + private void free(Pointer... ps) { for (Pointer p : ps) if (p != null) safe("clReleaseMemObject", p); } + private void writeG(Pointer dst, Memory src) throws Exception { + checkCl("clEnqueueWriteBuffer", calli("clEnqueueWriteBuffer", queue, dst, 1, 0L, src.size(), src, 0, null, null)); + } + private void readBack(Pointer src, double[] out) throws Exception { + try (Memory m = new Memory((long)out.length * 8)) { + checkCl("clEnqueueReadBuffer", calli("clEnqueueReadBuffer", queue, src, 1, 0L, m.size(), m, 0, null, null)); + checkCl("clFinish(read)", calli("clFinish", queue)); double[] flat = m.getDoubleArray(0, out.length); + System.arraycopy(flat, 0, out, 0, out.length); + } + } + private void readBack1D(Pointer src, double[] out, int n) throws Exception { + try (Memory m = new Memory((long)n * 8)) { + checkCl("clEnqueueReadBuffer", calli("clEnqueueReadBuffer", queue, src, 1, 0L, m.size(), m, 0, null, null)); + checkCl("clFinish(read)", calli("clFinish", queue)); + m.read(0, out, 0, n); + } + } + private void readBack2D(Pointer src, double[][] out, int B, int N) throws Exception { + try (Memory m = new Memory((long)B * N * 8)) { + checkCl("clEnqueueReadBuffer", calli("clEnqueueReadBuffer", queue, src, 1, 0L, m.size(), m, 0, null, null)); + checkCl("clFinish(read)", calli("clFinish", queue)); double[] flat = m.getDoubleArray(0, B * N); + for (int n = 0; n < B; n++) System.arraycopy(flat, n * N, out[n], 0, N); + } + } + private void readBack3D(Pointer src, double[][][] out, int S, int B, int N) throws Exception { + try (Memory m = new Memory((long)S * B * N * 8)) { + checkCl("clEnqueueReadBuffer", calli("clEnqueueReadBuffer", queue, src, 1, 0L, m.size(), m, 0, null, null)); + checkCl("clFinish(read)", calli("clFinish", queue)); double[] flat = m.getDoubleArray(0, S * B * N); + for (int s = 0; s < S; s++) + for (int n = 0; n < B; n++) + System.arraycopy(flat, (s * B + n) * N, out[s][n], 0, N); + } + } + private void setPtr(Pointer k, int idx, Pointer p) throws Exception { + try (Memory ref = new Memory(Native.POINTER_SIZE)) { + ref.setPointer(0, p); + checkCl("clSetKernelArg(pointer)", calli("clSetKernelArg", k, idx, + (long) Native.POINTER_SIZE, ref)); + } + } + private void setInt(Pointer k, int idx, int v) throws Exception { + try (Memory m = new Memory(4)) { + m.setInt(0, v); + checkCl("clSetKernelArg(int)", calli("clSetKernelArg", k, idx, 4L, m)); + } + } + private void setF64(Pointer k, int idx, double v) throws Exception { + try (Memory m = new Memory(8)) { + m.setDouble(0, v); + checkCl("clSetKernelArg(double)", calli("clSetKernelArg", k, idx, 8L, m)); + } + } + private void launch(Pointer k, int x, int y) throws Exception { + try (Memory g = new Memory((long) Native.SIZE_T_SIZE * 2)) { + writeSizeT(g, 0, ceil(x, 16) * 16); + writeSizeT(g, Native.SIZE_T_SIZE, ceil(y, 16) * 16); + checkCl("clEnqueueNDRangeKernel(2D)", calli("clEnqueueNDRangeKernel", queue, k, 2, null, g, null, 0, null, null)); + checkCl("clFinish(kernel)", calli("clFinish", queue)); + } + } + private void launch1D(Pointer k, int n) throws Exception { + long gs = ceil(n, 256) * 256; + try (Memory g = new Memory(Native.SIZE_T_SIZE)) { + writeSizeT(g, 0, gs); + checkCl("clEnqueueNDRangeKernel(1D)", calli("clEnqueueNDRangeKernel", queue, k, 1, null, g, null, 0, null, null)); + checkCl("clFinish(kernel)", calli("clFinish", queue)); + } + } + private void launch3D(Pointer k, int x, int y, int z) throws Exception { + // 三个维度都向上取整到 16 的倍数(sub_fwd/block_fwd 内核已加 s>=S 边界检查, + // 驱动向上填充全局尺寸时多余工作项会被拦截,不会越界写)。 + try (Memory g = new Memory((long) Native.SIZE_T_SIZE * 3)) { + writeSizeT(g, 0, ceil(x, 16) * 16); + writeSizeT(g, Native.SIZE_T_SIZE, ceil(y, 16) * 16); + writeSizeT(g, (long) Native.SIZE_T_SIZE * 2, ceil(z, 16) * 16); + checkCl("clEnqueueNDRangeKernel(3D)", calli("clEnqueueNDRangeKernel", queue, k, 3, null, g, null, 0, null, null)); + checkCl("clFinish(kernel)", calli("clFinish", queue)); + } + } + private static long ceil(long a, long b) { return (a + b - 1) / b; } + private static void writeSizeT(Memory memory, long offset, long value) { + if (Native.SIZE_T_SIZE == Long.BYTES) memory.setLong(offset, value); + else memory.setInt(offset, (int) value); + } + + // ── 展平 ── + private static Memory flatten3D(double[][][] m) { + int d1 = m.length, d2 = m[0].length, d3 = m[0][0].length; + Memory mem = new Memory((long)d1 * d2 * d3 * 8); + double[] f = new double[d1 * d2 * d3]; + for (int i = 0; i < d1; i++) for (int j = 0; j < d2; j++) System.arraycopy(m[i][j], 0, f, (i * d2 + j) * d3, d3); + mem.write(0, f, 0, f.length); return mem; + } + private static Memory flatten2D(double[][] m) { + int r = m.length, c = m[0].length; Memory mem = new Memory((long)r * c * 8); + double[] f = new double[r * c]; for (int i = 0; i < r; i++) System.arraycopy(m[i], 0, f, i * c, c); + mem.write(0, f, 0, f.length); return mem; + } + private static Memory flatten1D(double[] v) { + Memory mem = new Memory((long)v.length * 8); mem.write(0, v, 0, v.length); return mem; + } + + // ── OpenCL 底层 ── + private Pointer findDevice(Pointer platform, long type) throws Exception { + IntByReference n = new IntByReference(); + if (calli("clGetDeviceIDs", platform, type, 0, null, n) != 0 || n.getValue() == 0) return null; + Pointer[] devs = new Pointer[n.getValue()]; + checkCl("clGetDeviceIDs(list)", calli("clGetDeviceIDs", platform, type, n.getValue(), devs, null)); + for (Pointer candidate : devs) { + if (candidate != null && supportsFp64(candidate)) return candidate; + } + return null; + } + private boolean supportsFp64(Pointer dev) throws Exception { + try (Memory config = new Memory(Long.BYTES)) { + return calli("clGetDeviceInfo", dev, CL_DEVICE_DOUBLE_FP_CONFIG, + Long.BYTES, config, null) == 0 && config.getLong(0) != 0L; + } + } + private String fp64Pragma(Pointer dev) throws Exception { + String extensions = getDeviceString(dev, CL_DEVICE_EXTENSIONS); + if (extensions.contains("cl_khr_fp64")) { + return "#pragma OPENCL EXTENSION cl_khr_fp64 : enable\n"; + } + if (extensions.contains("cl_amd_fp64")) { + return "#pragma OPENCL EXTENSION cl_amd_fp64 : enable\n"; + } + return ""; + } + private String getDeviceName(Pointer dev) throws Exception { + return getDeviceString(dev, 0x102B); + } + private String getDeviceString(Pointer dev, int property) throws Exception { + try (Memory sizeOut = new Memory(Native.SIZE_T_SIZE)) { + checkCl("clGetDeviceInfo(size)", calli("clGetDeviceInfo", dev, property, 0, null, sizeOut)); + long size = readSizeT(sizeOut); + if (size <= 0 || size > Integer.MAX_VALUE) return ""; + try (Memory m = new Memory(size)) { + checkCl("clGetDeviceInfo(value)", calli("clGetDeviceInfo", dev, property, size, m, null)); + return m.getString(0, "UTF-8"); + } + } + } + private String getBuildLog(Pointer prog) throws Exception { + try (Memory sizeOut = new Memory(Native.SIZE_T_SIZE)) { + checkCl("clGetProgramBuildInfo(size)", calli("clGetProgramBuildInfo", prog, device, 0x1183, 0, null, sizeOut)); + long size = readSizeT(sizeOut); + if (size <= 0 || size > Integer.MAX_VALUE) return ""; + try (Memory m = new Memory(size)) { + checkCl("clGetProgramBuildInfo(value)", calli("clGetProgramBuildInfo", prog, device, 0x1183, size, m, null)); + return m.getString(0, "UTF-8"); + } + } + } + private static long readSizeT(Memory memory) { + return Native.SIZE_T_SIZE == Long.BYTES + ? memory.getLong(0) : Integer.toUnsignedLong(memory.getInt(0)); + } + private static void checkCl(String operation, int status) throws Exception { + if (status != 0) throw new Exception(operation + " 失败,OpenCL 错误码 " + status); + } + private Pointer create(String function, IntByReference error, Object... args) throws Exception { + return create(function, function, error, args); + } + private Pointer create(String operation, String function, IntByReference error, Object... args) throws Exception { + error.setValue(Integer.MIN_VALUE); + Pointer pointer = callp(function, args); + checkCl(operation, error.getValue()); + if (pointer == null) throw new Exception(operation + " 返回空句柄"); + return pointer; + } + private int calli(String fn, Object... args) throws Exception { return cl.getFunction(fn).invokeInt(args); } + private Pointer callp(String fn, Object... args) throws Exception { return cl.getFunction(fn).invokePointer(args); } + private void safe(String fn, Pointer p) { + if (p == null || cl == null) return; + try { + calli(fn, p); + } catch (Throwable ignored) { + // 释放阶段必须是 best-effort,不能让缺失/损坏的本地库越过 close 边界。 + } + } + + @Override + public void close() { + // The same mutex guards every native batch. Do not release any handle + // until the in-flight batch (including clFinish/readback) has returned. + lifecycleLock.lock(); + try { + if (closed) return; + closed = true; + available = false; + for (Pointer k : kernels.values()) safe("clReleaseKernel", k); + kernels.clear(); + safe("clReleaseProgram", program); + safe("clReleaseCommandQueue", queue); + safe("clReleaseContext", context); + program = null; + queue = null; + context = null; + device = null; + cl = null; + } finally { + lifecycleLock.unlock(); + } + } + + private static final String KERNEL_SOURCE = "" + + "__kernel void sub_fwd(__global double* in, __global double* w, __global double* b, __global double* out, int B) {\n" + + " int s=get_global_id(0), r=get_global_id(1), c=get_global_id(2);\n" + + " if(s>=81||r>=B||c>=16)return; double sum=b[s*16+c];\n" + + " for(int k=0;k<36;k++) sum+=in[(s*B+r)*36+k]*w[s*36*16+k*16+c];\n" + + " out[(s*B+r)*16+c] = sum>0?sum:0;\n" + + "}\n" + + "__kernel void block_fwd(__global double* in, __global double* w, __global double* b, __global double* out, int B) {\n" + + " int s=get_global_id(0), r=get_global_id(1), c=get_global_id(2);\n" + + " if(s>=9||r>=B||c>=64)return; double sum=b[s*64+c];\n" + + " for(int k=0;k<144;k++) sum+=in[(s*B+r)*144+k]*w[s*144*64+k*64+c];\n" + + " out[(s*B+r)*64+c] = sum>0?sum:0;\n" + + "}\n" + + "__kernel void top_fwd(__global double* in, __global double* w, __global double* b, __global double* out, int B) {\n" + + " int r=get_global_id(0), c=get_global_id(1);\n" + + " if(r>=B||c>=256)return; double sum=b[c];\n" + + " for(int k=0;k<600;k++) sum+=in[r*600+k]*w[k*256+c];\n" + + " out[r*256+c] = sum>0?sum:0;\n" + + "}\n" + + "__kernel void policy_fwd(__global double* in, __global double* w, __global double* b, __global double* out, int B) {\n" + + " int r=get_global_id(0); if(r>=B)return;\n" + + " double l[362]; double mx=-1e30;\n" + + " for(int j=0;j<362;j++){ double s=b[j]; for(int k=0;k<256;k++) s+=in[r*256+k]*w[k*362+j]; l[j]=s; if(s>mx)mx=s; }\n" + + " double se=0; for(int j=0;j<362;j++){ double e=exp(l[j]-mx); l[j]=e; se+=e; }\n" + + " double ise=1.0/max(se,1e-30); for(int j=0;j<362;j++) out[r*362+j]=l[j]*ise;\n" + + "}\n" + + "__kernel void value_fwd(__global double* in, __global double* w1, __global double* b1, __global double* w2, __global double* out, int B, double b2) {\n" + + " int r=get_global_id(0); if(r>=B)return;\n" + + " double h[128]; for(int j=0;j<128;j++){ double s=b1[j]; for(int k=0;k<256;k++) s+=in[r*256+k]*w1[k*128+j]; h[j]=s>0?s:0; }\n" + + " double s=b2; for(int k=0;k<128;k++) s+=w2[k]*h[k]; out[r]=tanh(s);\n" + + "}\n" + + "__kernel void sgd(__global double* w, __global double* g, double lr, double l2, int n) {\n" + + " int i=get_global_id(0); if(i>=n)return; w[i]-=lr*(g[i]+l2*w[i]);\n" + + "}\n"; +} \ No newline at end of file diff --git a/src/main/java/com/wzz/game_console/client/screens/games/landlord/AIPlayer.java b/src/main/java/com/wzz/game_console/client/screens/games/landlord/AIPlayer.java index 4a6bf60..a09dcb4 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/landlord/AIPlayer.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/landlord/AIPlayer.java @@ -5,139 +5,252 @@ public class AIPlayer { private Random random = new Random(); private LandlordGame gameReference; // 添加游戏引用以访问牌型分析 - + public void setGameReference(LandlordGame game) { this.gameReference = game; } - - public boolean decideBid(List hand, boolean isFirst) { + + public boolean decideBid(List hand) { // 简单AI:计算手牌强度 - int strength = calculateHandStrength(hand); - - if (isFirst) { - return strength > 60; // 第一个叫地主需要更强的牌 - } else { - return strength > 40; // 后面叫地主要求稍低 - } + return calculateHandStrength(hand) > 40; } - + + /** 兼容入口:不带身份信息(无法识别队友,按无队友逻辑处理)。 */ public List chooseCardsToPlay(List hand, List lastCards, boolean isMyTurn) { + return chooseCardsToPlay(hand, lastCards, isMyTurn, -1, -1, -1, null); + } + + /** + * 带身份上下文的出牌决策。 + * + * @param myIdx 我在哪一方(0/1/2,未知传 -1) + * @param lastPlayerIdx 上一个出牌者(桌面为空时传 -1) + * @param landlordIdx 地主是哪一方(未知传 -1) + * @param handCounts 三家手牌数(未知传 null) + */ + public List chooseCardsToPlay(List hand, List lastCards, boolean isMyTurn, + int myIdx, int lastPlayerIdx, int landlordIdx, int[] handCounts) { + if (!isMyTurn || hand == null || lastCards == null) { + return new ArrayList<>(); + } if (lastCards.isEmpty()) { // 主动出牌,优先选择较小的组合 return chooseActivePlay(hand); } - - // 被动出牌,尝试找能打过的最小组合 + + boolean iAmLandlord = landlordIdx >= 0 && myIdx == landlordIdx; + // 上家是队友:我是农民且出牌者是另一个农民 + boolean lastIsTeammate = !iAmLandlord && landlordIdx >= 0 + && lastPlayerIdx >= 0 && lastPlayerIdx != myIdx && lastPlayerIdx != landlordIdx; + + if (lastIsTeammate) { + // 默认不压队友(炸弹只留给地主);仅当队友报牌(剩 ≤2 张)时帮压 + boolean teammateLow = handCounts != null && lastPlayerIdx < handCounts.length + && handCounts[lastPlayerIdx] > 0 && handCounts[lastPlayerIdx] <= 2; + if (!teammateLow) { + return new ArrayList<>(); // 过牌让队友继续走 + } + } + + // 对手(地主视角的任一农民 / 农民视角的地主)报牌 ≤2 张时, + // 禁用过牌与保炸弹逻辑:能压必压,否则对手下一手出完直接获胜 + boolean mustPress = false; + if (handCounts != null && myIdx >= 0) { + for (int i = 0; i < handCounts.length && i < 3; i++) { + if (i == myIdx) continue; + boolean teammate = !iAmLandlord && landlordIdx >= 0 && i != landlordIdx; + if (!teammate && handCounts[i] > 0 && handCounts[i] <= 2) { + mustPress = true; + break; + } + } + } + + // ★ Bug修复:原版 findMinimalBeat 内部找不到压牌时回退到 findAnyBomb(炸), + // 即使"用炸弹压对方一张小牌"明显不划算,联机模式下 HOST 还会再被拒收 + // 导致 AI 丢回合。先看压牌结果是否真的是 bomb,如果是且手牌较多, + // 走"过牌"分支避免无谓消耗。result==null 仍走主动出牌兜底(对手刚出炸等场景)。 List result = findMinimalBeat(hand, lastCards); - + // 如果手牌很少,更积极地出牌 if (hand.size() <= 3) { return result != null ? result : new ArrayList<>(); } - - // 30%概率选择过牌(如果不是最后几张牌) - if (result != null && hand.size() > 5 && random.nextDouble() < 0.3) { + + // 30%概率选择过牌(如果不是最后几张牌;对手报牌时禁用) + if (!mustPress && result != null && hand.size() > 5 && random.nextDouble() < 0.3) { return new ArrayList<>(); // 过牌 } - + + // 若 result 是炸弹且压的是普通牌型,手牌仍较多时优先过牌(不无谓炸) + if (!mustPress && result != null && isBomb(result) && hand.size() > 5) { + return new ArrayList<>(); // 过牌 + } + return result != null ? result : new ArrayList<>(); } - + + /** 判断给定手牌组合是否为炸弹(4 张同 rank 或 王炸) */ + private boolean isBomb(List cards) { + if (cards == null || cards.isEmpty()) return false; + if (cards.size() == 2) { + boolean hasJoker = false, hasBig = false; + for (Card c : cards) { + if (c.getValue() == 16) hasJoker = true; + if (c.getValue() == 17) hasBig = true; + } + return hasJoker && hasBig; + } + if (cards.size() == 4) { + int v = cards.get(0).getValue(); + for (Card c : cards) if (c.getValue() != v) return false; + return true; + } + return false; + } + private List chooseActivePlay(List hand) { + // ★ Bug修复:原版无空手防御,game.getPlayerHand 返回空时 hand.get(0) 抛 + // IOOB 中断 tick 致 game 卡住 + if (hand == null || hand.isEmpty()) return new ArrayList<>(); + // groupByValue 返回 TreeMap(升序):领出/跟牌都从最小的组开始 Map> groups = groupByValue(hand); // 创建副本排序,避免修改原始手牌顺序 hand = new ArrayList<>(hand); Collections.sort(hand); - - // 优先出三带一或三带二 - for (int tripleValue : groups.keySet()) { - if (groups.get(tripleValue).size() >= 3) { - // 尝试三带一 - for (int singleValue : groups.keySet()) { - if (singleValue != tripleValue && groups.get(singleValue).size() >= 1) { - List result = new ArrayList<>(); - result.addAll(groups.get(tripleValue).subList(0, 3)); - result.add(groups.get(singleValue).get(0)); - return result; - } - } - - // 尝试三带二 - for (int pairValue : groups.keySet()) { - if (pairValue != tripleValue && groups.get(pairValue).size() >= 2) { - List result = new ArrayList<>(); - result.addAll(groups.get(tripleValue).subList(0, 3)); - result.addAll(groups.get(pairValue).subList(0, 2)); - return result; - } - } - - // 如果没有合适的带牌,出纯三张 - return groups.get(tripleValue).subList(0, 3); + + // 无拆牌风险的顺子领出(仅由散牌组成),避免永不领出顺子 + List safeStraight = findSafeStraightLead(groups); + if (safeStraight != null) return safeStraight; + + // 优先出三张:先找精确三张组,实在没有才允许拆四张组(炸弹) + Integer tripleValue = pickGroup(groups, 3, false); + if (tripleValue == null) tripleValue = pickGroup(groups, 3, true); + if (tripleValue != null) { + // 尝试三带一:优先散牌当翅(不从对子/炸弹里抽) + Integer singleValue = pickGroupExact(groups, 1, tripleValue); + if (singleValue != null) { + List result = new ArrayList<>(groups.get(tripleValue).subList(0, 3)); + result.add(groups.get(singleValue).get(0)); + return result; } + // 尝试三带二:优先精确对子 + Integer pairValue = pickGroupExact(groups, 2, tripleValue); + if (pairValue != null) { + List result = new ArrayList<>(groups.get(tripleValue).subList(0, 3)); + result.addAll(groups.get(pairValue).subList(0, 2)); + return result; + } + // 没有合适的带牌,出纯三张 + return new ArrayList<>(groups.get(tripleValue).subList(0, 3)); } - - // 出对子 - for (int value : groups.keySet()) { - if (groups.get(value).size() >= 2) { - return groups.get(value).subList(0, 2); + + // 出对子:优先精确对子(不拆三张/炸弹) + Integer pairValue = pickGroupExact(groups, 2, -1); + if (pairValue != null) { + return new ArrayList<>(groups.get(pairValue).subList(0, 2)); + } + + // 最后出单牌;返回独立可变列表,避免固定大小列表或输入视图泄漏给调用方 + return new ArrayList<>(List.of(hand.get(0))); + } + + /** 找最小的组大小 ≥ size 的值(允许拆更大的组);找不到返回 null。 */ + private Integer pickGroup(Map> groups, int size, boolean allowLarger) { + for (Map.Entry> e : groups.entrySet()) { + int sz = e.getValue().size(); + if (sz == size || (allowLarger && sz > size)) return e.getKey(); + } + return null; + } + + /** 找最小的组大小恰为 size 的值(绝不拆牌),排除 exclude 值;找不到返回 null。 */ + private Integer pickGroupExact(Map> groups, int size, int exclude) { + for (Map.Entry> e : groups.entrySet()) { + if (e.getKey() == exclude) continue; + if (e.getValue().size() == size) return e.getKey(); + } + return null; + } + + /** 仅由散牌(该值只剩一张且在 3-A 范围)组成的 5 连顺子领出;找不到返回 null。 */ + private List findSafeStraightLead(Map> groups) { + List singles = new ArrayList<>(); + for (Map.Entry> e : groups.entrySet()) { + if (e.getValue().size() == 1 && e.getKey() >= 3 && e.getKey() <= 14) singles.add(e.getKey()); + } + for (int i = 0; i + 5 <= singles.size(); i++) { + boolean consecutive = true; + for (int j = 1; j < 5; j++) { + if (singles.get(i + j) != singles.get(i) + j) { consecutive = false; break; } + } + if (consecutive) { + List result = new ArrayList<>(); + for (int j = 0; j < 5; j++) result.add(groups.get(singles.get(i + j)).get(0)); + return result; } } - - // 最后出单牌 - return Arrays.asList(hand.get(0)); + return null; } - + private int calculateHandStrength(List hand) { int strength = 0; - + // 统计各种牌型 Map rankCount = new HashMap<>(); for (Card card : hand) { rankCount.merge(card.getValue(), 1, Integer::sum); } - + // 大小王加分 strength += rankCount.getOrDefault(16, 0) * 15; // 小王 strength += rankCount.getOrDefault(17, 0) * 20; // 大王 - + // 2和A加分 strength += rankCount.getOrDefault(15, 0) * 8; // 2 strength += rankCount.getOrDefault(14, 0) * 6; // A - + // 对子、三张、炸弹加分 for (int count : rankCount.values()) { if (count == 2) strength += 3; else if (count == 3) strength += 8; else if (count == 4) strength += 25; // 炸弹 } - + // 检查王炸 if (rankCount.containsKey(16) && rankCount.containsKey(17)) { strength += 30; // 王炸额外加分 } - + return strength; } - + private List findMinimalBeat(List hand, List lastCards) { - Collections.sort(hand); + // 跟牌搜索需要升序,但不得重排游戏持有的原始手牌。 + List sortedHand = new ArrayList<>(hand); + Collections.sort(sortedHand); if (gameReference == null) { - return findSimpleBeat(hand, lastCards); + return findSimpleBeat(sortedHand, lastCards); } CardPattern targetPattern = gameReference.analyzeCards(lastCards); if (targetPattern == null) return null; return switch (targetPattern.getType()) { - case SINGLE -> findMinimalSingle(hand, targetPattern.getValue()); - case PAIR -> findMinimalPair(hand, targetPattern.getValue()); - case TRIPLE -> findMinimalTriple(hand, targetPattern.getValue()); - case TRIPLE_WITH_ONE -> findMinimalTripleWithOne(hand, targetPattern.getValue()); - case TRIPLE_WITH_PAIR -> findMinimalTripleWithPair(hand, targetPattern.getValue()); - case STRAIGHT -> findMinimalStraight(hand, targetPattern.getValue(), targetPattern.getLength()); - case PAIR_STRAIGHT -> findMinimalPairStraight(hand, targetPattern.getValue(), targetPattern.getLength()); + case SINGLE -> findMinimalSingle(sortedHand, targetPattern.getValue()); + case PAIR -> findMinimalPair(sortedHand, targetPattern.getValue()); + case TRIPLE -> findMinimalTriple(sortedHand, targetPattern.getValue()); + case TRIPLE_WITH_ONE -> findMinimalTripleWithOne(sortedHand, targetPattern.getValue()); + case TRIPLE_WITH_PAIR -> findMinimalTripleWithPair(sortedHand, targetPattern.getValue()); + case STRAIGHT -> findMinimalStraight(sortedHand, targetPattern.getValue(), targetPattern.getLength()); + case PAIR_STRAIGHT -> findMinimalPairStraight(sortedHand, targetPattern.getValue(), targetPattern.getLength()); case TRIPLE_STRAIGHT -> - findMinimalTripleStraight(hand, targetPattern.getValue(), targetPattern.getLength()); - case BOMB -> findMinimalBomb(hand, targetPattern.getValue()); + findMinimalTripleStraight(sortedHand, targetPattern.getValue(), targetPattern.getLength()); + case TRIPLE_STRAIGHT_WITH_SINGLE -> + findMinimalTripleStraightWithWings(sortedHand, targetPattern.getValue(), targetPattern.getLength(), false); + case TRIPLE_STRAIGHT_WITH_PAIR -> + findMinimalTripleStraightWithWings(sortedHand, targetPattern.getValue(), targetPattern.getLength(), true); + case FOUR_WITH_TWO_SINGLES -> findAnyBomb(sortedHand); + case FOUR_WITH_TWO_PAIRS -> findAnyBomb(sortedHand); + case BOMB -> findMinimalBomb(sortedHand, targetPattern.getValue()); case JOKER_BOMB -> null; }; @@ -156,78 +269,101 @@ private List findSimpleBeat(List hand, List lastCards) { } return findAnyBomb(hand); } - + private List findMinimalSingle(List hand, int targetValue) { for (Card card : hand) { if (card.getValue() > targetValue) { - return Arrays.asList(card); + return new ArrayList<>(List.of(card)); } } return findAnyBomb(hand); } - + private List findMinimalPair(List hand, int targetValue) { Map> pairs = groupByValue(hand); - + for (int value : pairs.keySet()) { if (value > targetValue && pairs.get(value).size() >= 2) { - return pairs.get(value).subList(0, 2); + return new ArrayList<>(pairs.get(value).subList(0, 2)); } } return findAnyBomb(hand); } - + private List findMinimalTriple(List hand, int targetValue) { Map> groups = groupByValue(hand); - + for (int value : groups.keySet()) { if (value > targetValue && groups.get(value).size() >= 3) { - return groups.get(value).subList(0, 3); + return new ArrayList<>(groups.get(value).subList(0, 3)); } } return findAnyBomb(hand); } - + private List findMinimalTripleWithOne(List hand, int targetValue) { Map> groups = groupByValue(hand); - + // 找合适的三张 for (int tripleValue : groups.keySet()) { if (tripleValue > targetValue && groups.get(tripleValue).size() >= 3) { - // 找一张单牌 - for (int singleValue : groups.keySet()) { - if (singleValue != tripleValue && groups.get(singleValue).size() >= 1) { - List result = new ArrayList<>(); - result.addAll(groups.get(tripleValue).subList(0, 3)); - result.add(groups.get(singleValue).get(0)); - return result; - } + // 找一张单牌当翅膀:★ Bug修复:原版按 size>=1 取最小值组,会把对子 + // 甚至炸弹(4张组)拆掉。改为先取精确散牌(size==1),没有才从 + // 非炸弹的更大组里拆(见 pickWingGroup),永不碰炸弹 + Integer singleValue = pickWingGroup(groups, tripleValue, 1); + if (singleValue != null) { + List result = new ArrayList<>(); + result.addAll(groups.get(tripleValue).subList(0, 3)); + result.add(groups.get(singleValue).get(0)); + return result; } } } return findAnyBomb(hand); } - + private List findMinimalTripleWithPair(List hand, int targetValue) { Map> groups = groupByValue(hand); - + // 找合适的三张 for (int tripleValue : groups.keySet()) { if (tripleValue > targetValue && groups.get(tripleValue).size() >= 3) { - // 找一对 - for (int pairValue : groups.keySet()) { - if (pairValue != tripleValue && groups.get(pairValue).size() >= 2) { - List result = new ArrayList<>(); - result.addAll(groups.get(tripleValue).subList(0, 3)); - result.addAll(groups.get(pairValue).subList(0, 2)); - return result; - } + // 找一对当翅膀:★ Bug修复:原版按 size>=2 取最小值组,会把三张 + // 甚至炸弹(4张组)拆掉。改为优先精确对子(size==2),没有才从 + // 非炸弹的更大组里拆(见 pickWingGroup),永不碰炸弹 + Integer pairValue = pickWingGroup(groups, tripleValue, 2); + if (pairValue != null) { + List result = new ArrayList<>(); + result.addAll(groups.get(tripleValue).subList(0, 3)); + result.addAll(groups.get(pairValue).subList(0, 2)); + return result; } } } return findAnyBomb(hand); } - + + /** + * 为三带一/三带二挑翅膀组(TreeMap 升序迭代,取最小值者): + * 优先张数恰好匹配的组;找不到才从更大的组拆,但绝不碰 size==4 的炸弹, + * 避免为凑翅膀拆掉炸弹(旧逻辑 size>=n 的判断会命中 4 张组)。 + * + * @param exactSize 翅膀精确张数(单牌=1,对子=2) + * @param exclude 三张主牌的值,不能从自身拆 + * @return 翅膀组的值;没有合法组返回 null + */ + private Integer pickWingGroup(Map> groups, int exclude, int exactSize) { + // 1) 精确张数的组(最小值优先) + Integer exact = pickGroupExact(groups, exactSize, exclude); + if (exact != null) return exact; + // 2) 从更大的组拆(跳过炸弹与主牌本身) + for (Map.Entry> e : groups.entrySet()) { + int sz = e.getValue().size(); + if (e.getKey() != exclude && sz > exactSize && sz != 4) return e.getKey(); + } + return null; + } + private List findMinimalStraight(List hand, int targetValue, int length) { Map> groups = groupByValue(hand); List values = new ArrayList<>(); @@ -239,7 +375,7 @@ private List findMinimalStraight(List hand, int targetValue, int len for (int j = 1; j < length; j++) { if (values.get(i + j) != values.get(i) + j) { consecutive = false; break; } } - if (consecutive && values.get(i) >= targetValue) { + if (consecutive && values.get(i) > targetValue) { // 等值压不住(canBeat 严格大于) List result = new ArrayList<>(); for (int j = 0; j < length; j++) result.add(groups.get(values.get(i + j)).get(0)); @@ -248,7 +384,7 @@ private List findMinimalStraight(List hand, int targetValue, int len } return findAnyBomb(hand); } - + private List findMinimalPairStraight(List hand, int targetValue, int length) { Map> groups = groupByValue(hand); List pairValues = new ArrayList<>(); @@ -260,7 +396,7 @@ private List findMinimalPairStraight(List hand, int targetValue, int for (int j = 1; j < length; j++) { if (pairValues.get(i + j) != pairValues.get(i) + j) { consecutive = false; break; } } - if (consecutive && pairValues.get(i) >= targetValue) { + if (consecutive && pairValues.get(i) > targetValue) { // 等值压不住 List result = new ArrayList<>(); for (int j = 0; j < length; j++) result.addAll(groups.get(pairValues.get(i + j)).subList(0, 2)); @@ -269,7 +405,7 @@ private List findMinimalPairStraight(List hand, int targetValue, int } return findAnyBomb(hand); } - + private List findMinimalTripleStraight(List hand, int targetValue, int length) { Map> groups = groupByValue(hand); List tripleValues = new ArrayList<>(); @@ -281,7 +417,7 @@ private List findMinimalTripleStraight(List hand, int targetValue, i for (int j = 1; j < length; j++) { if (tripleValues.get(i + j) != tripleValues.get(i) + j) { consecutive = false; break; } } - if (consecutive && tripleValues.get(i) >= targetValue) { + if (consecutive && tripleValues.get(i) > targetValue) { // 等值压不住 List result = new ArrayList<>(); for (int j = 0; j < length; j++) result.addAll(groups.get(tripleValues.get(i + j)).subList(0, 3)); @@ -290,38 +426,86 @@ private List findMinimalTripleStraight(List hand, int targetValue, i } return findAnyBomb(hand); } - + + /** 找同长度且主值更大的飞机,并按牌型严格凑齐翅牌;不拆四张炸弹。 */ + private List findMinimalTripleStraightWithWings(List hand, int targetValue, + int length, boolean pairs) { + Map> groups = groupByValue(hand); + List tripleValues = new ArrayList<>(); + for (int value : groups.keySet()) { + // 新牌型不能以拆炸弹的三张作为主体。 + if (value >= 3 && value <= 14 && groups.get(value).size() == 3) tripleValues.add(value); + } + for (int i = 0; i <= tripleValues.size() - length; i++) { + boolean consecutive = true; + for (int j = 1; j < length; j++) { + if (tripleValues.get(i + j) != tripleValues.get(i) + j) { + consecutive = false; + break; + } + } + if (!consecutive || tripleValues.get(i) <= targetValue) continue; + + Set body = new HashSet<>(tripleValues.subList(i, i + length)); + List result = new ArrayList<>(); + for (int value : body) result.addAll(groups.get(value)); + if (pairs) { + int added = 0; + for (Map.Entry> entry : groups.entrySet()) { + if (!body.contains(entry.getKey()) && entry.getValue().size() == 2) { + result.addAll(entry.getValue()); + if (++added == length) return result; + } + } + } else { + int added = 0; + for (Map.Entry> entry : groups.entrySet()) { + if (body.contains(entry.getKey())) continue; + // 四张组是炸弹不能拆;单/对/三张组可拆作单翅。 + if (entry.getValue().size() <= 3) { + int take = Math.min(entry.getValue().size(), Math.min(2, length - added)); + for (int index = 0; index < take; index++) { + result.add(entry.getValue().get(index)); + if (++added == length) return result; + } + } + } + } + } + return findAnyBomb(hand); + } + private List findMinimalBomb(List hand, int targetValue) { Map> groups = groupByValue(hand); - + for (int value : groups.keySet()) { if (value > targetValue && groups.get(value).size() == 4) { return new ArrayList<>(groups.get(value)); } } - + // 尝试王炸 return findJokerBomb(hand); } - + private List findAnyBomb(List hand) { Map> groups = groupByValue(hand); - + // 优先找普通炸弹 for (int value : groups.keySet()) { if (groups.get(value).size() == 4) { return new ArrayList<>(groups.get(value)); } } - + // 最后考虑王炸 return findJokerBomb(hand); } - + private List findJokerBomb(List hand) { boolean hasSmallJoker = hand.stream().anyMatch(c -> c.getValue() == 16); boolean hasBigJoker = hand.stream().anyMatch(c -> c.getValue() == 17); - + if (hasSmallJoker && hasBigJoker) { List jokers = new ArrayList<>(); for (Card card : hand) { @@ -331,15 +515,16 @@ private List findJokerBomb(List hand) { } return jokers; } - + return null; } - + + /** TreeMap 升序分组:所有 findMinimal 与 chooseActivePlay 依赖迭代序取"最小可压",HashMap 序会打出大牌浪费 */ private Map> groupByValue(List hand) { - Map> groups = new HashMap<>(); + Map> groups = new TreeMap<>(); for (Card card : hand) { groups.computeIfAbsent(card.getValue(), k -> new ArrayList<>()).add(card); } return groups; } -} \ No newline at end of file +} diff --git a/src/main/java/com/wzz/game_console/client/screens/games/landlord/CardPattern.java b/src/main/java/com/wzz/game_console/client/screens/games/landlord/CardPattern.java index 7c7ec8c..9c24c47 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/landlord/CardPattern.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/landlord/CardPattern.java @@ -2,16 +2,20 @@ public class CardPattern { public enum Type { - SINGLE, // 单牌 - PAIR, // 对子 - TRIPLE, // 三张 - TRIPLE_WITH_ONE, // 三带一 - TRIPLE_WITH_PAIR, // 三带二 - STRAIGHT, // 顺子 - PAIR_STRAIGHT, // 连对 - TRIPLE_STRAIGHT, // 飞机 - BOMB, // 炸弹 - JOKER_BOMB // 王炸 + SINGLE, // 单牌 + PAIR, // 对子 + TRIPLE, // 三张 + TRIPLE_WITH_ONE, // 三带一 + TRIPLE_WITH_PAIR, // 三带二 + STRAIGHT, // 顺子 + PAIR_STRAIGHT, // 连对 + TRIPLE_STRAIGHT, // 飞机 + FOUR_WITH_TWO_SINGLES, // 四带两单 + FOUR_WITH_TWO_PAIRS, // 四带两对 + BOMB, // 炸弹 + JOKER_BOMB, // 王炸 + TRIPLE_STRAIGHT_WITH_SINGLE, // 飞机带单 + TRIPLE_STRAIGHT_WITH_PAIR // 飞机带对 } private Type type; @@ -25,6 +29,7 @@ public CardPattern(Type type, int value, int length) { } public boolean canBeat(CardPattern other) { + if (other == null || type == null) return false; // 王炸最大 if (type == Type.JOKER_BOMB) { return other.type != Type.JOKER_BOMB; @@ -44,7 +49,7 @@ public boolean canBeat(CardPattern other) { return false; } - // 同类型比较 + // 同类型且长度相同才可比较;飞机带单/带对是独立牌型 if (type == other.type && length == other.length) { return value > other.value; } diff --git a/src/main/java/com/wzz/game_console/client/screens/games/landlord/LandlordGame.java b/src/main/java/com/wzz/game_console/client/screens/games/landlord/LandlordGame.java index 363670b..15b8f80 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/landlord/LandlordGame.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/landlord/LandlordGame.java @@ -1,8 +1,10 @@ package com.wzz.game_console.client.screens.games.landlord; import java.util.*; +import java.util.logging.Logger; public class LandlordGame { + private static final Logger LOGGER = Logger.getLogger(LandlordGame.class.getName()); public enum GameState { DEALING, BIDDING, PLAYING, ENDED } @@ -20,7 +22,13 @@ public enum PlayerType { private List lastPlayedCards; private int lastPlayer; private boolean[] passed; - private int[] scores; + private int[] scores = new int[3]; + /** 各玩家实际出牌次数(不出空过不计),用于春天/反春天判定 */ + private final int[] playCounts = new int[3]; + /** 结算前是否有人打出过王炸(火箭),翻倍用 */ + private boolean rocketPlayed = false; + /** 普通炸弹次数;每次炸弹独立使底分翻倍。 */ + private int bombCount = 0; public LandlordGame() { initializeGame(); @@ -56,7 +64,10 @@ private void initializeGame() { lastPlayedCards = new ArrayList<>(); lastPlayer = -1; passed = new boolean[3]; - scores = new int[3]; + // scores 不在重发/重开时清零:跨局累计积分 + Arrays.fill(playCounts, 0); + rocketPlayed = false; + bombCount = 0; } private List createDeck() { @@ -89,7 +100,7 @@ public boolean bid(int player, boolean wantToBeLandlord) { landlordPlayer = player; playerHands.get(player).addAll(landlordCards); Collections.sort(playerHands.get(player)); - landlordCards.clear(); // 底牌已分配给地主,清理集合避免联机同步语义不一致 + // 保留底牌:地主拿到的是副本,底牌仍需随状态报文恢复并显示。 gameState = GameState.PLAYING; currentPlayer = player; return true; @@ -135,23 +146,36 @@ public boolean playCards(int player, List cards) { if (!isValidPlay(cards)) { return false; } - - // 移除卡牌 + + // 移除卡牌——multiset 原子校验:先在副本上逐张扣减,全部成功才应用到真实手牌。 + // 修复:原版"先 contains 全部、再逐个 remove"两段式,远端 PLAY 报文含重复牌时 + // (deserializeCards 不去重),第二次 remove 静默落空 → 手牌少扣一张且 + // lastPlayedCards 记牌数虚高,联机状态永久失真。 List playerHand = playerHands.get(player); + List remaining = new ArrayList<>(playerHand); for (Card card : cards) { - if (!playerHand.contains(card)) { - return false; // 玩家没有这张牌 + if (!remaining.remove(card)) { + return false; // 玩家没有这张牌,或重复牌数超出持有数 } } - - for (Card card : cards) { - playerHand.remove(card); - } - + playerHand.clear(); + playerHand.addAll(remaining); + lastPlayedCards = new ArrayList<>(cards); lastPlayer = player; Arrays.fill(passed, false); - + + // 出牌统计:春天/反春天判定与炸弹翻倍 + playCounts[player]++; + CardPattern played = analyzePattern(cards); + if (played != null) { + if (played.getType() == CardPattern.Type.JOKER_BOMB) { + rocketPlayed = true; + } else if (played.getType() == CardPattern.Type.BOMB) { + bombCount++; + } + } + // 检查是否有人获胜 if (playerHand.isEmpty()) { gameState = GameState.ENDED; @@ -272,32 +296,57 @@ private CardPattern analyzePattern(List cards) { } } - // 飞机检查(三张的连续) + // 四带两单(6张)/ 四带两对(8张) + if (size == 6 && countToValues.containsKey(4) && countToValues.get(4).size() == 1) { + return new CardPattern(CardPattern.Type.FOUR_WITH_TWO_SINGLES, countToValues.get(4).get(0), 1); + } + if (size == 8 && countToValues.containsKey(4) && countToValues.get(4).size() == 1 + && countToValues.containsKey(2) && countToValues.get(2).size() == 2) { + return new CardPattern(CardPattern.Type.FOUR_WITH_TWO_PAIRS, countToValues.get(4).get(0), 1); + } + + // 飞机检查:主体必须是恰好三张且牌值连续,翅牌不能占用主体牌值。 if (countToValues.containsKey(3)) { - List tripleValues = countToValues.get(3); + List tripleValues = new ArrayList<>(); + for (Map.Entry entry : valueCount.entrySet()) { + if (entry.getValue() == 3) tripleValues.add(entry.getKey()); + } + Collections.sort(tripleValues); if (tripleValues.size() >= 2 && isConsecutiveValues(tripleValues)) { int tripleCount = tripleValues.size(); - int expectedSize = tripleCount * 3; // 基础飞机大小 - + int expectedSize = tripleCount * 3; + Set bodyValues = new HashSet<>(tripleValues); + int wingCards = 0; + boolean validSingleWings = true; + boolean validPairWings = true; + int pairWingValues = 0; + for (Map.Entry entry : valueCount.entrySet()) { + if (bodyValues.contains(entry.getKey())) continue; + int count = entry.getValue(); + wingCards += count; + if (count >= 3) validSingleWings = false; + if (count != 2) validPairWings = false; + if (count == 2) pairWingValues++; + } + // 纯飞机 - if (size == expectedSize) { + if (size == expectedSize && wingCards == 0) { return new CardPattern(CardPattern.Type.TRIPLE_STRAIGHT, tripleValues.get(0), tripleCount); } - - // 飞机带单牌 - if (size == expectedSize + tripleCount && countToValues.containsKey(1) - && countToValues.get(1).size() == tripleCount) { - return new CardPattern(CardPattern.Type.TRIPLE_STRAIGHT, tripleValues.get(0), tripleCount); + // 飞机带单:每个翅占一张,允许同值散牌,但不能带出额外三张。 + if (size == expectedSize + tripleCount && wingCards == tripleCount && validSingleWings) { + return new CardPattern(CardPattern.Type.TRIPLE_STRAIGHT_WITH_SINGLE, + tripleValues.get(0), tripleCount); } - - // 飞机带对子 - if (size == expectedSize + tripleCount * 2 && countToValues.containsKey(2) && - countToValues.get(2).size() == tripleCount) { - return new CardPattern(CardPattern.Type.TRIPLE_STRAIGHT, tripleValues.get(0), tripleCount); + // 飞机带对:每个翅是不同牌值的完整对子,不能拆三张或炸弹。 + if (size == expectedSize + tripleCount * 2 && wingCards == tripleCount * 2 + && validPairWings && pairWingValues == tripleCount) { + return new CardPattern(CardPattern.Type.TRIPLE_STRAIGHT_WITH_PAIR, + tripleValues.get(0), tripleCount); } } } - + return null; // 无效牌型 } @@ -341,16 +390,32 @@ private boolean isConsecutiveValues(List values) { private void calculateScores() { int baseScore = 1; if (landlordPlayer != -1) { - if (playerHands.get(landlordPlayer).isEmpty()) { + // 倍数:春天(农民零出牌)/反春天(地主仅出过一手)/王炸 各×2,可叠加 + int multiplier = 1; + boolean landlordWins = playerHands.get(landlordPlayer).isEmpty(); + int farmerA = (landlordPlayer + 1) % 3, farmerB = (landlordPlayer + 2) % 3; + if (landlordWins && playCounts[farmerA] == 0 && playCounts[farmerB] == 0) { + multiplier *= 2; // 春天 + } + if (!landlordWins && playCounts[landlordPlayer] == 1) { + multiplier *= 2; // 反春天 + } + if (bombCount > 0) { + multiplier *= 1 << Math.min(bombCount, 30); // 每个普通炸弹翻倍 + } + if (rocketPlayed) { + multiplier *= 2; // 火箭(王炸) + } + if (landlordWins) { // 地主获胜 - scores[landlordPlayer] = baseScore * 2; - scores[(landlordPlayer + 1) % 3] = -baseScore; - scores[(landlordPlayer + 2) % 3] = -baseScore; + scores[landlordPlayer] += baseScore * 2 * multiplier; + scores[farmerA] += -baseScore * multiplier; + scores[farmerB] += -baseScore * multiplier; } else { // 农民获胜 - scores[landlordPlayer] = -baseScore * 2; - scores[(landlordPlayer + 1) % 3] = baseScore; - scores[(landlordPlayer + 2) % 3] = baseScore; + scores[landlordPlayer] += -baseScore * 2 * multiplier; + scores[farmerA] += baseScore * multiplier; + scores[farmerB] += baseScore * multiplier; } } } @@ -365,6 +430,23 @@ private void calculateScores() { public int[] getScores() { return scores.clone(); } public List getLandlordCards() { return new ArrayList<>(landlordCards); } + /** 当前回合获胜者:终局时手牌为空的玩家,累计积分不参与判断。 */ + public int getRoundWinner() { + if (gameState != GameState.ENDED) return -1; + for (int player = 0; player < 3; player++) { + if (playerHands.get(player).isEmpty()) return player; + } + return -1; + } + + /** 地主胜出时仅地主赢;农民胜出时两名农民同队获胜。 */ + public static boolean isRoundWinForPlayer(int player, int landlord, int winner) { + if (player < 0 || player >= 3 || landlord < 0 || landlord >= 3 || winner < 0 || winner >= 3) { + return false; + } + return player == winner || (winner != landlord && player != landlord); + } + // 重新开始游戏 public void restart() { initializeGame(); @@ -372,6 +454,7 @@ public void restart() { // 在LandlordGame类中添加公共方法来分析牌型 public CardPattern analyzeCards(List cards) { + if (cards == null || cards.isEmpty()) return null; return analyzePattern(cards); } @@ -379,6 +462,33 @@ public CardPattern analyzeCards(List cards) { // LAN 序列化工具(供 LandlordGameScreen 使用) // ═══════════════════════════════════════════════ + /** + * INIT/STATE 的兼容封装。只扩展 GAME_STATE_SYNC 的 data 字段,外层 packet codec + * 保持不变;token 隔离不同屏幕实例,sequence 严格单调递增以丢弃重放/乱序状态。 + */ + public record NetworkState(long sessionToken, long sequence, String payload) {} + + public static String encodeNetworkState(long sessionToken, long sequence, String payload) { + if (sessionToken == 0 || sequence < 0 || payload == null || payload.isEmpty()) { + throw new IllegalArgumentException("invalid network state envelope"); + } + return "LG1|" + Long.toUnsignedString(sessionToken) + "|" + sequence + "|" + payload; + } + + public static NetworkState decodeNetworkState(String data) { + if (data == null) return null; + String[] fields = data.split("\\|", 4); + if (fields.length != 4 || !"LG1".equals(fields[0]) || fields[3].isEmpty()) return null; + try { + long token = Long.parseUnsignedLong(fields[1]); + long sequence = Long.parseLong(fields[2]); + if (token == 0 || sequence < 0) return null; + return new NetworkState(token, sequence, fields[3]); + } catch (NumberFormatException ignored) { + return null; + } + } + /** 把牌列表序列化成字符串,格式 "suit_rank,suit_rank,..." */ public static String serializeCards(List cards) { if (cards == null || cards.isEmpty()) return ""; @@ -418,6 +528,8 @@ public static List deserializeCards(String s) { * phase|currentPlayer|landlordPlayer|lastPlayer|myHand|lastPlayed|cnt0,cnt1,cnt2|sc0,sc1,sc2|landlordCards */ public String serializeFor(int forPlayer) { + // 底牌只在叫地主后揭示;叫地主阶段仍保留空字段以维持九段协议格式。 + String visibleBottom = gameState == GameState.BIDDING ? "" : serializeCards(landlordCards); return gameState.name() + "|" + currentPlayer + "|" + landlordPlayer + "|" @@ -426,37 +538,184 @@ public String serializeFor(int forPlayer) { + serializeCards(lastPlayedCards) + "|" + playerHands.get(0).size() + "," + playerHands.get(1).size() + "," + playerHands.get(2).size() + "|" + scores[0] + "," + scores[1] + "," + scores[2] + "|" - + serializeCards(landlordCards); + + visibleBottom; } /** * 从序列化字符串恢复状态(客户端调用)。 * myPlayerIndex: 本地玩家是哪一位(0/1/2) */ - public void applyState(String data, int myPlayerIndex, List myHand) { - String[] parts = data.split("\\|", -1); - if (parts.length < 9) return; - gameState = GameState.valueOf(parts[0]); - currentPlayer = Integer.parseInt(parts[1]); - landlordPlayer = Integer.parseInt(parts[2]); - lastPlayer = Integer.parseInt(parts[3]); - // 手牌:只有自己的是准确的,其他人只知数量 - List myCards = deserializeCards(parts[4]); - playerHands.set(myPlayerIndex, myCards); - myHand.clear(); myHand.addAll(myCards); - lastPlayedCards = deserializeCards(parts[5]); - String[] cnts = parts[6].split(","); - // 更新其他玩家手牌数量(用空Card占位,渲染时只显示背面) - for (int i = 0; i < 3; i++) { - if (i == myPlayerIndex) continue; - int cnt = Integer.parseInt(cnts[i]); - List ph = playerHands.get(i); - ph.clear(); - // 用 placeholder(不能用于真实出牌,只用于显示数量) - for (int k = 0; k < cnt; k++) ph.add(new Card(Card.Suit.SPADES, Card.Rank.THREE)); - } - String[] scs = parts[7].split(","); - for (int i = 0; i < 3; i++) scores[i] = Integer.parseInt(scs[i]); - landlordCards = deserializeCards(parts[8]); + public boolean applyState(String data, int myPlayerIndex, List myHand) { + try { + if (data == null || myHand == null || myPlayerIndex < 0 || myPlayerIndex >= 3) { + throw new IllegalArgumentException("invalid player index or null state"); + } + String[] parts = data.split("\\|", -1); + if (parts.length != 9) throw new IllegalArgumentException("invalid state field count"); + GameState parsedState = GameState.valueOf(parts[0]); + int parsedCurrent = parseRequiredPlayerIndex(parts[1]); + int parsedLandlord = parsePlayerIndex(parts[2], true); + int parsedLast = parsePlayerIndex(parts[3], true); + + List parsedHand = deserializeCardsStrict(parts[4]); + List parsedLastCards = deserializeCardsStrict(parts[5]); + List parsedBottom = deserializeCardsStrict(parts[8]); + if (parsedHand == null || parsedLastCards == null || parsedBottom == null) { + throw new IllegalArgumentException("invalid card list"); + } + String[] cnts = parts[6].split(",", -1); + String[] scs = parts[7].split(",", -1); + if (cnts.length != 3 || scs.length != 3) throw new IllegalArgumentException("invalid array length"); + int[] parsedCounts = new int[3]; + int[] parsedScores = new int[3]; + for (int i = 0; i < 3; i++) { + parsedCounts[i] = parseNonNegativeInt(cnts[i]); + if (parsedCounts[i] > 54) throw new IllegalArgumentException("invalid hand count"); + parsedScores[i] = parseInt(scs[i]); + } + if (parsedCounts[myPlayerIndex] != parsedHand.size() + || parsedHand.size() > 54 || parsedLastCards.size() > 54 || parsedBottom.size() > 3) { + throw new IllegalArgumentException("invalid card list size"); + } + validateStatePhase(parsedState, parsedCurrent, parsedLandlord, parsedLast, + parsedCounts, parsedHand, parsedLastCards, parsedBottom); + validateNoCardOverlap(parsedHand, parsedLastCards, parsedBottom, parsedLandlord == myPlayerIndex); + + List> newHands = new ArrayList<>(); + for (int i = 0; i < 3; i++) newHands.add(new ArrayList<>()); + newHands.set(myPlayerIndex, parsedHand); + for (int i = 0; i < 3; i++) { + if (i != myPlayerIndex) { + for (int k = 0; k < parsedCounts[i]; k++) { + newHands.get(i).add(new Card(Card.Suit.SPADES, Card.Rank.THREE)); + } + } + } + gameState = parsedState; + currentPlayer = parsedCurrent; + landlordPlayer = parsedLandlord; + lastPlayer = parsedLast; + playerHands = newHands; + lastPlayedCards = parsedLastCards; + System.arraycopy(parsedScores, 0, scores, 0, scores.length); + // 底牌是状态的一部分:叫地主后仍需恢复,不能只在 BIDDING 阶段赋值。 + landlordCards = new ArrayList<>(parsedBottom); + myHand.clear(); + myHand.addAll(parsedHand); + } catch (RuntimeException e) { + LOGGER.warning("[斗地主] 丢弃非法状态报文: " + e); + return false; + } + return true; + } + + /** 校验状态字段之间的阶段约束,拒绝能让客户端进入不可能阶段的报文。 */ + private static void validateStatePhase(GameState state, int current, int landlord, int last, + int[] counts, List hand, List lastCards, + List bottom) { + if (state == GameState.BIDDING) { + if (landlord != -1 || last != -1 || !lastCards.isEmpty() + || (bottom.size() != 0 && bottom.size() != 3)) { + throw new IllegalArgumentException("inconsistent bidding state"); + } + if (counts[0] + counts[1] + counts[2] != 51) { + throw new IllegalArgumentException("invalid bidding hand counts"); + } + } else if (state == GameState.PLAYING || state == GameState.ENDED) { + if (landlord < 0 || landlord >= 3) { + throw new IllegalArgumentException("missing landlord"); + } + if (bottom.size() != 3) { + throw new IllegalArgumentException("invalid bottom cards"); + } + // 已出完的旧牌不会出现在快照中,只能校验可见牌数不超过整副牌。 + if (counts[0] + counts[1] + counts[2] + lastCards.size() > 54) { + throw new IllegalArgumentException("invalid playing card counts"); + } + if (lastCards.isEmpty()) { + if (last != -1) throw new IllegalArgumentException("missing last cards"); + } else if (last < 0 || last >= 3 || (state == GameState.PLAYING && last == current)) { + // ENDED 快照保留获胜者为 currentPlayer;PLAYING 才要求回合已切换。 + throw new IllegalArgumentException("inconsistent last player"); + } + if (state == GameState.ENDED + && counts[0] > 0 && counts[1] > 0 && counts[2] > 0) { + throw new IllegalArgumentException("ended game has no winner"); + } + } else { + throw new IllegalArgumentException("unsupported game phase"); + } + if (current < 0 || current >= 3 || counts[0] < 0 || counts[1] < 0 || counts[2] < 0) { + throw new IllegalArgumentException("invalid phase indexes"); + } + } + + /** 同一牌区内不得重复;手牌与桌面出牌不得重叠。 + * 地主手牌与底牌允许重叠,因为底牌属于地主;缺失的底牌表示已正常打出,不能拒绝快照。 */ + private static void validateNoCardOverlap(List hand, List lastCards, + List bottom, boolean localIsLandlord) { + ensureUnique(hand, "hand"); + ensureUnique(lastCards, "last cards"); + ensureUnique(bottom, "bottom cards"); + Set handSet = new HashSet<>(hand); + for (Card card : lastCards) { + if (handSet.contains(card)) throw new IllegalArgumentException("hand/last card overlap"); + } + if (!localIsLandlord) { + for (Card card : bottom) { + if (handSet.contains(card)) throw new IllegalArgumentException("hand/bottom card overlap"); + } + } + } + + private static void ensureUnique(List cards, String area) { + if (new HashSet<>(cards).size() != cards.size()) { + throw new IllegalArgumentException("duplicate cards in " + area); + } + } + + private static int parseInt(String value) { + if (value == null || value.isEmpty()) throw new IllegalArgumentException("empty integer"); + return Integer.parseInt(value); + } + + private static int parseNonNegativeInt(String value) { + int result = parseInt(value); + if (result < 0) throw new IllegalArgumentException("negative integer"); + return result; + } + + private static int parsePlayerIndex(String value, boolean allowSentinel) { + int result = parseInt(value); + if ((allowSentinel && result == -1) || result >= 0 && result < 3) return result; + throw new IllegalArgumentException("invalid player index"); + } + + private static int parseRequiredPlayerIndex(String value) { + int result = parseInt(value); + if (result < 0 || result >= 3) throw new IllegalArgumentException("invalid player index"); + return result; + } + + public static List deserializeCardsStrict(String value) { + if (value == null || value.isEmpty()) return new ArrayList<>(); + List result = new ArrayList<>(); + for (String part : value.split(",", -1)) { + String[] fields = part.split("_", -1); + if (fields.length != 2) return null; + try { + int suitIndex = parseInt(fields[0]); + int rankValue = parseInt(fields[1]); + Card.Suit[] suits = Card.Suit.values(); + if (suitIndex < 0 || suitIndex >= suits.length || rankValue < 3 || rankValue > 17) return null; + Card.Suit suit = suits[suitIndex]; + Card.Rank rank = Arrays.stream(Card.Rank.values()).filter(r -> r.getValue() == rankValue).findFirst().orElse(null); + if (rank == null || (suit == Card.Suit.JOKER) != (rankValue >= 16)) return null; + result.add(new Card(suit, rank)); + } catch (RuntimeException e) { + return null; + } + } + return result; } } \ No newline at end of file diff --git a/src/main/java/com/wzz/game_console/client/screens/games/landlord/LandlordGameScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/landlord/LandlordGameScreen.java index 3c9162d..403dfc3 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/landlord/LandlordGameScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/landlord/LandlordGameScreen.java @@ -4,6 +4,7 @@ import com.wzz.game_console.client.screens.games.LanMultiplayerScreen; import com.wzz.game_console.init.ModNetworks; import com.wzz.game_console.network.MultiplayerGamePacket; +import com.wzz.game_console.network.MultiplayerInviteAttempt; import com.wzz.game_console.util.GameRenderHelper; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; @@ -47,8 +48,29 @@ public class LandlordGameScreen extends Screen implements LanMultiplayerScreen { private UUID peer1Uuid = null; private UUID peer2Uuid = null; private UUID hostUuid = null; + private UUID inviteAttemptNonce = null; private int myPlayerIdx = 0; private boolean waitingStart = false; + private boolean localTwoPlayer = false; + private final LandlordLanState.Receiver stateReceiver = new LandlordLanState.Receiver(); + private boolean peer1InitAcked = false; + private boolean peer2InitAcked = false; + private long peer1InitSequence = -1; + private long peer2InitSequence = -1; + private final Set peer1InitSequences = new HashSet<>(); + private final Set peer2InitSequences = new HashSet<>(); + private boolean initRetriesActive = true; + private long lastInitSendTick = Long.MIN_VALUE; + private int initSendAttempts = 0; + private long initStartTick = 0; + /** 每个屏幕实例独立 token,避免旧界面的 INIT/STATE 污染新对局。 */ + private long sessionToken = createSessionToken(); + private long nextStateSequence = 0; + + private static long createSessionToken() { + long token = UUID.randomUUID().getMostSignificantBits() ^ UUID.randomUUID().getLeastSignificantBits(); + return token == 0 ? 1 : token; + } // ── UI 状态 ─────────────────────────────────────── private String msg=""; private long msgTick=-9999; @@ -60,7 +82,12 @@ public class LandlordGameScreen extends Screen implements LanMultiplayerScreen { // ══ 构造器 ════════════════════════════════════════ public LandlordGameScreen(){ + this(false); + } + + public LandlordGameScreen(boolean localTwoPlayer){ super(Component.literal("斗地主")); + this.localTwoPlayer = localTwoPlayer; game=new LandlordGame(); ai1=new AIPlayer(); ai2=new AIPlayer(); // 让AI使用完整牌型分析(否则只走findSimpleBeat,无炸弹时不会应对顺子/连对等) ai1.setGameReference(game); ai2.setGameReference(game); @@ -71,14 +98,23 @@ public LandlordGameScreen(boolean isHost, UUID hostSelf, UUID p1, UUID p2){ game=new LandlordGame(); } public LandlordGameScreen(boolean isHost,UUID host){ + this(isHost, host, null); + } + public LandlordGameScreen(boolean isHost, UUID host, UUID inviteAttemptNonce){ super(Component.literal("斗地主")); - lanMode=LAN_CLIENT; hostUuid=host; myPlayerIdx=-1; waitingStart=true; + lanMode=LAN_CLIENT; hostUuid=host; this.inviteAttemptNonce=inviteAttemptNonce; + myPlayerIdx=-1; waitingStart=true; game=new LandlordGame(); } // ══ LanMultiplayerScreen ═════════════════════════ @Override public UUID getLanPeer(){return peer1Uuid!=null?peer1Uuid:hostUuid;} @Override public String getLanGameId(){return "landlord";} + /** 三人对局:HOST 端任一客机(peer1/peer2)的退出都合法,其余来源一律拒绝 */ + @Override public boolean isLeaveFromPeer(UUID sender){ + if(lanMode==LAN_HOST)return sender!=null&&(sender.equals(peer1Uuid)||sender.equals(peer2Uuid)); + return LanMultiplayerScreen.super.isLeaveFromPeer(sender); + } /** 根据报文来源 UUID 映射座位(HOST 端);非法来源返回 -1 */ private int seatForUuid(UUID from){ @@ -97,18 +133,46 @@ public void onRemoteMove(UUID from,String data){ LOGGER.warn("[斗地主联机] 忽略非对端来源的走法报文: {}",from); return; } + if (!data.startsWith("INIT_ACK:")) { + if (!(pl == 1 ? peer1InitAcked : peer2InitAcked)) return; + data = LandlordLanState.decodeAction(sessionToken, data); + if (data == null) return; + } try{ - if(data.startsWith("BID:")){ - String[]p=data.substring(4).split(":"); - if(p.length<2)return; - boolean w="1".equals(p[1]); + if(data.startsWith("INIT_ACK:")){ + String[] ack=data.substring(9).split(":",-1); + if(ack.length!=3)return; + int ackSeat=Integer.parseInt(ack[0]); + long ackToken=Long.parseLong(ack[1]); + long ackSequence=Long.parseLong(ack[2]); + long expectedSequence=pl==1?peer1InitSequence:peer2InitSequence; + if(ackSeat!=pl||ackToken!=sessionToken + ||!(pl==1?peer1InitSequences:peer2InitSequences).contains(ackSequence)){ + LOGGER.warn("[斗地主联机] 忽略不匹配的 INIT_ACK: sender={}, seat={}/{}, token={}, sequence={}/{}", + from,ackSeat,pl,ackToken,ackSequence,expectedSequence); + return; + } + if(pl==1)peer1InitAcked=true; + else peer2InitAcked=true; + if(peer1InitAcked&&peer2InitAcked)initRetriesActive=false; + sendToPeer(from, encodeState("STATE:", game.serializeFor(pl))); + }else if(data.startsWith("BID:")){ + String[] p = data.substring(4).split(":", -1); + if (p.length != 2 || !String.valueOf(pl).equals(p[0]) + || !("0".equals(p[1]) || "1".equals(p[1]))) return; + boolean w = "1".equals(p[1]); if(game.bid(pl,w)){showMsg(name(pl)+(w?" 叫地主!":" 不叫"));broadcastState();} }else if(data.startsWith("PLAY:")){ - String[]p=data.substring(5).split(":",2); - List cards=(p.length>1)?LandlordGame.deserializeCards(p[1]):new ArrayList<>(); + String[] p = data.substring(5).split(":", -1); + if (p.length != 2 || !String.valueOf(pl).equals(p[0])) return; + List cards = LandlordGame.deserializeCardsStrict(p[1]); + if (cards == null) return; if(game.playCards(pl,cards)){ lastInfo=name(pl)+(cards.isEmpty()?" 过牌":" 出: "+cardsStr(cards)); showMsg(lastInfo);broadcastState(); + }else{ + // 拒绝时回发 REJECT,客机不再面对"报文被静默吞掉、无任何反馈"的黑洞 + sendToPeer(from, encodeState("REJECT:", name(pl) + " 的出牌无效(状态不同步或牌型不合法)")); } } }catch(Exception e){ @@ -148,65 +212,105 @@ private boolean isFromHost(UUID senderUuid){ @Override public void onRemoteState(String data){ if(lanMode!=LAN_CLIENT||data==null)return; - try{ - if(data.startsWith("INIT:")){ - String body=data.substring(5); - int sep=body.indexOf('|'); - if(sep<=0)return; - myPlayerIdx=Integer.parseInt(body.substring(0,sep)); - if(myPlayerIdx<1||myPlayerIdx>2)return; // 客机只能是座位1/2 - waitingStart=false; - List h=new ArrayList<>(); - game.applyState(body.substring(sep+1),myPlayerIdx,h); - cardSelected=new boolean[h.size()]; - showMsg("游戏开始!你是 "+name(myPlayerIdx)); - }else if(data.startsWith("STATE:")){ - if(myPlayerIdx<0)return; - List h=new ArrayList<>(); - game.applyState(data.substring(6),myPlayerIdx,h); - if(cardSelected.length!=h.size())cardSelected=new boolean[h.size()]; - } - }catch(Exception e){ - // 远端状态报文防护:畸形数据不导致崩溃 - LOGGER.warn("[斗地主联机] 处理远端状态报文失败: {}",e.toString()); + var applied = stateReceiver.receive(game, data); + if (applied == null) return; + sessionToken = applied.token(); + myPlayerIdx = applied.seat(); + if (applied.rejection() != null) { + showMsg("主机拒绝: " + applied.rejection()); + return; + } + waitingStart = false; + cardSelected = new boolean[game.getPlayerHand(myPlayerIdx).size()]; + selectedCards.clear(); + if (applied.init()) { + showExitConfirm = false; + sendInitAck(applied.sequence()); + showMsg("游戏开始!你是 " + name(myPlayerIdx)); } } @Override public void onRemoteGameOver(String d){} private void sendToHost(String d){ + if (d.startsWith("BID:") || d.startsWith("PLAY:")) { + d = LandlordLanState.encodeAction(sessionToken, d); + } ModNetworks.PACKET_HANDLER.sendToServer(new MultiplayerGamePacket( - MultiplayerGamePacket.PacketType.GAME_MOVE,hostUuid,"landlord",d)); + MultiplayerGamePacket.PacketType.GAME_MOVE,hostUuid,"landlord",envelopeLanData(d))); + } + private void sendInitAck(long initSequence){ + sendToHost("INIT_ACK:"+myPlayerIdx+":"+sessionToken+":"+initSequence); } private void sendToPeer(UUID peer,String d){ ModNetworks.PACKET_HANDLER.sendToServer(new MultiplayerGamePacket( - MultiplayerGamePacket.PacketType.GAME_STATE_SYNC,peer,"landlord",d)); + MultiplayerGamePacket.PacketType.GAME_STATE_SYNC,peer,"landlord",envelopeLanData(d))); } private void broadcastState(){ if(lanMode!=LAN_HOST||peer1Uuid==null||peer2Uuid==null)return; - sendToPeer(peer1Uuid,"STATE:"+game.serializeFor(1)); - sendToPeer(peer2Uuid,"STATE:"+game.serializeFor(2)); + sendToPeer(peer1Uuid, encodeState("STATE:", game.serializeFor(1))); + sendToPeer(peer2Uuid, encodeState("STATE:", game.serializeFor(2))); } private void sendInit(UUID peer,int idx){ - sendToPeer(peer,"INIT:"+idx+"|"+game.serializeFor(idx)); + if(peer==null)return; + long sequence=nextStateSequence++; + if(idx==1){peer1InitSequence=sequence;peer1InitSequences.add(sequence);} + else if(idx==2){peer2InitSequence=sequence;peer2InitSequences.add(sequence);} + sendToPeer(peer, LandlordGame.encodeNetworkState( + sessionToken, sequence, "INIT:"+idx+"|"+game.serializeFor(idx))); + } + + private String encodeState(String prefix, String payload){ + return LandlordGame.encodeNetworkState(sessionToken, nextStateSequence++, prefix + payload); + } + + private void resendUnackedInit(){ + if(!initRetriesActive||lanMode!=LAN_HOST)return; + if(!peer1InitAcked)sendInit(peer1Uuid,1); + if(!peer2InitAcked)sendInit(peer2Uuid,2); + initSendAttempts++; + lastInitSendTick=tickCount; } // ══ Tick ═════════════════════════════════════════ @Override public void tick(){ tickCount++; - if(lanMode==LAN_HOST&&tickCount==5){ - sendInit(peer1Uuid,1); sendInit(peer2Uuid,2); - showMsg("游戏开始!"); + if(lanMode==LAN_HOST&&initRetriesActive){ + long elapsed=tickCount-initStartTick; + long lastElapsed=lastInitSendTick==Long.MIN_VALUE + ?Long.MIN_VALUE:lastInitSendTick-initStartTick; + LandlordStartupGuard.HostAction action=LandlordStartupGuard.hostAction( + elapsed,lastElapsed,initSendAttempts); + if(action==LandlordStartupGuard.HostAction.ABORT){ + abortLanStartup("等待客机确认超时,对局已取消"); + return; + } + if(action==LandlordStartupGuard.HostAction.SEND){ + boolean firstSend=lastInitSendTick==Long.MIN_VALUE; + resendUnackedInit(); + if(firstSend&&initRetriesActive)showMsg("游戏开始!"); + } + } + if(lanMode==LAN_CLIENT + && LandlordStartupGuard.clientTimedOut(waitingStart,tickCount-initStartTick)){ + abortLanStartup("等待主机开始超时,已退出对局"); + return; } if(lanMode==LAN_NONE){ int cp=game.getCurrentPlayer(); - if((cp==1||cp==2)&&tickCount-lastAiTick>AI_DELAY){ + // 本地双人时将当前的人类座位切换到操作栏,座位2仍由 AI 控制。 + if (localTwoPlayer && cp >= 0 && cp <= 1) myPlayerIdx = cp; + boolean aiTurn = cp == 1 && !localTwoPlayer || cp == 2; + if(aiTurn&&tickCount-lastAiTick>AI_DELAY){ lastAiTick=tickCount; AIPlayer ai=cp==1?ai1:ai2; if(game.getGameState()==LandlordGame.GameState.BIDDING){ - boolean b=ai.decideBid(game.getPlayerHand(cp),false); + boolean b=ai.decideBid(game.getPlayerHand(cp)); game.bid(cp,b); showMsg(name(cp)+(b?" 叫地主!":" 不叫")); }else if(game.getGameState()==LandlordGame.GameState.PLAYING){ - List pl=ai.chooseCardsToPlay(game.getPlayerHand(cp),game.getLastPlayedCards(),true); + // AI 带身份上下文:农民不压队友、对手报牌必压 + int[] counts={game.getPlayerHand(0).size(),game.getPlayerHand(1).size(),game.getPlayerHand(2).size()}; + List pl=ai.chooseCardsToPlay(game.getPlayerHand(cp),game.getLastPlayedCards(),true, + cp,game.getLastPlayer(),game.getLandlordPlayer(),counts); // 检查出牌返回值:AI 出牌非法时改为过牌,避免回合卡死 if(!game.playCards(cp,pl)){ // 兜底:桌面为空时领出禁止过牌,改出最小单张(手牌已排序,首张即最小) @@ -250,8 +354,11 @@ private void sendInit(UUID peer,int idx){ if(showExitConfirm) GameRenderHelper.drawExitConfirmOverlay(g, font, width, height, mx, my); } + /** 等待动画 "." 帧表(预计算,避免每帧 repeat 分配) */ + private static final String[] WAIT_DOTS = { "", ".", "..", "..." }; + private void renderWait(GuiGraphics g){ - String dots=".".repeat((int)(tickCount/10%4)); + String dots=WAIT_DOTS[(int)(tickCount/10%WAIT_DOTS.length)]; g.drawCenteredString(font,"等待游戏开始"+dots,width/2,height/2,0x44AAFF); } @@ -266,8 +373,13 @@ private void drawHUD(GuiGraphics g){ } private void drawSideHands(GuiGraphics g){ - drawSideHand(g,game.getPlayerHand(1).size(),name(1),14,height/2-70,game.getCurrentPlayer()==1); - drawSideHand(g,game.getPlayerHand(2).size(),name(2),width-54,height/2-70,game.getCurrentPlayer()==2); + if (myPlayerIdx < 0 || myPlayerIdx >= 3) return; + int leftPlayer = (myPlayerIdx + 1) % 3; + int rightPlayer = (myPlayerIdx + 2) % 3; + drawSideHand(g, game.getPlayerHand(leftPlayer).size(), name(leftPlayer), 14, height / 2 - 70, + game.getCurrentPlayer() == leftPlayer); + drawSideHand(g, game.getPlayerHand(rightPlayer).size(), name(rightPlayer), width - 54, height / 2 - 70, + game.getCurrentPlayer() == rightPlayer); } private void drawSideHand(GuiGraphics g,int cnt,String nm,int x,int y,boolean active){ @@ -309,7 +421,7 @@ private void drawMyHand(GuiGraphics g,int mx,int my){ if(hand.isEmpty())return; if(cardSelected.length!=hand.size())cardSelected=new boolean[hand.size()]; boolean myT=game.getCurrentPlayer()==myPlayerIdx&&(lanMode!=LAN_HOST||myPlayerIdx==0); // 与drawActionBar一致:HOST(座位0)也可操作 - int sx=Math.max(10,width/2-hand.size()*CARD_SP/2); + int sx=handStartX(hand.size()); int cy=height-CARD_H-52; for(int i=0;i0; + int[] sc = game.getScores(); + if (myPlayerIdx < 0 || myPlayerIdx >= sc.length) return; + int roundWinner = game.getRoundWinner(); + boolean win = LandlordGame.isRoundWinForPlayer(myPlayerIdx, + game.getLandlordPlayer(), roundWinner); int cw=300,ch=130,cax=cx-cw/2,cay=cy-ch/2; g.fill(cax-2,cay-2,cax+cw+2,cay+ch+2,win?0xFF44FF44:0xFFFF4444); g.fill(cax,cay,cax+cw,cay+ch,0xFF070F1E); @@ -417,7 +532,7 @@ private void drawBtn(GuiGraphics g,String text,int x,int y,int w,int h,int bc,in List hand=game.getPlayerHand(myPlayerIdx); if(!hand.isEmpty()){ if(cardSelected.length!=hand.size())cardSelected=new boolean[hand.size()]; - int sx=Math.max(10,width/2-hand.size()*CARD_SP/2); + int sx=handStartX(hand.size()); int cardY=height-CARD_H-52; for(int i=hand.size()-1;i>=0;i--){ int cx2=sx+i*CARD_SP,cy2=cardY-(cardSelected[i]?12:0); @@ -439,10 +554,23 @@ private void drawBtn(GuiGraphics g,String text,int x,int y,int w,int h,int bc,in // ══ 动作 ═════════════════════════════════════════ /** 退出对局:联机模式下先通知对方再返回,避免对端干等 */ private void exitWithLeave(){ + stopInitRetries(); if(lanMode!=LAN_NONE)sendLeaveGame(); Minecraft.getInstance().setScreen(new GameSelectorScreen()); } + private void stopInitRetries(){ + initRetriesActive=false; + } + + private void abortLanStartup(String reason){ + stopInitRetries(); + sendLeaveGame(); + Minecraft mc=Minecraft.getInstance(); + if(mc.player!=null)mc.player.displayClientMessage(Component.literal("[游戏机] "+reason),false); + mc.setScreen(new com.wzz.game_console.client.screens.MultiplayerLobbyScreen()); + } + /** 联机时主机需通知两位客机(getLanPeer 只返回其中一位),且只发一次 */ private boolean leaveSent=false; @Override public void sendLeaveGame(){ @@ -457,16 +585,25 @@ private void exitWithLeave(){ } private void sendLeaveTo(UUID peer){ if(peer==null)return; + String data=inviteAttemptNonce==null?"":MultiplayerInviteAttempt.encode(inviteAttemptNonce); ModNetworks.PACKET_HANDLER.sendToServer(new MultiplayerGamePacket( - MultiplayerGamePacket.PacketType.LEAVE_GAME,peer,"landlord","")); + MultiplayerGamePacket.PacketType.LEAVE_GAME,peer,"landlord",data)); } @Override public void onClose(){ + stopInitRetries(); // 兼容 ESC 以外的关闭路径(被其他界面顶替等),联机时补发退出通知 if(lanMode!=LAN_NONE)sendLeaveGame(); super.onClose(); } + @Override public void removed(){ + // setScreen(...) 替换界面不一定经过 onClose;离屏后必须停止 INIT 定时重发并通知对端。 + stopInitRetries(); + if(lanMode!=LAN_NONE)sendLeaveGame(); + super.removed(); + } + private void doBid(boolean w){ if(lanMode==LAN_NONE){game.bid(myPlayerIdx,w);showMsg(w?"你叫地主!":"你不叫");lastAiTick=tickCount;} else if(lanMode==LAN_CLIENT){sendToHost("BID:"+myPlayerIdx+":"+(w?"1":"0"));} @@ -479,7 +616,16 @@ private void doPlay(){ if(lanMode==LAN_NONE){ if(game.playCards(myPlayerIdx,selectedCards)){lastInfo="你出: "+cardsStr(selectedCards);showMsg(lastInfo);clearSel();lastAiTick=tickCount;} else showMsg("不能出这些牌!"); - }else if(lanMode==LAN_CLIENT){sendToHost("PLAY:"+myPlayerIdx+":"+LandlordGame.serializeCards(selectedCards));clearSel();} + }else if(lanMode==LAN_CLIENT){ + // 发送前本地预校验:状态滞后/压不过时直接提示,避免报文被主机拒绝后选牌状态丢失 + if(game.getCurrentPlayer()!=myPlayerIdx){showMsg("还没轮到你出牌!");return;} + List last=game.getLastPlayedCards(); + if(!last.isEmpty()){ + CardPattern lp=game.analyzeCards(last); + if(lp!=null&&!p.canBeat(lp)){showMsg("压不过上家!");return;} + } + sendToHost("PLAY:"+myPlayerIdx+":"+LandlordGame.serializeCards(selectedCards));clearSel(); + } else{ if(game.playCards(0,selectedCards)){lastInfo="你出: "+cardsStr(selectedCards);showMsg(lastInfo);clearSel();broadcastState();} else showMsg("不能出这些牌!"); @@ -491,17 +637,42 @@ private void doPass(){ if(game.playCards(myPlayerIdx,new ArrayList<>())){showMsg("你过牌");clearSel();lastAiTick=tickCount;} else showMsg("主动出牌轮不能过牌!"); } - else if(lanMode==LAN_CLIENT){sendToHost("PLAY:"+myPlayerIdx+":");clearSel();} + else if(lanMode==LAN_CLIENT){ + // 领出轮禁止过牌:发送前本地预校验,避免主机拒绝后无反馈 + if(game.getCurrentPlayer()!=myPlayerIdx){showMsg("还没轮到你出牌!");return;} + if(game.getLastPlayedCards().isEmpty()){showMsg("主动出牌轮不能过牌!");return;} + sendToHost("PLAY:"+myPlayerIdx+":");clearSel(); + } else{ if(game.playCards(0,new ArrayList<>())){showMsg("你过牌");clearSel();broadcastState();} else showMsg("主动出牌轮不能过牌!"); } } private void clearSel(){selectedCards.clear();Arrays.fill(cardSelected,false);} + /** 手牌起始 x:居中并双边钳制在 [10, width-10] 内(渲染与点击命中共用同一计算) */ + private int handStartX(int n){ + return Math.max(10,Math.min(width/2-n*CARD_SP/2,width-10-((n-1)*CARD_SP+CARD_W))); + } private void syncSel(List h){selectedCards.clear();for(int i=0;i c){StringBuilder sb=new StringBuilder();for(Card x:c){if(sb.length()>0)sb.append(' ');sb.append(x);}return sb.toString();} - private String getPatternName(CardPattern p){return switch(p.getType()){case SINGLE->"单";case PAIR->"对";case TRIPLE->"三张";case TRIPLE_WITH_ONE->"三带一";case TRIPLE_WITH_PAIR->"三带二";case STRAIGHT->"顺子";case PAIR_STRAIGHT->"连对";case TRIPLE_STRAIGHT->"飞机";case BOMB->"炸弹";case JOKER_BOMB->"王炸";};} + private String getPatternName(CardPattern p){return switch(p.getType()){case SINGLE->"单";case PAIR->"对";case TRIPLE->"三张";case TRIPLE_WITH_ONE->"三带一";case TRIPLE_WITH_PAIR->"三带二";case STRAIGHT->"顺子";case PAIR_STRAIGHT->"连对";case TRIPLE_STRAIGHT->"飞机";case TRIPLE_STRAIGHT_WITH_SINGLE->"飞机带单";case TRIPLE_STRAIGHT_WITH_PAIR->"飞机带对";case FOUR_WITH_TWO_SINGLES->"四带二单";case FOUR_WITH_TWO_PAIRS->"四带两对";case BOMB->"炸弹";case JOKER_BOMB->"王炸";};} @Override public boolean isPauseScreen(){return false;} } diff --git a/src/main/java/com/wzz/game_console/client/screens/games/landlord/LandlordLanState.java b/src/main/java/com/wzz/game_console/client/screens/games/landlord/LandlordLanState.java new file mode 100644 index 0000000..d4b3466 --- /dev/null +++ b/src/main/java/com/wzz/game_console/client/screens/games/landlord/LandlordLanState.java @@ -0,0 +1,64 @@ +package com.wzz.game_console.client.screens.games.landlord; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; + +final class LandlordLanState { + private LandlordLanState() {} + + record Applied(boolean init, int seat, long token, long sequence, String rejection) {} + + static final class Receiver { + private long token; + private long sequence = -1; + private int seat = -1; + private final Set retired = new HashSet<>(); + + Applied receive(LandlordGame game, String data) { + var envelope = LandlordGame.decodeNetworkState(data); + if (envelope == null || envelope.sequence() <= sequence || retired.contains(envelope.sessionToken())) { + return null; + } + String payload = envelope.payload(); + boolean init = payload.startsWith("INIT:"); + boolean newRound = token != envelope.sessionToken(); + if (newRound && !init) return null; + int incomingSeat = seat; + String rejection = null; + try { + if (init) { + int split = payload.indexOf('|', 5); + if (split < 0) return null; + incomingSeat = Integer.parseInt(payload.substring(5, split)); + if (incomingSeat < 1 || incomingSeat > 2 || (seat != -1 && seat != incomingSeat)) return null; + if (!game.applyState(payload.substring(split + 1), incomingSeat, new ArrayList<>())) return null; + } else if (payload.startsWith("STATE:")) { + if (seat < 1 || !game.applyState(payload.substring(6), seat, new ArrayList<>())) return null; + } else if (payload.startsWith("REJECT:")) { + rejection = payload.substring(7); + } else { + return null; + } + } catch (IllegalArgumentException ex) { + return null; + } + if (newRound && token != 0) retired.add(token); + token = envelope.sessionToken(); + sequence = envelope.sequence(); + seat = incomingSeat; + return new Applied(init, seat, token, sequence, rejection); + } + } + + static String encodeAction(long token, String action) { + return LandlordGame.encodeNetworkState(token, 0, action); + } + + static String decodeAction(long token, String data) { + var envelope = LandlordGame.decodeNetworkState(data); + if (envelope == null || envelope.sessionToken() != token) return null; + String action = envelope.payload(); + return action.startsWith("BID:") || action.startsWith("PLAY:") ? action : null; + } +} diff --git a/src/main/java/com/wzz/game_console/client/screens/games/landlord/LandlordStartupGuard.java b/src/main/java/com/wzz/game_console/client/screens/games/landlord/LandlordStartupGuard.java new file mode 100644 index 0000000..a15eb32 --- /dev/null +++ b/src/main/java/com/wzz/game_console/client/screens/games/landlord/LandlordStartupGuard.java @@ -0,0 +1,29 @@ +package com.wzz.game_console.client.screens.games.landlord; + +final class LandlordStartupGuard { + static final long FIRST_SEND_TICK = 5; + static final long RETRY_INTERVAL_TICKS = 20; + static final int MAX_SEND_ATTEMPTS = 5; + /** + * 45s:必须覆盖大厅邀请窗口(600 tick)——斗地主要等两名玩家都接受才发 INIT, + * 先接受的客机可能要等主机凑齐人;主机侧由 MAX_SEND_ATTEMPTS 在约 105 tick 收敛。 + */ + static final long TIMEOUT_TICKS = 900; + + enum HostAction { NONE, SEND, ABORT } + + private LandlordStartupGuard() {} + + static HostAction hostAction(long elapsedTicks, long lastSendElapsedTicks, int sendAttempts) { + if (elapsedTicks >= TIMEOUT_TICKS) return HostAction.ABORT; + boolean sendDue = elapsedTicks >= FIRST_SEND_TICK + && (lastSendElapsedTicks == Long.MIN_VALUE + || elapsedTicks - lastSendElapsedTicks >= RETRY_INTERVAL_TICKS); + if (!sendDue) return HostAction.NONE; + return sendAttempts >= MAX_SEND_ATTEMPTS ? HostAction.ABORT : HostAction.SEND; + } + + static boolean clientTimedOut(boolean waitingForInit, long elapsedTicks) { + return waitingForInit && elapsedTicks >= TIMEOUT_TICKS; + } +} diff --git a/src/main/java/com/wzz/game_console/client/screens/games/tictactoe/TicTacToeGame.java b/src/main/java/com/wzz/game_console/client/screens/games/tictactoe/TicTacToeGame.java index f2ea233..ada23c6 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/tictactoe/TicTacToeGame.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/tictactoe/TicTacToeGame.java @@ -6,7 +6,7 @@ protected enum Player { } public enum GameMode { - SINGLE_PLAYER + SINGLE_PLAYER, TWO_PLAYER } private Player[][] board; @@ -36,6 +36,16 @@ public final void resetGame() { } public boolean makeMove(int row, int col) { + if (gameMode == GameMode.SINGLE_PLAYER && !isPlayerTurn) return false; + return applyMove(row, col); + } + + boolean makeMove(int row, int col, Player player) { + if (gameMode != GameMode.TWO_PLAYER || player == Player.NONE || player != currentPlayer) return false; + return applyMove(row, col); + } + + private boolean applyMove(int row, int col) { if (row < 0 || row >= 3 || col < 0 || col >= 3) return false; // 联机数据防护 if (gameOver || board[row][col] != Player.NONE) { return false; @@ -70,11 +80,10 @@ public void makeAIMove() { // 简单的AI逻辑:优先获胜,其次阻止玩家获胜,最后随机下棋 int[] move = getBestMove(); if (move != null) { - // 若 getBestMove 返回的合法点被 makeMove 拒绝(未来扩展),扫描棋盘兜底 - if (!makeMove(move[0], move[1])) { + if (!applyMove(move[0], move[1])) { scanFallback: for (int i = 0; i < 3; i++) for (int j = 0; j < 3; j++) - if (makeMove(i, j)) break scanFallback; + if (applyMove(i, j)) break scanFallback; } } } @@ -95,7 +104,19 @@ private int[] getBestMove() { if (board[1][1] == Player.NONE) { return new int[]{1, 1}; } - + + // 3.5 双角叉防御:对手占据对角双角且己方只有中心时必须走边。 + // 原固定角落顺序会取第三个角,对手落最后一个对角形成行/列双威胁,必败 + if ((board[0][0] == opponent && board[2][2] == opponent) + || (board[0][2] == opponent && board[2][0] == opponent)) { + int[][] edges = {{0, 1}, {1, 0}, {1, 2}, {2, 1}}; + for (int[] edge : edges) { + if (board[edge[0]][edge[1]] == Player.NONE) { + return edge; + } + } + } + // 4. 选择角落 int[][] corners = {{0, 0}, {0, 2}, {2, 0}, {2, 2}}; for (int[] corner : corners) { diff --git a/src/main/java/com/wzz/game_console/client/screens/games/tictactoe/TicTacToeScreen.java b/src/main/java/com/wzz/game_console/client/screens/games/tictactoe/TicTacToeScreen.java index beb6dfc..609c778 100644 --- a/src/main/java/com/wzz/game_console/client/screens/games/tictactoe/TicTacToeScreen.java +++ b/src/main/java/com/wzz/game_console/client/screens/games/tictactoe/TicTacToeScreen.java @@ -40,7 +40,7 @@ public TicTacToeScreen(TicTacToeGame.GameMode mode) { /** LAN 联机构造:HOST=X先手,CLIENT=O后手 */ public TicTacToeScreen(boolean isHost, java.util.UUID remote) { super(Component.literal("井字棋-联机")); - this.game = new TicTacToeGame(TicTacToeGame.GameMode.SINGLE_PLAYER); + this.game = new TicTacToeGame(TicTacToeGame.GameMode.TWO_PLAYER); this.lanMode = isHost ? LAN_HOST : LAN_CLIENT; this.remotePeer = remote; this.isMyTurn = isHost; // HOST(X)先手 @@ -77,19 +77,34 @@ public void onClose() { super.onClose(); } + @Override + public void removed() { + sendLeaveGameOnce(); + super.removed(); + } + /** 收到对方走法 "row,col" 或 "RESTART" */ @Override public void onRemoteMove(String data) { + if (lanMode == LAN_NONE || data == null) return; if ("RESTART".equals(data)) { + if (lanMode != LAN_CLIENT) return; + lastAIMoveTime = 0; game.resetGame(); state = State.PLAYING; isMyTurn = false; // CLIENT是O后手,HOST(X)重开后先走 return; } + if (state != State.PLAYING || game.isGameOver() || isMyTurn) return; try { - String[] p = data.split(","); - int row = Integer.parseInt(p[0]), col = Integer.parseInt(p[1]); - game.makeMove(row, col); // 此时 currentPlayer 是对方,直接落子 + String[] p = data.split(",", -1); + if (p.length != 2) return; + int row = Integer.parseInt(p[0]); + int col = Integer.parseInt(p[1]); + if (row < 0 || row > 2 || col < 0 || col > 2) return; + TicTacToeGame.Player remotePlayer = lanMode == LAN_HOST + ? TicTacToeGame.Player.O : TicTacToeGame.Player.X; + if (!game.makeMove(row, col, remotePlayer)) return; isMyTurn = true; } catch (Exception ignored) {} } @@ -128,10 +143,10 @@ public void onRemoteMove(String data) { game.resetGame(); state = State.PLAYING; isMyTurn = (lanMode != LAN_CLIENT); // HOST=true,单机=true - if (lanMode == LAN_HOST) sendMove("RESTART"); + if (lanMode == LAN_HOST) sendMoveEnvelope("RESTART"); return true; } - return true; + return super.keyPressed(key, scan, mods); } @Override public void render(GuiGraphics g, int mx, int my, float pt) { @@ -197,9 +212,9 @@ private void renderPlaying(GuiGraphics g, int mx, int my) { } // 悬停 - if (!game.isGameOver() && game.isPlayerTurn()) { - int hc = (mx - gridStartX) / cellSize; - int hr = (my - gridStartY) / cellSize; + if (!game.isGameOver() && (lanMode == LAN_NONE ? game.isPlayerTurn() : isMyTurn)) { + int hc = Math.floorDiv(mx - gridStartX, cellSize); + int hr = Math.floorDiv(my - gridStartY, cellSize); if (hc >= 0 && hc < 3 && hr >= 0 && hr < 3 && game.getCell(hr, hc) == TicTacToeGame.Player.NONE) { g.fill(gridStartX + hc * cellSize, gridStartY + hr * cellSize, gridStartX + (hc+1) * cellSize, gridStartY + (hr+1) * cellSize, 0x22FFFFFF); @@ -244,7 +259,7 @@ private void renderPlaying(GuiGraphics g, int mx, int my) { // 重新开始 if (lanMode != LAN_CLIENT) { game.resetGame(); isMyTurn = (lanMode != LAN_CLIENT); - if (lanMode == LAN_HOST) sendMove("RESTART"); + if (lanMode == LAN_HOST) sendMoveEnvelope("RESTART"); } return true; } @@ -255,16 +270,17 @@ private void renderPlaying(GuiGraphics g, int mx, int my) { return true; } if (state == State.PLAYING && !game.isGameOver()) { - // 联机时只有轮到自己才能落子 - if (lanMode != LAN_NONE && !isMyTurn) return true; - int hc = ((int)mx - gridStartX) / cellSize; - int hr = ((int)my - gridStartY) / cellSize; + if (lanMode == LAN_NONE ? !game.isPlayerTurn() : !isMyTurn) return true; + int hc = Math.floorDiv((int)mx - gridStartX, cellSize); + int hr = Math.floorDiv((int)my - gridStartY, cellSize); if (hc >= 0 && hc < 3 && hr >= 0 && hr < 3) { - if (game.makeMove(hr, hc)) { + boolean moved = lanMode == LAN_NONE ? game.makeMove(hr, hc) + : game.makeMove(hr, hc, lanMode == LAN_HOST ? TicTacToeGame.Player.X : TicTacToeGame.Player.O); + if (moved) { if (lanMode != LAN_NONE) { // 发给对方,然后等待 isMyTurn = false; - sendMove(hr + "," + hc); + sendMoveEnvelope(hr + "," + hc); } else if (game.getGameMode() == TicTacToeGame.GameMode.SINGLE_PLAYER && !game.isPlayerTurn() && !game.isGameOver()) { lastAIMoveTime = System.currentTimeMillis(); diff --git a/src/main/java/com/wzz/game_console/init/ModNetworks.java b/src/main/java/com/wzz/game_console/init/ModNetworks.java index 5225c9f..46ae405 100644 --- a/src/main/java/com/wzz/game_console/init/ModNetworks.java +++ b/src/main/java/com/wzz/game_console/init/ModNetworks.java @@ -6,6 +6,10 @@ import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent; import net.neoforged.neoforge.network.handling.IPayloadContext; import net.neoforged.neoforge.network.registration.PayloadRegistrar; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.Method; /** * 网络包注册中心 @@ -13,11 +17,20 @@ */ public class ModNetworks { + private static final Logger LOGGER = LoggerFactory.getLogger(ModNetworks.class); + private static final String CLIENT_HANDLER = "com.wzz.game_console.network.ClientPayloadHandler"; + + /** 反射 Method 缓存:客户端处理器只查找一次,避免每个包都 Class.forName + getMethod */ + private static volatile Method handleGameSelectorMethod; + private static volatile Method handleMultiplayerClientMethod; + /** 兼容旧代码的 PACKET_HANDLER(委托到 PacketDistributor) */ public static final PacketHandlerCompat PACKET_HANDLER = new PacketHandlerCompat(); public static void register(final RegisterPayloadHandlersEvent event) { - final PayloadRegistrar registrar = event.registrar(ModMain.MODID).versioned("1.1.0"); + // ★ Bug修复:新增 INVITE_CANCELLED/PLAYER_QUIT 包类型后未升协议版本, + // 新旧客户端混连时可能因 codec 校验被服务端拒收,这里同步升版 + final PayloadRegistrar registrar = event.registrar(ModMain.MODID).versioned("1.3.0"); // GameSelectorPacket: 服务端→客户端(打开游戏选择器) // 处理器通过反射调用 ClientPayloadHandler,避免服务端加载客户端类 @@ -27,11 +40,20 @@ public static void register(final RegisterPayloadHandlersEvent event) { (packet, context) -> { context.enqueueWork(() -> { try { - Class.forName("com.wzz.game_console.network.ClientPayloadHandler") - .getMethod("handleGameSelector", GameSelectorPacket.class, IPayloadContext.class) - .invoke(null, packet, context); - } catch (Exception ignored) { - // 服务端无操作 + Method m = handleGameSelectorMethod; + if (m == null) { + synchronized (ModNetworks.class) { + if (handleGameSelectorMethod == null) { + handleGameSelectorMethod = Class.forName(CLIENT_HANDLER) + .getMethod("handleGameSelector", GameSelectorPacket.class, IPayloadContext.class); + } + m = handleGameSelectorMethod; + } + } + m.invoke(null, packet, context); + } catch (Throwable t) { + // 反射失败(类缺失/初始化异常)不能静默吞掉,否则客户端收不到任何包且无从排查 + LOGGER.error("[游戏机] 分发 GameSelectorPacket 失败", t); } }); } @@ -49,11 +71,19 @@ public static void register(final RegisterPayloadHandlersEvent event) { // 客户端接收处理:通过反射调用 ClientPayloadHandler context.enqueueWork(() -> { try { - Class.forName("com.wzz.game_console.network.ClientPayloadHandler") - .getMethod("handleMultiplayerClient", MultiplayerGamePacket.class, IPayloadContext.class) - .invoke(null, packet, context); - } catch (Exception ignored) { - // 服务端无操作 + Method m = handleMultiplayerClientMethod; + if (m == null) { + synchronized (ModNetworks.class) { + if (handleMultiplayerClientMethod == null) { + handleMultiplayerClientMethod = Class.forName(CLIENT_HANDLER) + .getMethod("handleMultiplayerClient", MultiplayerGamePacket.class, IPayloadContext.class); + } + m = handleMultiplayerClientMethod; + } + } + m.invoke(null, packet, context); + } catch (Throwable t) { + LOGGER.error("[游戏机] 分发 MultiplayerGamePacket({}) 失败", packet.getType(), t); } }); } diff --git a/src/main/java/com/wzz/game_console/items/GameConsoleItem.java b/src/main/java/com/wzz/game_console/items/GameConsoleItem.java index 60f9029..63cc3aa 100644 --- a/src/main/java/com/wzz/game_console/items/GameConsoleItem.java +++ b/src/main/java/com/wzz/game_console/items/GameConsoleItem.java @@ -24,8 +24,10 @@ public GameConsoleItem() { @Override public void appendHoverText(ItemStack p_41421_, Item.TooltipContext p_41422_, List p_41423_, TooltipFlag p_41424_) { super.appendHoverText(p_41421_, p_41422_, p_41423_, p_41424_); - p_41423_.add(Component.literal("游戏~游戏~我想玩游戏~").withStyle(ChatFormatting.GRAY, ChatFormatting.ITALIC)); - p_41423_.add(Component.literal("右键Play给木~").withStyle(ChatFormatting.DARK_GRAY, ChatFormatting.ITALIC)); + // ★ Bug修复:原版硬编码中文,英文 locale 下显示原中文+奇怪拼接。 + // 改用 translatable key,en_us.json / zh_cn.json 分别定义 + p_41423_.add(Component.translatable("tooltip.game_console.item_flavor").withStyle(ChatFormatting.GRAY, ChatFormatting.ITALIC)); + p_41423_.add(Component.translatable("tooltip.game_console.item_hint").withStyle(ChatFormatting.DARK_GRAY, ChatFormatting.ITALIC)); } @Override diff --git a/src/main/java/com/wzz/game_console/network/MultiplayerGameDataEnvelope.java b/src/main/java/com/wzz/game_console/network/MultiplayerGameDataEnvelope.java new file mode 100644 index 0000000..dbd5e37 --- /dev/null +++ b/src/main/java/com/wzz/game_console/network/MultiplayerGameDataEnvelope.java @@ -0,0 +1,89 @@ +package com.wzz.game_console.network; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.UUID; + +final class MultiplayerGameDataEnvelope { + private static final Logger LOGGER = LoggerFactory.getLogger("GameConsole"); + private static final String PREFIX = "MGP1|"; + private static final int MAX_DATA_BYTES = 32767; + + record Value(UUID sessionId, long sequence, String body, boolean legacy) { + Value { + body = body == null ? "" : body; + } + } + + private MultiplayerGameDataEnvelope() {} + + static Value of(UUID sessionId, long sequence, String body) { + if (sessionId == null) throw new IllegalArgumentException("sessionId cannot be null"); + if (sequence < 0) throw new IllegalArgumentException("sequence cannot be negative"); + return new Value(sessionId, sequence, body, false); + } + + static Value parse(String data) { + if (data == null || !data.startsWith(PREFIX)) return legacy(data); + try { + String[] fields = data.split("\\|", -1); + if (fields.length != 4 || !"MGP1".equals(fields[0])) return null; + UUID session = UUID.fromString(fields[1]); + long sequence = Long.parseLong(fields[2]); + if (sequence < 0 || fields[3].length() > MAX_DATA_BYTES * 2) return null; + String body = new String(Base64.getUrlDecoder().decode(fields[3]), StandardCharsets.UTF_8); + if (utf8Length(body) > MAX_DATA_BYTES) return null; + return new Value(session, sequence, body, false); + } catch (RuntimeException ignored) { + return null; + } + } + + static String encode(Value value) { + if (value.legacy() || value.sessionId() == null || value.sequence() < 0) return value.body(); + String head = PREFIX + value.sessionId() + "|" + value.sequence() + "|"; + String body = value.body(); + int bodyBytes = utf8Length(body); + // 正文预算 =(上限 - 头部长度)的 base64 换算:floor(chars/4)*3 保证 no-padding + // 编码后恒不超限。超限时按码点边界截断而非抛异常——与 wire 层 safeUtf 的 + // “截断优先于断连”策略一致;接收端对残缺 body 会在游戏逻辑层安全丢弃。 + int allowedBody = Math.max(0, (MAX_DATA_BYTES - utf8Length(head)) / 4 * 3); + if (bodyBytes > allowedBody) { + LOGGER.warn("[游戏机联机] MGP1 正文 {} 字节超过上限 {},已按码点边界截断", bodyBytes, allowedBody); + body = truncateUtf8(body, allowedBody); + } + String encodedBody = Base64.getUrlEncoder().withoutPadding().encodeToString( + body.getBytes(StandardCharsets.UTF_8)); + String envelope = head + encodedBody; + if (utf8Length(envelope) > MAX_DATA_BYTES) { + throw new IllegalStateException("MGP1 envelope exceeds " + MAX_DATA_BYTES + " UTF-8 bytes"); + } + return envelope; + } + + /** 按码点边界截断字符串到 maxBytes 个 UTF-8 字节以内(与 MultiplayerGamePacket.safeUtf 同策略)。 */ + private static String truncateUtf8(String s, int maxBytes) { + if (utf8Length(s) <= maxBytes) return s; + int bytes = 0; + int i = 0; + while (i < s.length()) { + int cp = s.codePointAt(i); + int cb = cp <= 0x7F ? 1 : cp <= 0x7FF ? 2 : cp <= 0xFFFF ? 3 : 4; + if (bytes + cb > maxBytes) break; + bytes += cb; + i += Character.charCount(cp); + } + return s.substring(0, i); + } + + private static Value legacy(String data) { + return new Value(null, -1L, data == null ? "" : data, true); + } + + private static int utf8Length(String value) { + return value.getBytes(StandardCharsets.UTF_8).length; + } +} diff --git a/src/main/java/com/wzz/game_console/network/MultiplayerGamePacket.java b/src/main/java/com/wzz/game_console/network/MultiplayerGamePacket.java index 5a88370..2824831 100644 --- a/src/main/java/com/wzz/game_console/network/MultiplayerGamePacket.java +++ b/src/main/java/com/wzz/game_console/network/MultiplayerGamePacket.java @@ -10,7 +10,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.Map; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; /** * 多人游戏网络包(双向) @@ -35,19 +37,64 @@ public record MultiplayerGamePacket( private static final int MAX_DATA_BYTES = 32767; private static final int MAX_NAME_BYTES = 256; + /** + * GAME_* data envelope. It is deliberately kept inside the existing UTF-8 + * data field, so the payload type, codec field order, and PacketType ordinals + * remain wire compatible with old clients. Legacy clients may still send a + * bare data string; parseData() then returns a legacy envelope. + * + * Format: MGP1|session UUID|sequence|base64url(body) + */ + public record DataEnvelope(UUID sessionId, long sequence, String body, boolean legacy) { + public DataEnvelope { + body = body == null ? "" : body; + } + + public static DataEnvelope of(UUID sessionId, long sequence, String body) { + return fromValue(MultiplayerGameDataEnvelope.of(sessionId, sequence, body)); + } + + /** Safe, non-throwing parser. Bare data is legacy; malformed envelopes are rejected. */ + public static DataEnvelope parse(String data) { + return fromValue(MultiplayerGameDataEnvelope.parse(data)); + } + + public String encode() { + return MultiplayerGameDataEnvelope.encode( + new MultiplayerGameDataEnvelope.Value(sessionId, sequence, body, legacy)); + } + + private static DataEnvelope fromValue(MultiplayerGameDataEnvelope.Value value) { + return value == null ? null + : new DataEnvelope(value.sessionId(), value.sequence(), value.body(), value.legacy()); + } + } + + /** Parse a GAME_* data field; bare data from old clients remains usable. */ + public static DataEnvelope parseData(String data) { return DataEnvelope.parse(data); } + + /** Encode a session/sequence/body envelope for a GAME_* data field. */ + public static String envelopeData(UUID sessionId, long sequence, String body) { + return DataEnvelope.of(sessionId, sequence, body).encode(); + } + public enum PacketType { INVITE, ACCEPT_INVITE, DECLINE_INVITE, GAME_MOVE, GAME_STATE_SYNC, + // ⚠ 当前无发送方,但序号已被旧客户端引用,保留占位防止后续类型序号位移 GAME_OVER, LEAVE_GAME, REQUEST_PLAYERS, PLAYER_LIST, // ⚠ 新类型必须追加在枚举末尾:序号即线上索引, // 插入/调整已有顺序会破坏与旧版本的 encode/decode 兼容性。 - INVITE_CANCELLED + INVITE_CANCELLED, + // 服务端断线看门狗广播(ServerDisconnectWatcher 生成,data=退出者 UUID), + // 仅服务端→客户端单向,客户端发送的 PLAYER_QUIT 在 handleServer 中被忽略 + PLAYER_QUIT } public static final Type TYPE = @@ -75,9 +122,13 @@ public MultiplayerGamePacket decode(FriendlyByteBuf buf) { boolean hasSender = buf.readBoolean(); UUID senderUuid = hasSender ? buf.readUUID() : null; String senderName = buf.readUtf(MAX_NAME_BYTES); - String gameId = buf.readUtf(MAX_GAME_ID_BYTES); String data = buf.readUtf(MAX_DATA_BYTES); + if (ByteBufUtil.utf8Bytes(senderName) > MAX_NAME_BYTES + || ByteBufUtil.utf8Bytes(gameId) > MAX_GAME_ID_BYTES + || ByteBufUtil.utf8Bytes(data) > MAX_DATA_BYTES) { + throw new IllegalArgumentException("多人游戏包 UTF-8 字节数超过限制"); + } return new MultiplayerGamePacket(type, target, senderUuid, senderName, gameId, data); } @@ -155,6 +206,14 @@ public static void handleServer(MultiplayerGamePacket packet, IPayloadContext co var server = sender.getServer(); if (server == null) return; + // 简单限流:单一客户端每秒超过 120 包直接丢弃, + // 防止被劫持/失控的客户端刷包占满转发带宽 + if (!tryAcquireForward(sender.getUUID())) { + LOGGER.warn("[游戏机联机] 玩家 {} 发包频率超限,丢弃 {}", + sender.getGameProfile().getName(), packet.packetType()); + return; + } + switch (packet.packetType()) { case REQUEST_PLAYERS -> { // 收集在线玩家列表发回给请求者 @@ -195,4 +254,25 @@ public static void handleServer(MultiplayerGamePacket packet, IPayloadContext co } }); } + + /** 转发限流:每玩家每秒最多包数 */ + private static final int FORWARD_RATE_LIMIT = 120; + /** 玩家 → {窗口起始 nanoTime, 窗口内已计数};long[] 复合值避免每包分配两个条目 */ + private static final Map FORWARD_WINDOWS = new ConcurrentHashMap<>(); + + private static boolean tryAcquireForward(UUID sender) { + long now = System.nanoTime(); + if (FORWARD_WINDOWS.size() > 512) { + // 粗粒度清理:移除 10 秒无流量的窗口,防止离线玩家条目无限累积 + FORWARD_WINDOWS.entrySet().removeIf(e -> now - e.getValue()[0] > 10_000_000_000L); + } + long[] w = FORWARD_WINDOWS.computeIfAbsent(sender, k -> new long[]{now, 0}); + synchronized (w) { + if (now - w[0] > 1_000_000_000L) { + w[0] = now; + w[1] = 0; + } + return ++w[1] <= FORWARD_RATE_LIMIT; + } + } } diff --git a/src/main/java/com/wzz/game_console/network/MultiplayerInviteAttempt.java b/src/main/java/com/wzz/game_console/network/MultiplayerInviteAttempt.java new file mode 100644 index 0000000..e1148bf --- /dev/null +++ b/src/main/java/com/wzz/game_console/network/MultiplayerInviteAttempt.java @@ -0,0 +1,30 @@ +package com.wzz.game_console.network; + +import java.util.UUID; + +/** Strict wire representation for one invitation attempt. */ +public final class MultiplayerInviteAttempt { + private static final String PREFIX = "INV1|"; + + private MultiplayerInviteAttempt() {} + + public static String encode(UUID nonce) { + if (nonce == null) throw new IllegalArgumentException("Invitation nonce is required"); + return PREFIX + nonce; + } + + public static UUID parse(String data) { + if (data == null || !data.startsWith(PREFIX)) return null; + String value = data.substring(PREFIX.length()); + if (value.isEmpty() || value.indexOf('|') >= 0) return null; + try { + return UUID.fromString(value); + } catch (IllegalArgumentException ignored) { + return null; + } + } + + public static boolean matches(String data, UUID expected) { + return expected != null && expected.equals(parse(data)); + } +} diff --git a/src/main/java/com/wzz/game_console/network/ServerDisconnectWatcher.java b/src/main/java/com/wzz/game_console/network/ServerDisconnectWatcher.java new file mode 100644 index 0000000..a2ce06f --- /dev/null +++ b/src/main/java/com/wzz/game_console/network/ServerDisconnectWatcher.java @@ -0,0 +1,34 @@ +package com.wzz.game_console.network; + +import net.minecraft.server.level.ServerPlayer; +import net.neoforged.neoforge.event.entity.player.PlayerEvent; + +/** + * 服务端断线看门狗:玩家退出服务器时向其余在线玩家广播 PLAYER_QUIT。 + * 客户端收到后若退出者正是自己的对局对端,按"对方退出对局"处理并关闭界面, + * 避免对端停留在"等待对方走棋"的死等状态(此前只能靠对方主动发 LEAVE_GAME)。 + * 在 ModMain 构造器中通过 NeoForge.EVENT_BUS.addListener 注册。 + */ +public final class ServerDisconnectWatcher { + + private ServerDisconnectWatcher() {} + + public static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) { + if (!(event.getEntity() instanceof ServerPlayer quitter)) return; + var server = quitter.getServer(); + if (server == null) return; + // data 携带退出者 UUID;senderName 盖章为退出者名字,客户端直接用于提示 + var notify = new MultiplayerGamePacket( + MultiplayerGamePacket.PacketType.PLAYER_QUIT, + null, + quitter.getUUID(), + quitter.getGameProfile().getName(), + "", + quitter.getUUID().toString()); + for (ServerPlayer p : server.getPlayerList().getPlayers()) { + if (!p.getUUID().equals(quitter.getUUID())) { + net.neoforged.neoforge.network.PacketDistributor.sendToPlayer(p, notify); + } + } + } +} diff --git a/src/main/java/com/wzz/game_console/util/ExternalFileManager.java b/src/main/java/com/wzz/game_console/util/ExternalFileManager.java index 4f8e315..1f8d13f 100644 --- a/src/main/java/com/wzz/game_console/util/ExternalFileManager.java +++ b/src/main/java/com/wzz/game_console/util/ExternalFileManager.java @@ -4,258 +4,287 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.nio.file.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.Collections; import java.util.List; -import java.util.stream.Collectors; import java.util.stream.Stream; -/** - * 外部文件管理器 - * 在 .minecraft 目录下创建 game_console 文件夹,并管理其子目录和文件读取。 - * - * 目录结构: - * .minecraft/game_console/ - * ├── music/ (谱面文件 .pts) - * ├── voice/ (音频/语音文件) - * └── data/ (其他数据文件) - */ -public class ExternalFileManager { - - /** 日志记录器 */ +/** 管理游戏目录下 game_console 文件夹中的外部文件。 */ +public final class ExternalFileManager { + private static final Logger LOGGER = LoggerFactory.getLogger("GameConsole"); - /** 根文件夹名称 */ public static final String ROOT_FOLDER = "game_console"; - /** 子文件夹名称 */ public static final String MUSIC_FOLDER = "music"; public static final String VOICE_FOLDER = "voice"; public static final String DATA_FOLDER = "data"; - private static Path gameDir; - private static Path rootDir; - private static volatile boolean initialized = false; + private enum InitState { + UNINITIALIZED, + INITIALIZED, + FAILED + } + + private static final Object INIT_LOCK = new Object(); + private static volatile InitState initState = InitState.UNINITIALIZED; + private static volatile Path gameDir; + private static volatile Path rootDir; + + private ExternalFileManager() {} /** - * 初始化:创建所有必要的文件夹。 - * 应在模组启动时调用(commonSetup 或 clientSetup)。 + * 初始化并创建标准目录。初始化仅执行一次;失败后固定为 FAILED,避免并发重试和重复日志。 */ public static void init() { - if (initialized) return; - gameDir = FMLPaths.GAMEDIR.get(); - rootDir = gameDir.resolve(ROOT_FOLDER); - try { - // 创建根目录和子目录 - Files.createDirectories(rootDir); - Files.createDirectories(rootDir.resolve(MUSIC_FOLDER)); - Files.createDirectories(rootDir.resolve(VOICE_FOLDER)); - Files.createDirectories(rootDir.resolve(DATA_FOLDER)); - - initialized = true; - LOGGER.info("外部文件夹已创建: {}", rootDir.toAbsolutePath()); - } catch (IOException e) { - LOGGER.error("创建外部文件夹失败", e); + if (initState != InitState.UNINITIALIZED) return; + + synchronized (INIT_LOCK) { + if (initState != InitState.UNINITIALIZED) return; + try { + Path resolvedGameDir = FMLPaths.GAMEDIR.get(); + Path resolvedRootDir = resolvedGameDir.resolve(ROOT_FOLDER); + Files.createDirectories(resolvedRootDir); + Files.createDirectories(resolvedRootDir.resolve(MUSIC_FOLDER)); + Files.createDirectories(resolvedRootDir.resolve(VOICE_FOLDER)); + Files.createDirectories(resolvedRootDir.resolve(DATA_FOLDER)); + + gameDir = resolvedGameDir; + rootDir = resolvedRootDir; + initState = InitState.INITIALIZED; + LOGGER.info("外部文件夹已创建: {}", resolvedRootDir.toAbsolutePath()); + } catch (Throwable failure) { + // 先发布失败状态,确保即使日志后续出现问题也不会重复初始化或重复记录该失败。 + gameDir = null; + rootDir = null; + initState = InitState.FAILED; + try { + LOGGER.error("创建外部文件夹失败,外部文件 API 将安全降级", failure); + } catch (Throwable ignored) { + // 日志后端异常不能越过公共 API 的异常边界。 + } + } } } - /** - * 获取 .minecraft 游戏目录 - */ public static Path getGameDir() { - if (!initialized) init(); - return gameDir; + ensureInitialized(); + return initState == InitState.INITIALIZED ? gameDir : null; } - /** - * 获取根目录 (.minecraft/game_console/) - */ public static Path getRootDir() { - if (!initialized) init(); - return rootDir; + return availableRoot(); } - /** - * 获取 music 子目录 - */ public static Path getMusicDir() { - if (!initialized) init(); - return rootDir.resolve(MUSIC_FOLDER); + return resolveSubFolder(MUSIC_FOLDER); } - /** - * 获取 voice 子目录 - */ public static Path getVoiceDir() { - if (!initialized) init(); - return rootDir.resolve(VOICE_FOLDER); + return resolveSubFolder(VOICE_FOLDER); } - /** - * 获取 data 子目录 - */ public static Path getDataDir() { - if (!initialized) init(); - return rootDir.resolve(DATA_FOLDER); + return resolveSubFolder(DATA_FOLDER); } - /** - * 列出指定子目录中匹配扩展名的文件 - * @param subFolder 子文件夹名(如 "music") - * @param extension 文件扩展名(如 ".pts"),传 null 则列出所有文件 - * @return 文件路径列表 - */ public static List listFiles(String subFolder, String extension) { - if (!initialized) init(); - Path dir = rootDir.resolve(subFolder); - if (!Files.exists(dir) || !Files.isDirectory(dir)) { - return Collections.emptyList(); - } - try (Stream stream = Files.list(dir)) { - return stream - .filter(Files::isRegularFile) - .filter(p -> extension == null || p.getFileName().toString().endsWith(extension)) - .sorted() - .collect(Collectors.toList()); - } catch (IOException e) { - LOGGER.error("列出文件失败", e); + Path dir = resolveSubFolder(subFolder); + if (dir == null) return Collections.emptyList(); + try { + if (!Files.isDirectory(dir)) return Collections.emptyList(); + try (Stream stream = Files.list(dir)) { + return stream.filter(Files::isRegularFile) + .filter(path -> extension == null || path.getFileName().toString().endsWith(extension)) + .sorted() + .toList(); + } + } catch (Throwable failure) { + logOperationFailure("列出文件失败: " + dir, failure); return Collections.emptyList(); } } - /** - * 列出指定子目录中的所有文件 - */ public static List listFiles(String subFolder) { return listFiles(subFolder, null); } - /** - * 列出根目录下的所有子文件夹名称 - */ public static List listSubFolders() { - if (!initialized) init(); - if (!Files.exists(rootDir) || !Files.isDirectory(rootDir)) { - return Collections.emptyList(); - } - try (Stream stream = Files.list(rootDir)) { - return stream - .filter(Files::isDirectory) - .map(p -> p.getFileName().toString()) - .sorted() - .collect(Collectors.toList()); - } catch (IOException e) { - LOGGER.error("列出子文件夹失败", e); + Path root = availableRoot(); + if (root == null) return Collections.emptyList(); + try { + if (!Files.isDirectory(root)) return Collections.emptyList(); + try (Stream stream = Files.list(root)) { + return stream.filter(Files::isDirectory) + .map(path -> path.getFileName().toString()) + .sorted() + .toList(); + } + } catch (Throwable failure) { + logOperationFailure("列出子文件夹失败: " + root, failure); return Collections.emptyList(); } } - /** - * 读取文本文件内容 - * @param subFolder 子文件夹名 - * @param fileName 文件名 - * @return 文件内容字符串,失败返回 null - */ + private static boolean isSafePathPart(String value) { + return value != null && !value.isEmpty() && !value.equals(".") && !value.contains("..") + && !value.contains("/") && !value.contains("\\"); + } + + private static boolean isSafeFileName(String fileName) { + return isSafePathPart(fileName); + } + public static String readTextFile(String subFolder, String fileName) { - if (!initialized) init(); - Path file = rootDir.resolve(subFolder).resolve(fileName); - if (!Files.exists(file)) return null; + Path file = resolveFile(subFolder, fileName); + if (file == null) return null; try { - return Files.readString(file, StandardCharsets.UTF_8); - } catch (IOException e) { - LOGGER.error("读取文件失败: {}", file, e); + return Files.isRegularFile(file) ? Files.readString(file, StandardCharsets.UTF_8) : null; + } catch (Throwable failure) { + logOperationFailure("读取文件失败: " + file, failure); return null; } } - /** - * 读取二进制文件内容 - * @param subFolder 子文件夹名 - * @param fileName 文件名 - * @return 文件字节数组,失败返回 null - */ public static byte[] readBytes(String subFolder, String fileName) { - if (!initialized) init(); - Path file = rootDir.resolve(subFolder).resolve(fileName); - if (!Files.exists(file)) return null; + Path file = resolveFile(subFolder, fileName); + if (file == null) return null; try { - return Files.readAllBytes(file); - } catch (IOException e) { - LOGGER.error("读取文件失败: {}", file, e); + return Files.isRegularFile(file) ? Files.readAllBytes(file) : null; + } catch (Throwable failure) { + logOperationFailure("读取文件失败: " + file, failure); return null; } } - /** - * 写入文本文件 - * @param subFolder 子文件夹名 - * @param fileName 文件名 - * @param content 内容 - * @return 是否成功 - */ public static boolean writeTextFile(String subFolder, String fileName, String content) { - if (!initialized) init(); - Path file = rootDir.resolve(subFolder).resolve(fileName); + Path file = resolveFile(subFolder, fileName); + if (file == null || content == null) return false; + return atomicWrite(file, temp -> Files.writeString(temp, content, StandardCharsets.UTF_8)); + } + + public static boolean writeBytes(String subFolder, String fileName, byte[] data) { + Path file = resolveFile(subFolder, fileName); + if (file == null || data == null) return false; + return atomicWrite(file, temp -> Files.write(temp, data)); + } + + public static String getFilePath(String subFolder, String fileName) { + Path file = resolveFile(subFolder, fileName); + if (file == null) return null; try { - Files.createDirectories(file.getParent()); - Files.writeString(file, content, StandardCharsets.UTF_8); - return true; - } catch (IOException e) { - LOGGER.error("写入文件失败: {}", file, e); - return false; + return file.toAbsolutePath().toString(); + } catch (Throwable failure) { + logOperationFailure("获取文件路径失败: " + file, failure); + return null; } } - /** - * 写入二进制文件 - * @param subFolder 子文件夹名 - * @param fileName 文件名 - * @param data 字节数据 - * @return 是否成功 - */ - public static boolean writeBytes(String subFolder, String fileName, byte[] data) { - if (!initialized) init(); - Path file = rootDir.resolve(subFolder).resolve(fileName); + public static boolean fileExists(String subFolder, String fileName) { + Path file = resolveFile(subFolder, fileName); + if (file == null) return false; try { - Files.createDirectories(file.getParent()); - Files.write(file, data); - return true; - } catch (IOException e) { - LOGGER.error("写入文件失败: {}", file, e); + return Files.exists(file); + } catch (Throwable failure) { + logOperationFailure("检查文件失败: " + file, failure); return false; } } - /** - * 获取文件的完整路径 - * @param subFolder 子文件夹名 - * @param fileName 文件名 - * @return 完整路径字符串 - */ - public static String getFilePath(String subFolder, String fileName) { - if (!initialized) init(); - return rootDir.resolve(subFolder).resolve(fileName).toAbsolutePath().toString(); + public static void ensureSubFolder(String subFolder) { + Path dir = resolveSubFolder(subFolder); + if (dir == null) return; + try { + Files.createDirectories(dir); + } catch (Throwable failure) { + logOperationFailure("创建子文件夹失败: " + subFolder, failure); + } } - /** - * 检查文件是否存在 - */ - public static boolean fileExists(String subFolder, String fileName) { - if (!initialized) init(); - return Files.exists(rootDir.resolve(subFolder).resolve(fileName)); + private static void ensureInitialized() { + try { + init(); + } catch (Throwable failure) { + // init 本身已有完整边界;此处作为公共 API 的最后防线。 + synchronized (INIT_LOCK) { + if (initState == InitState.UNINITIALIZED) initState = InitState.FAILED; + } + } } - /** - * 确保子目录存在(用于动态创建新子目录) - * @param subFolder 子文件夹名 - */ - public static void ensureSubFolder(String subFolder) { - if (!initialized) init(); + private static Path availableRoot() { + ensureInitialized(); + return initState == InitState.INITIALIZED ? rootDir : null; + } + + private static Path resolveSubFolder(String subFolder) { + Path root = availableRoot(); + if (root == null || !isSafePathPart(subFolder)) return null; + try { + Path normalizedRoot = root.toAbsolutePath().normalize(); + Path resolved = normalizedRoot.resolve(subFolder).normalize(); + return resolved.startsWith(normalizedRoot) ? resolved : null; + } catch (Throwable failure) { + logOperationFailure("解析子文件夹失败: " + subFolder, failure); + return null; + } + } + + private static Path resolveFile(String subFolder, String fileName) { + if (!isSafeFileName(fileName)) return null; + Path dir = resolveSubFolder(subFolder); + if (dir == null) return null; + try { + Path resolved = dir.resolve(fileName).toAbsolutePath().normalize(); + Path root = availableRoot(); + if (root == null) return null; + Path normalizedRoot = root.toAbsolutePath().normalize(); + return resolved.startsWith(normalizedRoot) && resolved.startsWith(dir) ? resolved : null; + } catch (Throwable failure) { + logOperationFailure("解析文件失败: " + fileName, failure); + return null; + } + } + + private static boolean atomicWrite(Path file, ThrowingPathWriter writer) { + Path temp = null; try { - Files.createDirectories(rootDir.resolve(subFolder)); - } catch (IOException e) { - LOGGER.error("创建子文件夹失败: {}", subFolder, e); + Files.createDirectories(file.getParent()); + temp = Files.createTempFile(file.getParent(), file.getFileName().toString() + ".", ".tmp"); + writer.write(temp); + try { + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException unsupported) { + // 某些文件系统不支持原子移动;仍完成替换,避免整个持久化操作失败。 + Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING); + } + return true; + } catch (Throwable failure) { + logOperationFailure("写入文件失败: " + file, failure); + return false; + } finally { + if (temp != null) { + try { + Files.deleteIfExists(temp); + } catch (Throwable ignored) { + // 临时文件清理失败不改变写入结果。 + } + } } } + + private static void logOperationFailure(String message, Throwable failure) { + try { + LOGGER.error(message, failure); + } catch (Throwable ignored) { + // 日志系统不可用时仍保持 API 安全降级。 + } + } + + @FunctionalInterface + private interface ThrowingPathWriter { + void write(Path path) throws Exception; + } } diff --git a/src/main/java/com/wzz/game_console/util/GameRenderHelper.java b/src/main/java/com/wzz/game_console/util/GameRenderHelper.java index 2053c13..2d6558d 100644 --- a/src/main/java/com/wzz/game_console/util/GameRenderHelper.java +++ b/src/main/java/com/wzz/game_console/util/GameRenderHelper.java @@ -168,13 +168,21 @@ public static void drawGameOverOverlay(GuiGraphics g, int w, int h) { /** 完整的游戏结束面板 */ public static void drawGameOverPanel(GuiGraphics g, Font font, int cx, int cy, boolean win, String title, String subtitle) { + drawGameOverPanel(g, font, cx, cy, win ? 1 : -1, title, subtitle); + } + + /** 完整的游戏结束面板(支持平局中性配色)。outcome:1=胜利(绿) -1=失败(红) 0=平局(中性金) */ + public static void drawGameOverPanel(GuiGraphics g, Font font, int cx, int cy, + int outcome, String title, String subtitle) { int pw = 280, ph = 140; - drawPanel(g, cx - pw/2, cy - ph/2, pw, ph, BG_PANEL, - win ? 0xFF44FF44 : 0xFFFF4444); + int borderColor = outcome > 0 ? 0xFF44FF44 : outcome < 0 ? 0xFFFF4444 : 0xFFFFCC44; + int titleColor = outcome > 0 ? TEXT_GREEN : outcome < 0 ? TEXT_RED : 0xFFFFCC44; + int subColor = outcome > 0 ? 0xCCFFCC : outcome < 0 ? 0xFFAAAA : 0xFFF0DDAA; + drawPanel(g, cx - pw/2, cy - ph/2, pw, ph, BG_PANEL, borderColor); - drawShadowedCenteredText(g, font, title, cx, cy - 40, win ? TEXT_GREEN : TEXT_RED, 1); + drawShadowedCenteredText(g, font, title, cx, cy - 40, titleColor, 1); if (subtitle != null && !subtitle.isEmpty()) { - g.drawCenteredString(font, subtitle, cx, cy - 20, win ? 0xCCFFCC : 0xFFAAAA); + g.drawCenteredString(font, subtitle, cx, cy - 20, subColor); } } @@ -248,13 +256,18 @@ public static void drawCheckerboard(GuiGraphics g, int ox, int oy, int cols, int (x + y) % 2 == 0 ? color1 : color2); } - /** 绘制方块带3D效果 */ + /** 绘制边长 s 的正方形方块带3D效果 */ public static void drawBlock3D(GuiGraphics g, int x, int y, int s, int color) { - g.fill(x, y, x + s, y + s, color); - g.fill(x, y, x + s, y + 1, brighten(color, 1.3f)); - g.fill(x, y, x + 1, y + s, brighten(color, 1.15f)); - g.fill(x, y + s - 1, x + s, y + s, darken(color, 0.6f)); - g.fill(x + s - 1, y, x + s, y + s, darken(color, 0.7f)); + drawBlock3D(g, x, y, s, s, color); + } + + /** 绘制 w×h 矩形方块带3D效果(非正方形碰撞盒用它,避免把宽当边长画成正方形) */ + public static void drawBlock3D(GuiGraphics g, int x, int y, int w, int h, int color) { + g.fill(x, y, x + w, y + h, color); + g.fill(x, y, x + w, y + 1, brighten(color, 1.3f)); + g.fill(x, y, x + 1, y + h, brighten(color, 1.15f)); + g.fill(x, y + h - 1, x + w, y + h, darken(color, 0.6f)); + g.fill(x + w - 1, y, x + w, y + h, darken(color, 0.7f)); } /** 绘制网格线 */ @@ -349,11 +362,26 @@ public static void spawnParticles(List particles, float x, float y, in } } - /** 更新和渲染粒子 */ - public static void tickAndRenderParticles(GuiGraphics g, List particles) { + /** 仅推进粒子(应从 tick() 调用,固定 20次/秒;暂停时不调用即冻结,不再随渲染帧率变化) */ + public static void tickParticles(List particles) { particles.removeIf(p -> !p.alive); for (Particle p : particles) { p.update(); + } + } + + /** 仅渲染粒子(应从 render() 调用,不再推进物理;顺带清理已死粒子) */ + public static void renderParticles(GuiGraphics g, List particles) { + particles.removeIf(p -> !p.alive); + for (Particle p : particles) { + p.render(g); + } + } + + /** 兼容旧调用点:更新并渲染粒子(尚未迁移 tick() 的游戏继续使用,行为不变) */ + public static void tickAndRenderParticles(GuiGraphics g, List particles) { + tickParticles(particles); + for (Particle p : particles) { p.render(g); } } diff --git a/src/main/java/com/wzz/game_console/util/GameSettings.java b/src/main/java/com/wzz/game_console/util/GameSettings.java index 37496d0..1c63504 100644 --- a/src/main/java/com/wzz/game_console/util/GameSettings.java +++ b/src/main/java/com/wzz/game_console/util/GameSettings.java @@ -6,73 +6,83 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.util.HashMap; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.Set; /** * 游戏外部设置管理器。从 data/ 目录加载 JSON 设置文件, * 供各游戏在初始化时读取自定义参数(难度、速度、开关等)。 - * - * 设置文件格式(data/game_settings.json): - * { - * "icefire": { "difficulty": 2 }, - * "tetris": { "level": 3, "speed": 1.5 }, - * "minesweeper": { "gridSize": 16, "mineCount": 40 } - * } */ public class GameSettings { private static final Logger LOGGER = LoggerFactory.getLogger("GameConsole"); - private static final String SETTINGS_FILE = "game_settings.json"; + public static final String SETTINGS_FILE = "game_settings.json"; + public static final int GO_SEARCH_TIME_MIN = 100; + public static final int GO_SEARCH_TIME_MAX = 60_000; + private static final long MAX_SETTINGS_BYTES = 1L * 1024 * 1024; + private static final Object LOAD_LOCK = new Object(); + private static volatile Map> settings = Collections.emptyMap(); + private static volatile boolean loaded; - private static Map> settings = new HashMap<>(); - private static boolean loaded = false; - - /** 获取某游戏的一个整型设置项 */ public static int getInt(String gameId, String key, int defaultValue) { - ensureLoaded(); - var game = settings.get(gameId); - if (game == null) return defaultValue; - Object val = game.get(key); - if (val instanceof Number) return ((Number) val).intValue(); - return defaultValue; + Object val = getValue(gameId, key); + if (!(val instanceof Number number)) return defaultValue; + long value = number.longValue(); + long min = 0, max = Integer.MAX_VALUE; + if ("go".equals(gameId) && "searchTime".equals(key)) { + min = GO_SEARCH_TIME_MIN; + max = GO_SEARCH_TIME_MAX; + } + else if ("go".equals(gameId) && "mctsIterations".equals(key)) { min = 1; max = 10_000_000; } + else if ("chess".equals(gameId) && "pikafishMovetime".equals(key)) { min = 100; max = 120_000; } + else if ("chess".equals(gameId) && "pikafishThreads".equals(key)) { min = 1; max = 64; } + else if ("icefire".equals(gameId) && "difficulty".equals(key)) { min = 0; max = 2; } + return (int) Math.max(min, Math.min(max, value)); } - /** 获取某游戏的一个浮点设置项 */ public static double getDouble(String gameId, String key, double defaultValue) { - ensureLoaded(); - var game = settings.get(gameId); - if (game == null) return defaultValue; - Object val = game.get(key); - if (val instanceof Number) return ((Number) val).doubleValue(); - return defaultValue; + Object val = getValue(gameId, key); + if (!(val instanceof Number number)) return defaultValue; + double value = number.doubleValue(); + if (!Double.isFinite(value)) return defaultValue; + if ("go".equals(gameId) && "komi".equals(key)) { + return Math.max(-100.0, Math.min(100.0, value)); + } + return value; } - /** 获取某游戏的一个字符串设置项 */ public static String getString(String gameId, String key, String defaultValue) { - ensureLoaded(); - var game = settings.get(gameId); - if (game == null) return defaultValue; - Object val = game.get(key); + Object val = getValue(gameId, key); return val instanceof String ? (String) val : defaultValue; } - /** 获取某游戏的一个布尔设置项 */ public static boolean getBoolean(String gameId, String key, boolean defaultValue) { - ensureLoaded(); - var game = settings.get(gameId); - if (game == null) return defaultValue; - Object val = game.get(key); + Object val = getValue(gameId, key); return val instanceof Boolean ? (Boolean) val : defaultValue; } - /** 从外部文件导入设置并保存到 data 目录 */ + private static Object getValue(String gameId, String key) { + ensureLoaded(); + if (gameId == null || key == null) return null; + Map game = settings.get(gameId); + return game == null ? null : game.get(key); + } + + /** 从外部文件导入设置并保存到 data 目录。 */ public static boolean importFromFile(Path sourcePath) { + if (sourcePath == null) return false; try { + if (!Files.isRegularFile(sourcePath) || Files.size(sourcePath) > MAX_SETTINGS_BYTES) { + LOGGER.warn("设置文件不存在或超过 {} 字节: {}", MAX_SETTINGS_BYTES, sourcePath); + return false; + } String content = Files.readString(sourcePath, StandardCharsets.UTF_8); Gson gson = new Gson(); java.lang.reflect.Type type = new TypeToken>>() {}.getType(); @@ -81,11 +91,16 @@ public static boolean importFromFile(Path sourcePath) { LOGGER.warn("导入设置文件为空: {}", sourcePath); return false; } - settings = imported; - loaded = true; - // 保存到 data 目录持久化 - saveToDataDir(); - LOGGER.info("游戏设置已导入: {} ({} 个游戏)", sourcePath.getFileName(), settings.size()); + Map> snapshot = freezeSettings(imported); + synchronized (LOAD_LOCK) { + if (!saveToDataDir(snapshot)) { + LOGGER.warn("设置无法持久化到 data/{},保留当前运行时配置", SETTINGS_FILE); + return false; + } + settings = snapshot; + loaded = true; + } + LOGGER.info("游戏设置已导入: {} ({} 个游戏)", sourcePath.getFileName(), snapshot.size()); return true; } catch (Exception e) { LOGGER.error("导入设置失败: {}", e.getMessage()); @@ -93,51 +108,94 @@ public static boolean importFromFile(Path sourcePath) { } } - /** 确保设置已加载 */ + /** 确保设置已加载;初始化状态发布与设置快照交换均受同一把锁保护。 */ private static void ensureLoaded() { if (loaded) return; - try { - Path dataDir = ExternalFileManager.getDataDir(); - Path settingsPath = dataDir.resolve(SETTINGS_FILE); - if (Files.exists(settingsPath)) { - String content = Files.readString(settingsPath, StandardCharsets.UTF_8); - Gson gson = new Gson(); - java.lang.reflect.Type type = new TypeToken>>() {}.getType(); - Map> loadedSettings = gson.fromJson(content, type); - if (loadedSettings != null) { - settings = loadedSettings; + synchronized (LOAD_LOCK) { + if (loaded) return; + Map> loadedSnapshot = Collections.emptyMap(); + try { + Path dataDir = ExternalFileManager.getDataDir(); + if (dataDir != null) { + Path settingsPath = dataDir.resolve(SETTINGS_FILE); + if (Files.exists(settingsPath) && Files.isRegularFile(settingsPath) + && Files.size(settingsPath) <= MAX_SETTINGS_BYTES) { + String content = Files.readString(settingsPath, StandardCharsets.UTF_8); + java.lang.reflect.Type type = new TypeToken>>() {}.getType(); + Map> parsed = new Gson().fromJson(content, type); + if (parsed != null) loadedSnapshot = freezeSettings(parsed); + } } + } catch (Exception e) { + LOGGER.warn("加载游戏设置失败,本次会话使用默认配置(不再重试): {}", e.getMessage()); } - } catch (Exception e) { - LOGGER.warn("加载游戏设置失败(使用默认值): {}", e.getMessage()); + settings = loadedSnapshot; + loaded = true; + } + } + + /** 创建不可变的深快照,避免调用方修改内部状态或并发读写。 */ + private static Map> freezeSettings(Map> source) { + Map> outer = new LinkedHashMap<>(); + for (Map.Entry> entry : source.entrySet()) { + String gameId = entry.getKey(); + Map values = entry.getValue(); + if (gameId == null || values == null) continue; + Map inner = new LinkedHashMap<>(); + for (Map.Entry value : values.entrySet()) { + if (value.getKey() != null && value.getValue() != null) { + inner.put(value.getKey(), freezeValue(value.getValue())); + } + } + outer.put(gameId, Collections.unmodifiableMap(inner)); + } + return Collections.unmodifiableMap(outer); + } + + private static Object freezeValue(Object value) { + if (value instanceof Map map) { + Map copy = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + if (entry.getKey() instanceof String key && entry.getValue() != null) { + copy.put(key, freezeValue(entry.getValue())); + } + } + return Collections.unmodifiableMap(copy); } - loaded = true; + if (value instanceof List list) { + List copy = new ArrayList<>(list.size()); + for (Object element : list) { + if (element != null) copy.add(freezeValue(element)); + } + return Collections.unmodifiableList(copy); + } + return value; } - /** 保存设置到 data 目录 */ - private static void saveToDataDir() { + private static boolean saveToDataDir(Map> snapshot) { try { - Path dataDir = ExternalFileManager.getDataDir(); - if (!Files.exists(dataDir)) Files.createDirectories(dataDir); - Path settingsPath = dataDir.resolve(SETTINGS_FILE); - Gson gson = new GsonBuilder().setPrettyPrinting().create(); - String json = gson.toJson(settings); - Files.writeString(settingsPath, json, StandardCharsets.UTF_8); - LOGGER.info("游戏设置已保存到: {}", settingsPath); - } catch (IOException e) { + String json = new GsonBuilder().setPrettyPrinting().create().toJson(snapshot); + boolean ok = ExternalFileManager.writeTextFile(ExternalFileManager.DATA_FOLDER, SETTINGS_FILE, json); + if (ok) LOGGER.info("游戏设置已保存到 data/{}", SETTINGS_FILE); + else LOGGER.error("保存游戏设置失败"); + return ok; + } catch (Exception e) { LOGGER.error("保存游戏设置失败: {}", e.getMessage()); + return false; } } - /** 获取所有已加载的游戏 ID 列表 */ - public static java.util.Set getConfiguredGames() { + /** 获取所有已加载的游戏 ID 列表的防御性快照。 */ + public static Set getConfiguredGames() { ensureLoaded(); - return settings.keySet(); + return Collections.unmodifiableSet(new java.util.HashSet<>(settings.keySet())); } - /** 获取某游戏的所有设置项 */ + /** 获取某游戏所有设置项的防御性快照。 */ public static Map getGameSettings(String gameId) { ensureLoaded(); - return settings.getOrDefault(gameId, new HashMap<>()); + if (gameId == null) return Collections.emptyMap(); + Map game = settings.get(gameId); + return game == null ? Collections.emptyMap() : Collections.unmodifiableMap(new LinkedHashMap<>(game)); } -} \ No newline at end of file +} diff --git a/src/main/resources/assets/game_console/lang/en_us.json b/src/main/resources/assets/game_console/lang/en_us.json index 7e0c553..72efe65 100644 --- a/src/main/resources/assets/game_console/lang/en_us.json +++ b/src/main/resources/assets/game_console/lang/en_us.json @@ -1,4 +1,6 @@ { "itemGroup.game_console": "Game Console", - "item.game_console.game_console": "Game Console" -} \ No newline at end of file + "item.game_console.game_console": "Game Console", + "tooltip.game_console.item_flavor": "Games~ Games~ I want to play!~", + "tooltip.game_console.item_hint": "Right-click to play a mini-game!" +} diff --git a/src/main/resources/assets/game_console/lang/zh_cn.json b/src/main/resources/assets/game_console/lang/zh_cn.json index 739b1dc..f1860e2 100644 --- a/src/main/resources/assets/game_console/lang/zh_cn.json +++ b/src/main/resources/assets/game_console/lang/zh_cn.json @@ -1,4 +1,6 @@ { "itemGroup.game_console": "游戏机", - "item.game_console.game_console": "游戏机" -} \ No newline at end of file + "item.game_console.game_console": "游戏机", + "tooltip.game_console.item_flavor": "游戏~游戏~我想玩游戏~", + "tooltip.game_console.item_hint": "右键Play给木~" +} diff --git a/src/main/resources/assets/game_console/models/item/game_console.json b/src/main/resources/assets/game_console/models/item/game_console.json index c4567bb..3389527 100644 --- a/src/main/resources/assets/game_console/models/item/game_console.json +++ b/src/main/resources/assets/game_console/models/item/game_console.json @@ -2,5 +2,45 @@ "parent": "item/generated", "textures": { "layer0": "game_console:item/game_console" + }, + "display": { + "thirdperson_righthand": { + "rotation": [ 0, 90, -35 ], + "translation": [ 0, 2, 0 ], + "scale": [ 0.85, 0.85, 0.85 ] + }, + "thirdperson_lefthand": { + "rotation": [ 0, 90, -35 ], + "translation": [ 0, 2, 0 ], + "scale": [ 0.85, 0.85, 0.85 ] + }, + "firstperson_righthand": { + "rotation": [ 0, -135, 25 ], + "translation": [ 0, 4, 2 ], + "scale": [ 0.68, 0.68, 0.68 ] + }, + "firstperson_lefthand": { + "rotation": [ 0, -135, 25 ], + "translation": [ 0, 4, 2 ], + "scale": [ 0.68, 0.68, 0.68 ] + }, + "gui": { + "rotation": [ 15, -25, 0 ], + "translation": [ 0, 0, 0 ], + "scale": [ 1.0, 1.0, 1.0 ] + }, + "head": { + "rotation": [ 0, 0, 0 ], + "translation": [ 0, 14, 0 ] + }, + "fixed": { + "rotation": [ 0, 0, 0 ], + "scale": [ 1.0, 1.0, 1.0 ] + }, + "ground": { + "rotation": [ 0, 0, 0 ], + "translation": [ 0, 3, 0 ], + "scale": [ 0.5, 0.5, 0.5 ] + } } -} \ No newline at end of file +} diff --git a/src/main/resources/data/game_console/.gitignore b/src/main/resources/data/game_console/.gitignore new file mode 100644 index 0000000..12a665b --- /dev/null +++ b/src/main/resources/data/game_console/.gitignore @@ -0,0 +1 @@ +recipes/ diff --git a/src/main/templates/META-INF/neoforge.mods.toml b/src/main/templates/META-INF/neoforge.mods.toml index d3a4edf..394e663 100644 --- a/src/main/templates/META-INF/neoforge.mods.toml +++ b/src/main/templates/META-INF/neoforge.mods.toml @@ -13,6 +13,7 @@ license = "${mod_license}" # A URL to refer people to when problems occur with this mod #issueTrackerURL="https://change.me.to.your.issue.tracker.example.invalid/" #optional # A list of mods - how many allowed here is determined by the individual mod loader +# 注意:一个 [[mods]] 块 = 一个 mod 定义,同 modId 重复声明会被 FML 判 duplicate 而拒载 [[mods]] #mandatory # The modid of the mod modId = "${mod_id}" #mandatory @@ -31,6 +32,12 @@ displayName = "${mod_name}" #mandatory # A text field displayed in the mod UI authors = "${mod_authors}" #optional +# 注意:本 mod 必须双端加载,不要声明 side/dist 限制。 +# 1) [[mods]] 块中的 side 键在 FML 中不是合法键,会被静默忽略(正确机制是 @Mod(dist=...)); +# 2) 更关键的是联机转发 MultiplayerGamePacket.handleServer 必须在专用服务器上注册生效, +# 若按 CLIENT 排除,LAN 联机转发将完全失效。客户端专属类(Screen 等)均位于 +# client.* 包并由 @OnlyIn(Dist.CLIENT) + 反射加载隔离,专用服务器上安全。 + # The description text for the mod (multi line!) (#mandatory) description = '''${mod_description}''' diff --git a/src/test/java/com/wzz/game_console/client/screens/games/GomokuAISimulationTest.java b/src/test/java/com/wzz/game_console/client/screens/games/GomokuAISimulationTest.java new file mode 100644 index 0000000..8f78652 --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/GomokuAISimulationTest.java @@ -0,0 +1,133 @@ +package com.wzz.game_console.client.screens.games; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 五子棋 AI 无头模拟对局(纯 JDK,无 Minecraft 依赖)。 + * 覆盖: + * - 各难度在固定中盘局面下返回的着点必须在界内且为空位 + * - getMove 不修改调用方棋盘 + * - 黑随机 + 白 AI 完整自对弈不崩溃、每步合法、终局可判 + */ +@Timeout(120) +class GomokuAISimulationTest { + + private static final int SIZE = 15; + + /** 天元附近双方各几子的中盘局面 */ + private static int[][] midGameBoard() { + int[][] b = new int[SIZE][SIZE]; + b[7][7] = GomokuAI.BLACK; // 天元黑 + b[8][7] = GomokuAI.WHITE; + b[6][6] = GomokuAI.BLACK; + return b; + } + + @Test + void everyDifficultyReturnsLegalMoveOnMidGame() { + int[][] board = midGameBoard(); + for (GomokuAI.Difficulty d : GomokuAI.Difficulty.values()) { + int[] mv = new GomokuAI(d).getMove(board); + assertNotNull(mv, "难度 " + d.label + " 应返回着点"); + assertEquals(2, mv.length, "着点应为 {x,y} 二元组"); + assertTrue(mv[0] >= 0 && mv[0] < SIZE && mv[1] >= 0 && mv[1] < SIZE, + "难度 " + d.label + " 着点越界: (" + mv[0] + "," + mv[1] + ")"); + assertEquals(GomokuAI.EMPTY, board[mv[0]][mv[1]], + "难度 " + d.label + " 落在已占格: (" + mv[0] + "," + mv[1] + ")"); + } + } + + @Test + void aiDoesNotTouchCallerBoardBeforeApplication() { + int[][] before = midGameBoard(); + int[][] snapshot = new int[SIZE][SIZE]; + for (int x = 0; x < SIZE; x++) System.arraycopy(before[x], 0, snapshot[x], 0, SIZE); + + new GomokuAI(GomokuAI.Difficulty.NORMAL).getMove(snapshot); + + assertArrayEquals(before, snapshot, "getMove 不得修改调用方棋盘"); + } + + @Test + void emptyCornerBoardReturnsCenterishFirstMove() { + int[][] board = new int[SIZE][SIZE]; // AI 执白,但开局盘面是空的:AI 需给出一个合法首着 + int[] mv = new GomokuAI(GomokuAI.Difficulty.EASY).getMove(board); + assertNotNull(mv, "空盘也必须有着可下"); + assertTrue(mv[0] >= 0 && mv[0] < SIZE && mv[1] >= 0 && mv[1] < SIZE, + "首着越界: (" + mv[0] + "," + mv[1] + ")"); + } + + /** 黑方随机的完整对局:白方由 AI 驱动,直到分出五连或棋满 */ + @Test + void fullGameAgainstRandomBlackTerminatesCleanly() { + Random rnd = new Random(42); + GomokuAI ai = new GomokuAI(GomokuAI.Difficulty.NORMAL); + int[][] board = new int[SIZE][SIZE]; + + boolean blackTurn = true; + for (int step = 0; step < SIZE * SIZE; step++) { + int lx = -1, ly = -1; + if (blackTurn) { + int empties = countEmpty(board); + if (empties == 0) break; + int target = rnd.nextInt(empties); + outer: + for (int x = 0; x < SIZE; x++) + for (int y = 0; y < SIZE; y++) + if (board[x][y] == GomokuAI.EMPTY && target-- == 0) { + lx = x; ly = y; + break outer; + } + board[lx][ly] = GomokuAI.BLACK; + } else { + int[] mv = ai.getMove(board); + if (mv == null) break; + assertTrue(mv[0] >= 0 && mv[0] < SIZE && mv[1] >= 0 && mv[1] < SIZE, + "第 " + step + " 步 AI 着点越界: " + java.util.Arrays.toString(mv)); + assertEquals(GomokuAI.EMPTY, board[mv[0]][mv[1]], + "第 " + step + " 步 AI 落已占格: " + java.util.Arrays.toString(mv)); + board[mv[0]][mv[1]] = GomokuAI.WHITE; + lx = mv[0]; + ly = mv[1]; + } + // 真实成五后对局合法终止,胜负本身不作断言 + if (checkFive(board, lx, ly, blackTurn ? GomokuAI.BLACK : GomokuAI.WHITE)) { + return; + } + blackTurn = !blackTurn; + } + if (countEmpty(board) > 0) { + for (int x = 0; x < SIZE; x++) + for (int y = 0; y < SIZE; y++) + if (board[x][y] != GomokuAI.EMPTY) + assertFalse(checkFive(board, x, y, board[x][y]), + "提前停手却没有完整五连,盘点位置: (" + x + "," + y + ")"); + } + } + + private static int countEmpty(int[][] board) { + int n = 0; + for (int[] row : board) for (int c : row) if (c == GomokuAI.EMPTY) n++; + return n; + } + + private static boolean checkFive(int[][] board, int x, int y, int p) { + int[][] dirs = {{1, 0}, {0, 1}, {1, 1}, {1, -1}}; + for (int[] d : dirs) { + int cnt = 1; + for (int s = -1; s <= 1; s += 2) + for (int k = 1; k <= 4; k++) { + int nx = x + d[0] * k * s, ny = y + d[1] * k * s; + if (nx < 0 || nx >= SIZE || ny < 0 || ny >= SIZE || board[nx][ny] != p) break; + cnt++; + } + if (cnt >= 5) return true; + } + return false; + } +} diff --git a/src/test/java/com/wzz/game_console/client/screens/games/LanSessionSequencerTest.java b/src/test/java/com/wzz/game_console/client/screens/games/LanSessionSequencerTest.java new file mode 100644 index 0000000..57d653b --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/LanSessionSequencerTest.java @@ -0,0 +1,137 @@ +package com.wzz.game_console.client.screens.games; + +import org.junit.jupiter.api.Test; + +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; + +class LanSessionSequencerTest { + private static final UUID SENDER = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static final UUID FIRST = UUID.fromString("11111111-1111-1111-1111-111111111111"); + private static final UUID SECOND = UUID.fromString("22222222-2222-2222-2222-222222222222"); + + @Test + void retiredSessionsCannotTakeOverAfterRotation() { + LanMultiplayerScreen.SessionSequencer sequencer = new LanMultiplayerScreen.SessionSequencer(); + assertTrue(sequencer.acceptSession(SENDER, "GAME_MOVE", FIRST, 0)); + assertTrue(sequencer.acceptSession(SENDER, "GAME_MOVE", SECOND, 0)); + assertTrue(sequencer.acceptSession(SENDER, "GAME_MOVE", SECOND, 1)); + assertFalse(sequencer.acceptSession(SENDER, "GAME_MOVE", FIRST, 0)); + assertFalse(sequencer.acceptSession(SENDER, "GAME_MOVE", FIRST, 1)); + } + + @Test + void sendersAndChannelsHaveIndependentSequenceWatermarks() { + LanMultiplayerScreen.SessionSequencer sequencer = new LanMultiplayerScreen.SessionSequencer(); + UUID otherSender = UUID.randomUUID(); + assertTrue(sequencer.acceptSession(SENDER, "GAME_MOVE", FIRST, 10)); + assertTrue(sequencer.acceptSession(SENDER, "GAME_STATE_SYNC", FIRST, 2)); + assertTrue(sequencer.acceptSession(otherSender, "GAME_MOVE", FIRST, 1)); + assertFalse(sequencer.acceptSession(SENDER, "GAME_MOVE", FIRST, 9)); + assertTrue(sequencer.acceptSession(SENDER, "GAME_MOVE", FIRST, 12)); + } + + @Test + void invalidSessionSwitchDoesNotConsumeCurrentSequence() { + LanMultiplayerScreen.SessionSequencer sequencer = new LanMultiplayerScreen.SessionSequencer(); + assertTrue(sequencer.acceptSession(SENDER, "GAME_MOVE", FIRST, 5)); + assertFalse(sequencer.acceptSession(SENDER, "GAME_MOVE", SECOND, 10)); + assertFalse(sequencer.acceptSession(SENDER, "GAME_MOVE", FIRST, -1)); + assertTrue(sequencer.acceptSession(SENDER, "GAME_MOVE", FIRST, 6)); + assertTrue(sequencer.acceptSession(SENDER, "GAME_MOVE", SECOND, 0)); + assertFalse(sequencer.acceptSession(SENDER, "GAME_MOVE", FIRST, 100)); + } + + @Test + void sameSessionRequiresStrictlyIncreasingSequences() { + LanMultiplayerScreen.SessionSequencer sequencer = new LanMultiplayerScreen.SessionSequencer(); + assertTrue(sequencer.acceptSession(SENDER, "GAME_STATE_SYNC", FIRST, 0)); + assertFalse(sequencer.acceptSession(SENDER, "GAME_STATE_SYNC", FIRST, 0)); + assertTrue(sequencer.acceptSession(SENDER, "GAME_STATE_SYNC", FIRST, 2)); + assertFalse(sequencer.acceptSession(SENDER, "GAME_STATE_SYNC", FIRST, 1)); + } + + @Test + void gateRejectsForeignAndUnknownSendersWithoutConsumingSlots() { + LanMultiplayerScreen.SessionSequencer sequencer = new LanMultiplayerScreen.SessionSequencer(); + UUID outsider = UUID.randomUUID(); + + assertFalse(sequencer.acceptFromPeer(null, SENDER, "GAME_MOVE", FIRST, 0)); + assertFalse(sequencer.acceptFromPeer(outsider, SENDER, "GAME_MOVE", FIRST, 0)); + assertFalse(sequencer.acceptFromPeer(SENDER, null, "GAME_MOVE", FIRST, 0)); + // 被拒绝的外来 sender 不得占用 sequence 水位:合法对端随后可从 0 正常开始 + assertTrue(sequencer.acceptFromPeer(SENDER, SENDER, "GAME_MOVE", FIRST, 0)); + assertTrue(sequencer.acceptFromPeer(SENDER, SENDER, "GAME_MOVE", FIRST, 1)); + assertFalse(sequencer.acceptFromPeer(outsider, SENDER, "GAME_MOVE", FIRST, 0)); + } + + @Test + void concurrentAcceptsStayConsistentWithoutDeadlock() throws Exception { + LanMultiplayerScreen.SessionSequencer sequencer = new LanMultiplayerScreen.SessionSequencer(); + int threads = 8; + int attemptsPerThread = 2_000; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CountDownLatch start = new CountDownLatch(1); + AtomicLong accepted = new AtomicLong(); + for (int t = 0; t < threads; t++) { + final UUID session = t % 2 == 0 ? FIRST : SECOND; + pool.submit(() -> { + try { + start.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + for (int i = 0; i < attemptsPerThread; i++) { + // 混合并发序列/会话轮换/非法输入,sequencer 必须保持同步且不抛异常 + long sequence = switch (i % 4) { + case 0 -> i; + case 1 -> -1L; + case 2 -> i + 1L; + default -> i; + }; + UUID incoming = i % 16 == 15 ? UUID.randomUUID() : session; + if (sequencer.acceptSession(SENDER, "GAME_MOVE", incoming, sequence)) { + accepted.incrementAndGet(); + } + if (Thread.currentThread().isInterrupted()) return; + } + }); + } + start.countDown(); + pool.shutdown(); + assertTrue(pool.awaitTermination(60, TimeUnit.SECONDS), "sequencer 并发压力出现死锁"); + assertTrue(accepted.get() > 0); + assertTrue(accepted.get() < (long) threads * attemptsPerThread, + "重复/非法序列不应全部被接受"); + } + + @Test + void retiredSessionsStayBoundedPerKey() throws Exception { + LanMultiplayerScreen.SessionSequencer sequencer = new LanMultiplayerScreen.SessionSequencer(); + assertTrue(sequencer.acceptSession(SENDER, "GAME_MOVE", FIRST, 0)); + // 连续 200 次换新会话,每次轮换都会退役上一个会话 + for (int i = 0; i < 200; i++) { + assertTrue(sequencer.acceptSession(SENDER, "GAME_MOVE", UUID.randomUUID(), 0)); + } + // 最近一次轮换产生的新会话是当前会话,可继续接收 + UUID current = UUID.randomUUID(); + assertTrue(sequencer.acceptSession(SENDER, "GAME_MOVE", current, 0)); + assertTrue(sequencer.acceptSession(SENDER, "GAME_MOVE", current, 1)); + // 反射核验退役集合不超过上限 + java.lang.reflect.Field field = + LanMultiplayerScreen.SessionSequencer.class.getDeclaredField("retiredSessions"); + field.setAccessible(true); + @SuppressWarnings("unchecked") + java.util.Map> retired = (java.util.Map>) field.get(sequencer); + assertEquals(1, retired.size()); + assertTrue(retired.values().iterator().next().size() <= 64, + "retired sessions must stay bounded"); + } +} diff --git a/src/test/java/com/wzz/game_console/client/screens/games/MiniGameRegressionTest.java b/src/test/java/com/wzz/game_console/client/screens/games/MiniGameRegressionTest.java new file mode 100644 index 0000000..d421a3d --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/MiniGameRegressionTest.java @@ -0,0 +1,40 @@ +package com.wzz.game_console.client.screens.games; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class MiniGameRegressionTest { + @Test + void mouseTunnelAppliesAllScheduledDifficultyIncreasesBeforeVictory() { + MouseTunnelProgress.Snapshot progress = MouseTunnelProgress.calculate(10_000, 10_000); + + assertEquals(100, progress.score()); + assertEquals(3, progress.difficultyIncreases()); + assertTrue(progress.won()); + } + + @Test + void puzzleRestartClearsFrozenCompletionTime() { + PuzzleElapsedTimer timer = new PuzzleElapsedTimer(); + timer.restart(1_000); + timer.complete(6_900); + assertEquals(5, timer.elapsedSeconds(20_000)); + + timer.restart(30_000); + + assertEquals(2, timer.elapsedSeconds(32_100)); + } + + @Test + void moleHitboxRejectsUndergroundAndHiddenPixels() { + assertFalse(MoleHitbox.containsVisiblePart(10, 20, 48, 32, + 32, true, 30, 52)); + assertFalse(MoleHitbox.containsVisiblePart(10, 20, 48, 32, + 24, true, 30, 50)); + assertTrue(MoleHitbox.containsVisiblePart(10, 20, 48, 32, + 24, true, 30, 61)); + assertFalse(MoleHitbox.containsVisiblePart(10, 20, 48, 32, + 0, false, 30, 40)); + } +} diff --git a/src/test/java/com/wzz/game_console/client/screens/games/RealtimeLanStateTest.java b/src/test/java/com/wzz/game_console/client/screens/games/RealtimeLanStateTest.java new file mode 100644 index 0000000..e59fd06 --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/RealtimeLanStateTest.java @@ -0,0 +1,186 @@ +package com.wzz.game_console.client.screens.games; + +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class RealtimeLanStateTest { + private static final UUID FIRST = UUID.fromString("11111111-1111-1111-1111-111111111111"); + private static final UUID SECOND = UUID.fromString("22222222-2222-2222-2222-222222222222"); + private static final UUID THIRD = UUID.fromString("33333333-3333-3333-3333-333333333333"); + + @Test + void laterSnapshotRecoversWhenRestartNotificationIsLost() { + RealtimeLanState.Receiver receiver = new RealtimeLanState.Receiver(); + + assertNotNull(receiver.receive(state(FIRST, 1, colorPayload(1)), RealtimeLanState::parseColor)); + RealtimeLanState.Received recovered = + receiver.receive(state(SECOND, 3, colorPayload(2)), RealtimeLanState::parseColor); + + assertNotNull(recovered); + assertTrue(recovered.newRound()); + assertEquals(2, recovered.snapshot().score1()); + assertFalse(receiver.restart("RESTART|" + SECOND + "|2"), + "迟到的重开通知不能重置已经接受的新局快照"); + } + + @Test + void restartReservesSequenceAndSupportsConsecutiveRounds() { + RealtimeLanState.Receiver receiver = new RealtimeLanState.Receiver(); + assertNotNull(receiver.receive(state(FIRST, 1, colorPayload(1)), RealtimeLanState::parseColor)); + + assertTrue(receiver.restart("RESTART|" + SECOND + "|2")); + assertNotNull(receiver.receive(state(SECOND, 3, colorPayload(2)), RealtimeLanState::parseColor)); + + assertTrue(receiver.restart("RESTART|" + THIRD + "|4")); + assertNotNull(receiver.receive(state(THIRD, 5, colorPayload(3)), RealtimeLanState::parseColor)); + assertNull(receiver.receive(state(SECOND, 6, colorPayload(4)), RealtimeLanState::parseColor)); + } + + @Test + void malformedSnapshotDoesNotConsumeSessionOrSequence() { + RealtimeLanState.Receiver receiver = new RealtimeLanState.Receiver(); + assertNull(receiver.receive(state(FIRST, 1, "not-a-color-state"), RealtimeLanState::parseColor)); + assertNotNull(receiver.receive(state(FIRST, 1, colorPayload(1)), RealtimeLanState::parseColor)); + + assertNull(receiver.receive(state(FIRST, 2, "0,0,0"), RealtimeLanState::parseColor)); + assertNotNull(receiver.receive(state(FIRST, 2, colorPayload(2)), RealtimeLanState::parseColor)); + } + + @Test + void legacyStateIsAcceptedOnlyBeforeVersionedSession() { + RealtimeLanState.Receiver receiver = new RealtimeLanState.Receiver(); + assertNotNull(receiver.receive(colorPayload(1), RealtimeLanState::parseColor)); + assertNotNull(receiver.receive(state(FIRST, 1, colorPayload(2)), RealtimeLanState::parseColor)); + assertNull(receiver.receive(colorPayload(3), RealtimeLanState::parseColor)); + } + + @Test + void inputIsBoundToCurrentRound() { + RealtimeLanState.Receiver receiver = new RealtimeLanState.Receiver(); + assertNotNull(receiver.receive(state(FIRST, 1, colorPayload(1)), RealtimeLanState::parseColor)); + + assertEquals("INPUT|" + FIRST + "|0", receiver.input("0")); + assertEquals("0", RealtimeLanState.decodeInput(FIRST, receiver.input("0"))); + assertNull(RealtimeLanState.decodeInput(SECOND, receiver.input("0"))); + assertNull(RealtimeLanState.decodeInput(FIRST, "0")); + } + + @Test + void colorParserKeepsGridIndexesAlignedAndRejectsInvalidFields() { + String[] cells = new String[256]; + java.util.Arrays.fill(cells, "0"); + cells[17] = "invalid"; + cells[18] = "6"; + RealtimeLanState.Color parsed = RealtimeLanState.parseColor( + "1,2,0,3,4,1,5,6,7,8,0;" + String.join(",", cells)); + assertNotNull(parsed); + assertEquals(0, parsed.grid()[1][1]); + assertNull(RealtimeLanState.parseColor( + "1,2,0,3,4,1,5,-1,7,8,0;" + String.join(",", cells))); + assertNull(RealtimeLanState.parseColor( + "1,2,2,3,4,1,5,6,7,8,0;" + String.join(",", cells))); + assertNull(RealtimeLanState.parseColor("1,2,0,3,4,1,5,6,7,8,0;0")); + } + + @Test + void iceParserValidatesRoundStateAndRemovedDiamonds() { + RealtimeLanState.Ice parsed = RealtimeLanState.parseIce( + "1,20,30,1,0,40,50,1,0,1,3,0,0,2;1_2|19_14", 1); + assertNotNull(parsed); + assertEquals(2, parsed.difficulty()); + assertEquals(2, parsed.removedDiamonds().size()); + assertNull(RealtimeLanState.parseIce("1,20,30,1,0,40,50,1,0,1,3,0,1,2", 1)); + assertNull(RealtimeLanState.parseIce("1,-1,30,1,0,40,50,1,0,1,3,0,0,2", 1)); + assertNull(RealtimeLanState.parseIce("1,20,30,1,0,40,50,1,0,1,3,0,0,2;20_14", 1)); + } + + @Test + void consecutiveRestartsBeforeAnySnapshotKeepOnlyNewestRound() { + RealtimeLanState.Receiver receiver = new RealtimeLanState.Receiver(); + assertNull(receiver.input("0")); + assertTrue(receiver.restart("RESTART|" + FIRST + "|1")); + assertTrue(receiver.restart("RESTART|" + SECOND + "|2")); + assertTrue(receiver.restart("RESTART|" + THIRD + "|3")); + assertFalse(receiver.restart("RESTART|" + FIRST + "|4")); + assertFalse(receiver.restart("RESTART|" + SECOND + "|5")); + assertFalse(receiver.restart("RESTART|" + THIRD + "|6")); + assertNull(receiver.receive(state(SECOND, 4, colorPayload(1)), RealtimeLanState::parseColor)); + assertNotNull(receiver.receive(state(THIRD, 4, colorPayload(2)), RealtimeLanState::parseColor)); + assertEquals("0", RealtimeLanState.decodeInput(THIRD, receiver.input("0"))); + } + + @Test + void invalidHigherSnapshotDoesNotRetireCurrentRoundOrConsumeSequence() { + RealtimeLanState.Receiver receiver = new RealtimeLanState.Receiver(); + assertNotNull(receiver.receive(state(FIRST, 10, colorPayload(1)), RealtimeLanState::parseColor)); + assertNull(receiver.receive(state(SECOND, 100, "bad"), RealtimeLanState::parseColor)); + assertNotNull(receiver.receive(state(FIRST, 11, colorPayload(2)), RealtimeLanState::parseColor)); + assertNotNull(receiver.receive(state(SECOND, 12, colorPayload(3)), RealtimeLanState::parseColor)); + assertNull(receiver.receive(state(THIRD, 11, colorPayload(1)), RealtimeLanState::parseColor)); + assertNull(receiver.receive(state(SECOND, 12, colorPayload(3)), RealtimeLanState::parseColor)); + assertNull(receiver.receive(state(FIRST, 1000, colorPayload(4)), RealtimeLanState::parseColor)); + } + + @Test + void malformedEnvelopeAndRestartDoNotBindReceiver() { + RealtimeLanState.Receiver receiver = new RealtimeLanState.Receiver(); + for (String invalid : new String[]{"RESTART|bad|1", "RESTART|" + FIRST + "|-1", + "RESTART|" + FIRST + "|invalid", "RESTART|" + FIRST, "RESTART|" + FIRST + "|1|extra"}) { + assertFalse(receiver.restart(invalid)); + } + for (String invalid : new String[]{"v2|bad|1|", "v2|" + FIRST + "|0|", + "v2|" + FIRST + "|9223372036854775808|"}) { + assertNull(receiver.receive(invalid + colorPayload(1), RealtimeLanState::parseColor)); + } + assertNull(receiver.input("0")); + assertNotNull(receiver.receive(state(SECOND, 1, colorPayload(1)), RealtimeLanState::parseColor)); + assertFalse(receiver.restart("RESTART")); + assertNull(RealtimeLanState.decodeInput(SECOND, "INPUT|bad|0")); + assertNull(RealtimeLanState.decodeInput(SECOND, "INPUT|" + SECOND + "|")); + } + + @Test + void iceSnapshotsAllowAboveMapJumpAndFinalFallingStep() { + RealtimeLanState.Ice jumping = RealtimeLanState.parseIce( + "1,20,-500,0,0,40,50,1,0,1,3,0,0,0;1_2", 1); + assertNotNull(jumping); + assertEquals(-50f, jumping.iceY()); + assertEquals(0, jumping.difficulty()); + RealtimeLanState.Ice falling = RealtimeLanState.parseIce( + "1,20,2700,0,1,40,50,1,0,1,3,1,0,2;1_2", 1); + assertNotNull(falling); + assertTrue(falling.gameOver()); + assertTrue(falling.iceDead()); + assertEquals(270f, falling.iceY()); + assertNull(RealtimeLanState.parseIce("1,20,2147483647,0,1,40,50,1,0,1,3,1,0,2", 1)); + } + + @Test + void iceMalformedFieldsNeverCommitReceiverState() { + RealtimeLanState.Receiver receiver = new RealtimeLanState.Receiver(); + String[] fields = "1,20,30,1,0,40,50,1,0,1,3,0,0,2".split(","); + for (int index : new int[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}) { + String[] invalid = fields.clone(); + invalid[index] = "invalid"; + assertNull(receiver.receive(state(FIRST, 100, String.join(",", invalid)), + payload -> RealtimeLanState.parseIce(payload, 1))); + } + assertNotNull(receiver.receive(state(SECOND, 1, String.join(",", fields)), + payload -> RealtimeLanState.parseIce(payload, 1))); + assertNull(RealtimeLanState.parseIce(null, 1)); + assertNull(RealtimeLanState.parseColor(null)); + } + + private static String state(UUID session, long sequence, String payload) { + return "v2|" + session + "|" + sequence + "|" + payload; + } + + private static String colorPayload(int score) { + String[] cells = new String[256]; + java.util.Arrays.fill(cells, "0"); + return "1,2,0,3,4,0,5," + score + ",7,1,0;" + String.join(",", cells); + } +} diff --git a/src/test/java/com/wzz/game_console/client/screens/games/chess/ChessRulesTest.java b/src/test/java/com/wzz/game_console/client/screens/games/chess/ChessRulesTest.java new file mode 100644 index 0000000..ca158f4 --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/chess/ChessRulesTest.java @@ -0,0 +1,142 @@ +package com.wzz.game_console.client.screens.games.chess; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 中国象棋规则库最小回归测试(纯 JDK,无 Minecraft 依赖,可在 JUnit 中直接运行)。 + * 重点覆盖: + * - 初始局面 FEN 序列化(外挂 Pikafish 通信的根基) + * - UCI 坐标 ↔ 棋盘坐标转换(含行号翻转,rank0=底部红方) + * - 开局合法走法数量与"将帅不能照面/走后自将"过滤 + */ +class ChessRulesTest { + + /** 构造与 ChessGameScreen.resetBoard() 一致的初始棋盘 */ + private static int[][] initialBoard() { + int[][] b = new int[ChessRules.COLS][ChessRules.ROWS]; + int[] back = {ChessRules.CHARIOT, ChessRules.HORSE, ChessRules.ELEPHANT, + ChessRules.ADVISOR, ChessRules.GENERAL, ChessRules.ADVISOR, + ChessRules.ELEPHANT, ChessRules.HORSE, ChessRules.CHARIOT}; + for (int c = 0; c < 9; c++) b[c][0] = -back[c]; + b[1][2] = -ChessRules.CANNON; b[7][2] = -ChessRules.CANNON; + for (int c = 0; c < 9; c += 2) b[c][3] = -ChessRules.SOLDIER; + for (int c = 0; c < 9; c++) b[c][9] = back[c]; + b[1][7] = ChessRules.CANNON; b[7][7] = ChessRules.CANNON; + for (int c = 0; c < 9; c += 2) b[c][6] = ChessRules.SOLDIER; + return b; + } + + @Test + void testInitialFen() { + int[][] b = initialBoard(); + assertEquals( + "rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w - - 0 1", + ChessRules.toFen(b, true), + "初始局面红先(w)的 FEN 必须与标准中国象棋 XFEN 一致(否则 Pikafish 无法开局)" + ); + } + + @Test + void testUciCoordinateRoundTrip() { + // UCI rank0 = 底部红方底线 → 棋盘 row9。 + // "a0a1" = 红车(0,9)→(0,8);"i9i8" = 黑车(8,0)→(8,1)(从上往下);"e4e5" = 中央(4,5)→(4,4) + assertArrayEquals(new int[]{0, 9, 0, 8}, ChessRules.parseUciMove("a0a1")); + assertArrayEquals(new int[]{8, 0, 8, 1}, ChessRules.parseUciMove("i9i8")); + assertArrayEquals(new int[]{4, 5, 4, 4}, ChessRules.parseUciMove("e4e5")); + + assertEquals("a0a1", ChessRules.toUciMove(0, 9, 0, 8)); + assertEquals("i9i8", ChessRules.toUciMove(8, 0, 8, 1)); + + assertNull(ChessRules.parseUciMove("j0a1"), "j 超出 a-i 文件范围应返回 null"); + assertNull(ChessRules.parseUciMove("a"), "过短应返回 null"); + } + + @Test + void testOpeningLegalMoveCount() { + // 象棋开局理论:红方第一步合法走法 = 44(全部能动的棋子) + // 帅(4区)… 直接断言一个合理区间 ±2,避免实现细节改变导致脆测 + int[][] b = initialBoard(); + List moves = ChessRules.legalMoves(b, true); + assertTrue(moves.size() >= 40 && moves.size() <= 48, + "红方初始合法走法应在 40~48 之间,实际=" + moves.size()); + } + + @Test + void testSelfCheckFiltered() { + // 构造一个红方老将被黑车纵向将军的局面:(4,0) 黑车 vs (4,9) 红帅 + int[][] b = new int[ChessRules.COLS][ChessRules.ROWS]; + b[4][0] = -ChessRules.CHARIOT; // 黑车 (4,0) + b[4][9] = ChessRules.GENERAL; // 红帅 (4,9) + + List moves = ChessRules.legalMoves(b, true); + assertFalse(moves.isEmpty(), "被将军时应有解将走法"); + for (int[] mv : moves) { + // 走后不能再被将军 + int captured = b[mv[2]][mv[3]]; + int piece = b[mv[0]][mv[1]]; + b[mv[2]][mv[3]] = piece; + b[mv[0]][mv[1]] = 0; + assertFalse(ChessRules.inCheckOnBoard(b, true), + "合法走法 (" + mv[0] + "," + mv[1] + ")->(" + mv[2] + "," + mv[3] + ") 不应留下自将"); + b[mv[0]][mv[1]] = piece; + b[mv[2]][mv[3]] = captured; + } + // 帅只能在九宫内横向移动((3,9) 或 (5,9))避开黑车视线,数量有限 + assertTrue(moves.size() <= 8, "解将走法数量应在合理范围内(实际=" + moves.size() + ")"); + } + + @Test + void checkDetectionSeesGeneralButMovesCannotCaptureIt() { + int[][] b = new int[ChessRules.COLS][ChessRules.ROWS]; + b[4][9] = ChessRules.GENERAL; + b[3][0] = -ChessRules.GENERAL; + b[4][0] = -ChessRules.CHARIOT; + + assertTrue(ChessRules.inCheckOnBoard(b, true), + "黑车直线攻击红帅时必须判定为将军"); + assertFalse(ChessRules.pseudoMoves(b, 4, 0).stream() + .anyMatch(move -> move[0] == 4 && move[1] == 9), + "实际走法生成不得包含直接捕获红帅"); + } + + @Test + void cannonCheckRequiresExactlyOneScreenAndPreservesBoard() { + for (boolean red : new boolean[]{true, false}) { + for (boolean horizontal : new boolean[]{true, false}) { + for (int screens = 0; screens <= 2; screens++) { + int[][] b = new int[ChessRules.COLS][ChessRules.ROWS]; + int row = red ? 9 : 0; + int cannonCol = horizontal ? 0 : 4; + int cannonRow = horizontal ? row : 9 - row; + b[4][row] = red ? ChessRules.GENERAL : -ChessRules.GENERAL; + b[3][9 - row] = red ? -ChessRules.GENERAL : ChessRules.GENERAL; + b[cannonCol][cannonRow] = red ? -ChessRules.CANNON : ChessRules.CANNON; + if (screens >= 1) b[horizontal ? 1 : 4][horizontal ? row : 4] = ChessRules.SOLDIER; + if (screens == 2) b[horizontal ? 2 : 4][horizontal ? row : 5] = -ChessRules.SOLDIER; + String before = ChessRules.toFen(b, red); + + assertEquals(screens == 1, ChessRules.inCheckOnBoard(b, red), + "red=" + red + ", horizontal=" + horizontal + ", screens=" + screens); + assertEquals(before, ChessRules.toFen(b, red)); + assertFalse(ChessRules.pseudoMoves(b, cannonCol, cannonRow).stream() + .anyMatch(move -> move[0] == 4 && move[1] == row)); + } + } + } + } + + @Test + void testZobristDiffersBySide() { + // 未覆盖内部 Zobrist(BuiltInChessAI 私有);此处仅确保 API 稳定可调用 + int[][] b = initialBoard(); + String fenRed = ChessRules.toFen(b, true); + String fenBlack = ChessRules.toFen(b, false); + assertNotEquals(fenRed, fenBlack, "行棋方不同,FEN 后缀 w/b 应不同"); + assertTrue(fenRed.endsWith(" w - - 0 1")); + assertTrue(fenBlack.endsWith(" b - - 0 1")); + } +} \ No newline at end of file diff --git a/src/test/java/com/wzz/game_console/client/screens/games/chess/ChessSelfPlayTest.java b/src/test/java/com/wzz/game_console/client/screens/games/chess/ChessSelfPlayTest.java new file mode 100644 index 0000000..1c31e66 --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/chess/ChessSelfPlayTest.java @@ -0,0 +1,124 @@ +package com.wzz.game_console.client.screens.games.chess; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 中国象棋内置引擎无头自对弈(纯 JDK,无 Minecraft 依赖)。 + * 覆盖: + * - 引擎每步给出的着法必须在该局面 legalMoves 集合内(不被自己将军、棋子移动合法) + * - 对弈过程中"将帅"总数守恒:引擎不得走出吃将着法(吃将在规则库中是非法着法的最后防线) + * - 快棋多局不崩溃、可终止 + */ +@Timeout(300) +class ChessSelfPlayTest { + + /** 构造与 ChessGameScreen.resetBoard() 一致的初始棋盘 */ + private static int[][] initialBoard() { + int[][] b = new int[ChessRules.COLS][ChessRules.ROWS]; + int[] back = {ChessRules.CHARIOT, ChessRules.HORSE, ChessRules.ELEPHANT, + ChessRules.ADVISOR, ChessRules.GENERAL, ChessRules.ADVISOR, + ChessRules.ELEPHANT, ChessRules.HORSE, ChessRules.CHARIOT}; + for (int c = 0; c < 9; c++) b[c][0] = -back[c]; + b[1][2] = -ChessRules.CANNON; b[7][2] = -ChessRules.CANNON; + for (int c = 0; c < 9; c += 2) b[c][3] = -ChessRules.SOLDIER; + for (int c = 0; c < 9; c++) b[c][9] = back[c]; + b[1][7] = ChessRules.CANNON; b[7][7] = ChessRules.CANNON; + for (int c = 0; c < 9; c += 2) b[c][6] = ChessRules.SOLDIER; + return b; + } + + /** + * ChessRules.legalMoves 返回的是五元组 {fromX,fromY,toX,toY,internalScore}, + * 成员判定只能比前四个坐标,不能用 Arrays.equals(长度不同恒为 false)。 + */ + private static boolean sameMove(int[] a, int[] b) { + return a.length >= 4 && b.length >= 4 + && a[0] == b[0] && a[1] == b[1] && a[2] == b[2] && a[3] == b[3]; + } + + private static int countGenerals(int[][] board) { + int n = 0; + for (int[] row : board) for (int p : row) if (Math.abs(p) == ChessRules.GENERAL) n++; + return n; + } + + @Test + void everyEngineMoveIsWithinLegalMoves() { + BuiltInChessAI engine = new BuiltInChessAI(); + engine.setSearchTime(40); + int[][] board = initialBoard(); + boolean redTurn = true; + + for (int step = 0; step < 120; step++) { + List legal = ChessRules.legalMoves(board, redTurn); + if (legal.isEmpty()) break; // 无子可动 = 终局 + assertEquals(2, countGenerals(board), "第 " + step + " 步前将帅数目异常"); + + int[] mv = engine.getBestMove(board, redTurn); + assertNotNull(mv, "第 " + step + " 步有合法着但引擎返回 null"); + assertTrue(legal.stream().anyMatch(l -> sameMove(l, mv)), + "第 " + step + " 步引擎着法不在合法集合内: " + + Arrays.toString(mv) + " redTurn=" + redTurn); + + board[mv[2]][mv[3]] = board[mv[0]][mv[1]]; + board[mv[0]][mv[1]] = 0; + redTurn = !redTurn; + } + } + + @Test + void fullSelfPlayTwoGamesTerminateCleanly() { + for (int game = 0; game < 2; game++) { + BuiltInChessAI engine = new BuiltInChessAI(game == 1); // 第二局开启 LMR 变体路径 + engine.setSearchTime(60); + int[][] board = initialBoard(); + boolean redTurn = true; + int appliedSteps = 0; + + for (int step = 0; step < 160 && !ChessRules.legalMoves(board, redTurn).isEmpty(); step++) { + List legalNow = ChessRules.legalMoves(board, redTurn); + if (legalNow.isEmpty()) break; + int[] mv = engine.getBestMove(board, redTurn); + assertNotNull(mv, "第 " + game + " 局第 " + step + " 步有合法着但引擎返回 null"); + assertTrue(legalNow.stream().anyMatch(l -> sameMove(l, mv)), + "第 " + game + " 局第 " + step + " 步引擎着法不在合法集合: " + + Arrays.toString(mv) + " redTurn=" + redTurn); + board[mv[2]][mv[3]] = board[mv[0]][mv[1]]; + board[mv[0]][mv[1]] = 0; + redTurn = !redTurn; + appliedSteps++; + } + assertTrue(appliedSteps >= 8, "第 " + game + " 局步数过少(" + appliedSteps + "),疑似引擎开局即卡死"); + } + } + + @Test + void fenRoundTripAcrossAppliedMovesStaysConsistent() { + BuiltInChessAI engine = new BuiltInChessAI(); + engine.setSearchTime(30); + int[][] board = initialBoard(); + boolean redTurn = true; + + for (int step = 0; step < 30; step++) { + if (ChessRules.legalMoves(board, redTurn).isEmpty()) break; + String fenBefore = ChessRules.toFen(board, redTurn); + assertFalse(fenBefore.isEmpty(), "FEN 序列化不得为空"); + + int[] mv = engine.getBestMove(board, redTurn); + assertNotNull(mv); + board[mv[2]][mv[3]] = board[mv[0]][mv[1]]; + board[mv[0]][mv[1]] = 0; + redTurn = !redTurn; + + String fenAfter = ChessRules.toFen(board, redTurn); + assertFalse(fenAfter.isEmpty()); + assertFalse(fenAfter.equals(fenBefore), "走子后 FEN 必须变化"); + } + } +} diff --git a/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoGameTest.java b/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoGameTest.java index 7be7d23..24498e7 100644 --- a/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoGameTest.java +++ b/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoGameTest.java @@ -1,6 +1,11 @@ package com.wzz.game_console.client.screens.games.gogame; import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + import static org.junit.jupiter.api.Assertions.*; /** @@ -58,6 +63,114 @@ void testSuperKoBlocksImmediateRecapture() { assertFalse(game.placeStone(4, 4), "全局同型:白(4,4) 立即回提必须被拒绝"); } + @Test + void testTypedAiActionsPreservePassResignAndErrorSemantics() { + GoGame game = GoGame.rulesOnly(); + game.setAiMode(true); + game.applyAiMoveResult(GoAI.MoveResult.error()); + assertFalse(game.isGameOver(), "AI error must not become a pass"); + assertEquals(0, game.moveHistorySize(), "AI error must not change history"); + + game.applyAiMoveResult(GoAI.MoveResult.pass()); + assertFalse(game.isGameOver(), "one AI pass must not end the game"); + assertEquals(1, game.moveHistorySize()); + assertEquals(GoPlayer.WHITE, game.getCurrentPlayer()); + + game.applyAiMoveResult(GoAI.MoveResult.resign()); + assertTrue(game.isGameOver(), "AI resignation must end the game"); + assertEquals(GoPlayer.WHITE, game.getResignedPlayer()); + } + + @Test + void testAiPipelinePreservesResignationWithoutAddingAPass() { + GoGame game = GoGame.rulesOnly(); + game.setAiMode(true); + game.placeStone(3, 3); + game.setAiEngine(new GoAI() { + @Override public int[] getBestMove(GoGame ignored) { fail("typed pipeline must not use legacy API"); return null; } + @Override public MoveResult getBestMoveResult(GoGame ignored) { return MoveResult.resign(); } + }); + game.makeAiMove(); + assertTrue(game.isGameOver()); + assertEquals(GoPlayer.WHITE, game.getResignedPlayer()); + assertEquals(1, game.moveHistorySize()); + game.reset(); + assertEquals(GoPlayer.NONE, game.getResignedPlayer()); + } + + @Test + void testLegacyAiNullIsPassButThrownExceptionIsError() { + GoGame game = GoGame.rulesOnly(); + game.setAiMode(true); + game.setAiEngine(ignored -> { throw new IllegalStateException("failed search"); }); + game.makeAiMove(); + assertEquals(0, game.moveHistorySize()); + game.setAiEngine(ignored -> null); + game.makeAiMove(); + assertEquals(1, game.moveHistorySize()); + assertFalse(game.isGameOver()); + } + + @Test + void staleAiMoveIsRejectedAfterPositionChanges() throws Exception { + assertStaleAiResultRejected(GoAI.MoveResult.move(4, 4), game -> game.placeStone(3, 3)); + } + + @Test + void staleAiPassIsRejectedAfterReset() throws Exception { + assertStaleAiResultRejected(GoAI.MoveResult.pass(), GoGame::reset); + } + + @Test + void staleAiResignationIsRejectedAfterEngineReplacement() throws Exception { + assertStaleAiResultRejected(GoAI.MoveResult.resign(), + game -> game.setAiEngine(ignored -> new int[]{5, 5})); + } + + @Test + void staleAiResultIsRejectedAfterClose() throws Exception { + assertStaleAiResultRejected(GoAI.MoveResult.pass(), GoGame::close); + } + + @Test + void testRulesOnlyGameDoesNotEnableAiLifecycle() { + assertFalse(GoGame.rulesOnly().isAiEnabled()); + assertTrue(new GoGame().isAiEnabled()); + } + + @Test + void testResignationRecordsCurrentPlayerAndIsIdempotent() { + GoGame game = GoGame.rulesOnly(); + assertEquals(GoPlayer.BLACK, game.getCurrentPlayer()); + game.resign(); + assertTrue(game.isGameOver()); + assertEquals(GoPlayer.BLACK, game.getResignedPlayer()); + game.resign(GoPlayer.WHITE); + assertEquals(GoPlayer.BLACK, game.getResignedPlayer()); + } + + @Test + void testMalformedLegacyAiMoveIsAnError() { + GoGame game = GoGame.rulesOnly(); + game.applyAiMove(new int[] {1}); + game.applyAiMove(new int[] {}); + game.applyAiMove(new int[] {-1, -1}); + game.applyAiMove(new int[] {1, -1}); + game.applyAiMove(new int[] {19, 0}); + assertFalse(game.isGameOver()); + assertEquals(0, game.moveHistorySize()); + assertEquals(GoPlayer.BLACK, game.getCurrentPlayer()); + } + + @Test + void testLegacyNullStillCompilesAndMeansPass() { + GoGame game = GoGame.rulesOnly(); + game.applyAiMove(null); + assertEquals(1, game.moveHistorySize()); + assertEquals(GoPlayer.WHITE, game.getCurrentPlayer()); + assertFalse(game.isGameOver()); + } + @Test void testOccupiedPositionRejected() { GoGame game = new GoGame(); @@ -75,11 +188,99 @@ void testOutOfBoundsRejected() { } @Test - void testResignation() { - GoGame game = new GoGame(); - assertFalse(game.isGameOver(), "开局未结束"); - game.resign(); - assertTrue(game.isGameOver(), "认输后游戏应结束"); + void testUnmarkedDeadStoneRemainsInScore() throws Exception { + GoGame game = new GoGame(false); + java.lang.reflect.Field boardField = GoGame.class.getDeclaredField("board"); + boardField.setAccessible(true); + GoPlayer[][] board = (GoPlayer[][]) boardField.get(game); + // A white stone with its only remaining liberty surrounded by black. + board[9][9] = GoPlayer.WHITE; + board[8][9] = GoPlayer.BLACK; + board[10][9] = GoPlayer.BLACK; + board[9][8] = GoPlayer.BLACK; + board[9][10] = GoPlayer.BLACK; + int[] score = game.calcTerritory(); + assertEquals(1, score[1], "未标记棋子仍应计入白方数子分"); + int[] marked = game.calcTerritory(java.util.Set.of(GoGameTest.key(9, 9))); + assertEquals(0, marked[1], "显式标记后白死子不计分"); + assertTrue(marked[0] >= 4, "黑方棋子仍应计入黑方数子分"); + } + + @Test + void testMarkedGroupRemovesWholeConnectedGroupAndCanBeCancelled() throws Exception { + GoGame game = new GoGame(false); + java.lang.reflect.Field boardField = GoGame.class.getDeclaredField("board"); + boardField.setAccessible(true); + GoPlayer[][] board = (GoPlayer[][]) boardField.get(game); + board[9][9] = GoPlayer.WHITE; + board[9][10] = GoPlayer.WHITE; + board[8][9] = GoPlayer.BLACK; + board[10][9] = GoPlayer.BLACK; + board[9][8] = GoPlayer.BLACK; + board[9][11] = GoPlayer.BLACK; + board[8][10] = GoPlayer.BLACK; + board[10][10] = GoPlayer.BLACK; + int[] marked = game.calcTerritory(java.util.Set.of(key(9, 10))); + assertEquals(0, marked[1], "标记组内一点应移除整组"); + assertEquals(2, game.calcTerritory().length, "计分结果应保持双方数组格式"); + assertEquals(GoPlayer.WHITE, game.getStone(9, 9), "计分副本不能修改实际棋盘"); + } + + @Test + void testInvalidDeadMarkRejected() { + GoGame game = new GoGame(false); + assertThrows(IllegalArgumentException.class, () -> game.calcTerritory(java.util.Set.of(key(0, 0)))); + assertThrows(IllegalArgumentException.class, () -> game.calcTerritory(java.util.Set.of(key(19, 0)))); + } + + @Test + void testExplicitKomiIsUsedForScoreAndMargin() { + GoGame game = new GoGame(false); + assertEquals(0.0, game.getScore(GoPlayer.BLACK, java.util.Collections.emptySet(), 7.5)); + assertEquals(7.5, game.getScore(GoPlayer.WHITE, java.util.Collections.emptySet(), 7.5)); + assertEquals(-7.5, game.getScoreMargin(GoPlayer.BLACK, java.util.Collections.emptySet(), 7.5)); + assertEquals(100.0, game.getScore(GoPlayer.WHITE, java.util.Collections.emptySet(), 250.0)); + assertEquals(-100.0, game.getScore(GoPlayer.WHITE, java.util.Collections.emptySet(), -250.0)); + } + + @Test + void testEqualScoresHaveNoWinner() { + try (GoGame game = GoGame.rulesOnly()) { + assertTrue(game.placeStone(0, 0)); + assertTrue(game.placeStone(18, 18)); + game.pass(); + game.pass(); + GoGame.Score score = game.getScores(java.util.Collections.emptySet(), 0.0); + assertEquals(1.0, score.black()); + assertEquals(score.black(), score.white()); + assertEquals(GoPlayer.NONE, score.winner()); + assertEquals(0.0, game.getScoreMargin(GoPlayer.BLACK, java.util.Collections.emptySet(), 0.0)); + } + } + + @Test + void testScoreWinnerUsesExplicitPositiveAndNegativeKomi() { + try (GoGame game = GoGame.rulesOnly()) { + game.pass(); + game.pass(); + assertEquals(GoPlayer.WHITE, game.getScores(java.util.Collections.emptySet(), 7.5).winner()); + assertEquals(GoPlayer.BLACK, game.getScores(java.util.Collections.emptySet(), -2.5).winner()); + assertEquals(GoPlayer.NONE, game.getScores(java.util.Collections.emptySet(), -0.0).winner()); + } + } + + @Test + void testScoringLeavesLivePositionUntouched() throws Exception { + GoGame game = new GoGame(false); + java.lang.reflect.Field boardField = GoGame.class.getDeclaredField("board"); + boardField.setAccessible(true); + GoPlayer[][] board = (GoPlayer[][]) boardField.get(game); + board[9][9] = GoPlayer.WHITE; + board[8][9] = GoPlayer.BLACK; + board[10][9] = GoPlayer.BLACK; + board[9][8] = GoPlayer.BLACK; + game.calcTerritory(); + assertEquals(GoPlayer.WHITE, game.getStone(9, 9), "计分不能修改实际棋盘"); } /** @@ -91,6 +292,47 @@ void testResignation() { * 使黑(4,5) 提白后自身无气、可被白下一手回提。 * 白(4,4) 只剩 (4,5) 一口气(被打吃状态)。 */ + private static void assertStaleAiResultRejected(GoAI.MoveResult result, + java.util.function.Consumer invalidate) + throws Exception { + try (GoGame game = GoGame.rulesOnly()) { + game.setAiMode(true); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + game.setAiEngine(new GoAI() { + @Override public int[] getBestMove(GoGame ignored) { return null; } + @Override public MoveResult getBestMoveResult(GoGame ignored) { + entered.countDown(); + try { + if (!release.await(5, TimeUnit.SECONDS)) return MoveResult.error(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return MoveResult.error(); + } + return result; + } + }); + + AtomicReference computed = new AtomicReference<>(); + Thread worker = new Thread(() -> computed.set(game.computeAiMoveComputation())); + worker.start(); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + invalidate.accept(game); + release.countDown(); + worker.join(5_000L); + assertFalse(worker.isAlive()); + + int historyBeforeApply = game.moveHistorySize(); + assertFalse(game.applyAiMoveComputation(computed.get())); + assertEquals(historyBeforeApply, game.moveHistorySize()); + assertEquals(GoPlayer.NONE, game.getResignedPlayer()); + } + } + + private static long key(int x, int y) { + return ((long) x << 32) | (y & 0xffffffffL); + } + private static void placeKoSetup(GoGame game) { assertTrue(game.placeStone(3, 4), "黑(3,4)"); assertTrue(game.placeStone(4, 4), "白(4,4) ← 劫形中心(被打吃)"); diff --git a/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoKoSimulationTest.java b/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoKoSimulationTest.java new file mode 100644 index 0000000..71a9e4d --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoKoSimulationTest.java @@ -0,0 +1,71 @@ +package com.wzz.game_console.client.screens.games.gogame; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 围棋劫争(全局同型禁止 / positional super-ko)与提子时序无头模拟。 + *

+ * 劫形:黑孤子 X=(4,4) 的三面是白 (3,4),(5,4),(4,3),唯一气是 Y=(4,5); + * 白孤子 Y=(4,5) 的三面是黑 (3,5),(5,5),(4,6),唯一气是 X 落点。 + * 两颗单气子互咬,X/Y 两点成为劫争焦点。super-ko(positionHistory 全历史 + * hash 比对)语义验证: + * - "先提对方、再判自杀":白落 Y 提黑 X 是合法着,不是自杀 + * - 无劫材的立即回提 → 局面重现历史 → 拒绝,且拒绝不翻转轮次 + * - 完整劫争循环:每方提劫前都须先下出新棋(劫材), + * 否则提劫后的局面会与己方上一手前的局面同型而被拒 + */ +@Timeout(60) +class GoKoSimulationTest { + + @Test + void superKoRejectsImmediateRecaptureButAllowsKoThreatFirst() throws Exception { + try (GoGame g = GoGame.rulesOnly()) { + // 布子:X 的三面白、Y 的三面黑,黑白交替落下(黑先) + int[][] build = { + {3, 5}, {3, 4}, // B(Y邻) W(X邻) + {5, 5}, {5, 4}, // B W + {4, 6}, {4, 3}, // B W + }; + for (int[] s : build) { + assertTrue(g.placeStone(s[0], s[1]), "布子手 (" + s[0] + "," + s[1] + ") 应成功"); + } + + // 黑落 X:唯一气=Y,存活 + assertTrue(g.placeStone(4, 4), "黑落 X 应成功"); + assertEquals(GoPlayer.BLACK, g.getStone(4, 4)); + + // 白落 Y:先提无气的黑 X,白子自身借提子获得 1 气 → 合法 + assertTrue(g.placeStone(4, 5), "白落 Y 应先提对方获得气(不得误判自杀)"); + assertEquals(GoPlayer.NONE, g.getStone(4, 4), "黑 X 应被提走"); + assertEquals(GoPlayer.WHITE, g.getStone(4, 5), "白 Y 应留在盘上"); + + // 黑无劫材立即回提:局面与 X 被提前完全相同 → super-ko 拒绝 + assertFalse(g.placeStone(4, 4), "无劫材的立即回提必须被 super-ko 拒绝"); + assertEquals(GoPlayer.NONE, g.getStone(4, 4), "被拒的落子不得改变棋盘"); + assertEquals(GoPlayer.WHITE, g.getStone(4, 5), "白 Y 应原样保留"); + assertEquals(GoPlayer.BLACK, g.getCurrentPlayer(), "拒绝后轮次不得翻转(仍黑)"); + + // 黑找劫材、白应劫 → 黑提劫(提白 Y)合法:劫材改变全局盘面 + assertTrue(g.placeStone(16, 16), "黑劫材应成功"); + assertTrue(g.placeStone(0, 0), "白应劫应成功"); + assertTrue(g.placeStone(4, 4), "劫材之后黑提劫应合法"); + assertEquals(GoPlayer.NONE, g.getStone(4, 5), "白 Y 应被提走"); + assertEquals(GoPlayer.BLACK, g.getStone(4, 4)); + + // 白回提前也必须先找新劫材——直接回提会重现"白应劫后"的局面 + assertTrue(g.placeStone(1, 1), "白新劫材应成功"); + assertTrue(g.placeStone(18, 18), "黑应劫应成功"); + assertTrue(g.placeStone(4, 5), "白回提应合法"); + assertEquals(GoPlayer.NONE, g.getStone(4, 4), "黑 X 应被提走"); + assertEquals(GoPlayer.WHITE, g.getStone(4, 5)); + + // 黑再次无劫材立即回提 → 重现黑提劫后局面 → 再次拒绝 + assertFalse(g.placeStone(4, 4), "再次无劫材的立即回提仍须被 super-ko 拒绝"); + assertEquals(GoPlayer.BLACK, g.getCurrentPlayer(), "拒绝后轮次不得翻转(仍黑)"); + assertFalse(g.isGameOver(), "劫争往来不应终局"); + } + } +} diff --git a/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoRulesSimulationTest.java b/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoRulesSimulationTest.java new file mode 100644 index 0000000..4ea4191 --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoRulesSimulationTest.java @@ -0,0 +1,90 @@ +package com.wzz.game_console.client.screens.games.gogame; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 围棋规则引擎无头模拟(纯 JDK,rulesOnly 模式,不触碰 AI/GPU)。 + * 覆盖: + * - 轮流落子次序交替 + * - 紧气提子:白方围死黑一子后该子消失 + * - 双 pass 终局、落子后局面 hash 变化 + * - 越界/已占格拒绝落子 + */ +@Timeout(60) +class GoRulesSimulationTest { + + /** 黑废子填充轮次,保持"黑垫子→白紧气"的节奏 */ + @Test + void whiteCapturesLoneBlackStoneWhenLastLibertyFilled() { + try (GoGame g = GoGame.rulesOnly()) { + // 手顺:黑垫一手废子,白每手紧 (5,5) 黑子的一口气 + assertTrue(g.placeStone(5, 5), "1: 黑(5,5)"); // B + assertTrue(g.placeStone(4, 5), "2: 白(4,5)"); // W 紧左 + assertTrue(g.placeStone(0, 0), "3: 黑废(0,0)"); // B 垫 + assertTrue(g.placeStone(6, 5), "4: 白(6,5)"); // W 紧右 + assertTrue(g.placeStone(0, 2), "5: 黑废(0,2)"); // B 垫 + assertTrue(g.placeStone(5, 4), "6: 白(5,4)"); // W 紧上 + assertTrue(g.placeStone(0, 4), "7: 黑废(0,4)"); // B 垫 + + assertEquals(GoPlayer.BLACK, g.getStone(5, 5), + "提子前 (5,5) 应仍是黑子(尚余最后一口气在下边)"); + + assertTrue(g.placeStone(5, 6), "8: 白(5,6)"); // W 紧下 → 提子 + assertEquals(GoPlayer.NONE, g.getStone(5, 5), + "(5,5) 黑子四口气被填满后必须被提走"); + } + } + + @Test + void turnAlternatesBetweenBlackAndWhite() { + try (GoGame g = GoGame.rulesOnly()) { + assertEquals(GoPlayer.BLACK, g.getCurrentPlayer(), "围棋黑先"); + g.placeStone(4, 4); + assertEquals(GoPlayer.WHITE, g.getCurrentPlayer(), "落子后应轮到白"); + g.placeStone(10, 10); + assertEquals(GoPlayer.BLACK, g.getCurrentPlayer(), "再落子应轮回黑"); + } + } + + @Test + void doublePassEndsGame() { + try (GoGame g = GoGame.rulesOnly()) { + g.pass(); + assertFalse(g.isGameOver(), "单次 pass 不应终局"); + g.pass(); + assertTrue(g.isGameOver(), "连续两次 pass 必须终局"); + } + } + + @Test + void hashChangesAfterPlacement() { + try (GoGame g = GoGame.rulesOnly()) { + long before = g.getCurrentHash(); + g.placeStone(9, 9); + assertNotEquals(before, g.getCurrentHash(), "落子后局面 hash 必须变化"); + } + } + + @Test + void illegalPlacementsAreRejected() { + try (GoGame g = GoGame.rulesOnly()) { + assertFalse(g.canPlaceStone(-1, 5), "负坐标不得可落"); + assertFalse(g.canPlaceStone(19, 5), "越界坐标不得可落"); + assertTrue(g.placeStone(3, 3)); + assertFalse(g.placeStone(3, 3), "已占格二次落子必须失败"); + assertFalse(g.canPlaceStone(0, -100), "远端越界不得可落"); + } + } + + @Test + void resignEndsGame() { + try (GoGame g = GoGame.rulesOnly()) { + assertFalse(g.isGameOver()); + g.resign(); + assertTrue(g.isGameOver(), "认输后必须终局"); + } + } +} diff --git a/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoScoringProtocolTest.java b/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoScoringProtocolTest.java new file mode 100644 index 0000000..9677f68 --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoScoringProtocolTest.java @@ -0,0 +1,188 @@ +package com.wzz.game_console.client.screens.games.gogame; + +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class GoScoringProtocolTest { + @Test + void canonicalEncodingAndDigestAreOrderIndependent() { + Set first = new LinkedHashSet<>(Set.of( + GoScoringProtocol.key(10, 2), GoScoringProtocol.key(1, 18))); + Set second = new LinkedHashSet<>(Set.of( + GoScoringProtocol.key(1, 18), GoScoringProtocol.key(10, 2))); + assertEquals("1,18;10,2", GoScoringProtocol.encodeMarks(first)); + assertEquals(GoScoringProtocol.digest(first), GoScoringProtocol.digest(second)); + assertEquals(first, GoScoringProtocol.parseMarks(GoScoringProtocol.encodeMarks(first))); + } + + @Test + void malformedAndDuplicateMarksAreRejected() { + assertThrows(IllegalArgumentException.class, () -> GoScoringProtocol.parseMarks("1,1;1,1")); + assertThrows(IllegalArgumentException.class, () -> GoScoringProtocol.parseMarks("19,0")); + assertThrows(IllegalArgumentException.class, () -> GoScoringProtocol.parseMarks("1")); + assertNull(GoScoringProtocol.parseEpoch("old-game")); + assertNull(GoScoringProtocol.parseRevision("-1")); + } + + @Test + void komiMustBeFiniteAndCanonical() { + assertEquals(7.5, GoScoringProtocol.parseKomi("7.5")); + assertEquals(-2.5, GoScoringProtocol.parseKomi("-2.5")); + assertEquals("7.5", GoScoringProtocol.formatKomi(7.5)); + assertNull(GoScoringProtocol.parseKomi("NaN")); + assertNull(GoScoringProtocol.parseKomi("Infinity")); + assertNull(GoScoringProtocol.parseKomi("100.1")); + assertNull(GoScoringProtocol.parseKomi("7.50")); + } + + @Test + void digestRejectsTampering() { + Set marks = Set.of(GoScoringProtocol.key(3, 4)); + String digest = GoScoringProtocol.digest(marks); + assertTrue(GoScoringProtocol.hasValidDigest(marks, digest)); + assertFalse(GoScoringProtocol.hasValidDigest(Set.of(GoScoringProtocol.key(3, 5)), digest)); + assertFalse(GoScoringProtocol.hasValidDigest(marks, digest + "0")); + } + + @Test + void earlySnapshotsDoNotPreventLastPassAndStateBootstrap() { + UUID peer = UUID.randomUUID(); + UUID epoch = UUID.randomUUID(); + GoScoringProtocol.Receiver receiver = new GoScoringProtocol.Receiver(true, peer, epoch, + GoScoringProtocol.Phase.PLAYING, null); + GoScoringProtocol.Snapshot snapshot = snapshot(epoch, 0, Set.of(), -2.5); + try (GoGame game = GoGame.rulesOnly()) { + game.pass(); + assertNull(receive(game, receiver, peer, snapshot, "BEGIN")); + assertNull(receive(game, receiver, peer, snapshot, "STATE")); + assertNull(receiver.current()); + assertEquals(1, game.moveHistorySize()); + assertFalse(game.isGameOver()); + game.pass(); + assertTrue(game.isGameOver()); + assertEquals(2, game.moveHistorySize()); + GoScoringProtocol.Snapshot accepted = receive(game, receiver, peer, snapshot, "STATE"); + assertEquals(snapshot, accepted); + assertEquals(-2.5, accepted.komi()); + GoScoringProtocol.Receiver scoring = new GoScoringProtocol.Receiver(true, peer, epoch, + GoScoringProtocol.Phase.SCORING, accepted); + assertNull(receive(game, scoring, peer, snapshot, "BEGIN")); + assertEquals(accepted, receive(game, scoring, peer, snapshot, "STATE")); + } + } + + @Test + void snapshotRejectsWrongPeerRolePhaseAndRound() { + UUID peer = UUID.randomUUID(); + UUID epoch = UUID.randomUUID(); + GoScoringProtocol.Snapshot snapshot = snapshot(epoch, 0, Set.of(), 7.5); + try (GoGame game = GoGame.rulesOnly()) { + game.pass(); + game.pass(); + GoScoringProtocol.Receiver receiver = new GoScoringProtocol.Receiver(true, peer, epoch, + GoScoringProtocol.Phase.PLAYING, null); + assertNull(receive(game, receiver, UUID.randomUUID(), snapshot, "STATE")); + assertNull(receive(game, new GoScoringProtocol.Receiver(false, peer, epoch, + GoScoringProtocol.Phase.PLAYING, null), peer, snapshot, "STATE")); + for (GoScoringProtocol.Phase phase : new GoScoringProtocol.Phase[]{ + GoScoringProtocol.Phase.FINISHED, GoScoringProtocol.Phase.OTHER}) { + assertNull(receive(game, new GoScoringProtocol.Receiver(true, peer, epoch, phase, null), + peer, snapshot, "STATE")); + } + assertNull(receive(game, receiver, peer, snapshot(UUID.randomUUID(), 0, Set.of(), 7.5), "BEGIN")); + assertNull(receive(game, receiver, peer, snapshot(UUID.randomUUID(), 0, Set.of(), 7.5), "STATE")); + assertNull(receive(game, receiver, peer, snapshot, "FINAL")); + } + } + + @Test + void scoringRejectsOldRevisionOrChangedKomiButAcceptsNewRevision() { + UUID peer = UUID.randomUUID(); + UUID epoch = UUID.randomUUID(); + GoScoringProtocol.Snapshot current = snapshot(epoch, 3, Set.of(), 7.5); + GoScoringProtocol.Receiver receiver = new GoScoringProtocol.Receiver(true, peer, epoch, + GoScoringProtocol.Phase.SCORING, current); + try (GoGame game = GoGame.rulesOnly()) { + game.pass(); + game.pass(); + assertNull(receive(game, receiver, peer, snapshot(epoch, 2, Set.of(), 7.5), "STATE")); + assertNull(receive(game, receiver, peer, snapshot(epoch, 4, Set.of(), 6.5), "STATE")); + assertNull(receive(game, receiver, peer, snapshot(UUID.randomUUID(), 4, Set.of(), 7.5), "STATE")); + assertEquals(current, receive(game, receiver, peer, current, "STATE")); + GoScoringProtocol.Snapshot next = snapshot(epoch, 4, Set.of(), 7.5); + assertEquals(next, receive(game, receiver, peer, next, "STATE")); + assertEquals(current, receiver.current()); + } + } + + @Test + void finalMustMatchCanonicalSnapshotAndCanBeRetriedAfterLoss() { + UUID peer = UUID.randomUUID(); + UUID epoch = UUID.randomUUID(); + Set marks = new LinkedHashSet<>(Set.of(GoScoringProtocol.key(0, 0))); + GoScoringProtocol.Snapshot current = snapshot(epoch, 3, marks, 7.5); + marks.clear(); + assertEquals(Set.of(GoScoringProtocol.key(0, 0)), current.marks()); + GoScoringProtocol.Receiver receiver = new GoScoringProtocol.Receiver(true, peer, epoch, + GoScoringProtocol.Phase.SCORING, current); + try (GoGame game = GoGame.rulesOnly()) { + assertTrue(game.placeStone(0, 0)); + game.pass(); + game.pass(); + assertNull(receive(game, receiver, peer, snapshot(epoch, 4, current.marks(), 7.5), "FINAL")); + assertNull(receive(game, receiver, peer, snapshot(epoch, 3, Set.of(), 7.5), "FINAL")); + assertNull(receive(game, receiver, peer, snapshot(epoch, 3, current.marks(), 6.5), "FINAL")); + assertNull(receive(game, receiver, peer, snapshot(UUID.randomUUID(), 3, current.marks(), 7.5), "FINAL")); + String original = current.encode("FINAL"); + assertEquals(original, current.encode("FINAL")); + assertEquals(current, GoScoringProtocol.receiveSnapshot(game, receiver, peer, original.split("\\|", -1))); + GoScoringProtocol.Receiver finished = new GoScoringProtocol.Receiver(true, peer, epoch, + GoScoringProtocol.Phase.FINISHED, current); + assertNull(receive(game, finished, peer, current, "FINAL")); + } + } + + @Test + void invalidSnapshotMarksDigestAndKomiNeverEstablishScoring() { + UUID peer = UUID.randomUUID(); + UUID epoch = UUID.randomUUID(); + GoScoringProtocol.Receiver receiver = new GoScoringProtocol.Receiver(true, peer, epoch, + GoScoringProtocol.Phase.PLAYING, null); + try (GoGame game = GoGame.rulesOnly()) { + game.pass(); + game.pass(); + assertNull(receive(game, receiver, peer, snapshot(epoch, 0, + Set.of(GoScoringProtocol.key(0, 0)), 7.5), "STATE")); + String[] parts = snapshot(epoch, 0, Set.of(), 7.5).encode("STATE").split("\\|", -1); + parts[5] = "bad-digest"; + assertNull(GoScoringProtocol.receiveSnapshot(game, receiver, peer, parts)); + parts[5] = GoScoringProtocol.digest(Set.of()); + for (String invalid : new String[]{"NaN", "Infinity", "100.1", "7.50"}) { + parts[6] = invalid; + assertNull(GoScoringProtocol.receiveSnapshot(game, receiver, peer, parts)); + } + assertNull(receiver.current()); + } + } + + private static GoScoringProtocol.Snapshot snapshot(UUID epoch, long revision, Set marks, double komi) { + return new GoScoringProtocol.Snapshot(epoch, revision, marks, GoScoringProtocol.digest(marks), komi); + } + + private static GoScoringProtocol.Snapshot receive(GoGame game, GoScoringProtocol.Receiver receiver, + UUID peer, GoScoringProtocol.Snapshot snapshot, String action) { + return GoScoringProtocol.receiveSnapshot(game, receiver, peer, snapshot.encode(action).split("\\|", -1)); + } + + @Test + void emptyMarkSetHasStableDigest() { + assertEquals("", GoScoringProtocol.encodeMarks(Set.of())); + assertEquals(64, GoScoringProtocol.digest(Set.of()).length()); + assertNotNull(UUID.randomUUID()); + } +} diff --git a/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoTrainingRegressionTest.java b/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoTrainingRegressionTest.java new file mode 100644 index 0000000..9efc8ec --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/gogame/GoTrainingRegressionTest.java @@ -0,0 +1,332 @@ +package com.wzz.game_console.client.screens.games.gogame; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import java.lang.reflect.InvocationTargetException; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.*; + +@Timeout(30) +class GoTrainingRegressionTest { + @Test + void fallbackCoordinatesAndPolicyMatchExecutedMove() { + try (GoGame game = GoGame.rulesOnly()) { + assertTrue(game.placeStone(0, 0)); + double[] oldPolicy = new double[362]; + oldPolicy[0] = 1.0; + GoTrainingMove.Applied result = GoTrainingMove.apply(game, new int[]{0, 0}, oldPolicy); + assertArrayEquals(new int[]{0, 1}, result.coordinates()); + assertArrayEquals(game.getLastMove(), result.coordinates()); + assertEquals(GoPlayer.WHITE, game.getStone(0, 1)); + assertEquals(1.0, result.policy()[1]); + assertEquals(0.0, result.policy()[0]); + assertEquals(1.0, Arrays.stream(result.policy()).sum()); + NeuralEvaluator evaluator = new NeuralEvaluator(); + try { + double[][][] planes = evaluator.buildInputPlanes(game.getBoardCopy(), game.getCurrentPlayer(), result.coordinates()); + assertEquals(1.0, planes[3][0][1]); + assertEquals(0.0, planes[3][0][0]); + } finally { + evaluator.release(); + } + } + } + + @Test + void legalMovePreservesVisitPolicy() { + try (GoGame game = GoGame.rulesOnly()) { + double[] policy = new double[362]; + policy[2] = 0.4; + policy[3] = 0.6; + int[] suggested = new int[]{0, 3}; + GoTrainingMove.Applied result = GoTrainingMove.apply(game, suggested, policy); + assertSame(policy, result.policy()); + assertArrayEquals(new int[]{0, 3}, result.coordinates()); + assertNotSame(suggested, result.coordinates()); + } + } + + @Test + void malformedSuggestionsFallBackToActualLegalMove() { + for (int[] suggested : new int[][]{{}, {0}, {-1, 0}, {19, 0}, {0, 19}}) { + try (GoGame game = GoGame.rulesOnly()) { + GoTrainingMove.Applied result = GoTrainingMove.apply(game, suggested, new double[362]); + assertArrayEquals(new int[]{0, 0}, result.coordinates()); + assertArrayEquals(game.getLastMove(), result.coordinates()); + assertEquals(1.0, result.policy()[0]); + assertEquals(1.0, Arrays.stream(result.policy()).sum()); + } + } + } + + @Test + void suggestedPassPreservesPolicyAndDoesNotPlaceStone() { + try (GoGame game = GoGame.rulesOnly()) { + assertTrue(game.placeStone(3, 3)); + long hash = game.getCurrentHash(); + double[] policy = new double[362]; + policy[0] = 0.25; + policy[361] = 0.75; + GoTrainingMove.Applied result = GoTrainingMove.apply(game, null, policy); + assertNull(result.coordinates()); + assertNull(game.getLastMove()); + assertSame(policy, result.policy()); + assertEquals(hash, game.getCurrentHash()); + assertEquals(2, game.moveHistorySize()); + assertEquals(GoPlayer.BLACK, game.getCurrentPlayer()); + assertFalse(game.isGameOver()); + } + } + + @Test + void consecutiveSuggestedPassesFinishGameWithLegalPointsRemaining() { + try (GoGame game = GoGame.rulesOnly()) { + double[] policy = new double[362]; + policy[361] = 1.0; + assertTrue(game.canPlaceStone(0, 0)); + GoTrainingMove.apply(game, null, policy); + assertTrue(game.canPlaceStone(0, 0)); + GoTrainingMove.apply(game, null, policy); + assertTrue(game.isGameOver()); + assertEquals(2, game.moveHistorySize()); + assertEquals(GoPlayer.NONE, game.getStone(0, 0)); + } + } + + @Test + void noLegalMoveProducesOnlyPassPolicy() throws Exception { + try (GoGame game = GoGame.rulesOnly()) { + var field = GoGame.class.getDeclaredField("board"); + field.setAccessible(true); + GoPlayer[][] board = (GoPlayer[][]) field.get(game); + for (GoPlayer[] column : board) Arrays.fill(column, GoPlayer.BLACK); + double[] policy = new double[362]; + policy[0] = 1.0; + GoTrainingMove.Applied result = GoTrainingMove.apply(game, new int[]{0, 0}, policy); + assertNull(result.coordinates()); + assertEquals(1.0, result.policy()[361]); + assertEquals(1.0, Arrays.stream(result.policy()).sum()); + assertEquals(1, game.moveHistorySize()); + assertEquals(GoPlayer.WHITE, game.getCurrentPlayer()); + } + } + + @Test + void truncatedSelfPlayGameDoesNotEnterReplayBuffer() { + GoSelfPlayTrainer.Config config = new GoSelfPlayTrainer.Config(); + config.maxMoves = 1; + config.maxIterations = 0; + config.searchTimeMillis = 0; + NeuralEvaluator evaluator = new NeuralEvaluator(); + try { + GoSelfPlayTrainer trainer = new GoSelfPlayTrainer(config, evaluator); + GoSelfPlayTrainer.Result result = trainer.runGeneration(1, 1, 0, 0.01, 42); + assertEquals(0, result.completedGames); + assertEquals(0, result.samples); + assertEquals(0, trainer.getReplayBufferSize()); + } finally { + evaluator.release(); + } + } + + @Test + void missingKataGoDoesNotCountAsCompletedGame() { + NeuralEvaluator evaluator = new NeuralEvaluator(); + try { + GoAdversarialTrainer trainer = new GoAdversarialTrainer(new GoAdversarialTrainer.Config(), evaluator); + GoAdversarialTrainer.Result result = trainer.runGeneration(1, 1, 0, 0.01, 42); + assertEquals(0, result.completedGames); + assertEquals(0, result.samples); + assertEquals(0, trainer.getReplayBufferSize()); + } finally { + evaluator.release(); + } + } + + @Test + void cancelledGenerationPreservesInterruptAndDoesNotTrain() { + NeuralEvaluator evaluator = new NeuralEvaluator(); + try { + GoSelfPlayTrainer selfPlay = new GoSelfPlayTrainer(new GoSelfPlayTrainer.Config(), evaluator); + GoAdversarialTrainer adversarial = new GoAdversarialTrainer(new GoAdversarialTrainer.Config(), evaluator); + Thread.currentThread().interrupt(); + assertEquals(0, selfPlay.runGeneration(1, 1, 1, 0.01, 42).samples); + assertTrue(Thread.currentThread().isInterrupted()); + assertEquals(0, adversarial.runGeneration(1, 1, 1, 0.01, 42).samples); + assertTrue(Thread.currentThread().isInterrupted()); + assertEquals(0, selfPlay.getReplayBufferSize()); + assertEquals(0, adversarial.getReplayBufferSize()); + } finally { + Thread.interrupted(); + evaluator.release(); + } + } + + @Test + void interruptDuringTrainingStopsBeforeNextBatch() throws Exception { + for (boolean adversarial : new boolean[]{false, true}) { + InterruptingEvaluator evaluator = new InterruptingEvaluator(); + try { + Object trainer; + if (adversarial) { + GoAdversarialTrainer.Config config = new GoAdversarialTrainer.Config(); + config.batchSize = 1; + trainer = new GoAdversarialTrainer(config, evaluator); + } else { + GoSelfPlayTrainer.Config config = new GoSelfPlayTrainer.Config(); + config.batchSize = 1; + trainer = new GoSelfPlayTrainer(config, evaluator); + } + Class sampleType = Class.forName(trainer.getClass().getName() + "$Sample"); + var constructor = sampleType.getDeclaredConstructor(GoPlayer[][].class, + GoPlayer.class, int[].class, double[].class); + constructor.setAccessible(true); + java.util.List samples = new java.util.ArrayList<>(); + try (GoGame game = GoGame.rulesOnly()) { + for (int i = 0; i < 2; i++) { + double[] policy = new double[362]; + policy[0] = 1.0; + samples.add(constructor.newInstance(game.getBoardCopy(), GoPlayer.BLACK, null, policy)); + } + } + var train = trainer.getClass().getDeclaredMethod("train", java.util.List.class, + int.class, double.class, long.class); + train.setAccessible(true); + assertEquals(2.0, (double) train.invoke(trainer, samples, 3, 0.01, 42L)); + assertEquals(1, evaluator.batches); + assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + evaluator.release(); + } + } + } + + private static final class InterruptingEvaluator extends NeuralEvaluator { + int batches; + + @Override + public double trainMiniBatch(double[][][][] planes, double[][] auxFeatures, + double[] values, double[][] policies, double learningRate, + double l2, double gradientClip, double momentum) { + batches++; + Thread.currentThread().interrupt(); + return 2.0; + } + } + + @Test + void gtpActionsUseStrictCoordinatesAndExactPassResign() { + assertEquals(GoAI.MoveResult.move(8, 9), GoAdversarialTrainer.parseGTPAction("=12 J10\n")); + assertEquals(GoAI.MoveType.PASS, GoAdversarialTrainer.parseGTPAction("= pass\n").type()); + assertEquals(GoAI.MoveType.RESIGN, GoAdversarialTrainer.parseGTPAction("=12 resign\n").type()); + for (String invalid : new String[]{null, "=", "= I10", "? illegal move", "= not pass", "= D4 extra", "=12D4"}) { + assertEquals(GoAI.MoveType.ERROR, GoAdversarialTrainer.parseGTPAction(invalid).type(), invalid); + } + } + + @Test + void rejectedGtpCommandThrowsInsteadOfReturningSuccess() throws Exception { + NeuralEvaluator evaluator = new NeuralEvaluator(); + try { + GoAdversarialTrainer trainer = new GoAdversarialTrainer(new GoAdversarialTrainer.Config(), evaluator); + var method = GoAdversarialTrainer.class.getDeclaredMethod("sendGTP", java.io.Writer.class, java.io.Reader.class, String.class); + method.setAccessible(true); + StringWriter writer = new StringWriter(); + InvocationTargetException error = assertThrows(InvocationTargetException.class, () -> method.invoke(trainer, + writer, new BufferedReader(new StringReader("? illegal move\n\n")), "play black a1")); + assertInstanceOf(IOException.class, error.getCause()); + assertEquals("play black a1\n", writer.toString()); + } finally { + evaluator.release(); + } + } + + /** + * 回归:超时路径绝不能在主线程 close() reader——reader 线程可能仍持有 + * BufferedReader 内部锁阻塞在管道读上,同步 close 在 Windows 上永久死锁 + * (压力实测复现)。超时必须及时抛出且流保持打开,由进程销毁解除 reader。 + */ + @Test + void gtpTimeoutThrowsPromptlyWithoutClosingReader() throws Exception { + NeuralEvaluator evaluator = new NeuralEvaluator(); + try { + GoAdversarialTrainer trainer = new GoAdversarialTrainer(new GoAdversarialTrainer.Config(), evaluator); + var method = GoAdversarialTrainer.class.getDeclaredMethod("sendGTP", + java.io.Writer.class, java.io.Reader.class, String.class, long.class); + method.setAccessible(true); + CloseTrackingReader source = new CloseTrackingReader(); + BufferedReader reader = new BufferedReader(source); + long start = System.nanoTime(); + InvocationTargetException error = assertThrows(InvocationTargetException.class, + () -> method.invoke(trainer, new StringWriter(), reader, "genmove b", 300L)); + long elapsedMillis = (System.nanoTime() - start) / 1_000_000L; + assertInstanceOf(java.util.concurrent.TimeoutException.class, error.getCause()); + assertTrue(elapsedMillis < 3_000L, "timeout took " + elapsedMillis + "ms"); + assertFalse(source.closed, "timeout path must not close the reader"); + } finally { + evaluator.release(); + } + } + + @Test + void gtpInterruptThrowsPromptlyWithoutClosingReader() throws Exception { + NeuralEvaluator evaluator = new NeuralEvaluator(); + try { + GoAdversarialTrainer trainer = new GoAdversarialTrainer(new GoAdversarialTrainer.Config(), evaluator); + var method = GoAdversarialTrainer.class.getDeclaredMethod("sendGTP", + java.io.Writer.class, java.io.Reader.class, String.class, long.class); + method.setAccessible(true); + CloseTrackingReader source = new CloseTrackingReader(); + BufferedReader reader = new BufferedReader(source); + Thread caller = new Thread(() -> { + try { + method.invoke(trainer, new StringWriter(), reader, "genmove b", 60_000L); + } catch (Exception ignored) { + // 断言只关心中断是否及时解除阻塞与流未关闭 + } + }); + caller.setDaemon(true); + caller.start(); + // reader 线程已进入 read 之后才中断,保证中断命中 await 分片而非启动竞态 + while (!source.readEntered) Thread.sleep(10L); + long start = System.nanoTime(); + caller.interrupt(); + caller.join(3_000L); + long elapsedMillis = (System.nanoTime() - start) / 1_000_000L; + assertFalse(caller.isAlive(), "interrupt did not unblock sendGTP"); + assertTrue(elapsedMillis < 3_000L, "interrupt took " + elapsedMillis + "ms"); + assertFalse(source.closed, "interrupt path must not close the reader"); + } finally { + evaluator.release(); + } + } + + /** Blocking reader that records close() so tests can prove the failure path never closes it. */ + private static final class CloseTrackingReader extends java.io.Reader { + volatile boolean closed; + volatile boolean readEntered; + + @Override + public int read(char[] cbuf, int off, int len) { + readEntered = true; + try { + Thread.sleep(10_000L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return -1; + } + + @Override + public void close() { + closed = true; + } + } +} diff --git a/src/test/java/com/wzz/game_console/client/screens/games/gogame/KataGoGoAITest.java b/src/test/java/com/wzz/game_console/client/screens/games/gogame/KataGoGoAITest.java new file mode 100644 index 0000000..6f861e7 --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/gogame/KataGoGoAITest.java @@ -0,0 +1,205 @@ +package com.wzz.game_console.client.screens.games.gogame; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class KataGoGoAITest { + @Test + void normalizesUnsupportedEngineNamesToMcts() { + assertEquals("mcts", GoAI.normalizeEngine(null)); + assertEquals("mcts", GoAI.normalizeEngine("")); + assertEquals("mcts", GoAI.normalizeEngine("unknown")); + assertEquals("mcts", GoAI.normalizeEngine("MCTS")); + assertEquals("katago", GoAI.normalizeEngine(" KataGo ")); + } + + @Test + void labelsActualRuntimeEngineWithoutStartingIt() { + assertEquals("MCTS", GoAI.runtimeEngineLabel(MCTSGoAI.class)); + assertEquals("KataGo", GoAI.runtimeEngineLabel(KataGoGoAI.class)); + assertNull(GoAI.runtimeEngineLabel(null)); + } + + @Test + void parsesMoveAndSkipsGtpIColumn() { + GoAI.MoveResult move = KataGoGoAI.parseMoveResult("J10"); + assertEquals(GoAI.MoveType.MOVE, move.type()); + assertEquals(8, move.x()); + assertEquals(9, move.y()); + assertEquals(GoAI.MoveType.ERROR, KataGoGoAI.parseMoveResult("I10").type()); + } + + @Test + void keepsPassResignAndMalformedResponsesDistinct() { + assertEquals(GoAI.MoveType.PASS, KataGoGoAI.parseMoveResult("pass").type()); + assertEquals(GoAI.MoveType.RESIGN, KataGoGoAI.parseMoveResult("resign").type()); + for (String invalid : new String[] {null, "", "not-a-move", "A0", "T20", "U1"}) { + assertEquals(GoAI.MoveType.ERROR, KataGoGoAI.parseMoveResult(invalid).type()); + } + } + + @Test + void acknowledgedGenmoveIsNotReplayed() throws IOException { + FakeTransport transport = new FakeTransport(); + KataGoGoAI.BoardSync sync = new KataGoGoAI.BoardSync(transport); + List history = new ArrayList<>(List.of(move(0, 0, GoPlayer.BLACK))); + GoAI.MoveResult result = sync.generate(history, GoPlayer.WHITE); + assertEquals(GoAI.MoveResult.move(3, 3), result); + assertEquals(List.of("clear_board", "play black a1", "genmove white"), transport.commands); + + history.add(move(3, 3, GoPlayer.WHITE)); + history.add(move(1, 0, GoPlayer.BLACK)); + transport.commands.clear(); + transport.response = "E5"; + sync.generate(history, GoPlayer.WHITE); + assertEquals(List.of("play black b1", "genmove white"), transport.commands); + } + + @Test + void acknowledgedGeneratedPassIsNotReplayed() throws IOException { + FakeTransport transport = new FakeTransport(); + transport.response = "pass"; + KataGoGoAI.BoardSync sync = new KataGoGoAI.BoardSync(transport); + List history = new ArrayList<>(List.of(move(0, 0, GoPlayer.BLACK))); + assertEquals(GoAI.MoveType.PASS, sync.generate(history, GoPlayer.WHITE).type()); + history.add(move(-1, -1, GoPlayer.WHITE)); + history.add(move(1, 0, GoPlayer.BLACK)); + transport.commands.clear(); + sync.generate(history, GoPlayer.WHITE); + assertEquals(List.of("play black b1", "genmove white"), transport.commands); + } + + @Test + void unacknowledgedGenmoveRebuildIncludesPass() throws IOException { + FakeTransport transport = new FakeTransport(); + KataGoGoAI.BoardSync sync = new KataGoGoAI.BoardSync(transport); + List history = List.of(move(-1, -1, GoPlayer.BLACK)); + sync.generate(history, GoPlayer.WHITE); + transport.commands.clear(); + sync.generate(history, GoPlayer.WHITE); + assertEquals(List.of("clear_board", "play black pass", "genmove white"), transport.commands); + } + + @Test + void sameLengthDivergenceRebuildsInsteadOfTrustingMoveCount() throws IOException { + FakeTransport transport = new FakeTransport(); + KataGoGoAI.BoardSync sync = new KataGoGoAI.BoardSync(transport); + sync.generate(List.of(move(0, 0, GoPlayer.BLACK)), GoPlayer.WHITE); + transport.commands.clear(); + // Same length as engine history, but the caller used a different move. + sync.generate(List.of(move(0, 0, GoPlayer.BLACK), move(4, 4, GoPlayer.WHITE)), GoPlayer.BLACK); + assertEquals(List.of("clear_board", "play black a1", "play white e5", "genmove black"), transport.commands); + } + + @Test + void partialReplayFailureForcesFullRebuildOnRetry() throws IOException { + FakeTransport transport = new FakeTransport(); + KataGoGoAI.BoardSync sync = new KataGoGoAI.BoardSync(transport); + List history = List.of(move(0, 0, GoPlayer.BLACK), move(-1, -1, GoPlayer.WHITE), + move(1, 0, GoPlayer.BLACK)); + transport.failOn = "play black b1"; + assertThrows(IOException.class, () -> sync.generate(history, GoPlayer.WHITE)); + assertEquals(List.of("clear_board", "play black a1", "play white pass", "play black b1"), transport.commands); + transport.commands.clear(); + sync.generate(history, GoPlayer.WHITE); + assertEquals(List.of("clear_board", "play black a1", "play white pass", "play black b1", "genmove white"), + transport.commands); + } + + @Test + void incrementalReplayFailureAlsoForcesFullRebuild() throws IOException { + FakeTransport transport = new FakeTransport(); + KataGoGoAI.BoardSync sync = new KataGoGoAI.BoardSync(transport); + sync.generate(List.of(move(0, 0, GoPlayer.BLACK)), GoPlayer.WHITE); + List history = List.of(move(0, 0, GoPlayer.BLACK), move(3, 3, GoPlayer.WHITE), + move(1, 0, GoPlayer.BLACK), move(-1, -1, GoPlayer.WHITE), move(2, 0, GoPlayer.BLACK)); + transport.commands.clear(); + transport.failOn = "play black c1"; + assertThrows(IOException.class, () -> sync.generate(history, GoPlayer.WHITE)); + assertEquals(List.of("play black b1", "play white pass", "play black c1"), transport.commands); + transport.commands.clear(); + sync.generate(history, GoPlayer.WHITE); + assertEquals(List.of("clear_board", "play black a1", "play white d4", "play black b1", + "play white pass", "play black c1", "genmove white"), transport.commands); + } + + @Test + void genmoveFailureAndMalformedReplyInvalidateEngineHistory() throws IOException { + for (boolean malformed : new boolean[] {false, true}) { + FakeTransport transport = new FakeTransport(); + KataGoGoAI.BoardSync sync = new KataGoGoAI.BoardSync(transport); + List history = List.of(move(0, 0, GoPlayer.BLACK)); + if (malformed) { + transport.response = "garbage"; + assertEquals(GoAI.MoveType.ERROR, sync.generate(history, GoPlayer.WHITE).type()); + } else { + transport.failOn = "genmove white"; + assertThrows(IOException.class, () -> sync.generate(history, GoPlayer.WHITE)); + } + transport.commands.clear(); + transport.response = "D4"; + sync.generate(history, GoPlayer.WHITE); + assertEquals(List.of("clear_board", "play black a1", "genmove white"), transport.commands); + } + } + + @Test + void destroysProcessBeforeClosingEitherStream() { + for (boolean requiresForce : new boolean[] {false, true}) { + List events = new ArrayList<>(); + Process process = new Process() { + private boolean alive = true; + @Override public java.io.OutputStream getOutputStream() { return java.io.OutputStream.nullOutputStream(); } + @Override public java.io.InputStream getInputStream() { return java.io.InputStream.nullInputStream(); } + @Override public java.io.InputStream getErrorStream() { return java.io.InputStream.nullInputStream(); } + @Override public int waitFor() { return 0; } + @Override public boolean waitFor(long timeout, java.util.concurrent.TimeUnit unit) { return !alive; } + @Override public int exitValue() { return 0; } + @Override public boolean isAlive() { return alive; } + @Override public void destroy() { + events.add("destroy"); + if (!requiresForce) alive = false; + } + @Override public Process destroyForcibly() { + events.add("force"); + alive = false; + return this; + } + }; + KataGoGoAI.closeProcess(process, () -> { + assertFalse(process.isAlive(), "writer close must follow process termination"); + events.add("writer"); + }, () -> { + assertFalse(process.isAlive(), "reader close must follow process termination"); + events.add("reader"); + }); + assertEquals(requiresForce ? List.of("destroy", "force", "writer", "reader") + : List.of("destroy", "writer", "reader"), events); + } + } + + private static GoMove move(int x, int y, GoPlayer color) { + return new GoMove(x, y, color, 0); + } + + private static final class FakeTransport implements KataGoGoAI.CommandTransport { + final List commands = new ArrayList<>(); + String response = "D4"; + String failOn; + + @Override + public String send(String command) throws IOException { + commands.add(command); + if (command.equals(failOn)) { + failOn = null; + throw new IOException("simulated GTP failure"); + } + return command.startsWith("genmove ") ? response : ""; + } + } +} diff --git a/src/test/java/com/wzz/game_console/client/screens/games/gogame/MCTSGoAICheckpointLoadTest.java b/src/test/java/com/wzz/game_console/client/screens/games/gogame/MCTSGoAICheckpointLoadTest.java new file mode 100644 index 0000000..ae738ab --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/gogame/MCTSGoAICheckpointLoadTest.java @@ -0,0 +1,165 @@ +package com.wzz.game_console.client.screens.games.gogame; + +import com.wzz.game_console.util.GameSettings; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.DataOutputStream; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * 运行时 checkpoint 加载契约回归测试:{@code go.modelPath} 指向训练入口保存的 + * NEV2/NEV3 权重文件时,{@link MCTSGoAI#createFromSettings()} 必须把它接入对局; + * 缺失或损坏时必须静默回退到随机初始化,绝不抛异常。 + */ +class MCTSGoAICheckpointLoadTest { + + @TempDir + Path tempDir; + + private Object originalSettings; + private boolean originalLoaded; + private Field settingsField; + private Field loadedField; + + @BeforeEach + void snapshotGameSettingsState() throws Exception { + settingsField = GameSettings.class.getDeclaredField("settings"); + settingsField.setAccessible(true); + loadedField = GameSettings.class.getDeclaredField("loaded"); + loadedField.setAccessible(true); + originalSettings = settingsField.get(null); + originalLoaded = loadedField.getBoolean(null); + } + + @AfterEach + void restoreGameSettingsState() throws Exception { + settingsField.set(null, originalSettings); + loadedField.setBoolean(null, originalLoaded); + } + + /** 只改内存快照,不落盘(importFromFile 会持久化到 data/,测试不得污染)。 */ + private void setModelPath(String modelPath) throws Exception { + Map go = modelPath == null + ? Map.of() + : Map.of("modelPath", modelPath); + settingsField.set(null, Map.of("go", go)); + loadedField.setBoolean(null, true); + } + + private static NeuralEvaluator evaluatorOf(MCTSGoAI ai) throws Exception { + Field f = MCTSGoAI.class.getDeclaredField("neuralEvaluator"); + f.setAccessible(true); + return (NeuralEvaluator) f.get(ai); + } + + private static double probeValue(NeuralEvaluator evaluator) { + GoPlayer[][] board = new GoPlayer[GoAI.BOARD_SIZE][GoAI.BOARD_SIZE]; + for (GoPlayer[] row : board) java.util.Arrays.fill(row, GoPlayer.NONE); + board[9][9] = GoPlayer.BLACK; + board[3][3] = GoPlayer.WHITE; + return evaluator.forwardValue(board, GoPlayer.BLACK, new int[]{9, 9}); + } + + @Test + void configuredNev3CheckpointIsUsedByCreatedAi() throws Exception { + NeuralEvaluator reference = new NeuralEvaluator(); + Path model = tempDir.resolve("trained.nev3"); + reference.save(model); + + setModelPath(model.toString()); + MCTSGoAI ai = MCTSGoAI.createFromSettings(); + assertNotNull(ai); + NeuralEvaluator loaded = evaluatorOf(ai); + NeuralEvaluator direct = new NeuralEvaluator(); + direct.load(model); + assertEquals(direct.getModelVersion(), loaded.getModelVersion()); + assertEquals(probeValue(direct), probeValue(loaded), 1e-12, + "created AI must run on the checkpoint weights, not random init"); + } + + @Test + void legacyDoubleFormatCheckpointLoadsAtRuntime() throws Exception { + Nev2File file = writeNev2(tempDir.resolve("legacy.nev2")); + + setModelPath(file.path().toString()); + MCTSGoAI ai = MCTSGoAI.createFromSettings(); + assertNotNull(ai); + NeuralEvaluator loaded = evaluatorOf(ai); + assertEquals(file.expectedVersion(), loaded.getModelVersion(), + "legacy checkpoint version must survive the runtime load path"); + assertEquals(probeValue(file.reference()), probeValue(loaded), 1e-12); + } + + @Test + void corruptModelFileFallsBackToRandomInitWithoutThrowing() throws Exception { + Path model = tempDir.resolve("corrupt.nev"); + Files.writeString(model, "this is not a model file"); + + setModelPath(model.toString()); + MCTSGoAI ai = MCTSGoAI.createFromSettings(); + assertNotNull(ai); + assertEquals(0L, evaluatorOf(ai).getModelVersion(), + "fallback must be a fresh random-init evaluator"); + } + + @Test + void missingModelFileFallsBackToRandomInitWithoutThrowing() throws Exception { + setModelPath(tempDir.resolve("absent.nev").toString()); + MCTSGoAI ai = MCTSGoAI.createFromSettings(); + assertNotNull(ai); + assertEquals(0L, evaluatorOf(ai).getModelVersion()); + } + + @Test + void emptyModelPathUsesRandomInit() throws Exception { + setModelPath(""); + MCTSGoAI ai = MCTSGoAI.createFromSettings(); + assertNotNull(ai); + assertEquals(0L, evaluatorOf(ai).getModelVersion()); + } + + /** NEV2(double)格式的手工编码文件 + 独立参考加载器,用于断言运行时等价。 */ + private record Nev2File(Path path, NeuralEvaluator reference, long expectedVersion) {} + + private static Nev2File writeNev2(Path path) throws Exception { + NeuralEvaluator source = new NeuralEvaluator(); + NeuralEvaluator.ModelWeights m = source.snapshot(); + try (DataOutputStream out = new DataOutputStream(Files.newOutputStream(path))) { + out.writeInt(0x4E455632); // LEGACY_MODEL_MAGIC "NEV2" + out.writeInt(2); // LEGACY_MODEL_FORMAT + out.writeLong(m.version); + for (double[][] block : m.subW1) writeMatrixDoubles(out, block); + for (double[] bias : m.subB1) writeVectorDoubles(out, bias); + for (double[][] block : m.blockW1) writeMatrixDoubles(out, block); + for (double[] bias : m.blockB1) writeVectorDoubles(out, bias); + writeMatrixDoubles(out, m.topW1); + writeVectorDoubles(out, m.topB1); + writeMatrixDoubles(out, m.policyW); + writeVectorDoubles(out, m.policyB); + writeMatrixDoubles(out, m.valueW1); + writeVectorDoubles(out, m.valueB1); + writeVectorDoubles(out, m.valueW2); + out.writeDouble(m.valueB2); + } + NeuralEvaluator reference = new NeuralEvaluator(); + reference.load(path); + return new Nev2File(path, reference, m.version); + } + + private static void writeMatrixDoubles(DataOutputStream out, double[][] matrix) throws Exception { + for (double[] row : matrix) writeVectorDoubles(out, row); + } + + private static void writeVectorDoubles(DataOutputStream out, double[] vector) throws Exception { + for (double v : vector) out.writeDouble(v); + } +} diff --git a/src/test/java/com/wzz/game_console/client/screens/games/gogame/MCTSGoAIRegressionTest.java b/src/test/java/com/wzz/game_console/client/screens/games/gogame/MCTSGoAIRegressionTest.java new file mode 100644 index 0000000..23880ff --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/gogame/MCTSGoAIRegressionTest.java @@ -0,0 +1,324 @@ +package com.wzz.game_console.client.screens.games.gogame; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +@Timeout(30) +class MCTSGoAIRegressionTest { + private MCTSGoAI ai; + + @BeforeEach + void createAi() { + ai = new MCTSGoAI(0, 0, 1); + } + + @AfterEach + void closeAi() { + ai.shutdown(); + } + + @Test + void zeroVisitChildrenUseSelectedMoveInsteadOfPass() throws Exception { + GoPlayer[][] board = emptyBoard(); + Object root = node(board, null, null); + Object child = node(board, root, new int[]{4, 5}); + setField(root, "children", new ArrayList<>(List.of(child))); + setField(ai, "currentRoot", root); + setField(ai, "lastMove", new int[]{4, 5}); + + double[] policy = ai.getVisitDistribution(); + assertEquals(1.0, policy[4 * 19 + 5]); + assertEquals(0.0, policy[361]); + assertEquals(1.0, Arrays.stream(policy).sum()); + } + + @Test + void noLegalMoveClearsPreviousTreeAndSelectedMove() throws Exception { + try (GoGame game = GoGame.rulesOnly()) { + Object root = node(emptyBoard(), null, null); + setField(ai, "currentRoot", root); + setField(ai, "lastRoot", root); + setField(ai, "lastMove", new int[]{4, 5}); + GoPlayer[][] board = (GoPlayer[][]) getField(game, "board"); + for (GoPlayer[] column : board) Arrays.fill(column, GoPlayer.BLACK); + + assertNull(ai.getBestMove(game)); + assertNull(getField(ai, "currentRoot")); + assertNull(getField(ai, "lastRoot")); + assertNull(getField(ai, "lastMove")); + assertEquals(1.0, ai.getVisitDistribution()[361]); + } + } + + @Test + void zeroIterationSearchReturnsLegalMoveAndMatchingPolicy() { + try (GoGame game = GoGame.rulesOnly()) { + int[] move = ai.getBestMove(game); + assertNotNull(move); + assertTrue(game.placeStone(move[0], move[1])); + double[] policy = ai.getVisitDistribution(); + assertEquals(1.0, policy[move[0] * 19 + move[1]]); + assertEquals(1.0, Arrays.stream(policy).sum()); + } + } + + @Test + void zeroIterationSearchPassesAfterOpponentPass() { + try (GoGame game = GoGame.rulesOnly()) { + game.pass(); + assertTrue(game.canPlaceStone(0, 0)); + + assertNull(ai.getBestMove(game)); + assertEquals(1.0, ai.getVisitDistribution()[361]); + GoTrainingMove.apply(game, null, ai.getVisitDistribution()); + assertTrue(game.isGameOver()); + } + } + + @Test + void invalidLegalMoveProbeDoesNotOverwriteBoard() throws Exception { + GoPlayer[][] board = emptyBoard(); + board[3][4] = GoPlayer.BLACK; + GoPlayer[][] before = copy(board); + var method = MCTSGoAI.class.getDeclaredMethod("isLegalMove", GoPlayer[][].class, + int.class, int.class, GoPlayer.class); + method.setAccessible(true); + for (int[] move : new int[][]{{3, 4}, {-1, 4}, {19, 4}, {3, -1}, {3, 19}}) { + assertEquals(false, method.invoke(ai, board, move[0], move[1], GoPlayer.WHITE)); + assertBoardEquals(before, board); + } + assertEquals(false, method.invoke(ai, board, 0, 0, GoPlayer.NONE)); + assertBoardEquals(before, board); + } + + @Test + void legalCaptureProbeRestoresCapturedStones() throws Exception { + GoPlayer[][] board = emptyBoard(); + board[0][0] = GoPlayer.WHITE; + board[1][0] = GoPlayer.BLACK; + GoPlayer[][] before = copy(board); + var method = MCTSGoAI.class.getDeclaredMethod("isLegalMove", GoPlayer[][].class, + int.class, int.class, GoPlayer.class); + method.setAccessible(true); + assertEquals(true, method.invoke(ai, board, 0, 1, GoPlayer.BLACK)); + assertBoardEquals(before, board); + } + + @Test + void tacticalRegionRejectsOccupiedAndMalformedPointsWithoutMutation() throws Exception { + GoPlayer[][] board = emptyBoard(); + board[3][4] = GoPlayer.BLACK; + GoPlayer[][] before = copy(board); + var method = MCTSGoAI.class.getDeclaredMethod("legalMovesInRegion", GoPlayer[][].class, + GoPlayer.class, Set.class); + method.setAccessible(true); + @SuppressWarnings("unchecked") + List moves = (List) method.invoke(ai, board, GoPlayer.WHITE, + Set.of("3,4", "-1,0", "19,0", "bad", "x,2", "1,2,3", "0,0")); + assertEquals(1, moves.size()); + assertArrayEquals(new int[]{0, 0}, moves.get(0)); + assertBoardEquals(before, board); + } + + @Test + void oneSearchIterationVisitsExpandedChildAndParent() throws Exception { + MCTSGoAI searchAi = new MCTSGoAI(1_000, 1, 1); + try { + Object root = node(emptyBoard(), GoPlayer.BLACK, null, null, + List.of(new int[]{3, 3, 0})); + setField(searchAi, "currentRoot", root); + var search = MCTSGoAI.class.getDeclaredMethod( + "sequentialSearchWithEarlyTerminate", long.class); + search.setAccessible(true); + search.invoke(searchAi, System.currentTimeMillis() + 1_000); + + @SuppressWarnings("unchecked") + List children = (List) getField(root, "children"); + assertEquals(1, children.size()); + assertEquals(1.0, (double) getField(children.get(0), "visits")); + assertEquals(1.0, (double) getField(root, "visits")); + } finally { + searchAi.shutdown(); + } + } + + @Test + void parallelSearchSharesTreeWithoutExceedingIterationBudget() throws Exception { + MCTSGoAI searchAi = new MCTSGoAI(1_000, 8, 32); + try (GoGame game = GoGame.rulesOnly()) { + game.pass(); + searchAi.getBestMove(game); + + AtomicLong iterations = (AtomicLong) getField(searchAi, "totalIterations"); + assertTrue(iterations.get() > 0); + assertTrue(iterations.get() <= 8); + double[] policy = searchAi.getVisitDistribution(); + assertEquals(362, policy.length); + assertEquals(1.0, Arrays.stream(policy).sum(), 1.0e-9); + for (double probability : policy) { + assertTrue(Double.isFinite(probability)); + assertTrue(probability >= 0.0); + } + } finally { + searchAi.shutdown(); + } + } + + @Test + void secondPassCreatesTerminalLeafWithoutChangingBoardOrCheckingSuperko() throws Exception { + GoPlayer[][] board = emptyBoard(); + Object root = node(board, GoPlayer.BLACK, null, null, + List.of(new int[]{-1, -1, 0})); + setField(root, "consecutivePasses", 1); + long hash = GoGame.boardHash(board); + setField(root, "hash", hash); + double[] policy = new double[362]; + policy[361] = 0.25; + setField(root, "policyCache", policy); + setField(root, "valueCached", true); + setField(ai, "koHistory", Set.of(hash)); + + var expand = MCTSGoAI.class.getDeclaredMethod("expand", root.getClass()); + expand.setAccessible(true); + Object child = expand.invoke(ai, root); + + assertNotNull(child); + assertTrue((boolean) getField(child, "terminal")); + assertEquals(2, getField(child, "consecutivePasses")); + assertEquals(GoPlayer.WHITE, getField(child, "player")); + assertEquals(hash, getField(child, "hash")); + assertBoardEquals(board, (GoPlayer[][]) getField(child, "board")); + assertTrue(((List) getField(child, "untriedMoves")).isEmpty()); + assertEquals(0.25 * 361.0, (double) getField(child, "prior"), 1.0e-9); + + var simulate = MCTSGoAI.class.getDeclaredMethod("simulate", root.getClass()); + simulate.setAccessible(true); + assertEquals(0.075, (double) simulate.invoke(ai, child), 1.0e-9); + } + + @Test + void passChildVisitsUsePolicySlot361AndSelectedPassReturnsNull() throws Exception { + MCTSGoAI searchAi = new MCTSGoAI(1_000, 1, 1); + try (GoGame game = GoGame.rulesOnly()) { + GoPlayer[][] board = (GoPlayer[][]) getField(game, "board"); + for (GoPlayer[] column : board) Arrays.fill(column, GoPlayer.BLACK); + + int[] move = searchAi.getBestMove(game); + assertNull(move); + double[] policy = searchAi.getVisitDistribution(); + assertEquals(1.0, policy[361]); + assertEquals(1.0, Arrays.stream(policy).sum()); + + GoTrainingMove.Applied applied = GoTrainingMove.apply(game, move, policy); + assertNull(applied.coordinates()); + assertSame(policy, applied.policy()); + assertEquals(1, game.getConsecutivePasses()); + assertEquals(GoPlayer.WHITE, game.getCurrentPlayer()); + } finally { + searchAi.shutdown(); + } + } + + @Test + void shutdownAndInterruptAreTypedErrorsInsteadOfPasses() throws Exception { + MCTSGoAI stopped = new MCTSGoAI(100, 10, 1); + stopped.shutdown(); + assertEquals(GoAI.MoveType.ERROR, + stopped.getBestMoveResult(GoGame.rulesOnly()).type()); + + MCTSGoAI interrupted = new MCTSGoAI(100, 10, 1); + try { + Thread.currentThread().interrupt(); + assertEquals(GoAI.MoveType.ERROR, + interrupted.getBestMoveResult(GoGame.rulesOnly()).type()); + } finally { + Thread.interrupted(); + interrupted.shutdown(); + } + } + + @Test + void treeReusePreservesIncomingEdgePrior() throws Exception { + GoPlayer[][] board = emptyBoard(); + Object root = node(board, GoPlayer.BLACK, null, null, List.of()); + Object child = node(copy(board), GoPlayer.WHITE, root, new int[]{4, 4}, List.of()); + setField(child, "prior", 17.5); + setField(root, "children", new ArrayList<>(List.of(child))); + setField(ai, "lastRoot", root); + setField(ai, "lastMove", new int[]{4, 4}); + + var reuse = MCTSGoAI.class.getDeclaredMethod("tryReuseTree", GoPlayer[][].class, + GoPlayer.class); + reuse.setAccessible(true); + Object reused = reuse.invoke(ai, board, GoPlayer.WHITE); + assertNotNull(reused); + assertEquals(17.5, (double) getField(reused, "prior")); + } + + @Test + void treeReuseRejectsSameBoardWithWrongPlayerToMove() throws Exception { + GoPlayer[][] board = emptyBoard(); + Object root = node(board, GoPlayer.BLACK, null, null, List.of()); + Object child = node(copy(board), GoPlayer.WHITE, root, new int[]{-1, -1}, List.of()); + setField(root, "children", new ArrayList<>(List.of(child))); + setField(ai, "lastRoot", root); + setField(ai, "lastMove", new int[]{-1, -1}); + + var reuse = MCTSGoAI.class.getDeclaredMethod("tryReuseTree", GoPlayer[][].class, + GoPlayer.class); + reuse.setAccessible(true); + assertNull(reuse.invoke(ai, board, GoPlayer.BLACK)); + } + + private static Object node(GoPlayer[][] board, Object parent, int[] move) throws Exception { + return node(board, GoPlayer.BLACK, parent, move, List.of()); + } + + private static Object node(GoPlayer[][] board, GoPlayer player, Object parent, int[] move, + List untriedMoves) throws Exception { + Class type = Class.forName(MCTSGoAI.class.getName() + "$MCTSNode"); + var constructor = type.getDeclaredConstructor(GoPlayer[][].class, GoPlayer.class, + type, int[].class, List.class); + constructor.setAccessible(true); + return constructor.newInstance(board, player, parent, move, untriedMoves); + } + + private static void setField(Object target, String name, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + private static Object getField(Object target, String name) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.get(target); + } + + private static GoPlayer[][] emptyBoard() { + GoPlayer[][] board = new GoPlayer[19][19]; + for (GoPlayer[] column : board) Arrays.fill(column, GoPlayer.NONE); + return board; + } + + private static GoPlayer[][] copy(GoPlayer[][] board) { + return Arrays.stream(board).map(GoPlayer[]::clone).toArray(GoPlayer[][]::new); + } + + private static void assertBoardEquals(GoPlayer[][] expected, GoPlayer[][] actual) { + for (int x = 0; x < expected.length; x++) assertArrayEquals(expected[x], actual[x]); + } +} diff --git a/src/test/java/com/wzz/game_console/client/screens/games/gogame/NeuralEvaluatorPersistenceTest.java b/src/test/java/com/wzz/game_console/client/screens/games/gogame/NeuralEvaluatorPersistenceTest.java new file mode 100644 index 0000000..cd62fe8 --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/gogame/NeuralEvaluatorPersistenceTest.java @@ -0,0 +1,98 @@ +package com.wzz.game_console.client.screens.games.gogame; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class NeuralEvaluatorPersistenceTest { + private static final int FORMAT_OFFSET = Integer.BYTES; + private static final int FIRST_WEIGHT_OFFSET = Integer.BYTES * 2 + Long.BYTES; + + @TempDir + Path tempDir; + + @Test + void currentFormatRoundTripPreservesModel() throws Exception { + Path model = tempDir.resolve("round-trip.nev"); + NeuralEvaluator source = new NeuralEvaluator(); + NeuralEvaluator.ModelWeights expected; + try { + expected = source.snapshot(); + source.save(model); + } finally { + source.release(); + } + + NeuralEvaluator target = new NeuralEvaluator(); + try { + target.load(model); + NeuralEvaluator.ModelWeights actual = target.snapshot(); + assertEquals(expected.version, actual.version); + assertEquals((double) (float) expected.subW1[0][0][0], actual.subW1[0][0][0]); + assertEquals((double) (float) expected.valueB2, actual.valueB2); + } finally { + target.release(); + } + } + + @Test + void rejectsUnknownVersionWithoutChangingCurrentModel() throws Exception { + Path model = saveModel("unknown-format.nev"); + byte[] bytes = Files.readAllBytes(model); + ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN).putInt(FORMAT_OFFSET, 999); + Files.write(model, bytes); + + assertRejectedWithoutMutation(model); + } + + @Test + void rejectsNonFiniteWeightWithoutChangingCurrentModel() throws Exception { + Path model = saveModel("nan-weight.nev"); + byte[] bytes = Files.readAllBytes(model); + ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN) + .putInt(FIRST_WEIGHT_OFFSET, Float.floatToRawIntBits(Float.NaN)); + Files.write(model, bytes); + + assertRejectedWithoutMutation(model); + } + + @Test + void rejectsTrailingDataWithoutChangingCurrentModel() throws Exception { + Path model = saveModel("trailing-data.nev"); + Files.write(model, new byte[]{1}, java.nio.file.StandardOpenOption.APPEND); + + assertRejectedWithoutMutation(model); + } + + private Path saveModel(String name) throws IOException { + Path model = tempDir.resolve(name); + NeuralEvaluator evaluator = new NeuralEvaluator(); + try { + evaluator.save(model); + } finally { + evaluator.release(); + } + return model; + } + + private void assertRejectedWithoutMutation(Path model) { + NeuralEvaluator evaluator = new NeuralEvaluator(); + try { + NeuralEvaluator.ModelWeights before = evaluator.snapshot(); + assertThrows(IOException.class, () -> evaluator.load(model)); + NeuralEvaluator.ModelWeights after = evaluator.snapshot(); + assertEquals(before.version, after.version); + assertEquals(before.subW1[0][0][0], after.subW1[0][0][0]); + } finally { + evaluator.release(); + } + } +} diff --git a/src/test/java/com/wzz/game_console/client/screens/games/landlord/AIPlayerListIsolationTest.java b/src/test/java/com/wzz/game_console/client/screens/games/landlord/AIPlayerListIsolationTest.java new file mode 100644 index 0000000..de5f26d --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/landlord/AIPlayerListIsolationTest.java @@ -0,0 +1,84 @@ +package com.wzz.game_console.client.screens.games.landlord; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +class AIPlayerListIsolationTest { + + private final AIPlayer ai = new AIPlayer(); + + @Test + void followingPlaySortsCopyWithoutChangingInputOrder() { + Card six = card(Card.Rank.SIX); + Card four = card(Card.Rank.FOUR); + Card five = card(Card.Rank.FIVE); + List hand = new ArrayList<>(List.of(six, four, five)); + + List result = ai.chooseCardsToPlay(hand, List.of(card(Card.Rank.THREE)), true); + + assertEquals(List.of(four), result); + assertSame(six, hand.get(0)); + assertSame(four, hand.get(1)); + assertSame(five, hand.get(2)); + } + + @Test + void activeSingleResultIsMutableAndIndependentFromInput() { + assertMutableIndependentResult( + new ArrayList<>(List.of(card(Card.Rank.SIX))), + List.of()); + } + + @Test + void followingSingleResultIsMutableAndIndependentFromInput() { + assertMutableIndependentResult( + new ArrayList<>(List.of(card(Card.Rank.SIX), card(Card.Rank.FOUR))), + List.of(card(Card.Rank.THREE))); + } + + @Test + void followingPairResultIsMutableAndIndependentFromInput() { + assertMutableIndependentResult( + new ArrayList<>(List.of( + card(Card.Rank.SIX), card(Card.Rank.FOUR), + new Card(Card.Suit.HEARTS, Card.Rank.FOUR))), + List.of(card(Card.Rank.THREE), new Card(Card.Suit.HEARTS, Card.Rank.THREE))); + } + + @Test + void followingTripleResultIsMutableAndIndependentFromInput() { + assertMutableIndependentResult( + new ArrayList<>(List.of( + card(Card.Rank.FOUR), + new Card(Card.Suit.HEARTS, Card.Rank.FOUR), + new Card(Card.Suit.DIAMONDS, Card.Rank.FOUR))), + List.of( + card(Card.Rank.THREE), + new Card(Card.Suit.HEARTS, Card.Rank.THREE), + new Card(Card.Suit.DIAMONDS, Card.Rank.THREE))); + } + + private void assertMutableIndependentResult(List hand, List lastCards) { + List originalOrder = new ArrayList<>(hand); + List result = ai.chooseCardsToPlay(hand, lastCards, true); + + assertDoesNotThrow(() -> { + result.clear(); + result.add(card(Card.Rank.BIG_JOKER)); + }); + assertEquals(originalOrder, hand); + } + + private static Card card(Card.Rank rank) { + Card.Suit suit = rank == Card.Rank.SMALL_JOKER || rank == Card.Rank.BIG_JOKER + ? Card.Suit.JOKER + : Card.Suit.SPADES; + return new Card(suit, rank); + } +} diff --git a/src/test/java/com/wzz/game_console/client/screens/games/landlord/LandlordFullGameSimulationTest.java b/src/test/java/com/wzz/game_console/client/screens/games/landlord/LandlordFullGameSimulationTest.java new file mode 100644 index 0000000..eb68f62 --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/landlord/LandlordFullGameSimulationTest.java @@ -0,0 +1,162 @@ +package com.wzz.game_console.client.screens.games.landlord; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 斗地主完整对局无头模拟:三方 AI 从叫地主打到 ENDED。 + * 覆盖: + * - 出牌推进闭环:AI 决策 → playCards 校验 → pass/领出轮转 → 终局计分 + * - 不变量:手牌总量守恒(不重复扣、不蒸发)、分数零和、终局必达 + * - playCards 的 multiset 校验(重复牌 token 必须整体拒绝) + */ +@Timeout(60) +class LandlordFullGameSimulationTest { + + @Test + void threeAiPlayersPlayThroughToEnd() { + LandlordGame game = new LandlordGame(); + AIPlayer[] ais = { new AIPlayer(), new AIPlayer(), new AIPlayer() }; + for (AIPlayer ai : ais) ai.setGameReference(game); + + // 直接叫地主(首个叫 true 的立即成为地主),跳过"三家全不叫重发"分支 + assertTrue(game.bid(0, true), "指定玩家叫地主必须成功"); + assertEquals(LandlordGame.GameState.PLAYING, game.getGameState()); + + for (int push = 0; push < 5000 && game.getGameState() == LandlordGame.GameState.PLAYING; push++) { + // 不变量:三手牌总数不超过 54(桌面牌来自某手牌,此消彼长) + int total = game.getPlayerHand(0).size() + + game.getPlayerHand(1).size() + + game.getPlayerHand(2).size(); + assertTrue(total <= 54, "推进 " + push + " 次后手牌总数超界: " + total); + + int p = game.getCurrentPlayer(); + List hand = game.getPlayerHand(p); + assertFalse(hand.isEmpty(), "PLAYING 态中轮到空手牌玩家"); + List last = game.getLastPlayedCards(); + + List choice = ais[p].chooseCardsToPlay(new ArrayList<>(hand), new ArrayList<>(last), true); + boolean ok; + if (choice == null || choice.isEmpty()) { + if (last.isEmpty()) { + ok = playMinSingle(game, p, hand); // 领出禁止空过 + } else { + ok = game.playCards(p, new ArrayList<>()); // 过牌(桌面非空恒成功) + } + } else { + ok = game.playCards(p, choice); + if (!ok) { + // AI 平值/非法提案被拒:跟牌场景试过牌,领出场景出最小单张 + if (last.isEmpty()) { + ok = playMinSingle(game, p, hand); + } else { + ok = game.playCards(p, new ArrayList<>()); + } + } + } + assertTrue(ok, "推进 " + push + " 次时玩家 " + p + " 所有兜底都被拒,对局卡死"); + } + + assertEquals(LandlordGame.GameState.ENDED, game.getGameState(), "对局必须在步数上限内终局"); + int roundWinner = game.getRoundWinner(); + assertTrue(roundWinner >= 0 && roundWinner < 3, "终局必须能识别本局获胜者"); + assertTrue(game.getPlayerHand(roundWinner).isEmpty(), "本局获胜者必须是空手牌玩家"); + int landlord = game.getLandlordPlayer(); + for (int player = 0; player < 3; player++) { + boolean expected = player == roundWinner + || (roundWinner != landlord && player != landlord); + assertEquals(expected, LandlordGame.isRoundWinForPlayer(player, landlord, roundWinner), + "农民队伍的本局胜负应与地主身份一致"); + } + int[] scores = game.getScores(); + assertEquals(0, scores[0] + scores[1] + scores[2], "计分必须零和"); + assertTrue(game.getPlayerHand(game.getLandlordPlayer()).isEmpty() + || game.getPlayerHand(0).isEmpty() + || game.getPlayerHand(1).isEmpty() + || game.getPlayerHand(2).isEmpty(), + "ENDED 时必须有玩家手牌打空"); + } + + private static boolean playMinSingle(LandlordGame game, int p, List hand) { + List sorted = new ArrayList<>(hand); + Collections.sort(sorted); + List single = new ArrayList<>(); + single.add(sorted.get(0)); + return game.playCards(p, single); + } + + @Test + void landlordSnapshotAcceptsPlayedBottomCard() { + LandlordGame host = new LandlordGame(); + assertTrue(host.bid(0, true)); + Card playedBottomCard = host.getLandlordCards().get(0); + assertTrue(host.playCards(0, List.of(playedBottomCard))); + + String state = host.serializeFor(0); + LandlordGame client = new LandlordGame(); + List restoredHand = new ArrayList<>(); + + assertTrue(client.applyState(state, 0, restoredHand), + "地主打出一张原底牌后仍应能应用合法状态快照"); + assertFalse(restoredHand.contains(playedBottomCard), + "已打出的底牌不应仍出现在地主手牌中"); + assertTrue(client.getLandlordCards().contains(playedBottomCard), + "底牌集合用于展示和审计,应保留原始三张底牌"); + } + + @Test + void duplicateCardTokensAreRejectedAtomically() { + LandlordGame game = new LandlordGame(); + assertTrue(game.bid(0, true)); + // 构造重复 token:同一张牌出现两次,序列化/反序列化不去重 + String dup = Card.Suit.SPADES.ordinal() + "_" + + Card.Rank.THREE.getValue() + "," + + Card.Suit.SPADES.ordinal() + "_" + + Card.Rank.THREE.getValue(); + List duplicated = LandlordGame.deserializeCards(dup); + assertEquals(2, duplicated.size(), "反序列化保留两个 token(不去重是既有行为)"); + + int p = game.getCurrentPlayer(); + // 无论该玩家是否真持有黑桃3,重复 token 都必须被整体拒绝(不能只扣一张) + boolean accepted = game.playCards(p, duplicated); + if (accepted) { + // 唯一接受可能:手牌里恰好有两张等值牌(不同花色同 rank 构成真实对子) + // 但 token 是同一张牌的两份拷贝——multiset 校验下若手牌只有一张必拒 + long spadeThrees = game.getPlayerHand(p).stream() + .filter(c -> c.getSuit() == Card.Suit.SPADES && c.getRank() == Card.Rank.THREE) + .count(); + assertTrue(spadeThrees == 0, + "重复 token 被接受后手牌不得残留同牌(残留=少扣了一张)"); + } + } + + @Test + void biddingSnapshotsHideBottomCardsAndStillRoundTrip() { + LandlordGame host = new LandlordGame(); + String state = host.serializeFor(1); + assertTrue(state.endsWith("|"), "叫地主阶段底牌字段必须隐藏"); + + LandlordGame client = new LandlordGame(); + List restoredHand = new ArrayList<>(); + assertTrue(client.applyState(state, 1, restoredHand)); + assertEquals(LandlordGame.GameState.BIDDING, client.getGameState()); + assertTrue(client.getLandlordCards().isEmpty(), "客机叫地主阶段不应看到底牌"); + } + + @Test + void roundWinnerUsesEmptyHandAndFarmerTeamDespiteCumulativeScores() { + assertFalse(LandlordGame.isRoundWinForPlayer(0, 0, 1)); + assertTrue(LandlordGame.isRoundWinForPlayer(1, 0, 1)); + assertTrue(LandlordGame.isRoundWinForPlayer(2, 0, 1)); + assertTrue(LandlordGame.isRoundWinForPlayer(0, 0, 0)); + assertFalse(LandlordGame.isRoundWinForPlayer(1, 0, 0)); + assertFalse(LandlordGame.isRoundWinForPlayer(2, 0, 0)); + assertFalse(LandlordGame.isRoundWinForPlayer(0, 0, -1)); + } +} diff --git a/src/test/java/com/wzz/game_console/client/screens/games/landlord/LandlordLanStateTest.java b/src/test/java/com/wzz/game_console/client/screens/games/landlord/LandlordLanStateTest.java new file mode 100644 index 0000000..f470922 --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/landlord/LandlordLanStateTest.java @@ -0,0 +1,139 @@ +package com.wzz.game_console.client.screens.games.landlord; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; + +import static org.junit.jupiter.api.Assertions.*; + +class LandlordLanStateTest { + @Test + void validInitBindsSeatAndState() { + LandlordGame host = new LandlordGame(); + LandlordGame client = new LandlordGame(); + LandlordLanState.Receiver receiver = new LandlordLanState.Receiver(); + String init = LandlordGame.encodeNetworkState(11L, 0L, + "INIT:2|" + host.serializeFor(2)); + + LandlordLanState.Applied applied = receiver.receive(client, init); + + assertNotNull(applied); + assertTrue(applied.init()); + assertEquals(2, applied.seat()); + assertEquals(11L, applied.token()); + assertEquals(0L, applied.sequence()); + assertEquals(host.getGameState(), client.getGameState()); + } + + @Test + void malformedInitDoesNotBindOrMutateClient() { + LandlordGame client = new LandlordGame(); + LandlordLanState.Receiver receiver = new LandlordLanState.Receiver(); + String before = client.serializeFor(0); + + assertNull(receiver.receive(client, LandlordGame.encodeNetworkState(12L, 0L, "INIT:2|bad"))); + assertEquals(before, client.serializeFor(0)); + assertNotNull(receiver.receive(client, LandlordGame.encodeNetworkState(12L, 0L, + "INIT:1|" + new LandlordGame().serializeFor(1)))); + assertNull(receiver.receive(client, LandlordGame.encodeNetworkState(12L, 1L, + "INIT:2|" + new LandlordGame().serializeFor(2)))); + } + + @Test + void stateCannotStartOrSwitchRound() { + LandlordGame client = new LandlordGame(); + LandlordLanState.Receiver receiver = new LandlordLanState.Receiver(); + assertNull(receiver.receive(client, LandlordGame.encodeNetworkState(20L, 0L, + "STATE:" + new LandlordGame().serializeFor(1)))); + + assertNotNull(receiver.receive(client, LandlordGame.encodeNetworkState(20L, 0L, + "INIT:1|" + new LandlordGame().serializeFor(1)))); + assertNull(receiver.receive(client, LandlordGame.encodeNetworkState(21L, 1L, + "STATE:" + new LandlordGame().serializeFor(1)))); + } + + @Test + void duplicateAndRetiredRoundsAreRejected() { + LandlordGame client = new LandlordGame(); + LandlordLanState.Receiver receiver = new LandlordLanState.Receiver(); + String first = LandlordGame.encodeNetworkState(30L, 0L, + "INIT:1|" + new LandlordGame().serializeFor(1)); + assertNotNull(receiver.receive(client, first)); + assertNull(receiver.receive(client, first)); + + String second = LandlordGame.encodeNetworkState(31L, 1L, + "INIT:1|" + new LandlordGame().serializeFor(1)); + assertNotNull(receiver.receive(client, second)); + assertNull(receiver.receive(client, LandlordGame.encodeNetworkState(30L, 2L, + "INIT:1|" + new LandlordGame().serializeFor(1)))); + } + + @Test + void malformedNewRoundAndStateLeaveSnapshotAndSequenceUnchanged() { + LandlordGame host = new LandlordGame(); + LandlordGame client = new LandlordGame(); + LandlordLanState.Receiver receiver = new LandlordLanState.Receiver(); + assertNotNull(receiver.receive(client, LandlordGame.encodeNetworkState(35L, 0L, + "INIT:1|" + host.serializeFor(1)))); + String before = client.serializeFor(1); + assertNull(receiver.receive(client, LandlordGame.encodeNetworkState(36L, 100L, "INIT:1|bad"))); + assertNull(receiver.receive(client, LandlordGame.encodeNetworkState(35L, 100L, "STATE:bad"))); + assertEquals(before, client.serializeFor(1)); + assertTrue(host.bid(0, true)); + assertNotNull(receiver.receive(client, LandlordGame.encodeNetworkState(35L, 1L, + "STATE:" + host.serializeFor(1)))); + assertEquals(host.serializeFor(1), client.serializeFor(1)); + } + + @Test + void hostRestartPreservesScoresAndReinitializesBothClientSeats() { + LandlordGame host = new LandlordGame(); + String[] fields = host.serializeFor(0).split("\\|", -1); + fields[7] = "20,-10,-10"; + assertTrue(host.applyState(String.join("|", fields), 0, new ArrayList<>())); + host.restart(); + LandlordGame[] clients = {new LandlordGame(), new LandlordGame()}; + LandlordLanState.Receiver[] receivers = { + new LandlordLanState.Receiver(), new LandlordLanState.Receiver()}; + for (int i = 0; i < clients.length; i++) { + assertNotNull(receivers[i].receive(clients[i], LandlordGame.encodeNetworkState(37L, i, + "INIT:" + (i + 1) + "|" + host.serializeFor(i + 1)))); + } + assertTrue(host.bid(0, true)); + host.restart(); + assertArrayEquals(new int[]{20, -10, -10}, host.getScores()); + for (int i = 0; i < clients.length; i++) { + assertNotNull(receivers[i].receive(clients[i], LandlordGame.encodeNetworkState(38L, i + 2L, + "INIT:" + (i + 1) + "|" + host.serializeFor(i + 1)))); + assertEquals(host.serializeFor(i + 1), clients[i].serializeFor(i + 1)); + assertEquals(17, clients[i].getPlayerHand(i + 1).size()); + } + } + + @Test + void actionEnvelopeRequiresCurrentRoundAndKnownAction() { + String action = "BID:1:1"; + String encoded = LandlordLanState.encodeAction(41L, action); + assertEquals(action, LandlordLanState.decodeAction(41L, encoded)); + assertNull(LandlordLanState.decodeAction(42L, encoded)); + assertNull(LandlordLanState.decodeAction(41L, action)); + assertNull(LandlordLanState.decodeAction(41L, + LandlordGame.encodeNetworkState(41L, 0L, "INIT:1|x"))); + } + + @Test + void retryInitWithHigherSequenceAppliesLatestSnapshot() { + LandlordGame first = new LandlordGame(); + LandlordGame client = new LandlordGame(); + LandlordLanState.Receiver receiver = new LandlordLanState.Receiver(); + assertNotNull(receiver.receive(client, LandlordGame.encodeNetworkState(50L, 0L, + "INIT:1|" + first.serializeFor(1)))); + + assertTrue(first.bid(0, true)); + String retry = LandlordGame.encodeNetworkState(50L, 2L, + "INIT:1|" + first.serializeFor(1)); + assertNotNull(receiver.receive(client, retry)); + assertEquals(first.getGameState(), client.getGameState()); + assertEquals(first.getCurrentPlayer(), client.getCurrentPlayer()); + } +} diff --git a/src/test/java/com/wzz/game_console/client/screens/games/landlord/LandlordNetworkStateTest.java b/src/test/java/com/wzz/game_console/client/screens/games/landlord/LandlordNetworkStateTest.java new file mode 100644 index 0000000..3b13728 --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/landlord/LandlordNetworkStateTest.java @@ -0,0 +1,54 @@ +package com.wzz.game_console.client.screens.games.landlord; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class LandlordNetworkStateTest { + + @Test + void roundTripPreservesUnsignedTokenSequenceAndPayload() { + String encoded = LandlordGame.encodeNetworkState(-1L, Long.MAX_VALUE, "STATE:a|b|c"); + LandlordGame.NetworkState decoded = LandlordGame.decodeNetworkState(encoded); + + assertNotNull(decoded); + assertEquals(-1L, decoded.sessionToken()); + assertEquals(Long.MAX_VALUE, decoded.sequence()); + assertEquals("STATE:a|b|c", decoded.payload()); + } + + @Test + void zeroSequenceRoundTrips() { + LandlordGame.NetworkState decoded = LandlordGame.decodeNetworkState( + LandlordGame.encodeNetworkState(1L, 0L, "INIT:data")); + + assertNotNull(decoded); + assertEquals(0L, decoded.sequence()); + } + + @Test + void encoderRejectsInvalidMetadataAndPayload() { + assertThrows(IllegalArgumentException.class, + () -> LandlordGame.encodeNetworkState(0L, 0L, "STATE:x")); + assertThrows(IllegalArgumentException.class, + () -> LandlordGame.encodeNetworkState(1L, -1L, "STATE:x")); + assertThrows(IllegalArgumentException.class, + () -> LandlordGame.encodeNetworkState(1L, 0L, null)); + assertThrows(IllegalArgumentException.class, + () -> LandlordGame.encodeNetworkState(1L, 0L, "")); + } + + @Test + void malformedEnvelopeIsRejected() { + assertNull(LandlordGame.decodeNetworkState(null)); + assertNull(LandlordGame.decodeNetworkState("STATE:x")); + assertNull(LandlordGame.decodeNetworkState("LG1|1|0")); + assertNull(LandlordGame.decodeNetworkState("LG1|0|0|STATE:x")); + assertNull(LandlordGame.decodeNetworkState("LG1|1|-1|STATE:x")); + assertNull(LandlordGame.decodeNetworkState("LG1|x|0|STATE:x")); + assertNull(LandlordGame.decodeNetworkState("LG1|1|x|STATE:x")); + assertNull(LandlordGame.decodeNetworkState("LG1|18446744073709551616|0|STATE:x")); + assertNull(LandlordGame.decodeNetworkState("LG1|1|9223372036854775808|STATE:x")); + assertNull(LandlordGame.decodeNetworkState("LG1|1|0|")); + } +} diff --git a/src/test/java/com/wzz/game_console/client/screens/games/landlord/LandlordSimulationTest.java b/src/test/java/com/wzz/game_console/client/screens/games/landlord/LandlordSimulationTest.java new file mode 100644 index 0000000..076f5bc --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/landlord/LandlordSimulationTest.java @@ -0,0 +1,116 @@ +package com.wzz.game_console.client.screens.games.landlord; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 斗地主逻辑包无头模拟(纯 JDK)。 + * 覆盖: + * - 发牌不变量:3×17 + 底牌 3 = 54 张全唯一 + * - 牌型识别与压制关系(canBeat 单调性、炸弹/王炸通吃) + * - LAN 序列化往返一致 + * - AI 叫分与出牌决策产出合法(出牌是手牌子集) + */ +@Timeout(60) +class LandlordSimulationTest { + + @Test + void dealSatisfiesFiftyFourUniqueCardsInvariant() { + LandlordGame game = new LandlordGame(); + Set seen = new HashSet<>(); + List all = new ArrayList<>(); + for (int p = 0; p < 3; p++) { + List hand = game.getPlayerHand(p); + assertEquals(17, hand.size(), "玩家 " + p + " 手牌必须 17 张"); + all.addAll(hand); + } + List bottom = game.getLandlordCards(); + assertEquals(3, bottom.size(), "底牌必须 3 张"); + all.addAll(bottom); + + assertEquals(54, all.size(), "总牌数必须 54"); + for (Card c : all) assertTrue(seen.add(c.getSuit() + "#" + c.getRank()), + "出现重复牌: " + c); + } + + @Test + void cardPatternDominanceRelationsHold() { + List pairSmall = patternOf(Card.Rank.FIVE, 2); + List pairBig = patternOf(Card.Rank.TEN, 2); + CardPattern ps = new LandlordGame().analyzeCards(pairSmall); + CardPattern pb = new LandlordGame().analyzeCards(pairBig); + assertEquals(CardPattern.Type.PAIR, ps.getType()); + assertEquals(Card.Rank.FIVE.getValue(), ps.getValue(), "对子主值必须等于牌面点数"); + assertTrue(pb.canBeat(ps), "大对子必须压小对子"); + assertFalse(ps.canBeat(pb), "小对子不得反压大对子"); + + // 炸弹通吃非炸,王炸通吃炸弹 + List bomb = new ArrayList<>(); + for (Card.Suit s : new Card.Suit[]{Card.Suit.SPADES, Card.Suit.HEARTS, + Card.Suit.DIAMONDS, Card.Suit.CLUBS}) + bomb.add(new Card(s, Card.Rank.SEVEN)); + CardPattern bombP = new LandlordGame().analyzeCards(bomb); + assertEquals(CardPattern.Type.BOMB, bombP.getType()); + assertTrue(bombP.canBeat(pb), "炸弹必须压普通牌型"); + + List jokerBomb = List.of( + new Card(Card.Suit.JOKER, Card.Rank.SMALL_JOKER), + new Card(Card.Suit.JOKER, Card.Rank.BIG_JOKER)); + CardPattern jb = new LandlordGame().analyzeCards(jokerBomb); + assertEquals(CardPattern.Type.JOKER_BOMB, jb.getType()); + assertTrue(jb.canBeat(bombP), "王炸必须压炸弹"); + assertFalse(ps.canBeat(bombP), "小对子不得压炸弹"); + } + + private static List patternOf(Card.Rank rank, int n) { + // 同 rank 只有一个 Suit.JOKER,其余用四花色;n ≤ 4 的场景 + Card.Suit[] suits = {Card.Suit.SPADES, Card.Suit.HEARTS, Card.Suit.DIAMONDS, Card.Suit.CLUBS}; + List list = new ArrayList<>(); + for (int i = 0; i < n; i++) list.add(new Card(suits[i], rank)); + return list; + } + + @Test + void serializationRoundTripIsLossless() { + LandlordGame game = new LandlordGame(); + List hand = game.getPlayerHand(0); + String enc = LandlordGame.serializeCards(hand); + assertFalse(enc.isEmpty()); + List dec = LandlordGame.deserializeCards(enc); + assertEquals(hand.size(), dec.size(), "往返后张数必须一致"); + assertEquals(enc, LandlordGame.serializeCards(dec), "再序列化字符串必须逐字一致"); + assertTrue(LandlordGame.deserializeCards("").isEmpty(), "空串应还原为空列表"); + } + + @Test + void aiDecisionsAreAlwaysSubsetsOfOwnHand() { + LandlordGame game = new LandlordGame(); + AIPlayer ai = new AIPlayer(); + ai.setGameReference(game); + + for (int p = 0; p < 3; p++) { + List hand = game.getPlayerHand(p); + ai.decideBid(hand); + + List lead = ai.chooseCardsToPlay(hand, new ArrayList<>(), true); + if (lead != null && !lead.isEmpty()) { + assertTrue(hand.containsAll(lead), "玩家 " + p + " 首出的牌不在自己手里"); + } + } + + // 跟牌:给一个真实存在的单张压一压 + List hand = game.getPlayerHand(0); + List lastSingle = new ArrayList<>(hand.subList(0, 1)); + List follow = ai.chooseCardsToPlay(hand, lastSingle, true); + if (follow != null && !follow.isEmpty()) { + assertTrue(hand.containsAll(follow), "跟牌必须是手牌子集"); + } + } +} diff --git a/src/test/java/com/wzz/game_console/client/screens/games/landlord/LandlordStartupGuardTest.java b/src/test/java/com/wzz/game_console/client/screens/games/landlord/LandlordStartupGuardTest.java new file mode 100644 index 0000000..4384147 --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/landlord/LandlordStartupGuardTest.java @@ -0,0 +1,35 @@ +package com.wzz.game_console.client.screens.games.landlord; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class LandlordStartupGuardTest { + @Test + void hostSendsOnScheduleThenAbortsAfterFinalAckWindow() { + assertEquals(LandlordStartupGuard.HostAction.NONE, + LandlordStartupGuard.hostAction(4, Long.MIN_VALUE, 0)); + assertEquals(LandlordStartupGuard.HostAction.SEND, + LandlordStartupGuard.hostAction(5, Long.MIN_VALUE, 0)); + assertEquals(LandlordStartupGuard.HostAction.NONE, + LandlordStartupGuard.hostAction(104, 85, 5)); + assertEquals(LandlordStartupGuard.HostAction.ABORT, + LandlordStartupGuard.hostAction(105, 85, 5)); + } + + @Test + void hardTimeoutAbortsHostEvenBeforeAnotherRetryBoundary() { + assertEquals(LandlordStartupGuard.HostAction.ABORT, + LandlordStartupGuard.hostAction(LandlordStartupGuard.TIMEOUT_TICKS, 199, 1)); + } + + @Test + void clientTimeoutOnlyAppliesWhileWaitingForInit() { + assertFalse(LandlordStartupGuard.clientTimedOut(true, + LandlordStartupGuard.TIMEOUT_TICKS - 1)); + assertTrue(LandlordStartupGuard.clientTimedOut(true, + LandlordStartupGuard.TIMEOUT_TICKS)); + assertFalse(LandlordStartupGuard.clientTimedOut(false, + LandlordStartupGuard.TIMEOUT_TICKS)); + } +} diff --git a/src/test/java/com/wzz/game_console/client/screens/games/tictactoe/TicTacToeSimulationTest.java b/src/test/java/com/wzz/game_console/client/screens/games/tictactoe/TicTacToeSimulationTest.java new file mode 100644 index 0000000..e01c024 --- /dev/null +++ b/src/test/java/com/wzz/game_console/client/screens/games/tictactoe/TicTacToeSimulationTest.java @@ -0,0 +1,179 @@ +package com.wzz.game_console.client.screens.games.tictactoe; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 井字棋完整对局模拟(纯 JDK,零外部依赖)。 + * 覆盖: + * - 固定种子随机玩家 vs AI 跑满多局:终局标志、胜负一致性、回合守卫 + * - 已占格重复落子必须被拒绝 + * - 明知必胜局面下 AI(X)能抓住直接获胜着 + */ +@Timeout(60) +class TicTacToeSimulationTest { + + @Test + void twentyRandomGamesAllTerminateConsistently() { + Random rnd = new Random(20260827L); + for (int game = 0; game < 20; game++) { + TicTacToeGame g = new TicTacToeGame(TicTacToeGame.GameMode.SINGLE_PLAYER); + int moves = 0; + while (!g.isGameOver()) { + assertTrue(moves < 9, "超过 9 步仍未终局"); + if (g.isPlayerTurn()) { + List empties = new ArrayList<>(); + for (int r = 0; r < 3; r++) + for (int c = 0; c < 3; c++) + if (g.getCell(r, c) == TicTacToeGame.Player.NONE) + empties.add(new int[]{r, c}); + assertFalse(empties.isEmpty(), "未终局但棋盘已满: 局 " + game); + int[] spot = empties.get(rnd.nextInt(empties.size())); + assertTrue(g.makeMove(spot[0], spot[1]), "空格落子被拒绝: 局 " + game); + } else { + g.makeAIMove(); + } + moves++; + } + TicTacToeGame.Player w = g.getWinner(); + // 平局时 getWinner() 返回 NONE(而非 null) + assertTrue(w == TicTacToeGame.Player.X || w == TicTacToeGame.Player.O + || w == TicTacToeGame.Player.NONE, + "winner 只能为 X/O/NONE, 实际=" + w); + if (w != null) { + assertNotEquals("", g.getGameStatus(), "分出胜负后状态文案不得为空"); + } + } + } + + @Test + void occupiedCellRejectsSecondPlacement() { + TicTacToeGame g = new TicTacToeGame(TicTacToeGame.GameMode.SINGLE_PLAYER); + assertTrue(g.makeMove(0, 0)); + assertFalse(g.makeMove(0, 0), "同格二次落子必须失败"); + assertEquals(1, countStones(g), "拒绝的落子不得改动棋盘"); + } + + @Test + void aiRespondsImmediatelyWhenItsTurnArrives() { + TicTacToeGame g = new TicTacToeGame(TicTacToeGame.GameMode.SINGLE_PLAYER); + assertTrue(g.isPlayerTurn(), "单机模式玩家应先手"); + assertTrue(g.makeMove(1, 1)); + g.makeAIMove(); + assertEquals(2, countStones(g), "AI 回合结束后必须已落一手"); + int[] cell = aiCell(g); + assertEquals(TicTacToeGame.Player.O, g.getCell(cell[0], cell[1]), + "AI 落的必须是 O(玩家执 X)"); + assertTrue(g.isPlayerTurn(), "AI 落完必须轮回玩家"); + } + + @Test + void aiSchedulingDuringPlayersTurnIsNoOp() { + TicTacToeGame g = new TicTacToeGame(TicTacToeGame.GameMode.SINGLE_PLAYER); + g.makeAIMove(); // 玩家未动,AI 不得偷跑 + assertEquals(0, countStones(g), "玩家回合里 AI 调度不得改变棋盘"); + g.makeMove(2, 2); // 玩家落子后游戏翻转为 AI 方 + } + + @Test + void playerCannotPlaceAgainWhileAiIsThinking() { + TicTacToeGame g = new TicTacToeGame(TicTacToeGame.GameMode.SINGLE_PLAYER); + assertTrue(g.makeMove(0, 0)); + assertFalse(g.makeMove(0, 1)); + assertEquals(TicTacToeGame.Player.NONE, g.getCell(0, 1)); + assertEquals(1, countStones(g)); + g.makeAIMove(); + assertEquals(2, countStones(g)); + assertTrue(g.isPlayerTurn()); + } + + @Test + void localTwoPlayerAlternatesWithoutAiAndResets() { + TicTacToeGame g = new TicTacToeGame(TicTacToeGame.GameMode.TWO_PLAYER); + assertTrue(g.makeMove(0, 0)); + assertEquals(TicTacToeGame.Player.O, g.getCurrentPlayer()); + g.makeAIMove(); + assertEquals(1, countStones(g)); + assertTrue(g.makeMove(1, 0)); + assertTrue(g.makeMove(0, 1)); + assertTrue(g.makeMove(1, 1)); + assertTrue(g.makeMove(0, 2)); + assertEquals(TicTacToeGame.Player.X, g.getWinner()); + assertFalse(g.makeMove(2, 2)); + g.resetGame(); + assertEquals(0, countStones(g)); + assertEquals(TicTacToeGame.Player.X, g.getCurrentPlayer()); + assertEquals(TicTacToeGame.GameMode.TWO_PLAYER, g.getGameMode()); + } + + @Test + void networkPlayerCannotTakeOpponentsTurn() { + TicTacToeGame g = new TicTacToeGame(TicTacToeGame.GameMode.TWO_PLAYER); + assertFalse(g.makeMove(0, 0, TicTacToeGame.Player.O)); + assertTrue(g.makeMove(0, 0, TicTacToeGame.Player.X)); + assertFalse(g.makeMove(0, 1, TicTacToeGame.Player.X)); + assertFalse(g.makeMove(0, 1, TicTacToeGame.Player.NONE)); + assertTrue(g.makeMove(0, 1, TicTacToeGame.Player.O)); + assertFalse(g.makeMove(0, 2, TicTacToeGame.Player.O)); + assertEquals(2, countStones(g)); + } + + @Test + void rejectedTwoPlayerMovesPreserveTurn() { + TicTacToeGame g = new TicTacToeGame(TicTacToeGame.GameMode.TWO_PLAYER); + assertTrue(g.makeMove(1, 1)); + assertFalse(g.makeMove(1, 1)); + assertFalse(g.makeMove(-1, 0)); + assertFalse(g.makeMove(0, 3)); + assertFalse(g.makeMove(0, 0, null)); + assertEquals(TicTacToeGame.Player.O, g.getCurrentPlayer()); + assertEquals(1, countStones(g)); + assertTrue(g.makeMove(0, 0, TicTacToeGame.Player.O)); + } + + @Test + void twoPlayerDrawRejectsFurtherMoves() { + TicTacToeGame g = new TicTacToeGame(TicTacToeGame.GameMode.TWO_PLAYER); + int[][] moves = {{0, 0}, {0, 1}, {0, 2}, {1, 1}, {1, 0}, + {1, 2}, {2, 1}, {2, 0}, {2, 2}}; + for (int[] move : moves) assertTrue(g.makeMove(move[0], move[1])); + assertTrue(g.isGameOver()); + assertEquals(TicTacToeGame.Player.NONE, g.getWinner()); + assertEquals(9, countStones(g)); + assertFalse(g.makeMove(0, 0)); + } + + @Test + void resetDuringAiTurnRestoresHumanTurn() { + TicTacToeGame g = new TicTacToeGame(TicTacToeGame.GameMode.SINGLE_PLAYER); + assertTrue(g.makeMove(0, 0)); + g.resetGame(); + g.makeAIMove(); + assertTrue(g.isPlayerTurn()); + assertEquals(0, countStones(g)); + assertTrue(g.makeMove(2, 2)); + assertEquals(TicTacToeGame.Player.X, g.getCell(2, 2)); + } + + /** 返回 AI 刚落下的格点(唯一的 O) */ + private static int[] aiCell(TicTacToeGame g) { + for (int r = 0; r < 3; r++) + for (int c = 0; c < 3; c++) + if (g.getCell(r, c) == TicTacToeGame.Player.O) return new int[]{r, c}; + throw new AssertionError("找不到 AI 落下的 O 子"); + } + + private static int countStones(TicTacToeGame g) { + int n = 0; + for (int r = 0; r < 3; r++) + for (int c = 0; c < 3; c++) + if (g.getCell(r, c) != TicTacToeGame.Player.NONE) n++; + return n; + } +} diff --git a/src/test/java/com/wzz/game_console/network/MultiplayerGamePacketEnvelopeTest.java b/src/test/java/com/wzz/game_console/network/MultiplayerGamePacketEnvelopeTest.java new file mode 100644 index 0000000..200afb0 --- /dev/null +++ b/src/test/java/com/wzz/game_console/network/MultiplayerGamePacketEnvelopeTest.java @@ -0,0 +1,100 @@ +package com.wzz.game_console.network; + +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class MultiplayerGamePacketEnvelopeTest { + + private static final UUID SESSION = UUID.fromString("12345678-1234-5678-9abc-def012345678"); + + @Test + void roundTripPreservesMetadataAndBody() { + String encoded = MultiplayerGameDataEnvelope.encode( + MultiplayerGameDataEnvelope.of(SESSION, 42L, "落子|RESTART|测试")); + MultiplayerGameDataEnvelope.Value decoded = MultiplayerGameDataEnvelope.parse(encoded); + + assertNotNull(decoded); + assertFalse(decoded.legacy()); + assertEquals(SESSION, decoded.sessionId()); + assertEquals(42L, decoded.sequence()); + assertEquals("落子|RESTART|测试", decoded.body()); + } + + @Test + void emptyBodyRoundTrips() { + MultiplayerGameDataEnvelope.Value decoded = MultiplayerGameDataEnvelope.parse( + MultiplayerGameDataEnvelope.encode(MultiplayerGameDataEnvelope.of(SESSION, 0L, ""))); + + assertNotNull(decoded); + assertEquals("", decoded.body()); + assertFalse(decoded.legacy()); + } + + @Test + void bareAndNullDataRemainLegacyCompatible() { + MultiplayerGameDataEnvelope.Value bare = MultiplayerGameDataEnvelope.parse("3,4"); + MultiplayerGameDataEnvelope.Value nullData = MultiplayerGameDataEnvelope.parse(null); + + assertTrue(bare.legacy()); + assertEquals("3,4", bare.body()); + assertNull(bare.sessionId()); + assertEquals(-1L, bare.sequence()); + assertTrue(nullData.legacy()); + assertEquals("", nullData.body()); + } + + @Test + void malformedMgp1DataIsRejected() { + assertNull(MultiplayerGameDataEnvelope.parse("MGP1|")); + assertNull(MultiplayerGameDataEnvelope.parse("MGP1|bad-uuid|1|YQ")); + assertNull(MultiplayerGameDataEnvelope.parse("MGP1|" + SESSION + "|-1|YQ")); + assertNull(MultiplayerGameDataEnvelope.parse("MGP1|" + SESSION + "|x|YQ")); + assertNull(MultiplayerGameDataEnvelope.parse("MGP1|" + SESSION + "|1|%%%")); + assertNull(MultiplayerGameDataEnvelope.parse("MGP1|" + SESSION + "|1|YQ|extra")); + } + + @Test + void oversizedBodyTruncatesInsteadOfThrowing() { + // 中英混排 + 表情,验证按码点边界截断(不产生半个字符) + String oversizedBody = ("落子ABC" + "x").repeat(6_000) + "😀😀😀"; + + String encoded = assertDoesNotThrow(() -> MultiplayerGameDataEnvelope.encode( + MultiplayerGameDataEnvelope.of(SESSION, 0L, oversizedBody))); + + assertTrue(encoded.getBytes(java.nio.charset.StandardCharsets.UTF_8).length <= 32_767, + "encoded envelope must fit MAX_DATA_BYTES"); + + MultiplayerGameDataEnvelope.Value decoded = MultiplayerGameDataEnvelope.parse(encoded); + assertNotNull(decoded); + assertFalse(decoded.legacy()); + assertEquals(SESSION, decoded.sessionId()); + assertEquals(0L, decoded.sequence()); + assertTrue(oversizedBody.startsWith(decoded.body()), "truncated body must be a strict prefix"); + assertFalse(decoded.body().isEmpty()); + // 截断不得劈开代理对 + String tail = decoded.body().substring(decoded.body().length() - 2); + assertFalse(Character.isHighSurrogate(tail.charAt(0)) && !Character.isLowSurrogate(tail.charAt(1)), + "truncation must respect code-point boundaries"); + } + + @Test + void bodyJustUnderBudgetRoundTripsExactly() { + String body = "x".repeat(24_540); + String encoded = MultiplayerGameDataEnvelope.encode(MultiplayerGameDataEnvelope.of(SESSION, 7L, body)); + assertTrue(encoded.getBytes(java.nio.charset.StandardCharsets.UTF_8).length <= 32_767); + MultiplayerGameDataEnvelope.Value decoded = MultiplayerGameDataEnvelope.parse(encoded); + assertNotNull(decoded); + assertEquals(body, decoded.body()); + } + + @Test + void factoryRejectsInvalidMetadata() { + assertThrows(IllegalArgumentException.class, + () -> MultiplayerGameDataEnvelope.of(null, 0L, "body")); + assertThrows(IllegalArgumentException.class, + () -> MultiplayerGameDataEnvelope.of(SESSION, -1L, "body")); + } +} diff --git a/src/test/java/com/wzz/game_console/network/MultiplayerInviteAttemptTest.java b/src/test/java/com/wzz/game_console/network/MultiplayerInviteAttemptTest.java new file mode 100644 index 0000000..2ddc05a --- /dev/null +++ b/src/test/java/com/wzz/game_console/network/MultiplayerInviteAttemptTest.java @@ -0,0 +1,35 @@ +package com.wzz.game_console.network; + +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class MultiplayerInviteAttemptTest { + @Test + void roundTripAndExactMatchingUseOneAttemptNonce() { + UUID nonce = UUID.fromString("12345678-1234-5678-9abc-def012345678"); + + String encoded = MultiplayerInviteAttempt.encode(nonce); + + assertEquals(nonce, MultiplayerInviteAttempt.parse(encoded)); + assertTrue(MultiplayerInviteAttempt.matches(encoded, nonce)); + assertFalse(MultiplayerInviteAttempt.matches(encoded, UUID.randomUUID())); + } + + @Test + void malformedAndLegacyControlDataAreRejected() { + assertNull(MultiplayerInviteAttempt.parse(null)); + assertNull(MultiplayerInviteAttempt.parse("")); + assertNull(MultiplayerInviteAttempt.parse("busy")); + assertNull(MultiplayerInviteAttempt.parse("INV1|bad")); + assertNull(MultiplayerInviteAttempt.parse("INV1|" + UUID.randomUUID() + "|extra")); + assertFalse(MultiplayerInviteAttempt.matches("INV1|bad", UUID.randomUUID())); + } + + @Test + void encodingRequiresNonce() { + assertThrows(IllegalArgumentException.class, () -> MultiplayerInviteAttempt.encode(null)); + } +} diff --git a/src/test/java/com/wzz/game_console/util/GameSettingsTest.java b/src/test/java/com/wzz/game_console/util/GameSettingsTest.java new file mode 100644 index 0000000..ec3c37e --- /dev/null +++ b/src/test/java/com/wzz/game_console/util/GameSettingsTest.java @@ -0,0 +1,57 @@ +package com.wzz.game_console.util; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class GameSettingsTest { + @Test + void iceFireDifficultyPreservesEasyAndClampsToSupportedModes() throws ReflectiveOperationException { + Field settings = GameSettings.class.getDeclaredField("settings"); + Field loaded = GameSettings.class.getDeclaredField("loaded"); + settings.setAccessible(true); + loaded.setAccessible(true); + Object previousSettings = settings.get(null); + boolean previousLoaded = loaded.getBoolean(null); + try { + loaded.setBoolean(null, true); + int[] values = {-10, 0, 1, 2, 3, 10, Integer.MAX_VALUE}; + int[] expected = {0, 0, 1, 2, 2, 2, 2}; + for (int i = 0; i < values.length; i++) { + settings.set(null, Map.of("icefire", Map.of("difficulty", values[i]))); + assertEquals(expected[i], GameSettings.getInt("icefire", "difficulty", 1)); + } + settings.set(null, Map.of()); + assertEquals(0, GameSettings.getInt("icefire", "difficulty", 0)); + } finally { + settings.set(null, previousSettings); + loaded.setBoolean(null, previousLoaded); + } + } + + @Test + void goSearchTimeUsesTheSameRangeAsTheSettingsUi() throws ReflectiveOperationException { + Field settings = GameSettings.class.getDeclaredField("settings"); + Field loaded = GameSettings.class.getDeclaredField("loaded"); + settings.setAccessible(true); + loaded.setAccessible(true); + Object previousSettings = settings.get(null); + boolean previousLoaded = loaded.getBoolean(null); + try { + loaded.setBoolean(null, true); + int[] values = {-1, 100, 10_000, 60_000, 60_001, Integer.MAX_VALUE}; + int[] expected = {GameSettings.GO_SEARCH_TIME_MIN, 100, 10_000, 60_000, + GameSettings.GO_SEARCH_TIME_MAX, GameSettings.GO_SEARCH_TIME_MAX}; + for (int i = 0; i < values.length; i++) { + settings.set(null, Map.of("go", Map.of("searchTime", values[i]))); + assertEquals(expected[i], GameSettings.getInt("go", "searchTime", 3_000)); + } + } finally { + settings.set(null, previousSettings); + loaded.setBoolean(null, previousLoaded); + } + } +}