Ai/BotSearch.cs

The BotSearch class manages the artificial intelligence logic for a CitySim bot. It generates potential moves based on card effects and board state, calculates their heuristic value, and selects a move using a beam search lookahead algorithm.

using System;
using System.Collections.Generic;
using System.Linq;
using CitySim.Cards;
using CitySim.Match;

namespace CitySim.Ai;

internal enum BotMoveKind { Place, Event, Claim, Renovate }
internal sealed record BotMove(BotMoveKind Kind,string Card,int Slot,int X=0,int Y=0,int ReplacementSlot=-1,double Rough=0)
{
    internal bool Apply(MatchState board,Guid player) => Kind switch {
        BotMoveKind.Event=>board.PlayEvent(player,Slot),
        BotMoveKind.Claim=>board.TryClaim(player,Slot,X,Y).Success,
        BotMoveKind.Renovate=>board.TryRenovate(player,Slot,ReplacementSlot,X,Y).Success,
        _=>board.TryPlace(player,Card,X,Y).Success
    };
    internal bool Valid(MatchState board,Guid player)
    {
        var hand=board.Players[player].Hand;
        if(Slot<0 || Slot>=hand.Count || hand[Slot]!=(Kind==BotMoveKind.Renovate ? "renovation":Card)) return false;
        return Kind switch {
            BotMoveKind.Event=>true,
            BotMoveKind.Claim=>board.CardPlayError(player,Card,X,Y)==null,
            BotMoveKind.Renovate=>board.RenovationError(player,Slot,ReplacementSlot,X,Y)==null,
            _=>board.PlacementError(player,Card,X,Y)==null
        };
    }
}
internal sealed record BotMoveScore(BotMove Move,double Value,MatchState After);

/// <summary>One step scans at most 64 sites or scores one move; no whole-board deep search in Update.</summary>
internal sealed class BotSearch
{
    readonly MatchState board;
    readonly Guid owner;
    readonly BotEvaluation evaluation;
    readonly BotDifficulty difficulty;
    readonly bool lookahead;
    readonly int limit;
    readonly double baseline;
    readonly IEnumerator<BotMove> generator;
    readonly List<BotMove> rough=new();
    readonly List<BotMoveScore> scores=new();
    List<BotMove> shortlist;
    int cursor,beam;
    BotSearch continuation;
    BotMoveScore[] beams;
    internal bool Complete { get; private set; }
    internal int EvaluatedMoves { get; private set; }
    internal IReadOnlyList<BotMoveScore> Scores=>scores;
    internal BotMoveScore Best => scores.OrderByDescending(s=>s.Value).FirstOrDefault();

    internal BotSearch(MatchState board,Guid owner,BotEvaluation evaluation,BotDifficulty difficulty,string onlyCard=null,
        bool lookahead=true,double? baseline=null,int? limit=null)
    {
        this.board=board;this.owner=owner;this.evaluation=evaluation;this.difficulty=difficulty;this.lookahead=lookahead;
        this.baseline=baseline ?? evaluation.Score(board);
        this.limit=limit ?? (difficulty switch {BotDifficulty.Easy=>6,BotDifficulty.Normal=>10,_=>14});
        generator=Generate(onlyCard).GetEnumerator();
    }
    internal void Step()
    {
        if(Complete) return;
        if(shortlist==null)
        {
            for(var i=0;i<64;i++)
            {
                if(!generator.MoveNext())
                {
                    // Keep the best site for each card, then fill remaining slots by merit.
                    shortlist=rough.GroupBy(m=>m.Card).Select(g=>g.OrderByDescending(m=>m.Rough).First())
                        .Concat(rough.OrderByDescending(m=>m.Rough)).DistinctBy(m=>(m.Kind,m.Slot,m.X,m.Y,m.ReplacementSlot)).Take(limit).ToList();
                    break;
                }
                if(generator.Current!=null) rough.Add(generator.Current);
            }
            return;
        }
        if(cursor<shortlist.Count)
        {
            var action=shortlist[cursor++];
            var copy=BotEvaluation.PublicClone(board,owner);
            if(action.Apply(copy,owner))
            {
                var territory=action.Kind==BotMoveKind.Claim ? Math.Min(0.3,action.Rough*0.05):0;
                scores.Add(new(action,evaluation.Score(copy)-baseline+evaluation.Affinity(action.Card)+territory,copy));
                EvaluatedMoves++;
            }
            return;
        }
        if(lookahead && difficulty==BotDifficulty.Hard)
        {
            beams ??= scores.OrderByDescending(s=>s.Value).Where(s=>s.After.Players[owner].Hand.Count>0).Take(2).ToArray();
            if(beam<beams.Length)
            {
                var first=beams[beam];
                continuation ??= new(first.After,owner,evaluation,difficulty,lookahead:false,baseline:baseline,limit:4);
                continuation.Step();
                if(continuation.Complete)
                {
                    if(continuation.Best is { } second)
                    {
                        var index=scores.FindIndex(s=>s.Move==first.Move);
                        scores[index]=first with { Value=first.Value+Math.Max(0,second.Value-first.Value)*0.65 };
                    }
                    continuation=null;beam++;
                }
                return;
            }
        }
        Complete=true;
    }

    internal BotMoveScore Choose(Random random,Func<BotMove,bool> valid=null)
    {
        var groups=scores.Where(s=>double.IsFinite(s.Value) && s.Value>=-0.05 && (valid==null || valid(s.Move)))
            .GroupBy(s=>s.Move.Card).Select(g=>g.ToArray()).ToArray();
        if(groups.Length==0) return null;
        // Rate cards first so a card with many possible sites does not receive more
        // lottery tickets. Then choose among that card's rated legal placements.
        var card=groups[BotChoice.PickIndex(groups.Select(g=>g.Max(s=>s.Value)).ToArray(),difficulty,random)];
        return card[BotChoice.PickIndex(card.Select(s=>s.Value).ToArray(),difficulty,random)];
    }

    IEnumerable<BotMove> Generate(string onlyCard)
    {
        var hand=board.Players[owner].Hand;
        var cells=board.Cells().ToArray();
        var homeCells=cells.Where(BoardRules.IsHome).ToArray();
        var shops=cells.Where(c=>c.CardId=="shop").ToArray();
        var eventSlot=hand.ToList().IndexOf("renovation");
        for(var slot=0;slot<hand.Count;slot++)
        {
            var card=CardLibrary.Get(hand[slot]);
            if(onlyCard!=null && card.Id!=onlyCard) continue;
            if(card.Id is "grant" or "rush-hour" or "ribbon-cutting")
            {
                if(card.Id=="rush-hour" && (!shops.Any(c=>c.Owner==owner))) continue;
                if(card.Id=="ribbon-cutting" && board.BuildingsPlacedThisRound(owner)==0) continue;
                yield return new(BotMoveKind.Event,card.Id,slot,Rough:2);continue;
            }
            if(card.Id=="renovation") continue;
            for(var y=0;y<board.Height;y++)
            for(var x=0;x<board.Width;x++)
            {
                if(board.Get(x,y)!=null || board.CardPlayError(owner,card.Id,x,y)!=null) {yield return null;continue;}
                var neighbours=board.Neighbours(x,y).ToArray();
                var own=neighbours.Count(c=>c.Owner==owner);
                var nearbyHomes=homeCells.Count(c=>Math.Max(Math.Abs(c.X-x),Math.Abs(c.Y-y))<=2);
                var shopNear=shops.Any(c=>Math.Max(Math.Abs(c.X-x),Math.Abs(c.Y-y))<=2);
                var buildings=neighbours.Count(c=>CardLibrary.Get(c.CardId).Type!=CardType.Road);
                double estimate=0.15*own+evaluation.Affinity(card.Id);
                foreach(var effect in card.Effects)
                {
                    estimate+=effect.Rule switch {
                        "adjacent-home-tag-vp"=>neighbours.Count(BoardRules.IsHome)*effect.Amount,
                        "adjacent-park-vp"=>neighbours.Count(c=>BoardRules.IsPark(c) || c.CardId=="lake")*effect.Amount,
                        "beside-road-vp"=>neighbours.Any(c=>CardLibrary.Get(c.CardId).Type==CardType.Road)?effect.Amount:0,
                        "beside-nature-vp"=>neighbours.Any(c=>CardLibrary.Get(c.CardId).Tags.HasFlag(Tag.Nature))?effect.Amount:0,
                        "park-score"=>effect.Amount,
                        "shop-condition"=>shopNear ? effect.Amount:0,
                        "nearby-any-homes"=>Math.Min(effect.Cap,nearbyHomes)*0.65,
                        "cafe-neighbours"=>Math.Min(effect.Cap,neighbours.Count(c=>BoardRules.IsHome(c) || c.CardId=="shop"))*0.6,
                        "adjacent-farm-condition"=>neighbours.Any(BoardRules.IsFarm)?0.7:0,
                        "coins"=>effect.Amount*0.45,
                        "vp"=>effect.Amount,
                        "nearby-building-vp"=>Math.Min(effect.Cap,cells.Count(c=>CardLibrary.Get(c.CardId).Type!=CardType.Road && Math.Max(Math.Abs(c.X-x),Math.Abs(c.Y-y))<=effect.Range)),
                        _=>0
                    };
                }
                if(card.ParkSize>0) estimate+=0.5+card.ParkSize*0.2;
                if(card.Id=="street")
                {
                    var access=4-neighbours.Length;
                    estimate+=access*0.12+(hand.Any(id=>CardLibrary.Get(id).NeedsRoad || CardLibrary.Get(id).Effects.Any(e=>e.Rule=="beside-road-vp"))?0.4:0);
                }
                if(card.Id=="land-claim") estimate=neighbours.Count(c=>c.Owner!=owner)*0.5+buildings*0.25;
                yield return new(card.Id=="land-claim"?BotMoveKind.Claim:BotMoveKind.Place,card.Id,slot,x,y,Rough:estimate);
            }
        }
        if(eventSlot>=0 && onlyCard is null or "renovation")
        for(var slot=0;slot<hand.Count;slot++)
        {
            var card=CardLibrary.Get(hand[slot]);
            if(card.Type is CardType.Road or CardType.Event) continue;
            foreach(var target in cells.Where(c=>c.Owner==owner))
            {
                if(board.RenovationError(owner,eventSlot,slot,target.X,target.Y)!=null) { yield return null;continue; }
                var fixedVp=card.Effects.Where(e=>e.Rule=="vp").Sum(e=>e.Amount);
                var original=CardLibrary.Get(target.CardId);
                var cost=original.Effects.Where(e=>e.Rule=="coins" && e.Hook==EffectHook.Payout).Sum(e=>e.Amount)*0.5;
                yield return new(BotMoveKind.Renovate,card.Id,eventSlot,target.X,target.Y,slot,fixedVp-cost+evaluation.Affinity(card.Id));
            }
        }
    }
}