四国军棋引擎开发(10)局面评估优化

这次对局面评估做了一些优化,棋力有了一些提升,可以定为2.1版本,测试结果如下:

引擎A vs 引擎B 战绩(胜:负:和)
1.1 vs 1.0 8:2:0
1.2 vs 1.1 8:2:0
1.2 vs 1.0 10:0:0
2.0 vs 1.2 8:1:1
2.0 vs 1.1

8:0:2

2.1 vs 2.0 7:2:1
2.1 vs 1.2 8:0:2

1.线路评估

在之前的局面评估中,我们主要关注子力的分值情况,另外还加了炸弹和地雷的暗信息,但是军棋中影响局面的优劣还有很多其他因素,如军旗的危险程度、地雷是否被飞开、子力的线路分布,子力的受保护情况,令子和炸弹的数量等等。

一开始我选择了子力的线路分布来做局面评估的优化,如下图所示,分别对左右2边初始化到大本营的所有路线,对应的函数为GenJunqiPath()

在局面评估时会搜索所有可行的线路,根据线路上地雷数量的多少优先选择地雷少的线路,如果地雷数量一样优先选择棋子数量少的,也就是说找到一条最容易到达军棋的线路,找到之后放在pEngine->pJunqiPath[0]里面。如果亮棋了,则以军旗为目的地,否则以大本营为目的地。这里有2点需要值得注意:

  1. 如果路线中包含底营,底营中有自家棋子,那么这条线路是不可行的。
  2. 如果线路中有敌方棋子,那么路径的起始点更改为敌方棋子所在位置,因为这个最短路径是对敌方而言的

接着根据最短路径上地雷和棋子的数量计算出相应的分值,现在问题来了,搜索路径相对于之前子力评估所需要的时间多了数十倍,而局面评估函数是每一次搜索都需要执行的函数,也就是说原来不到1秒的搜索时间现在需要几十秒来完成,这是不可接受的。所以线路评估不能放在局面评估中,这段代码现在暂时不用,后续可以考虑在β 剪枝中再综合线路评估来决定是否需要剪枝,另外还一个可能的作用是检测地雷是否挖开,线路上子力分布情况向引擎反馈相应事件,引擎收到事件后会做出针对性的处理。

2.位置评估

由于搜索线路的时间太多,我们改为对棋子的位置评估。在军旗中后2排属于雷区,这里靠近军旗,属于防守的关键区域,非常重要,现在我们进行分类,如下图所示

分别为底角(红色),三角雷的位置(黄色),中路(绿色 )。开始的时候对这三种类型分别做判断,然后根据位置上的子是否为地雷,是自家的棋还是敌方的棋分别做评分,如果是自家的棋是加分,如果是敌方的棋则减分,混在一起不好调整,还有考虑到效率的问题,干脆只考虑出现敌方棋子的情况,这里相当于一个危险系数的评分。

现在只考虑亮军旗的情况,根据军旗在哪边分成左右2种情况,每种各7个位置,军旗侧的黄色和红色3个位置,另一边铁路上的3个位置,再加中路底下一个位置,右路坑底的那个位置没什么威胁性,所以不考虑。现在以右边红色的位置编号为21,然后往左数,各位置定义如下

    u8 aLeftBarrier[7] = {23,29,27,24,22,20,21};
    u8 aRightBarrier[7] = {21,25,27,20,22,23,24};

这里分数评估首先要考虑雷是否被挖开,雷被挖开了,那么需要扣除一定的分数,地雷被挖开的位置又分为主线侧还是副线侧,旗上同时属于2侧,由于主线被挖开的威胁比较大,扣的分数要多一点。如果敌方有棋子在三角雷的位置,将直接威胁到军旗,这里将扣最多的分数,其他位置需要看这一侧的雷是否挖开而进行扣分,相关代码如下:

int CalDangerValue(Junqi *pJunqi, int iDir, u8 *pDir)
{
    int value = 0;
    BoardChess *pChess;

    int i;
    int landFlag1 = 0;//主线雷是否挖开
    int landFlag2 = 0;//副线雷是否挖开
    Engine *pEngine;
    Value_Parameter *pVal;

    pEngine = pJunqi->pEngine;
    pVal= &pEngine->valPara;

    for(i=0;i<7;i++)
    {
        pChess = &pJunqi->ChessPos[iDir][pDir[i]];
        switch( i )
        {
        case 0:
            if( NONE==pChess->type || pChess->pLineup->isNotLand )
            {
                landFlag1 = 1;
                landFlag2 = 1;
            }
            break;
        case 1:
            if(  NONE==pChess->type || pChess->pLineup->isNotLand )
            {
                landFlag1 = 1;
            }
            break;
        case 2:
            if(  NONE==pChess->type || pChess->pLineup->isNotLand )
            {
                landFlag2 = 1;
            }
            break;
        default:
            break;
        }

        if( pChess->type!=NONE )
        {
            //存在敌方棋子
            if((pChess->pLineup->iDir&1)!=(iDir&1))
            {
                if(i<3)
                {
                    value += pVal->vDanger<<2;//军旗旁边
                }
                else
                {
                    if( 3==i && landFlag1 )
                    {
                        value += pVal->vDanger<<1;//角上
                    }
                    else if( landFlag2 )
                    {
                        value += pVal->vDanger;//副线侧
                    }
                }
            }

        }
    }
    if( landFlag1 )
    {
        value += pVal->vDanger<<1;
    }
    else if( landFlag2 )
    {
        value += pVal->vDanger;
    }


    return value;
}

营在军旗中有着很关键的作用,如果自家的营被占了,那么子力将被牵制而无法进攻,也会影响棋子的联结而不能有效防守。营的类型分为3类,分别是上营、中营和下营,显然当军旗明了后,旗上的营和中营的位置非常关键,相应的分值设定要比其他营高。当敌方的营被己方占领后,将会增加一定的分数,但是如果是令子的话,更应该去控盘而不是一直在营里,所以这里额外判断大于37的子得分要少一些,当然后有好的控盘分值的表示方法,可以把这个判断去掉。

3.调试记录

调试中出现很多问题,这里做一个记录:

1.我们在上一次搜索时会记录最好的主要变例着法,这一次每层搜索都会先搜主要变例,接下来再搜生成的着法,生成的着法中出现主要变例就跳过,之前的代码是和pData->pBest比较,但pData->pBest记录的是这一层搜到的最佳着法,而不是上一次搜到的主要变例,所以需要pData->aInitBest记录这一层的主要变例,把

        if ( pData->pBest!=NULL && !memcmp(pData->pBest, &p->move, 4) )
        {
            UnMakeMove(pJunqi,&p->move);
            goto continue_search;
        }

改成

        if ( pData->pBest!=NULL && !memcmp(pData->aInitBest, &p->move, 4) )
        {
            UnMakeMove(pJunqi,&p->move);
            goto continue_search;
        }

2.每一种碰撞有3种情况,在得到分数后需要返回上一层做平均值,这时要完全搜索,不能剪枝,否则会影响最好分数的准确性,所以需要增加一个isMove变量来判断是移动还是碰撞,如果是碰撞,那么将β 设为无穷大就不会剪枝

int CallAlphaBeta1(
        Junqi *pJunqi,
        int depth,
        int alpha,
        int beta,
        int iDir,
        u8 isMove )
{
    int val;

    if( iDir%2==pJunqi->eTurn%2 )
    {
        if( isMove )
            val = AlphaBeta1(pJunqi,depth,alpha,beta);
        else
            val = AlphaBeta1(pJunqi,depth,alpha,INFINITY);
    }
    else
    {
        if( isMove )
            val = -AlphaBeta1(pJunqi,depth,-beta,-alpha);
        else
            val = -AlphaBeta1(pJunqi,depth,-beta,INFINITY);
    }

    return val;
}

3.每一次搜到的主要变例会存放在aBestMove[0].pHead链表里,可能碰到一开始搜到的主要变例有3步,后来搜到主要变例为2步,因为出现无棋可走,由于代码结构的问题第3步并没有在链表里被删除,所以在碰到无棋可走时就不要再继续往下搜主要变例

    if( NULL==pJunqi->pMoveList )
    {
        pJunqi->eTurn = iDir;
        ChessTurn(pJunqi);
        val = CallAlphaBeta1(pJunqi,depth-1,alpha,beta,iDir,1);
        aBestMove[cnt-1].flag2 = 0;

        //在SearchBestMove里,一层可能有多种碰撞结果
        //不是概率最大的一种,不要修改pNode
        //因为best的搜索只在概率最大的分支下面
        if( aBestMove[cnt-1].mxPerFlag )
        {
            aBestMove[0].pNode = NULL;
        }
    }

AlphaBeta1和AlphaBeta函数不同,并不是一开始就生成所有着法,而是边生成着法边搜索,所以一开始并不知道NULL==pJunqi->pMoveList这个条件,所以把SearchBestMove放在SearchAlphaBeta函数里,要注意只在第一次搜索,所以加一个变量来标记第一次

    //如果这层是无棋可走,代码是不会运行到这里
    if( !pData->bestFlag )
    {
        pData->bestFlag = 1;
        pData->mxVal = SearchBestMove(pJunqi,aBestMove,cnt,alpha,beta,&pData->pBest,depth,1);
        if( pData->pBest!=NULL )
        {
            memcpy(pData->aInitBest,pData->pBest,4);
        }

        if( pData->mxVal>alpha )
            pData->alpha = pData->mxVal;
    }

另外这部分代码不能放在下面代码的前面,因为pJunqi->pMoveList是搜索着法时得到的值,在SearchBestMove函数里可能被更改,将影响后续的搜索

    //注意SearchBestMove不能放前面
    //否则会破坏pJunqi->pMoveList这个全局临时变量
    if( pData->pCur==NULL )
    {
        pData->pCur = pJunqi->pMoveList;
        pData->pHead = pJunqi->pMoveList;
    }
    else
    {
        ...
    }

4.敌方行棋时,我们假定敌方不会撞上己方吃过2个子棋而killed,所以我们需要对碰撞的原始概率进行调整,减少killed的概率,增加bomb的概率,也就是说判断更容易被炸。

4.源代码

https://github.com/pfysw/JunQi

猜你喜欢

转载自blog.csdn.net/pfysw/article/details/85006146
今日推荐