This class acts as a traffic simulation controller, managing the spawning and movement of vehicles within the city grid based on game state and traffic demand.
using CitySim.Match;
namespace CitySim;
/// <summary>A client-local living-city layer. Reads public buildings/events; never mutates match state.</summary>
public sealed class TrafficController : Component
{
// A separate RNG is essential: cosmetic departures must not advance gameplay randomness.
public Random Random { get; } = new();
public CityGrid Grid => game?.Grid;
public bool Running => game.IsValid() && Grid?.IsBuilt==true && game.Screens.Top is not (GameScreen.Pause or GameScreen.Settings or GameScreen.Recovery);
public int TargetPopulation { get; private set; }
public int SuccessfulSpawns { get; private set; }
public int UnroutableTrips { get; private set; }
public int CachedRoutes => routes.Count;
CityGame game;
MatchState match;
int roadRevision=-1;
float untilSpawn,burstUntil,age;
readonly List<Tile> roads=new(),edges=new();
readonly Dictionary<(Tile,Tile),TrafficPath> routes=new();
readonly Dictionary<Tile,List<Vehicle>> junctions=new();
sealed record Trip(Tile From,Tile To,VehicleKind Kind,Tile Origin,Tile Destination,int Attempts);
readonly Queue<Trip> requests=new();
public void Bind(CityGame city)
{
if(game!=null) game.MatchChanged-=MatchChanged;
game=city; game.MatchChanged+=MatchChanged; MatchChanged();
}
void MatchChanged()
{
if(match!=null) { match.Events.PayoutResolved-=Payout; match.Events.HospitalEmergency-=Emergency; match.Events.EventPlayed-=EventPlayed; match.Events.PhaseChanged-=Phase; }
Clear(); match=game.Match;
if(match!=null) { match.Events.PayoutResolved+=Payout; match.Events.HospitalEmergency+=Emergency; match.Events.EventPlayed+=EventPlayed; match.Events.PhaseChanged+=Phase; }
}
public void Clear()
{
foreach(var car in Vehicle.All.Where(c=>c.Owner==this).ToArray()) if(car.IsValid()) car.GameObject.Destroy();
requests.Clear(); junctions.Clear(); routes.Clear(); roads.Clear(); edges.Clear();
roadRevision=-1; untilSpawn=0.25f; burstUntil=0; TargetPopulation=0;
}
void RefreshRoads()
{
if(Grid==null || roadRevision==Grid.RoadRevision) return;
roadRevision=Grid.RoadRevision; routes.Clear(); roads.Clear(); edges.Clear();
roads.AddRange(Grid.All().Where(t=>t.IsRoad)); edges.AddRange(Grid.EdgeRoads());
}
TrafficPath Route(Tile from,Tile to)
{
RefreshRoads();
if(from?.IsRoad!=true || to?.IsRoad!=true) return null;
var key=(from,to);
if(routes.TryGetValue(key,out var cached)) return cached;
if(routes.Count>=256) routes.Clear();
var path=Grid.FindRoadPath(from,to);
return routes[key]=TrafficPath.Build(Grid,path);
}
Tile Pick(IReadOnlyList<Tile> choices) => choices.Count==0 ? null : choices[Random.Next(choices.Count)];
Tile RoadAt(BoardCell cell)
{
var tile=cell==null ? null : Grid.Get(cell.X,cell.Y);
return tile==null ? null : Pick(Grid.AdjacentRoads(tile).ToArray());
}
void QueueTrip(Tile from,Tile to,VehicleKind kind,Tile origin=null,Tile destination=null,int attempts=0)
{
if(from==null || to==null || requests.Count>=32) return;
if(kind==VehicleKind.Ambulance)
{
var pending=requests.ToArray(); requests.Clear(); requests.Enqueue(new(from,to,kind,origin,destination,attempts));
foreach(var item in pending) requests.Enqueue(item);
}
else requests.Enqueue(new(from,to,kind,origin,destination,attempts));
}
(Tile From,Tile To) RouteBetweenBuildings(BoardCell a,BoardCell b)
{
var from=a==null ? null : Grid.Get(a.X,a.Y); var to=b==null ? null : Grid.Get(b.X,b.Y);
if(from==null || to==null) return default;
foreach(var start in Grid.AdjacentRoads(from)) foreach(var end in Grid.AdjacentRoads(to))
if(Route(start,end)!=null) return (start,end);
return default;
}
void Payout(PayoutResult result)
{
burstUntil=age+7;
RefreshRoads();
// Cosmetic arrivals illustrate busy buildings without economic pairings.
foreach(var cell in result.Entries.Where(e=>e.Coins>0 && e.Source!=null).Select(e=>e.Source).Distinct().OrderBy(_=>Random.Next()).Take(12))
QueueTrip(Pick(edges),RoadAt(cell),cell.CardId is "farm" or "orchard" or "factory" ? VehicleKind.Delivery : VehicleKind.Sedan,destination:Grid.Get(cell.X,cell.Y));
}
void EventPlayed(CardEventResult result) { if(result.CardId=="rush-hour") burstUntil=age+12; }
void Phase(PhaseChange phase)
{
if(phase.Phase!=MatchPhase.Build || match==null) return;
RefreshRoads();
foreach(var cell in match.Cells().Where(c=>c.CardId is "farm" or "shop" or "warehouse").OrderBy(_=>Random.Next()).Take(3))
QueueTrip(Pick(edges),RoadAt(cell),VehicleKind.Delivery,destination:Grid.Get(cell.X,cell.Y));
}
void Emergency(HospitalEmergencyResult result)
{
if(result?.House==null || Vehicle.All.Any(v=>v.Owner==this && v.IsEmergency)) return;
RefreshRoads();
var pair=RouteBetweenBuildings(result.Hospital,result.House);
var end=pair.To ?? RoadAt(result.House); if(end==null) return;
// The public result already decided the reward. A missing road never changes it.
var start=pair.From;
if(start==null || Route(start,end)==null) start=edges.OrderBy(t=>t.Center.Distance(end.Center)).FirstOrDefault(t=>Route(t,end)!=null);
if(start!=null) QueueTrip(start,end,VehicleKind.Ambulance,pair.From==null ? null : Grid.Get(result.Hospital.X,result.Hospital.Y),Grid.Get(result.House.X,result.House.Y));
}
protected override void OnUpdate()
{
if(!game.IsValid() || !Running) return;
if(!ReferenceEquals(match,game.Match)) MatchChanged();
RefreshRoads(); age+=Time.Delta; untilSpawn-=Time.Delta;
if(untilSpawn>0) return;
var buildings=Grid.All().Where(t=>!t.IsRoad && t.Type!=TileType.Empty).ToArray();
TargetPopulation=roads.Count<2 ? 0 : Math.Clamp(3+buildings.Length/4+roads.Count/20,3,18)+(age<burstUntil ? 4 : 0);
untilSpawn=Random.Float(0.65f,1.3f);
if(Vehicle.All.Count>=CityGame.MaxVehicles) return;
var emergencyWaiting=requests.TryPeek(out var next) && next.Kind==VehicleKind.Ambulance;
// An emergency has a reserved presentation slot, even when ambient traffic is full.
if(!emergencyWaiting && Vehicle.All.Count(v=>v.Owner==this)>=TargetPopulation) return;
if(requests.TryDequeue(out var request))
{
if(!TrySpawn(request.From,request.To,request.Kind,request.Origin,request.Destination) && request.Attempts<6 && Route(request.From,request.To)!=null)
QueueTrip(request.From,request.To,request.Kind,request.Origin,request.Destination,request.Attempts+1);
return;
}
// Through traffic establishes life even on the first streets and in sandbox.
var destinations=buildings.Where(t=>t.Type is TileType.House or TileType.Business && Grid.AdjacentRoads(t).Any()).ToArray();
for(var attempt=0;attempt<6;attempt++)
{
var from=Pick(edges.Count>0 ? edges : roads);
Tile to=null,origin=null,destination=null;
if(destinations.Length>0 && Random.Next(3)==0) { origin=Pick(destinations); from=Pick(Grid.AdjacentRoads(origin).ToArray()); }
if(destinations.Length>0 && Random.Next(3)==0) { destination=Pick(destinations); to=Pick(Grid.AdjacentRoads(destination).ToArray()); }
to ??= Pick(edges.Count>1 ? edges : roads);
if(origin==destination && origin!=null || from==to && origin==null && destination==null) continue;
var kind=Random.Next(12) switch {0=>VehicleKind.Taxi,1=>VehicleKind.Van,2=>VehicleKind.Delivery,3=>VehicleKind.Suv,4=>VehicleKind.Hatchback,_=>VehicleKind.Sedan};
if(destination?.CardId is "farm" or "warehouse" or "factory" || origin?.CardId is "farm" or "warehouse" or "factory") kind=Random.Next(3)==0 ? VehicleKind.Truck : VehicleKind.Delivery;
if((origin?.CardId=="farm" || destination?.CardId=="farm") && Random.Next(5)==0) kind=VehicleKind.Tractor;
if(TrySpawn(from,to,kind,origin,destination)) break;
}
}
bool TrySpawn(Tile from,Tile to,VehicleKind kind,Tile origin=null,Tile destination=null)
{
var path=Route(from,to);
if(path==null) { UnroutableTrips++; return false; }
path=path.WithBuildings(origin,destination);
var first=path.At(0);
if(IsJunction(first.Tile) && junctions.TryGetValue(first.Tile,out var occupants) && occupants.Any(v=>v.IsValid() && path.ConflictsAt(v.Path,first.Tile))) return false;
if(Vehicle.All.Any(v=>v.IsValid() && v.Owner==this && v.WorldPosition.Distance(first.Position)<23)) return false;
var car=Vehicle.Spawn(this,path,kind); if(car==null) return false;
CanEnter(car,first.Tile);
SuccessfulSpawns++; return true;
}
bool IsJunction(Tile tile) => tile!=null && (tile.CardId=="roundabout" || Enumerable.Range(0,4).Count(d=>Grid.Neighbour(tile,d)?.IsRoad==true)>=3);
public bool CanEnter(Vehicle car,Tile tile)
{
if(!IsJunction(tile)) return true;
if(!junctions.TryGetValue(tile,out var occupants)) junctions[tile]=occupants=new();
occupants.RemoveAll(v=>!v.IsValid());
if(occupants.Any(v=>v!=car && car.Path.ConflictsAt(v.Path,tile))) return false;
if(!occupants.Contains(car)) occupants.Add(car);
return true;
}
public void ReleaseBehind(Vehicle car,Tile current)
{
var front=car.Path.At(car.DistanceTravelled+23).Tile;
var rear=car.Path.At(Math.Max(0,car.DistanceTravelled-9)).Tile;
foreach(var pair in junctions.Where(p=>p.Key!=current && p.Key!=front && p.Key!=rear).ToArray()) pair.Value.Remove(car);
}
public void ReleaseAhead(Vehicle car)
{
var rear=car.Path.At(Math.Max(0,car.DistanceTravelled-9)).Tile;
foreach(var pair in junctions.Where(p=>p.Key!=car.CurrentTile && p.Key!=rear).ToArray()) pair.Value.Remove(car);
}
public void Release(Vehicle car) { foreach(var occupants in junctions.Values) occupants.Remove(car); }
protected override void OnDestroy()
{
if(game!=null) game.MatchChanged-=MatchChanged;
if(match!=null) { match.Events.PayoutResolved-=Payout; match.Events.HospitalEmergency-=Emergency; match.Events.EventPlayed-=EventPlayed; match.Events.PhaseChanged-=Phase; }
Clear();
}
}