Files
Ananta-ps/Ananta.Server/Ananta.Handlers/Game/ChineseChessBoard.cs
T

539 lines
25 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// =============================================================================================
// 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
{
/// <summary>棋子类型(同侧内的功能种类,与 chessId 的 %16 分段一一对应)。</summary>
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
}
/// <summary>一步棋(坐标即线上坐标,可直接写进 ChineseChessMove)。</summary>
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})";
}
/// <summary>
/// 9×10 的象棋盘 + 完整走法生成 + 将军/绝杀判定 + 一个 2 层极小极大 AI。
/// 非线程安全;一个 zone 一份,由 handler 持有在 session 里。
/// </summary>
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;
/// <summary>轮到红方?(服务端镜像状态,仅用于日志/AI 选择)</summary>
public bool RedToMove;
/// <summary>本局已走过的着数(写进 ChineseChessMove.MoveIndex)。</summary>
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;
}
/// <summary>红方九宫 x∈[6,8] y∈[3,5];黑方 x∈[6,8] y∈[10,12]。</summary>
public static bool InPalace(bool red, int x, int y) =>
x >= 6 && x <= 8 && (red ? y >= 3 && y <= 5 : y >= 10 && y <= 12);
/// <summary>是否还在本方半场(相/象不可过河)。红 y∈[3,7],黑 y∈[8,12]。</summary>
public static bool InOwnHalf(bool red, int y) => red ? y <= 7 : y >= 8;
/// <summary>兵/卒是否已过河。红过河 = y≥8;黑过河 = y≤7。</summary>
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;
}
/// <summary>清空盘面(残局 / 自定义局面 / 回放重置用)。</summary>
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;
}
/// <summary>直接把一枚子摆在某个点(残局 / 回放 / 摆位校验用)。</summary>
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;
/// <summary>把 32 子的位置吐成一行(日志/断言用)。</summary>
public string Dump()
{
var parts = new List<string>();
for (var id = 0; id < 32; id++)
parts.Add(_pos[id] < 0 ? $"{id}=-" : $"{id}=({_pos[id] % Stride},{_pos[id] / Stride})");
return string.Join(" ", parts);
}
/// <summary>紧凑盘面串(棋盘 9×10,空格用 '.'),日志里一眼能看出错位。</summary>
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';
}
}
// ─────────────────────────────────────────────────────────────────────────────────
// 走法生成
// ─────────────────────────────────────────────────────────────────────────────────
/// <summary>
/// 伪合法走法(几何 + 不吃己子 + 蹩腿/塞眼/炮架),**不**过滤「自将」。
/// ★ 王永远不会被「吃」——所以这里不把对方王的格子当落点;王的「输」由
/// 「无合法着法」(绝杀/困毙)表达。飞将的将军判定另见 <see cref="IsInCheck"/>。
/// </summary>
public List<(int X, int Y)> PseudoTargets(int id) => Targets(id, forAttack: false);
/// <summary>
/// 攻击集:与可落点唯一的差别是**包含对方王的格子**(用于将军判定)。
/// 把这两者分开是必须的 —— 混在一起会让「绝杀」永远判不出来(对方王总能"吃"回来)。
/// </summary>
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;
}
/// <summary>
/// 某一方是否被将军。
/// ① 常规:敌方任何一枚子的**攻击集**覆盖我方王的格子;
/// ② 飞将:双方王同列且中间无子 —— 这一条必须显式写,
/// 因为「王」的攻击集里不含对方王(王不吃王)。
/// </summary>
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;
}
/// <summary>走一步(不改回合、不校验合法性),返回被吃子的 id(无 = -1)。</summary>
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;
}
/// <summary>撤销 ApplyMove。参数就是刚才那一步的原样。</summary>
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--;
}
/// <summary>该子走这一步之后,自己是否被将军(= 送将 / 自将)。</summary>
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;
}
/// <summary>某一方所有**合法**着法(已过滤自将)。</summary>
public List<ChessMove> LegalMoves(bool red)
{
var res = new List<ChessMove>();
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
}
}
/// <summary>从黑方视角的静态子力差(正 = 黑优)。</summary>
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;
}
/// <summary>从某一方视角的静态分(正 = 该方占优)。</summary>
public int EvalFor(bool red) => red ? -EvalBlack() : EvalBlack();
/// <summary>黑方挑一步棋(= <see cref="PickMove"/> 的便捷壳,离线自测用)。</summary>
public ChessMove? PickBlackMove(byte difficulty) => PickMove(red: false, difficulty);
/// <summary>
/// 给<b>某一方</b>挑一步棋。difficulty = 0..4
/// (<c>UX.Game.ChineseChessAIDifficulty</c>: Beginner 0 / Easy 1 / Medium 2 / Hard 3 / Expert 4)。
/// Beginner/Easy 会有一小部分随机着,避免一开局就把玩家掐死。
///
/// ★ R183:原来只有 <see cref="PickBlackMove"/>。加了「玩家可以选择执黑」这条路之后,
/// 服务端还得替 AI 走**红方**(红先手,玩家执黑时开局是 AI 的),所以改成按颜色取。
/// </summary>
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;
}
}
}