// ============================================================================================= // R183 · 中国象棋规则引擎(服务端侧,纯逻辑、零依赖) // // 为什么需要它 // ------------ // R182 把「进 zone + 推对手单位」补上之后,客户端**棋盘出来了、棋子能选中**,但「下不了子」。 // 实测(logs/console-20260929-101956.log:1194+)给出精确病灶: // // -> rpc AskChineseChessMovePiece [0x040568ED] #128430095 24b // ! [RPC4229938] unimplemented AskChineseChessMovePiece -> typed-default void 0b // <- ret AskChineseChessMovePiece [0x040568ED] #128430095 e=0 0b // // 客户端 `ChineseChessMgr.MoveChess` 发完这条**只**等 `SyncChineseChessMove` 回推才落子 // (本地不乐观应用 —— `ChineseChessBoardBase.MoveChessTo` 只调 `Chart:MoveChess`,不翻回合), // 而回合翻转的唯一来源是 `SyncGameGroundZoneTurnChange`(68479818)。 // ⇒ 服务端必须:① 回显玩家那一手;② 自己算黑方(AI)那一手;③ 两边都推 + 推回合。 // // 为什么规则要「自己写」而不是照抄客户端 Lua // ------------------------------------------ // ★★★ 客户端 `lua\lua\LX6\MiniGame\ChineseChess\ChineseChessChart.lua` 是 LuaJIT // **反编译产物**,`==`/`~=` 全部反了(铁律 1),而且「吃子」分支被反编译器吞成了 `-- Nothing`: // // M.GetMovePoints_Che → if self.PointHasChess(self, newPoint) then // ... if IsRedChess(a) ~= IsRedChess(b) then // -- Nothing ← 真值这里应有 table.insert(result, newPoint) // M.IsCheck → if shuaiPoint ~= -1 then return false end ← 真值是 == -1 // // ⇒ **照抄必错**。本文件按标准中国象棋规则独立实现(走法几何 + 自将过滤 + 飞将), // 并拿真实报文做对照:实测那一手 炮(10,5)→(7,5) 必须判合法(见 gym_tools/_chess_rules_check)。 // // 坐标约定(★ 实测锚定,不是猜的) // -------------------------------- // `gChineseChessTools.PointToCoord(point)` 返回的就是 (x, y): // x = 列 ∈ [3, 11](9 路) y = 行 ∈ [3, 12](10 行) // 锚点:`ChineseChessChart.InitChartBoard` 把红炮放在 `GetPointByPosition(10, 5)`, // 而实测报文正是 from=(10,5) → to=(7,5)。两者对上 ⇒ 该约定成立。 // 红方在下(y 小),黑方在上(y 大);河界在 y=7 / y=8 之间。 // // 棋子 id(0..31) // --------------- // 红 = 0..15,黑 = 16..31(`IsCheck` 里 `shuaiID = isRed and 0 or 16` 为证)。 // 同侧内按 `InitChartBoard` 的声明顺序分组: // +0 = 帅/将 +1..2 = 仕/士 +3..4 = 相/象 +5..6 = 马 // +7..8 = 车 +9..10 = 炮 +11..15 = 兵/卒 // 初始摆位(与 InitChartBoard 逐条对齐,就是标准象棋): // 红 帅(7,3) 仕(6,3)(8,3) 相(5,3)(9,3) 马(4,3)(10,3) 车(3,3)(11,3) 炮(4,5)(10,5) 兵(3,6)(5,6)(7,6)(9,6)(11,6) // 黑 将(7,12) 士(6,12)(8,12) 象(5,12)(9,12) 马(4,12)(10,12) 车(3,12)(11,12) 炮(4,10)(10,10) 卒(3,9)(5,9)(7,9)(9,9)(11,9) // // 本文件**刻意不引用任何 Drmk 类型** —— 这样 gym_tools/_chess_rules_check 可以用 // `Compile Include` 直接把它编进来做离线断言(见 uxrpc-wire-verification §15.3 的手法)。 // ============================================================================================= using System; using System.Collections.Generic; namespace Ananta.Handlers.Game { /// 棋子类型(同侧内的功能种类,与 chessId 的 %16 分段一一对应)。 internal enum ChessKind { King = 0, // 帅 / 将 id%16 == 0 Advisor = 1, // 仕 / 士 1..2 Elephant = 2, // 相 / 象 3..4 Horse = 3, // 马 5..6 Rook = 4, // 车 7..8 Cannon = 5, // 炮 9..10 Pawn = 6, // 兵 / 卒 11..15 } /// 一步棋(坐标即线上坐标,可直接写进 ChineseChessMove)。 internal readonly struct ChessMove { public readonly int FromId; public readonly int FromX, FromY, ToX, ToY; public ChessMove(int fromId, int fx, int fy, int tx, int ty) { FromId = fromId; FromX = fx; FromY = fy; ToX = tx; ToY = ty; } public override string ToString() => $"#{FromId} ({FromX},{FromY})->({ToX},{ToY})"; } /// /// 9×10 的象棋盘 + 完整走法生成 + 将军/绝杀判定 + 一个 2 层极小极大 AI。 /// 非线程安全;一个 zone 一份,由 handler 持有在 session 里。 /// internal sealed class ChineseChessBoard { public const int MinX = 3, MaxX = 11; // 9 路 public const int MinY = 3, MaxY = 12; // 10 行 public const int Empty = -1; private const int Stride = 16; private readonly int[] _at = new int[16 * Stride]; // 格子 → 棋子 id(index = y*16 + x) private readonly int[] _pos = new int[32]; // 棋子 id → 格子(-1 = 不在盘上) private readonly Random _rng; /// 轮到红方?(服务端镜像状态,仅用于日志/AI 选择) public bool RedToMove; /// 本局已走过的着数(写进 ChineseChessMove.MoveIndex)。 public int MoveCount; public ChineseChessBoard(int seed = 0) { _rng = seed == 0 ? new Random() : new Random(seed); Init(); } // ───────────────────────────────────────────────────────────────────────────────── // 基础 // ───────────────────────────────────────────────────────────────────────────────── public static bool IsRedId(int id) => id >= 0 && id < 16; public static bool InBoard(int x, int y) => x >= MinX && x <= MaxX && y >= MinY && y <= MaxY; public static ChessKind KindOf(int id) { var t = id & 15; if (t == 0) return ChessKind.King; if (t <= 2) return ChessKind.Advisor; if (t <= 4) return ChessKind.Elephant; if (t <= 6) return ChessKind.Horse; if (t <= 8) return ChessKind.Rook; if (t <= 10) return ChessKind.Cannon; return ChessKind.Pawn; } /// 红方九宫 x∈[6,8] y∈[3,5];黑方 x∈[6,8] y∈[10,12]。 public static bool InPalace(bool red, int x, int y) => x >= 6 && x <= 8 && (red ? y >= 3 && y <= 5 : y >= 10 && y <= 12); /// 是否还在本方半场(相/象不可过河)。红 y∈[3,7],黑 y∈[8,12]。 public static bool InOwnHalf(bool red, int y) => red ? y <= 7 : y >= 8; /// 兵/卒是否已过河。红过河 = y≥8;黑过河 = y≤7。 public static bool PawnCrossedRiver(bool red, int y) => red ? y >= 8 : y <= 7; private static readonly (int, int)[] Dirs = { (1, 0), (-1, 0), (0, 1), (0, -1) }; public int At(int x, int y) => InBoard(x, y) ? _at[(y * Stride) + x] : Empty; private void Put(int x, int y, int id) { if (InBoard(x, y)) _at[(y * Stride) + x] = id; if (id >= 0 && id < 32) _pos[id] = InBoard(x, y) ? (y * Stride) + x : -1; } private void ClearAt(int x, int y) { if (!InBoard(x, y)) return; var old = _at[(y * Stride) + x]; _at[(y * Stride) + x] = Empty; if (old >= 0 && old < 32) _pos[old] = -1; } /// 清空盘面(残局 / 自定义局面 / 回放重置用)。 public void Clear() { for (var i = 0; i < _at.Length; i++) _at[i] = Empty; for (var i = 0; i < 32; i++) _pos[i] = -1; MoveCount = 0; } /// 直接把一枚子摆在某个点(残局 / 回放 / 摆位校验用)。 public void Place(int x, int y, int id) { if (!InBoard(x, y) || id < 0 || id >= 32) return; ClearAt(x, y); Put(x, y, id); } public void Init() { Clear(); RedToMove = true; // 红 —— 与 ChineseChessChart.InitChartBoard 逐条对齐 Put(7, 3, 0); // 帅 Put(6, 3, 1); Put(8, 3, 2); // 仕 ×2 Put(5, 3, 3); Put(9, 3, 4); // 相 ×2 Put(4, 3, 5); Put(10, 3, 6); // 马 ×2 Put(3, 3, 7); Put(11, 3, 8); // 车 ×2 Put(4, 5, 9); Put(10, 5, 10); // 炮 ×2 Put(3, 6, 11); Put(5, 6, 12); Put(7, 6, 13); Put(9, 6, 14); Put(11, 6, 15); // 兵 ×5 // 黑 Put(7, 12, 16); // 将 Put(6, 12, 17); Put(8, 12, 18); // 士 ×2 Put(5, 12, 19); Put(9, 12, 20); // 象 ×2 Put(4, 12, 21); Put(10, 12, 22); // 马 ×2 Put(3, 12, 23); Put(11, 12, 24); // 车 ×2 Put(4, 10, 25); Put(10, 10, 26); // 炮 ×2 Put(3, 9, 27); Put(5, 9, 28); Put(7, 9, 29); Put(9, 9, 30); Put(11, 9, 31); // 卒 ×5 } public bool TryLocate(int id, out int x, out int y) { var p = (id >= 0 && id < 32) ? _pos[id] : -1; if (p < 0) { x = y = 0; return false; } x = p % Stride; y = p / Stride; return true; } public bool IsAlive(int id) => id >= 0 && id < 32 && _pos[id] >= 0; private static int KingId(bool red) => red ? 0 : 16; /// 把 32 子的位置吐成一行(日志/断言用)。 public string Dump() { var parts = new List(); for (var id = 0; id < 32; id++) parts.Add(_pos[id] < 0 ? $"{id}=-" : $"{id}=({_pos[id] % Stride},{_pos[id] / Stride})"); return string.Join(" ", parts); } /// 紧凑盘面串(棋盘 9×10,空格用 '.'),日志里一眼能看出错位。 public string Render() { var sb = new System.Text.StringBuilder(); for (var y = MinY; y <= MaxY; y++) { sb.Append(y - MinY).Append(' '); for (var x = MinX; x <= MaxX; x++) { var id = At(x, y); sb.Append(id == Empty ? '.' : Glyph(id).ToString()); sb.Append(' '); } sb.Append('\n'); } return sb.ToString(); } private static char Glyph(int id) { var black = !IsRedId(id); switch (KindOf(id)) { case ChessKind.King: return black ? 'k' : 'K'; case ChessKind.Advisor: return black ? 'a' : 'A'; case ChessKind.Elephant: return black ? 'b' : 'B'; case ChessKind.Horse: return black ? 'n' : 'N'; case ChessKind.Rook: return black ? 'r' : 'R'; case ChessKind.Cannon: return black ? 'c' : 'C'; default: return black ? 'p' : 'P'; } } // ───────────────────────────────────────────────────────────────────────────────── // 走法生成 // ───────────────────────────────────────────────────────────────────────────────── /// /// 伪合法走法(几何 + 不吃己子 + 蹩腿/塞眼/炮架),**不**过滤「自将」。 /// ★ 王永远不会被「吃」——所以这里不把对方王的格子当落点;王的「输」由 /// 「无合法着法」(绝杀/困毙)表达。飞将的将军判定另见 。 /// public List<(int X, int Y)> PseudoTargets(int id) => Targets(id, forAttack: false); /// /// 攻击集:与可落点唯一的差别是**包含对方王的格子**(用于将军判定)。 /// 把这两者分开是必须的 —— 混在一起会让「绝杀」永远判不出来(对方王总能"吃"回来)。 /// private List<(int X, int Y)> Targets(int id, bool forAttack) { var res = new List<(int, int)>(); if (!TryLocate(id, out var x, out var y)) return res; var red = IsRedId(id); var kind = KindOf(id); var enemyKing = KingId(!red); void Add(int tx, int ty) { if (!InBoard(tx, ty)) return; var t = At(tx, ty); if (t != Empty) { if (IsRedId(t) == red) return; // 不吃己子 if (t == enemyKing && !forAttack) return; // 不吃王(攻击集里要留) } res.Add((tx, ty)); } switch (kind) { case ChessKind.King: Add(x + 1, y); Add(x - 1, y); Add(x, y + 1); Add(x, y - 1); res.RemoveAll(p => !InPalace(red, p.Item1, p.Item2)); // 九宫限制 break; case ChessKind.Advisor: Add(x + 1, y + 1); Add(x + 1, y - 1); Add(x - 1, y + 1); Add(x - 1, y - 1); res.RemoveAll(p => !InPalace(red, p.Item1, p.Item2)); break; case ChessKind.Elephant: for (var sx = -1; sx <= 1; sx += 2) for (var sy = -1; sy <= 1; sy += 2) { var tx = x + (2 * sx); var ty = y + (2 * sy); if (!InBoard(tx, ty)) continue; if (!InOwnHalf(red, ty)) continue; // 象不过河 if (At(x + sx, y + sy) != Empty) continue; // 塞象眼 Add(tx, ty); } break; case ChessKind.Horse: for (var sx = -1; sx <= 1; sx += 2) for (var sy = -1; sy <= 1; sy += 2) { // 蹩马腿:走 2 的那一轴上的相邻格必须为空 if (At(x + sx, y) == Empty) Add(x + (2 * sx), y + sy); if (At(x, y + sy) == Empty) Add(x + sx, y + (2 * sy)); } break; case ChessKind.Rook: foreach (var (dx, dy) in Dirs) { var tx = x + dx; var ty = y + dy; while (InBoard(tx, ty)) { var t = At(tx, ty); if (t == Empty) res.Add((tx, ty)); else { if (IsRedId(t) != red) res.Add((tx, ty)); break; } tx += dx; ty += dy; } } break; case ChessKind.Cannon: foreach (var (dx, dy) in Dirs) { var tx = x + dx; var ty = y + dy; // ① 未遇炮架前:空格可走 while (InBoard(tx, ty) && At(tx, ty) == Empty) { res.Add((tx, ty)); tx += dx; ty += dy; } if (!InBoard(tx, ty)) continue; // ② 越过炮架(该枚敌我皆可当架子):找架子之后的第一个子 tx += dx; ty += dy; while (InBoard(tx, ty) && At(tx, ty) == Empty) { tx += dx; ty += dy; } if (!InBoard(tx, ty)) continue; var t2 = At(tx, ty); if (IsRedId(t2) != red) res.Add((tx, ty)); // 隔一子吃敌子 } break; case ChessKind.Pawn: Add(x, red ? y + 1 : y - 1); // 只能向前 if (PawnCrossedRiver(red, y)) { Add(x + 1, y); Add(x - 1, y); } // 过河可横走 break; } return res; } /// /// 某一方是否被将军。 /// ① 常规:敌方任何一枚子的**攻击集**覆盖我方王的格子; /// ② 飞将:双方王同列且中间无子 —— 这一条必须显式写, /// 因为「王」的攻击集里不含对方王(王不吃王)。 /// public bool IsInCheck(bool red) { if (!TryLocate(KingId(red), out var kx, out var ky)) return false; // 王已不在盘上 // ② 飞将 if (TryLocate(KingId(!red), out var ex, out var ey) && ex == kx) { var clear = true; for (var yy = Math.Min(ky, ey) + 1; yy <= Math.Max(ky, ey) - 1; yy++) if (At(kx, yy) != Empty) { clear = false; break; } if (clear) return true; } // ① 常规 for (var id = 0; id < 32; id++) { if (IsRedId(id) == red) continue; // 只看敌方 if (_pos[id] < 0) continue; foreach (var (tx, ty) in Targets(id, forAttack: true)) if (tx == kx && ty == ky) return true; } return false; } /// 走一步(不改回合、不校验合法性),返回被吃子的 id(无 = -1)。 public int ApplyMove(int id, int tx, int ty) { if (!TryLocate(id, out var x, out var y)) return Empty; var captured = At(tx, ty); ClearAt(x, y); if (captured != Empty) ClearAt(tx, ty); Put(tx, ty, id); MoveCount++; return captured; } /// 撤销 ApplyMove。参数就是刚才那一步的原样。 public void UndoMove(int id, int fx, int fy, int tx, int ty, int captured) { ClearAt(tx, ty); Put(fx, fy, id); if (captured != Empty) Put(tx, ty, captured); MoveCount--; } /// 该子走这一步之后,自己是否被将军(= 送将 / 自将)。 public bool LeavesOwnKingInCheck(int id, int tx, int ty) { if (!TryLocate(id, out var fx, out var fy)) return true; var captured = ApplyMove(id, tx, ty); var bad = IsInCheck(IsRedId(id)); UndoMove(id, fx, fy, tx, ty, captured); return bad; } /// 某一方所有**合法**着法(已过滤自将)。 public List LegalMoves(bool red) { var res = new List(); for (var id = red ? 0 : 16; id < (red ? 16 : 32); id++) { if (_pos[id] < 0) continue; if (!TryLocate(id, out var fx, out var fy)) continue; foreach (var (tx, ty) in PseudoTargets(id)) if (!LeavesOwnKingInCheck(id, tx, ty)) res.Add(new ChessMove(id, fx, fy, tx, ty)); } return res; } public bool HasAnyLegalMove(bool red) => LegalMoves(red).Count > 0; // ───────────────────────────────────────────────────────────────────────────────── // 评估 + AI // ───────────────────────────────────────────────────────────────────────────────── // 子力价值:与客户端 ChineseChessEnum.lua 的打分表同量级 // (帅10000 / 仕100 / 相100 / 马400 / 车900 / 炮450 / 兵100) public static int ValueOf(int id) { switch (KindOf(id)) { case ChessKind.King: return 10000; case ChessKind.Advisor: return 100; case ChessKind.Elephant: return 100; case ChessKind.Horse: return 400; case ChessKind.Rook: return 900; case ChessKind.Cannon: return 450; default: return 100; // Pawn } } /// 从黑方视角的静态子力差(正 = 黑优)。 public int EvalBlack() { var score = 0; for (var id = 0; id < 32; id++) { if (_pos[id] < 0) continue; score += IsRedId(id) ? -ValueOf(id) : ValueOf(id); } return score; } /// 从某一方视角的静态分(正 = 该方占优)。 public int EvalFor(bool red) => red ? -EvalBlack() : EvalBlack(); /// 黑方挑一步棋(= 的便捷壳,离线自测用)。 public ChessMove? PickBlackMove(byte difficulty) => PickMove(red: false, difficulty); /// /// 给某一方挑一步棋。difficulty = 0..4 /// (UX.Game.ChineseChessAIDifficulty: Beginner 0 / Easy 1 / Medium 2 / Hard 3 / Expert 4)。 /// Beginner/Easy 会有一小部分随机着,避免一开局就把玩家掐死。 /// /// ★ R183:原来只有 。加了「玩家可以选择执黑」这条路之后, /// 服务端还得替 AI 走**红方**(红先手,玩家执黑时开局是 AI 的),所以改成按颜色取。 /// public ChessMove? PickMove(bool red, byte difficulty) { var mine = LegalMoves(red); if (mine.Count == 0) return null; // 无着可走 = 已被将死/困毙 if (difficulty <= 1 && _rng.Next(100) < 20) return mine[_rng.Next(mine.Count)]; ChessMove? best = null; var bestScore = int.MinValue; var myKing = KingId(red); var enemy = !red; foreach (var m in mine) { var captured = ApplyMove(m.FromId, m.ToX, m.ToY); int score; if (!IsAlive(myKing)) { score = int.MinValue + 1; // 理论不可达(自将已被 LegalMoves 过滤) } else if (!IsAlive(KingId(enemy))) { score = 2_000_000; // 吃到对方王(同上,留作兜底) } else if (!HasAnyLegalMove(enemy)) { score = 1_000_000; // 绝杀 / 困毙(象棋里困毙也算输) } else { // 第 2 层:对手会挑「对我最不利」的那一手(极小极大) var worst = int.MaxValue; foreach (var r in LegalMoves(enemy)) { var rc = ApplyMove(r.FromId, r.ToX, r.ToY); var v = EvalFor(red); UndoMove(r.FromId, r.FromX, r.FromY, r.ToX, r.ToY, rc); if (v < worst) worst = v; } score = worst; if (captured != Empty) score += 5; // 同分时略偏好「吃到东西」 } UndoMove(m.FromId, m.FromX, m.FromY, m.ToX, m.ToY, captured); score += _rng.Next(-3, 4); // 轻微扰动,避免每局一模一样 if (score > bestScore) { bestScore = score; best = m; } } return best; } } }