The AI implementation for the Bot opponent. It manages the bot's behavior during auctions and city development phases using planning and search algorithms.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using CitySim.Cards;
using CitySim.Match;
namespace CitySim.Ai;
public enum BotDifficulty { Easy, Normal, Hard }
// Retained for historical simulation callers; live matches use BotStyle.
public enum BotStrategy { Balanced, Tag, ParkRush, EconomyFirst }
/// <summary>Seeded opponent with bounded planning steps and separate decision/pacing randomness.</summary>
public sealed class Bot
{
readonly Guid player;
readonly MatchState state;
readonly RoundMachine rounds;
readonly BotDifficulty difficulty;
readonly Random decisions, timing;
readonly BotStyle style;
readonly Func<bool> finishBuildingQuickly;
bool finishingBuild;
MatchPhase lastPhase=(MatchPhase)(-1);
int lastRound=-1,lastAuction=-1;
double actAt;
BotSearch buildSearch,marketSearch;
PlanningStamp searchStamp;
OwnLotBid[] lots;
int lotCursor;
readonly List<(OwnLotBid Lot,double Value)> offers=new();
BotEvaluation evaluation;
readonly record struct PlanningStamp(int Round,int Order,int Claims,long Hand,int Coins);
PlanningStamp Stamp=>new(state.Round,state.NextPlacementOrder,state.Claims.Count,state.Players[player].HandVersion,state.Players[player].Coins);
public BotDifficulty Difficulty=>difficulty;
public BotStyle Style=>style;
public int RejectedActions { get; private set; }
public int DecisionsCompleted { get; private set; }
public int SearchRestarts { get; private set; }
public double MaximumPlanningStepMilliseconds { get; private set; }
public double LastPlanningStepMilliseconds { get; private set; }
void Record(bool accepted) { if(!accepted) RejectedActions++; }
public Bot(Guid player,MatchState state,RoundMachine rounds,BotDifficulty difficulty,int seed,
BotStrategy strategy=BotStrategy.Balanced,Func<bool> finishBuildingQuickly=null,BotStyle? style=null)
{
if(!state.Players.ContainsKey(player)) throw new ArgumentException("Unknown player",nameof(player));
if(!Enum.IsDefined(difficulty)) throw new ArgumentOutOfRangeException(nameof(difficulty));
if(!Enum.IsDefined(strategy) || style.HasValue && !Enum.IsDefined(style.Value)) throw new ArgumentOutOfRangeException(nameof(strategy));
this.player=player;this.state=state;this.rounds=rounds;this.difficulty=difficulty;this.finishBuildingQuickly=finishBuildingQuickly;
this.style=style ?? strategy switch {BotStrategy.Tag=>BotStyle.Industry,BotStrategy.ParkRush=>BotStyle.Nature,BotStrategy.EconomyFirst=>BotStyle.Trade,_=>BotStyle.Balanced};
decisions=new(seed);timing=new(unchecked(seed ^ 83492791));
}
BotEvaluation Evaluation => evaluation ??= new(player,difficulty,style,
rounds.GetContractView(player)?.Selected is { } contract ? Contracts.Get(contract):null);
public void ChooseContract()
{
var view=rounds.GetContractView(player);
if(view==null || view.Selected!=null || view.Choices.Count==0) return;
var evaluator=new BotEvaluation(player,difficulty,style,null);
var ranked=view.Choices.Select(id=>(Id:id,Value:evaluator.ContractFit(state,Contracts.Get(id))))
.OrderByDescending(c=>c.Value).ToArray();
var pick=ranked[BotChoice.PickIndex(ranked.Select(c=>c.Value).ToArray(),difficulty,decisions)];
rounds.ChooseContract(player,view.DraftId,pick.Id);evaluation=null;
}
void ClearSearch()
{
buildSearch=null;marketSearch=null;lots=null;offers.Clear();lotCursor=0;
}
public void Update(double now)
{
if(!double.IsFinite(now) || !state.Players.ContainsKey(player)) return;
var view=rounds.GetAuctionView(player);var auction=view?.Id ?? -1;
var fast=state.Phase==MatchPhase.Build && finishBuildingQuickly?.Invoke()==true;
if(lastPhase!=state.Phase || lastRound!=state.Round || lastAuction!=auction)
{
lastPhase=state.Phase;lastRound=state.Round;lastAuction=auction;
ClearSearch();evaluation=null;finishingBuild=fast;actAt=now+Delay(now,true);
}
else if(finishingBuild!=fast) {finishingBuild=fast;actAt=now+Delay(now);}
if(state.Phase is not (MatchPhase.Bid or MatchPhase.Build) || state.PhaseEndsAt>=0 && now>=state.PhaseEndsAt) return;
if(state.Phase==MatchPhase.Bid)
{
if(view==null || !view.Eligible || view.Resolving || view.Ready) return;
MarketStep(view,now);return;
}
if(rounds.IsBuildReady(player)) return;
BuildStep(now);
}
double Delay(double now,bool initial=false)
{
var remaining=state.PhaseEndsAt<0 ? state.Settings.MiniAuctionSeconds:Math.Max(0,state.PhaseEndsAt-now);
var seconds=state.Phase==MatchPhase.Build
? finishingBuild ? 0.15+timing.NextDouble()*0.20 : initial ? 2+timing.NextDouble()*2 : 1.2+timing.NextDouble()*1.4
: 0.35+timing.NextDouble()*0.3;
return Math.Min(seconds,remaining*(state.Phase==MatchPhase.Build?0.15:0.2));
}
void Step(BotSearch search)
{
var started=Stopwatch.GetTimestamp();search.Step();
LastPlanningStepMilliseconds=Stopwatch.GetElapsedTime(started).TotalMilliseconds;
MaximumPlanningStepMilliseconds=Math.Max(MaximumPlanningStepMilliseconds,LastPlanningStepMilliseconds);
}
void MarketStep(AuctionView view,double now)
{
if(lots==null || searchStamp!=Stamp)
{
searchStamp=Stamp;marketSearch=null;lotCursor=0;offers.Clear();
lots=view.Lots.Where(l=>l.Allowed && (long)l.Lot.MinimumBid+state.Settings.BuildCost(CardLibrary.Get(l.Lot.CardId))<=view.AvailableCoins).ToArray();
}
if(lotCursor<lots.Length)
{
var lot=lots[lotCursor];var card=CardLibrary.Get(lot.Lot.CardId);
if(card.Id is "grant" or "rush-hour" or "ribbon-cutting")
{
var amount=card.Id switch {
"grant"=>card.Effects.Single(e=>e.Rule=="coins").Amount,
"rush-hour"=>Math.Min(4,state.Cells().Count(c=>c.Owner==player && c.CardId=="shop")+state.Players[player].Hand.Count(id=>id=="shop")),
_=>Math.Min(4,state.BuildingsPlacedThisRound(player)+state.Players[player].Hand.Count(id=>CardLibrary.Get(id).Type is not (CardType.Road or CardType.Event)))
};
var coins=state.Players[player].Coins;
var value=Evaluation.CashValue(coins-lot.Lot.MinimumBid+amount,state)-Evaluation.CashValue(coins,state);
offers.Add((lot,value));lotCursor++;
}
else
{
marketSearch ??= new(BotEvaluation.PublicClone(state,player,card.Id,lot.Lot.MinimumBid),player,Evaluation,difficulty,card.Id,baseline:Evaluation.Score(state));
Step(marketSearch);
if(marketSearch.Complete) {offers.Add((lot,marketSearch.Best?.Value ?? double.NegativeInfinity));marketSearch=null;lotCursor++;}
}
}
var deadline=state.PhaseEndsAt>=0 && state.PhaseEndsAt-now<=0.25;
if(now<actAt || lotCursor<lots.Length && !deadline) return;
if(deadline && marketSearch?.Best is { } partial) offers.Add((lots[lotCursor],partial.Value));
var choices=offers.Where(o=>double.IsFinite(o.Value) && o.Value>0.05).ToList();
choices.Add((null,0)); // Passing is a rated option; bad purchases never enter the draw.
var chosen=choices[BotChoice.PickIndex(choices.Select(o=>o.Value).ToArray(),difficulty,decisions)];
if(chosen.Lot!=null)
{
Record(rounds.BuyMarketCard(player,view.Id,chosen.Lot.Lot.Index,now));
}
else Record(rounds.Ready(player,now));
DecisionsCompleted++;
}
void BuildStep(double now)
{
var owner=state.Players[player];
if(owner.Hand.Count==0) {if(now>=actAt) Record(rounds.Ready(player,now));return;}
state.ChooseBuilderDefault(player,state.Cells().Count(c=>c.Owner==player && CardLibrary.Get(c.CardId).Type==CardType.Road)>=3);
if(buildSearch==null || searchStamp.Round!=Stamp.Round || searchStamp.Hand!=Stamp.Hand || searchStamp.Coins!=Stamp.Coins)
{
if(buildSearch!=null) SearchRestarts++;
searchStamp=Stamp;buildSearch=new(BotEvaluation.PublicClone(state,player),player,Evaluation,difficulty);
}
Step(buildSearch);
var deadline=state.PhaseEndsAt-now<=0.5;
if(now<actAt || !buildSearch.Complete && !deadline) return;
var chosen=buildSearch.Choose(decisions,move=>move.Valid(state,player));
// Other players can occupy a planned site while this bot thinks. Keep useful
// completed work, but choose only a move that is still legal on the live board.
if(chosen!=null && chosen.Value>=-0.05 && chosen.Move.Valid(state,player))
{
Record(chosen.Move.Apply(state,player));DecisionsCompleted++;
}
else if(buildSearch.Complete || deadline)
{
if(owner.Hand.Count>=state.Settings.HandTarget)
{
// Free capacity for future purchases; keep useful blocked cards when there is room.
var slot=Enumerable.Range(0,owner.Hand.Count).OrderBy(i=>CardLibrary.Get(owner.Hand[i]).Rarity).First();
Record(state.Discard(player,slot));
}
else Record(rounds.Ready(player,now));
}
buildSearch=null;actAt=now+Delay(now);
}
}
/// <summary>Round-robin group budget; individual planning steps are also resumable.</summary>
internal static class BotFrameScheduler
{
internal static int Run(int count,ref int cursor,double budget,Action<int> update,Func<double> elapsed)
{
if(count<=0) {cursor=0;return 0;}
var start=((cursor%count)+count)%count;var done=0;
do {update((start+done)%count);done++;} while(done<count && elapsed()<budget);
cursor=(start+(done==count?1:done))%count;return done;
}
}