// =============================================================================================
// 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;
}
}
}