This file defines a deterministic simulation harness for running multiple four-strategy matches without engine I/O. It orchestrates the setup, execution, and reporting of board games using a specific set of predefined bots and strategies.
using System;
using System.Collections.Generic;
using System.Linq;
using CitySim.Match;
namespace CitySim.Ai;
public sealed record SimulationSeat(int Seat, BotStrategy Strategy, int BotSeed, int FinalVp, int FinalCoins, double WinShare, int[] RoundVp, int[] RoundCoins);
public sealed record SimulationMatch(int Index, int MatchSeed, int BoardSide, int SeedRoadRow, int SeedRoadColumn, SimulationSeat[] Seats, int InvalidActions, int BudgetViolations);
public sealed record SimulationStrategy(BotStrategy Strategy, int Games, double Wins, double WinRate, double AverageFinalVp, double[] AverageRoundVp, double[] AverageRoundCoins);
public sealed record SimulationReport(int Seed, int MatchCount, int BoardSide, MatchSettings Settings, SimulationMatch[] Matches, SimulationStrategy[] Strategies);
/// <summary>Deterministic, transport-free four-strategy matches. No engine or file I/O.</summary>
public static class SimulationHarness
{
static readonly Guid[] Ids = Enumerable.Range(1,4).Select(i=>new Guid($"00000000-0000-0000-0000-{i:000000000000}")).ToArray();
// Called only after Update/Tick returns: Payout.Apply has then finished Growth.Advance.
internal static void CaptureCompletedPayout(MatchState state, Guid[] ids, int[][] vp, int[][] coins, ref int lastRecordedRound)
{
if (state.Phase != MatchPhase.Payout || state.LastPaidRound != state.Round || state.Round <= lastRecordedRound) return;
for(var i=0;i<ids.Length;i++) { vp[i][state.Round-1]=state.Players[ids[i]].Vp; coins[i][state.Round-1]=state.Players[ids[i]].Coins; }
lastRecordedRound=state.Round;
}
public static SimulationReport Run(int matches, int seed, Action<int,int> progress = null)
{
if (matches < 1 || matches > 10000) throw new ArgumentOutOfRangeException(nameof(matches));
var results=new List<SimulationMatch>(matches);
for(var m=0;m<matches;m++)
{
var matchSeed=unchecked(seed + (m / 4) * 104729);
var settings=new MatchSettings();
var side=Math.Clamp((int)Math.Ceiling(Math.Sqrt(settings.TilesPerPlayer * 4)),settings.MinimumBoardSide,settings.MaximumBoardSide);
var state=new MatchState(side,side,matchSeed,settings);
for(var i=0;i<4;i++) state.AddPlayer(Ids[i],$"Seat {i+1}");
var (row, column)=StartingRoads.Choose(side,side,matchSeed);
for(var x=0;x<side;x++) state.SeedStreet(x,row);
for(var y=0;y<side;y++) state.SeedStreet(column,y);
var rounds=new RoundMachine(state);
var strategies=Enumerable.Range(0,4).Select(i=>(BotStrategy)((i+m)%4)).ToArray();
var seeds=strategies.Select(x=>unchecked(matchSeed ^ (int)((uint)((int)x+1)*0x9e3779b9u))).ToArray();
var bots=Enumerable.Range(0,4).Select(i=>new Bot(Ids[i],state,rounds,BotDifficulty.Normal,seeds[i],strategies[i])).ToArray();
var vp=Enumerable.Range(0,4).Select(_=>new int[settings.Rounds]).ToArray();
var coins=Enumerable.Range(0,4).Select(_=>new int[settings.Rounds]).ToArray();
var lastRecordedRound=0;
var invalid=0; var budget=0; state.Events.PlacementRejected+=_=>invalid++;
foreach(var bot in bots) bot.ChooseContract();
if(!rounds.ContractsChosen) throw new InvalidOperationException("Simulation bots did not choose contracts.");
rounds.Start(0);
for(var step=0;step<30000 && state.Phase!=MatchPhase.EndGame;step++)
{
double now=step/30.0;
foreach(var bot in bots) { bot.Update(now); CaptureCompletedPayout(state,Ids,vp,coins,ref lastRecordedRound); }
foreach(var id in Ids)
{
var view=rounds.GetAuctionView(id);
if(view!=null && !view.Resolving && (view.AvailableCoins<0 || view.ReservedCoins<0 || view.AvailableCoins+view.ReservedCoins!=state.Players[id].Coins)) budget++;
if(state.Players[id].Coins<0) budget++;
}
rounds.Tick(now);
CaptureCompletedPayout(state,Ids,vp,coins,ref lastRecordedRound);
}
if(state.Phase!=MatchPhase.EndGame || state.FinalScore==null || state.Round!=settings.Rounds || lastRecordedRound!=settings.Rounds) throw new InvalidOperationException($"Match {m} did not finish.");
var winners=state.FinalScore.Winners;
if(winners.Count==0) throw new InvalidOperationException("Simulation produced no winner.");
var seats=Enumerable.Range(0,4).Select(i=>{
var score=state.FinalScore.Players.Single(p=>p.Player==Ids[i]);
return new SimulationSeat(i,strategies[i],seeds[i],score.TotalVp,score.Coins,winners.Contains(Ids[i])?1.0/winners.Count:0,vp[i],coins[i]);
}).ToArray();
invalid = Math.Max(invalid,bots.Sum(b=>b.RejectedActions));
results.Add(new(m,matchSeed,side,row,column,seats,invalid,budget));
if(invalid!=0 || budget!=0) throw new InvalidOperationException($"Match {m}: {invalid} rejected placements, {budget} budget violations.");
progress?.Invoke(m+1,matches);
}
var aggregates=Enum.GetValues<BotStrategy>().Select(strategy=>{
var seats=results.SelectMany(r=>r.Seats).Where(s=>s.Strategy==strategy).ToArray();
var wins=seats.Sum(s=>s.WinShare);
return new SimulationStrategy(strategy,seats.Length,wins,wins/seats.Length,seats.Average(s=>s.FinalVp),
Enumerable.Range(0,8).Select(r=>seats.Average(s=>s.RoundVp[r])).ToArray(),
Enumerable.Range(0,8).Select(r=>seats.Average(s=>s.RoundCoins[r])).ToArray());
}).ToArray();
var provenanceSettings=new MatchSettings();
var boardSide=Math.Clamp((int)Math.Ceiling(Math.Sqrt(provenanceSettings.TilesPerPlayer*4)),provenanceSettings.MinimumBoardSide,provenanceSettings.MaximumBoardSide);
return new(seed,matches,boardSide,provenanceSettings,results.ToArray(),aggregates);
}
}