Ai/BotChoice.cs

The static class BotChoice defines a set of functions for AI agent decision making, calculating probability distributions for options based on their ratings and the difficulty setting.

using System;
using System.Collections.Generic;
using System.Linq;

namespace CitySim.Ai;

/// <summary>Seeded, rating-weighted decisions shared by market, contract and build choices.</summary>
public static class BotChoice
{
    public const string PolicyVersion="2026-10-30.5-weighted-choices";
    public static double BestChoiceChance(BotDifficulty difficulty) => difficulty switch {
        BotDifficulty.Easy=>0.55, BotDifficulty.Normal=>0.75, BotDifficulty.Hard=>0.90,
        _=>throw new ArgumentOutOfRangeException(nameof(difficulty))
    };

    /// <summary>Best-rated ties share the accuracy budget. Other credible options are
    /// weighted by rating; clearly inferior and nonfinite options have no chance.
    /// A sole credible option is selected with certainty.</summary>
    public static double[] Probabilities(IReadOnlyList<double> ratings,BotDifficulty difficulty)
    {
        var accuracy=BestChoiceChance(difficulty);
        var probabilities=new double[ratings.Count];
        var finite=Enumerable.Range(0,ratings.Count).Where(i=>double.IsFinite(ratings[i])).ToArray();
        if(finite.Length==0) return probabilities;
        var best=finite.Max(i=>ratings[i]);
        var fraction=difficulty switch {BotDifficulty.Easy=>0.65,BotDifficulty.Normal=>0.40,_=>0.25};
        var minimumGap=difficulty switch {BotDifficulty.Easy=>1.0,BotDifficulty.Normal=>0.75,_=>0.5};
        var gap=Math.Max(minimumGap,Math.Abs(best)*fraction);
        var top=finite.Where(i=>Math.Abs(ratings[i]-best)<=1e-8).ToArray();
        var other=finite.Where(i=>ratings[i]>=best-gap && best-ratings[i]>1e-8).ToArray();
        var topBudget=other.Length==0?1:accuracy;
        foreach(var i in top) probabilities[i]=topBudget/top.Length;
        // Subtracting the best keeps exponentials finite. A higher-rated alternative
        // always has greater weight than a lower-rated one.
        var temperature=Math.Max(0.25,gap*0.5);
        var weights=other.Select(i=>Math.Exp((ratings[i]-best)/temperature)).ToArray();
        var sum=weights.Sum();
        for(var j=0;j<other.Length;j++) probabilities[other[j]]=(1-topBudget)*weights[j]/sum;
        return probabilities;
    }

    public static int PickIndex(IReadOnlyList<double> ratings,BotDifficulty difficulty,Random random)
    {
        var probabilities=Probabilities(ratings,difficulty);
        var remaining=random.NextDouble();var last=-1;
        for(var i=0;i<probabilities.Length;i++)
        {
            if(probabilities[i]<=0) continue;
            last=i;remaining-=probabilities[i];if(remaining<0) return i;
        }
        return last;
    }
}