Match/MatchState.cs

The MatchState class holds all data for a single game match. It manages the game board, player assets, and handles rules for actions like placing cards or claiming tiles.

using System;
using System.Collections.Generic;
using System.Linq;
using CitySim.Cards;

namespace CitySim.Match;

public sealed record BoardCell( int X, int Y, string CardId, Guid Owner, int Order, int PlacedRound, int Age = 0 );
public sealed record TileClaim(int X,int Y,Guid Owner,int ExpiresRound);
public sealed record RoundActivity(Guid Player,int Buildings,bool RushHour,int RushHourCount = 0);

public sealed class MatchPlayer
{
	readonly List<string> hand = new();
	public Guid Id { get; }
	public string Name { get; }
	public int Coins { get; internal set; }
	public int Vp { get; internal set; }
	public long HandVersion { get; internal set; }
	public IReadOnlyList<string> Hand => hand.AsReadOnly();
	internal List<string> MutableHand => hand;
	internal MatchPlayer( Guid id, string name, int coins ) { Id = id; Name = name; Coins = coins; }
}

/// <summary>Authoritative rules data. Contains no engine types or scene references.</summary>
public sealed partial class MatchState
{
	readonly BoardCell[,] board;
	readonly Dictionary<Guid, MatchPlayer> players = new();
	readonly Dictionary<(int,int),TileClaim> claims = new();
	int order;
	readonly Dictionary<(int SourceOrder,int Round),int> triggerUsage = new();
	readonly Dictionary<Guid,int> roundBuildings = new();
	readonly Dictionary<Guid,int> rushHours = new();
	public int BuildingsPlacedThisRound(Guid player) => roundBuildings.GetValueOrDefault(player);
	public int RushHourCount(Guid player) => rushHours.GetValueOrDefault(player);
	public bool RushHourActive(Guid player) => RushHourCount(player) > 0;
	internal RoundActivity[] ActivitySnapshot() => players.Keys.Select(id=>new RoundActivity(id,BuildingsPlacedThisRound(id),RushHourActive(id),RushHourCount(id))).ToArray();
	void LoadActivity(IEnumerable<RoundActivity> entries)
	{
		roundBuildings.Clear(); rushHours.Clear();
		foreach (var item in entries ?? Array.Empty<RoundActivity>()) { roundBuildings[item.Player]=item.Buildings; if (item.RushHourCount > 0 || item.RushHour) rushHours[item.Player] = Math.Max(item.RushHourCount, item.RushHour ? 1 : 0); }
	}
	internal int TriggerUses(int sourceOrder,int round) => triggerUsage.GetValueOrDefault((sourceOrder,round));
	internal TriggerUsageCheckpoint[] TriggerUsageSnapshot() => triggerUsage.OrderBy(p => p.Key.SourceOrder).ThenBy(p => p.Key.Round).Select(p => new TriggerUsageCheckpoint(p.Key.SourceOrder,p.Key.Round,p.Value)).ToArray();
	public int Width { get; }
	public int Height { get; }
	public int Seed { get; }
	public int Round { get; private set; }
	public MatchPhase Phase { get; private set; } = MatchPhase.Lobby;
	public double PhaseEndsAt { get; private set; }
	public MatchSettings Settings { get; }
	public MatchEvents Events { get; } = new();
	public IReadOnlyList<TileClaim> Claims => claims.Values.ToList().AsReadOnly();
	public EndScore FinalScore { get; internal set; }
	public Goals Goals { get; }
	public IReadOnlyList<ContractReveal> ContractReveals { get; internal set; } = Array.Empty<ContractReveal>();
	public int LastPaidRound { get; internal set; }
	internal int NextPlacementOrder => order+1;
	internal MatchCheckpoint Capture() => new(2,Width,Height,Seed,Settings,Round,Phase,PhaseEndsAt,order,LastPaidRound,Cells().ToArray(),Claims.ToArray(),
		players.Values.Select(p => new PlayerCheckpoint(p.Id,p.Name,p.Coins,p.Vp,p.HandVersion,p.Hand.ToArray())).ToArray(),Goals.Capture(),ContractReveals.ToArray(),FinalScore,TriggerUsageSnapshot(),ActivitySnapshot(),BuilderChoiceSnapshot());
	internal static MatchState Restore(MatchCheckpoint data)
	{
		if (data.Version != 2) throw new ArgumentException("Unsupported match checkpoint version.");
		var usage = data.TriggerUsage ?? Array.Empty<TriggerUsageCheckpoint>();
		if (usage.Any(u => u == null || u.SourceOrder <= 0 || u.SourceOrder > data.PlacementOrder || u.Round <= 0 || u.Round > data.Round || u.Count <= 0 || u.Count > 2) ||
			usage.GroupBy(u => (u.SourceOrder,u.Round)).Any(g => g.Count() != 1))
			throw new ArgumentException("Invalid placement trigger usage.");
		var s = new MatchState(data.Width,data.Height,data.Seed,data.Settings) { Round=data.Round,Phase=data.Phase,PhaseEndsAt=data.PhaseEndsAt,order=data.PlacementOrder,LastPaidRound=data.LastPaidRound,FinalScore=data.FinalScore,ContractReveals=Array.AsReadOnly(data.ContractReveals) };
		foreach (var p in data.Players) { var player = s.AddPlayer(p.Id,p.Name); player.Coins=p.Coins; player.Vp=p.Vp; player.HandVersion=p.HandVersion; player.MutableHand.AddRange(p.Hand); }
		foreach (var cell in data.Cells) s.board[cell.X,cell.Y] = cell;
		foreach (var claim in data.Claims) s.claims[(claim.X,claim.Y)] = claim;
		var activity=data.Activity ?? Array.Empty<RoundActivity>();
		if (activity.Any(a=>a==null || !s.players.ContainsKey(a.Player) || a.Buildings<0 || a.Buildings>data.PlacementOrder || a.RushHourCount<0) || activity.Select(a=>a.Player).Distinct().Count()!=activity.Length || activity.Sum(a=>(long)a.Buildings)>data.PlacementOrder) throw new ArgumentException("Invalid round activity.");
		s.LoadActivity(activity); s.LoadBuilderChoices(data.BuilderChoices); s.Goals.Restore(data.Goals); foreach (var entry in usage) s.triggerUsage[(entry.SourceOrder,entry.Round)] = entry.Count; return s;
	}
	internal void ApplyReplica(PublicMatchSnapshot data,OwnerMatchSnapshot own)
	{
		if (data.Width != Width || data.Height != Height) throw new ArgumentException("Replica board dimensions changed.");
		var resourceChanges = new List<(ResourceChange Coins,ResourceChange Vp)>();
		var handChanges = new List<Guid>(); var placed = new List<BoardCell>(); var updated = new List<BoardCell>(); var replaced = new List<BoardReplacement>();
		var previousPhase = (Phase,Round,PhaseEndsAt); var wasScored = FinalScore != null;
		foreach (var source in data.Players)
		{
			if (!players.TryGetValue(source.Id,out var player)) player = AddPlayer(source.Id,source.Name);
			resourceChanges.Add((new(source.Id,player.Coins,source.Coins),new(source.Id,player.Vp,source.Vp)));
			player.Coins=source.Coins; player.Vp=source.Vp;
			var hand = own?.Owner == source.Id ? own.Hand : Array.Empty<string>();
			if (!player.Hand.SequenceEqual(hand)) handChanges.Add(source.Id);
			player.MutableHand.Clear(); player.MutableHand.AddRange(hand); player.HandVersion=own?.Owner == source.Id ? own.HandVersion : 0;
		}
		foreach (var cell in data.Cells)
		{
			var old = board[cell.X,cell.Y];
			if (old == null) placed.Add(cell); else if (old.Order != cell.Order || old.CardId != cell.CardId) { replaced.Add(new(old,cell)); placed.Add(cell); } else if (old != cell) updated.Add(cell);
			board[cell.X,cell.Y]=cell;
		}
		order=data.Cells.Select(c => c.Order).DefaultIfEmpty(0).Max();
		claims.Clear(); foreach (var claim in data.Claims) claims[(claim.X,claim.Y)]=claim;
		Goals.ApplyPublic(data.Goals,data.GoalHistory); ContractReveals=Array.AsReadOnly(data.ContractReveals); FinalScore=data.FinalScore;
		Phase=data.Phase; Round=data.Round; PhaseEndsAt=data.PhaseEndsAt; LoadActivity(data.Activity);
		LoadBuilderChoices(own == null ? Array.Empty<BuilderChoice>() : new[] { new BuilderChoice(own.Owner,own.BuilderCommon) });
		foreach (var change in resourceChanges) { if (change.Coins.Previous != change.Coins.Current) Events.Coins(change.Coins); if (change.Vp.Previous != change.Vp.Current) Events.Vp(change.Vp); }
		foreach (var id in handChanges) Events.Hand(id);
		foreach (var change in replaced) { Events.Replaced(change); if (CardLibrary.Get(change.Before.CardId).EffectTray) Events.Tray(change.Before.Owner); }
		foreach (var cell in placed) { Events.Placed(cell); if (CardLibrary.Get(cell.CardId).EffectTray) Events.Tray(cell.Owner); }
		foreach (var cell in updated) Events.Updated(cell);
		Events.Claims(); Events.GoalsChanged();
		if (previousPhase != (Phase,Round,PhaseEndsAt)) Events.Phase(new(Phase,Round,PhaseEndsAt));
		if (!wasScored && FinalScore != null) Events.Scored(FinalScore);
	}
	public IReadOnlyDictionary<Guid, MatchPlayer> Players => new System.Collections.ObjectModel.ReadOnlyDictionary<Guid, MatchPlayer>( players );

	public MatchState( int width, int height, int seed = 0, MatchSettings settings = null )
	{
		if ( width <= 0 || height <= 0 ) throw new ArgumentOutOfRangeException(nameof(width));
		Width = width; Height = height; Seed = seed; Settings = settings ?? new();
		if ( Settings.HandLimit < 1 || Settings.StartingCoins < 0 || Settings.RareBuildCost < 0 || Settings.LegendaryBuildCost < 0 || Settings.Rounds < 1 ||
			Settings.BuildSeconds <= 0 || !double.IsFinite(Settings.BuildSeconds) || !double.IsFinite(Settings.HospitalEmergencyFraction) || Settings.HospitalEmergencyFraction < 0 || Settings.HospitalEmergencyFraction > 1 ||
			Settings.PayoutSeconds <= 0 || !double.IsFinite(Settings.PayoutSeconds) ||
			Settings.StubBidSeconds < 0 || !double.IsFinite(Settings.StubBidSeconds) ||
			Settings.MiniAuctionSeconds <= 0 || !double.IsFinite(Settings.MiniAuctionSeconds) ||
			Settings.AuctionResolveSeconds < 0 || !double.IsFinite(Settings.AuctionResolveSeconds) ||
			Settings.HandTarget < 1 || Settings.HandTarget > Settings.HandLimit || Settings.MiniAuctionCap < 1 || Settings.ExtraLots < 1 ||
			Settings.CoinsPerEndVp < 1 ||
			Settings.PublicGoalCount < 1 || Settings.PublicGoalCount > GoalLibrary.All.Count || Settings.ContractChoices < 1 || Settings.ContractChoices > Contracts.All.Count || Settings.GoalVp < 0 || Settings.TiedGoalVp < 0 ||
			Settings.StubOpponentBid < 0 || Settings.StubThinkSeconds < 0 || !double.IsFinite(Settings.StubThinkSeconds) ||
			Settings.AiFrameBudgetMilliseconds <= 0 || !double.IsFinite(Settings.AiFrameBudgetMilliseconds) ||
			Settings.Rarities.Count != 4 || Settings.Rarities.Select(r => r.Rarity).Distinct().Count() != 4 || Settings.Rarities.Any(r => r.MinimumBid < 1 || r.RevealWeight < 0) ) throw new ArgumentException("Invalid match settings.");
		board = new BoardCell[width,height];
		Goals = new Goals(this);
	}

	public MatchPlayer AddPlayer( Guid id, string name )
	{
		var player = new MatchPlayer( id, name, Settings.StartingCoins );
		players.Add( id, player );
		return player;
	}

	public bool InBounds( int x, int y ) => x >= 0 && y >= 0 && x < Width && y < Height;
	public BoardCell Get( int x, int y ) => InBounds(x,y) ? board[x,y] : null;
	public IEnumerable<BoardCell> Cells()
	{
		for ( var y = 0; y < Height; y++ )
		for ( var x = 0; x < Width; x++ )
			if ( board[x,y] is { } cell ) yield return cell;
	}
	public IEnumerable<BoardCell> Neighbours( int x, int y )
	{
		foreach ( var offset in new[] { (0,-1), (1,0), (0,1), (-1,0) } )
			if ( Get(x+offset.Item1,y+offset.Item2) is { } cell ) yield return cell;
	}

	public void SetPhase( MatchPhase phase, int round, double endsAt )
	{
		if ( round < 0 || round > Settings.Rounds || !double.IsFinite(endsAt) ) throw new ArgumentOutOfRangeException(nameof(round));
		if (round!=Round) { roundBuildings.Clear(); rushHours.Clear(); }
		Phase = phase; Round = round; PhaseEndsAt = endsAt;
		if (phase == MatchPhase.RoundStart)
		{
			var expired = claims.Values.Where(c => c.ExpiresRound <= round).ToArray();
			foreach (var claim in expired) claims.Remove((claim.X,claim.Y));
			if (expired.Length > 0) Events.Claims();
		}
		Events.Phase( new(phase,round,endsAt) );
	}

	public bool GiveCard( Guid playerId, string cardId )
	{
		if ( !players.TryGetValue(playerId,out var player) || !CardLibrary.TryGet(cardId,out _) || player.Hand.Count >= Settings.HandLimit ) return false;
		player.MutableHand.Add(cardId);
		HandChanged(playerId);
		Events.Received(new(playerId,cardId));
		return true;
	}

	public string PlacementError( Guid playerId, string cardId, int x, int y )
	{
		if ( Phase != MatchPhase.Build ) return "#rules.match_state.wait_for_the_build_phase";
		if ( !players.TryGetValue(playerId,out var player) ) return "#rules.match_state.unknown_player";
		if ( !CardLibrary.TryGet(cardId,out var card) ) return "#rules.match_state.unknown_card";
		if ( !player.Hand.Contains(cardId) ) return "#rules.match_state.card_is_not_in_your_hand";
		if ( card.Type == CardType.Event ) return "#rules.match_state.play_this_event_through_its_effect_rule";
		if ( player.Coins < Settings.BuildCost(card) ) return GameText.Message("rules.match_state.not_enough_coins_to_build_cost", Settings.BuildCost(card));
		if ( !InBounds(x,y) ) return "#rules.match_state.outside_the_board";
		if ( Get(x,y) != null ) return "#rules.match_state.tile_is_occupied";
		if (claims.TryGetValue((x,y),out var claim) && claim.Owner != playerId && claim.ExpiresRound > Round) return "#rules.match_state.reserved_by_another_player";
		if ( card.Id == "street" && x != 0 && y != 0 && x != Width-1 && y != Height-1 && !Neighbours(x,y).Any(c => CardLibrary.Get(c.CardId).Type == CardType.Road) ) return "#rules.match_state.street_needs_an_adjacent_road_or_the_board_edge";
		if ( card.NeedsRoad && !Neighbours(x,y).Any(c => CardLibrary.Get(c.CardId).Type == CardType.Road) ) return "#rules.match_state.needs_an_adjacent_road";
		foreach (var requirement in card.Effects.Where(e => e.Hook == EffectHook.Place && e.Rule == "minimum-buildings"))
			if (Neighbours(x,y).Count(c => CardLibrary.Get(c.CardId).Type is not (CardType.Road or CardType.Event)) < requirement.Cap) return GameText.Message("rules.match_state.needs_at_least_adjacent_buildings", requirement.Cap);
		if ( card.OwnershipLimit > 0 && Cells().Count(c => c.Owner == playerId && c.CardId == cardId) >= card.OwnershipLimit ) return "#rules.match_state.ownership_limit_reached";
		return null;
	}

	public PlacementResult TryPlace( Guid playerId, string cardId, int x, int y )
	{
		var error = PlacementError(playerId,cardId,x,y);
		if ( error != null ) { var rejected = new PlacementResult(false,error); Events.Rejected(rejected); return rejected; }
		var cell = new BoardCell(x,y,cardId,playerId,++order,Round);
		var effects = PlacementScoring.Calculate(this,cell);
		players[playerId].MutableHand.Remove(cardId);
		ChangeCoins(playerId,-Settings.BuildCost(CardLibrary.Get(cardId)));
		CommitPlacement(cell,effects);
		return new(true,"",cell);
	}
	internal void CommitPlacement(BoardCell cell,PlacementEffects effects,BoardCell replaced=null)
	{
		var (x,y,cardId,playerId)=(cell.X,cell.Y,cell.CardId,cell.Owner);
		board[x,y] = cell;
		if (CardLibrary.Get(cardId).Type is not (CardType.Road or CardType.Event)) roundBuildings[playerId]=BuildingsPlacedThisRound(playerId)+1;
		foreach (var award in effects.Awards)
		{
			if (award.Reason == "#cards.warehouse.name" && award.Source != null)
			{
				var key = (award.Source.Order,Round);
				triggerUsage[key] = triggerUsage.GetValueOrDefault(key)+1;
			}
			ApplyAward(award);
		}
		if (claims.Remove((x,y))) Events.Claims();
		HandChanged(playerId);
		if (replaced != null) { Events.Replaced(new(replaced,cell)); if (CardLibrary.Get(replaced.CardId).EffectTray) Events.Tray(replaced.Owner); }
		Events.Placed(cell);
		Events.Placement(effects);
		if (effects.Park != null) Events.Park(effects.Park);
		if (CardLibrary.Get(cardId).EffectTray) Events.Tray(playerId);
	}

	public string CardPlayError(Guid owner,string cardId,int x,int y)
	{
		if (cardId != "land-claim") return PlacementError(owner,cardId,x,y);
		if (Phase != MatchPhase.Build) return "#rules.match_state.wait_for_the_build_phase";
		if (!players.TryGetValue(owner,out var player) || !player.Hand.Contains(cardId)) return "#rules.match_state.card_is_not_in_your_hand";
		if (!InBounds(x,y)) return "#rules.match_state.outside_the_board";
		if (Get(x,y) != null) return "#rules.match_state.tile_is_occupied";
		if (claims.TryGetValue((x,y),out var claim) && claim.ExpiresRound > Round) return "#rules.match_state.tile_is_already_reserved";
		return null;
	}
	public PlacementResult TryClaim(Guid owner,int slot,int x,int y)
	{
		if (!players.TryGetValue(owner,out var player) || slot < 0 || slot >= player.Hand.Count || player.Hand[slot] != "land-claim") return new(false,"#rules.match_state.select_land_claim_first");
		var error = CardPlayError(owner,"land-claim",x,y);
		if (error != null) { var denied = new PlacementResult(false,error); Events.Rejected(denied); return denied; }
		var duration = CardLibrary.Get("land-claim").Effects.Single(e => e.Rule == "land-claim").Amount;
		claims[(x,y)] = new(x,y,owner,Round+duration);
		player.MutableHand.RemoveAt(slot); HandChanged(owner); Events.Claims();
		Events.Played(new(owner,"land-claim",Claim:claims[(x,y)]));
		return new(true,"");
	}
	internal void UpdateCell(BoardCell cell) { board[cell.X,cell.Y] = cell; Events.Updated(cell); }
	internal void ApplyAward(RuleAward award) { if (award.Coins != 0) ChangeCoins(award.Player,award.Coins); if (award.Vp != 0) ChangeVp(award.Player,award.Vp); }

	public bool Discard( Guid playerId, int slot )
	{
		if ( Phase != MatchPhase.Build || !players.TryGetValue(playerId,out var player) || slot < 0 || slot >= player.Hand.Count ) return false;
		var cardId=player.Hand[slot];
		player.MutableHand.RemoveAt(slot); HandChanged(playerId); Events.Discarded(new(playerId,cardId)); return true;
	}

	public bool PlayGrant( Guid playerId, int slot )
	{
		return players.TryGetValue(playerId,out var player) && slot>=0 && slot<player.Hand.Count && player.Hand[slot]=="grant" && PlayEvent(playerId,slot);
	}
	/// <summary>Each Rush Hour played adds a separate bonus for this round.</summary>
	public bool PlayEvent(Guid playerId,int slot)
	{
		if (Phase!=MatchPhase.Build || !players.TryGetValue(playerId,out var player) || slot<0 || slot>=player.Hand.Count) return false;
		var id=player.Hand[slot]; if (id is not ("grant" or "ribbon-cutting" or "rush-hour")) return false;
		var amount=0;
		if (id=="grant") amount=CardLibrary.Get(id).Effects.First(e=>e.Rule=="coins").Amount;
		if (id=="ribbon-cutting") { var effect=CardLibrary.Get(id).Effects.First(e=>e.Rule=="round-building-vp"); amount=(int)Math.Min(effect.Cap,(long)BuildingsPlacedThisRound(playerId)*effect.Amount); }
		if (id=="ribbon-cutting" ? (long)player.Vp+amount>int.MaxValue : (long)player.Coins+amount>int.MaxValue) return false;
		if (id=="rush-hour") rushHours[playerId] = RushHourCount(playerId) + 1;
		player.MutableHand.RemoveAt(slot); if (id=="ribbon-cutting") ChangeVp(playerId,amount); else ChangeCoins(playerId,amount); HandChanged(playerId); Events.Played(new(playerId,id,Coins:id=="ribbon-cutting" ? 0 : amount,Vp:id=="ribbon-cutting" ? amount : 0)); return true;
	}
	void HandChanged(Guid owner) { players[owner].HandVersion++; Events.Hand(owner); }

	/// <summary>Seed scenery before a match. Neutral roads never belong to a player.</summary>
	public bool SeedStreet( int x, int y )
	{
		if ( Phase != MatchPhase.Lobby || !InBounds(x,y) || Get(x,y) != null ) return false;
		var cell = new BoardCell(x,y,"street",Guid.Empty,++order,0);
		board[x,y] = cell; Events.Placed(cell); return true;
	}

	public bool ChangeCoins( Guid playerId, int delta )
	{
		if ( !players.TryGetValue(playerId,out var p) || (long)p.Coins + delta < 0 || (long)p.Coins + delta > int.MaxValue ) return false;
		var previous = p.Coins; p.Coins += delta;
		if ( delta != 0 ) Events.Coins(new(playerId,previous,p.Coins));
		return true;
	}
	public void ChangeVp( Guid playerId, int delta )
	{
		var p = players[playerId]; var previous = p.Vp; p.Vp = checked(p.Vp + delta);
		if ( delta != 0 ) Events.Vp(new(playerId,previous,p.Vp));
	}
}