Ai/AiRedesignChecks.cs

The file is a comprehensive test suite for AI logic in a card game. It runs simulations to verify setup constraints, state persistence, and the quality of AI decisions for specific gameplay scenarios.

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

namespace CitySim.Ai;

public static class AiRedesignChecks
{
    static readonly Guid A=new("a1111111-0000-0000-0000-000000000001"),B=new("b1111111-0000-0000-0000-000000000002");
    public static IReadOnlyList<string> Run()
    {
        var results=new List<string>();
        void Check(bool ok,string text) {if(!ok) throw new InvalidOperationException(text);results.Add("PASS "+text);}
        var setup=SoloSetup.Create(144);
        Check(Enumerable.Range(0,100).All(seed=>SoloSetup.Create(seed).Opponents.Select(o=>o.Name).Distinct(StringComparer.OrdinalIgnoreCase).Count()==4),"random opponent names are unique within each four-slot setup");
        Check(JsonSerializer.Serialize(setup)==JsonSerializer.Serialize(SoloSetup.Create(144)),"name generation is reproducible with its own seed");
        Check(SoloSetup.Resize(setup,0).OpponentCount==1 && SoloSetup.Resize(setup,99).OpponentCount==4,"solo setup enforces one to four AI opponents");
        setup=SoloSetup.Edit(SoloSetup.Resize(setup,4),3,"  Captain\n Casey  ",BotDifficulty.Hard);
        var restored=SoloSetup.Resize(SoloSetup.Resize(setup,1),4);
        Check(restored.Opponents[3].Name=="Captain Casey" && restored.Opponents[3].Difficulty==BotDifficulty.Hard,"removing and restoring a slot preserves its edited name and difficulty");
        var frozen=SoloSetup.StartRoster(setup,1);
        var changed=SoloSetup.Edit(setup,3,"Another",BotDifficulty.Easy);
        Check(frozen[3].Name=="Captain Casey" && frozen[3].Difficulty==BotDifficulty.Hard && changed.Opponents[3].Name=="Another","starting a match freezes an independent roster snapshot");
        var serialized=JsonSerializer.Deserialize<SoloSetupData>(JsonSerializer.Serialize(setup));
        Check(JsonSerializer.Serialize(SoloSetup.Normalize(serialized,99))==JsonSerializer.Serialize(setup),"names and per-opponent difficulties survive setup serialization");
        var rerolled=SoloSetup.Randomize(setup,97);
        Check(rerolled.Opponents.Select(o=>o.Difficulty).SequenceEqual(setup.Opponents.Select(o=>o.Difficulty)) && rerolled.Opponents.Select(o=>o.Name).Distinct().Count()==4,"randomizing names preserves each opponent's difficulty");
        var invalid=new SoloSetupData(8,new[]{new OpponentSetup(0,"",(BotDifficulty)99)});
        Check(SoloSetup.Normalize(invalid,4).OpponentCount==4 && SoloSetup.Normalize(invalid,4).Opponents.All(o=>Enum.IsDefined(o.Difficulty) && o.Name.Length>0),"invalid saved setup falls back to legal names and difficulties");
        Check(SoloSetup.CleanName(new string('a',40)).Length==24 && SoloSetup.CleanName("\t\n")=="","opponent names have a bounded length and no control characters");
        Check(Enumerable.Range(0,4).Select(SoloSetup.PlayerId).Distinct().Count()==4 && Enumerable.Range(0,4).All(i=>SoloSetup.PlayerId(i)!=A),"all four opponent slots have stable distinct gameplay IDs");
        Check(Enumerable.Range(0,4).Select(i=>BotStyles.ForSeat(17,i)).Distinct().Count()==4,"four opponents receive distinct seeded playing styles");
        var settings=new MatchSettings();
        Check(Math.Clamp((int)Math.Ceiling(Math.Sqrt(settings.TilesPerPlayer*5)),settings.MinimumBoardSide,settings.MaximumBoardSide)==17,"five-player solo matches retain the existing space-per-player target on a 17 by 17 board");

        MatchState Board(int round=2)
        {
            var s=new MatchState(9,9,1,new MatchSettings {EnableContracts=false,EnableGoals=false,StartingCoins=100});
            s.AddPlayer(A,"A");s.AddPlayer(B,"B");
            for(var x=0;x<9;x++) s.SeedStreet(x,4);
            s.SetPhase(MatchPhase.Build,round,40);return s;
        }
        void Place(MatchState s,Guid id,string card,int x,int y)
        {if(!s.GiveCard(id,card) || !s.TryPlace(id,card,x,y).Success) throw new InvalidOperationException($"AI fixture cannot place {card}");}
        var street=Board();Place(street,A,"house",4,3);street.GiveCard(A,"street");
        var evaluator=new BotEvaluation(A,BotDifficulty.Hard,BotStyle.Balanced,null);
        var before=evaluator.Score(street);var after=BotEvaluation.PublicClone(street,A);after.TryPlace(A,"street",3,3);
        Check(Payout.Calculate(after).Entries.Where(e=>e.Player==A).Sum(e=>e.Vp)==0 && evaluator.Score(after)-before<4,"AI does not invent recurring Street VP beside existing buildings");
        var homes=Board(8);Place(homes,A,"house",3,3);Place(homes,B,"house",0,3);homes.GiveCard(A,"house");
        var homesAfter=BotEvaluation.PublicClone(homes,A);var homeMove=homesAfter.TryPlace(A,"house",4,3);
        Check(homeMove.Success && evaluator.Score(homesAfter)-evaluator.Score(homes)>=2.8-0.000001 &&
            FinalScoring.Calculate(homesAfter).Players.Single(p=>p.Player==A).Lines.Single(l=>l.Reason=="#rules.final_scoring.full_house").Vp==4,
            "AI values winning Full House and placement VP while accounting for lost catch-up income");
        var cash=Board(1);
        Check(Math.Abs(evaluator.CashValue(1010,cash)-evaluator.CashValue(1000,cash)-2)<0.00001,"excess cash has only the real five-coins-per-VP value");
        Check(evaluator.CashValue(6,cash)>1.2,"cash needed for future purchases carries an early spending value");
        var privacy=Board();Place(privacy,A,"house",4,3);privacy.GiveCard(A,"shop");privacy.GiveCard(B,"monument");
        var publicClone=BotEvaluation.PublicClone(privacy,A);
        Check(publicClone.Players[A].Hand.SequenceEqual(privacy.Players[A].Hand) && publicClone.Players[B].Hand.Count==0 && publicClone.Seed==0,"planning clones include only the AI's own hand and omit the hidden match seed");
        var privacyScore=evaluator.Score(privacy);privacy.Discard(B,0);privacy.GiveCard(B,"bank");
        Check(evaluator.Score(privacy)==privacyScore,"rival hidden-hand changes do not affect AI evaluation");
        var search=new BotSearch(BotEvaluation.PublicClone(privacy,A),A,evaluator,BotDifficulty.Hard);
        search.Step();Check(!search.Complete && search.EvaluatedMoves==0,"planning yields during site generation before expensive scoring");
        for(var i=0;i<5000 && !search.Complete;i++) search.Step();
        Check(search.Complete && search.Best!=null && search.Scores.Count<=14,"bounded planning finishes with a finite candidate shortlist");

        BotSearch Finish(BotSearch job)
        {for(var step=0;step<5000 && !job.Complete;step++) job.Step();if(!job.Complete) throw new InvalidOperationException("Planning fixture did not finish.");return job;}
        var combo=new MatchState(7,7,19,new MatchSettings {EnableContracts=false,EnableGoals=false});
        combo.AddPlayer(A,"Planner");combo.AddPlayer(B,"Other");combo.SetPhase(MatchPhase.Build,1,40);
        combo.GiveCard(A,"street");combo.GiveCard(A,"apartment");
        var immediate=Finish(new BotSearch(BotEvaluation.PublicClone(combo,A),A,evaluator,BotDifficulty.Hard,lookahead:false));
        var sequence=Finish(new BotSearch(BotEvaluation.PublicClone(combo,A),A,evaluator,BotDifficulty.Hard));
        Check(sequence.Best.Move.Card=="street" && sequence.Best.Value>immediate.Best.Value+1,
            "Hard plans a Street followed by a held Apartment when the Apartment is initially blocked");

        var stale=Board();stale.GiveCard(A,"house");
        var staleJob=Finish(new BotSearch(BotEvaluation.PublicClone(stale,A),A,evaluator,BotDifficulty.Hard));
        var blocked=staleJob.Best.Move;Place(stale,B,"house",blocked.X,blocked.Y);
        Check(!blocked.Valid(stale,A) && staleJob.Scores.Any(s=>s.Move.Valid(stale,A)),
            "a rival occupying the planned tile invalidates that move while legal alternatives remain");

        string RenamedDecision(string name,string rivalCard)
        {
            var s=new MatchState(7,7,244,new MatchSettings {EnableContracts=false,EnableGoals=false});
            s.AddPlayer(A,name);s.AddPlayer(B,"Rival "+name);s.SeedStreet(3,3);s.SetPhase(MatchPhase.Build,2,40);
            s.GiveCard(A,"house");s.GiveCard(A,"park");s.GiveCard(B,rivalCard);
            var job=Finish(new BotSearch(BotEvaluation.PublicClone(s,A),A,new(A,BotDifficulty.Easy,BotStyle.Housing,null),BotDifficulty.Easy));
            return JsonSerializer.Serialize(job.Choose(new Random(912)).Move);
        }
        Check(RenamedDecision("Alex","bank")==RenamedDecision("A different name","monument"),
            "display names and rival private cards do not change a seeded AI decision");

        var contractBoard=Board();
        var contractEvaluator=new BotEvaluation(A,BotDifficulty.Normal,BotStyle.Housing,Contracts.Get("suburbia"));
        var noContract=new BotEvaluation(A,BotDifficulty.Normal,BotStyle.Housing,null);
        var contractAfter=BotEvaluation.PublicClone(contractBoard,A);Place(contractAfter,A,"house",3,3);
        Check(contractEvaluator.Score(contractAfter)-contractEvaluator.Score(contractBoard)>noContract.Score(contractAfter)-noContract.Score(contractBoard),
            "an AI values progress toward its own Home contract");

        var passBoard=new MatchState(5,5,99,new MatchSettings {EnableContracts=false,EnableGoals=false,DefaultCommons=0,StartingCoins=20});
        passBoard.AddPlayer(A,"Buyer");passBoard.AddPlayer(B,"Other");passBoard.SeedStreet(2,2);
        var passRounds=new RoundMachine(passBoard);passRounds.Start(0);
        var checkpoint=passRounds.Capture();
        passRounds=RoundMachine.Restore(passBoard,checkpoint with {Auction=checkpoint.Auction with {Lots=new[]{new AuctionLot(0,"grant",11)}}});
        var passer=new Bot(A,passBoard,passRounds,BotDifficulty.Hard,72);
        for(var step=0;step<600 && !passRounds.GetAuctionView(A).Ready;step++) passer.Update(step/60.0);
        Check(passRounds.GetAuctionView(A).Ready && passBoard.Players[A].Coins==20 && passBoard.Players[A].Hand.Count==0,
            "AI passes instead of spending eleven coins on a smaller cash grant");

        foreach(var card in CardLibrary.All)
        {
            var s=new MatchState(15,15,91,new MatchSettings {EnableContracts=false,EnableGoals=false,StartingCoins=100});s.AddPlayer(A,"A");s.AddPlayer(B,"B");
            for(var x=0;x<15;x++) s.SeedStreet(x,7);
            for(var y=0;y<15;y++) s.SeedStreet(7,y);
            s.SetPhase(MatchPhase.Build,3,40);
            Place(s,A,"house",6,6);Place(s,B,"house",5,6);Place(s,A,"shop",8,6);
            Place(s,A,"farm",9,6);Place(s,A,"farm",10,6);Place(s,A,"park",11,6);
            Place(s,A,"cottage",11,5);Place(s,A,"cottage",6,5);Place(s,A,"cottage",9,5);
            s.GiveCard(A,card.Id);if(card.Id=="renovation") s.GiveCard(A,"monument");
            var job=new BotSearch(BotEvaluation.PublicClone(s,A),A,new(A,BotDifficulty.Hard,BotStyle.Balanced,null),BotDifficulty.Hard,card.Id,lookahead:false);
            for(var step=0;step<5000 && !job.Complete;step++) job.Step();
            Check(job.Complete && job.Best!=null && double.IsFinite(job.Best.Value),$"AI scenario covers {card.Name} with a legal finite-valued move");
        }
        return results;
    }
}