Match/Goals.cs

Defines goal types and implements logic for evaluating goals based on player achievements and manages the goal deck throughout the match lifecycle.

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

namespace CitySim.Match;

public enum GoalKind { ConnectedHomes, ShopLine, ParkOwner, ConnectedFarms, TagCount, TagCollector, MiniMall, Coins }
public sealed record GoalDef(string Id,string Name,string Icon,string Description,GoalKind Kind,int Target,Tag Tag = Tag.None,int Groups = 1);
public sealed record ObjectiveProgress(int Current,int Target,string Detail) { public bool Complete => Current >= Target; }
public sealed record GoalAward(Guid Player,int Vp);
public sealed record GoalClaim(string GoalId,int Round,IReadOnlyList<GoalAward> Awards);
public sealed record PublicGoal(string GoalId,GoalClaim Claim = null);
public sealed record GoalsResult(int Round,IReadOnlyList<GoalClaim> Claims);

public static class GoalLibrary
{
	public static IReadOnlyList<GoalDef> All { get; } = Array.AsReadOnly(new[] {
		new GoalDef("neighbourhood","#goals.neighbourhood.name","🏘️","#goals.neighbourhood.description",GoalKind.ConnectedHomes,4),
		new GoalDef("high-street","#goals.high_street.name","🏪","#goals.high_street.description",GoalKind.ShopLine,3),
		new GoalDef("green-thumb","#goals.green_thumb.name","🌿","#goals.green_thumb.description",GoalKind.ParkOwner,4),
		new GoalDef("farm-belt","#goals.farm_belt.name","🌾","#goals.farm_belt.description",GoalKind.ConnectedFarms,3),
		new GoalDef("civic-pride","#goals.civic_pride.name","🏛️","#goals.civic_pride.description",GoalKind.TagCount,3,Tag.Civic),
		new GoalDef("arts-district","#goals.arts_district.name","🎭","#goals.arts_district.description",GoalKind.TagCount,3,Tag.Culture),
		new GoalDef("tag-collector","#goals.tag_collector.name","🎨","#goals.tag_collector.description",GoalKind.TagCollector,4,Groups:3),
		new GoalDef("mini-mall","#goals.mini_mall.name","🛍️","#goals.mini_mall.description",GoalKind.MiniMall,3),
		new GoalDef("rainy-day","#goals.rainy_day.name","🪙","#goals.rainy_day.description",GoalKind.Coins,12)
	});
	public static GoalDef Get(string id) => All.FirstOrDefault(g => g.Id == id) ?? throw new ArgumentException("Unknown goal: "+id);
}

public static class GoalRules
{
	public static int Connected(MatchState state,Guid owner,Func<BoardCell,bool> accepts)
	{
		var cells = state.Cells().Where(c => c.Owner == owner && accepts(c)).ToDictionary(c => c.Order);
		var unseen = cells.Keys.ToHashSet(); var best = 0;
		while (unseen.Count > 0)
		{
			var queue = new Queue<BoardCell>(); queue.Enqueue(cells[unseen.First()]); var count = 0;
			while (queue.Count > 0) { var c = queue.Dequeue(); if (!unseen.Remove(c.Order)) continue; count++; foreach (var n in state.Neighbours(c.X,c.Y)) if (unseen.Contains(n.Order)) queue.Enqueue(n); }
			best = Math.Max(best,count);
		}
		return best;
	}
	public static ObjectiveProgress Evaluate(MatchState state,Guid owner,GoalDef goal)
	{
		var own = state.Cells().Where(c => c.Owner == owner).ToArray(); var current = 0; var target = goal.Target;
		switch (goal.Kind)
		{
			case GoalKind.ConnectedHomes: current = Connected(state,owner,BoardRules.IsHome); break;
			case GoalKind.ConnectedFarms: current = Connected(state,owner,BoardRules.IsFarm); break;
			case GoalKind.ParkOwner: current = ParkScoring.Regions(state).Select(r => r.Count(c => c.Owner == owner)).DefaultIfEmpty(0).Max(); break;
			case GoalKind.TagCount: current = own.Count(c => CardLibrary.Get(c.CardId).Tags.HasFlag(goal.Tag)); break;
			case GoalKind.Coins: current = state.Players.GetValueOrDefault(owner)?.Coins ?? 0; break;
			case GoalKind.TagCollector:
				current = new[] {Tag.Home,Tag.Trade,Tag.Nature,Tag.Industry,Tag.Culture,Tag.Civic}.Count(t => own.Count(c => CardLibrary.Get(c.CardId).Tags.HasFlag(t)) >= goal.Target);
				target = goal.Groups; break;
			case GoalKind.ShopLine:
				foreach (var c in own.Where(c => c.CardId == "shop")) foreach (var d in new[] {(1,0),(0,1)})
				{ var length = 0; var x = c.X; var y = c.Y; while (state.Get(x,y) is { } n && n.Owner == owner && n.CardId == "shop") { length++; x += d.Item1; y += d.Item2; } current = Math.Max(current,length); }
				break;
			case GoalKind.MiniMall:
				foreach (var road in state.Cells().Where(c => CardLibrary.Get(c.CardId).Type == CardType.Road))
				{
					var neighbours = state.Neighbours(road.X,road.Y).Where(c => c.Owner == owner).ToArray();
					var count = (neighbours.Any(c => c.CardId == "shop") ? 1 : 0)+(neighbours.Any(c => c.CardId == "cafe") ? 1 : 0)+(neighbours.Any(BoardRules.IsPark) ? 1 : 0);
					current = Math.Max(current,count);
				}
				break;
		}
		return new(current,target,$"{current} / {target}");
	}
}

/// <summary>Public goal state and a deterministic shuffled draw/discard pile. No private player data.</summary>
public sealed class Goals
{
	readonly MatchState state;
	readonly MatchRandom random;
	readonly List<PublicGoal> active = new();
	readonly List<GoalClaim> history = new();
	readonly List<string> deck = new();
	readonly List<string> discard = new();
	bool initialized;
	int checkedRound;
	public IReadOnlyList<PublicGoal> Active => active.AsReadOnly();
	public IReadOnlyList<GoalClaim> History => history.AsReadOnly();
	public Goals(MatchState state) { this.state = state; random = new MatchRandom(unchecked(state.Seed ^ 0x3B77F01)); }
	internal GoalsCheckpoint Capture() => new(initialized,checkedRound,random.State,active.ToArray(),history.ToArray(),deck.ToArray(),discard.ToArray());
	internal void Restore(GoalsCheckpoint data)
	{
		initialized = data.Initialized; checkedRound = data.CheckedRound; random.State = data.RandomState;
		active.Clear(); active.AddRange(data.Active); history.Clear(); history.AddRange(data.History);
		deck.Clear(); deck.AddRange(data.Deck); discard.Clear(); discard.AddRange(data.Discard);
	}
	internal void ApplyPublic(PublicGoal[] current,GoalClaim[] previous)
	{
		active.Clear(); active.AddRange(current); history.Clear(); history.AddRange(previous);
		// A replica has no draw pile and cannot authoritatively settle goals.
		initialized = false;
	}
	void Shuffle(List<string> cards) { for (var i = cards.Count-1; i > 0; i--) { var j = random.Next(i+1); (cards[i],cards[j]) = (cards[j],cards[i]); } }
	string Draw()
	{
		if (deck.Count == 0) { deck.AddRange(discard.Where(id => active.All(g => g == null || g.GoalId != id))); foreach (var id in deck) discard.Remove(id); Shuffle(deck); }
		var index = deck.FindLastIndex(id => active.All(g => g == null || g.GoalId != id));
		if (index < 0) throw new InvalidOperationException("Goal deck cannot supply a distinct active goal.");
		var card = deck[index]; deck.RemoveAt(index); return card;
	}
	public void Initialize(IReadOnlyList<string> initial = null)
	{
		if (initialized || !state.Settings.EnableGoals) return;
		if (initial != null && (initial.Count != state.Settings.PublicGoalCount || initial.Distinct().Count() != initial.Count)) throw new ArgumentException("Initial goals must be distinct and fill every slot.");
		if (initial != null) foreach (var id in initial) GoalLibrary.Get(id);
		initialized = true; deck.AddRange(GoalLibrary.All.Select(g => g.Id).Where(id => initial == null || !initial.Contains(id))); Shuffle(deck);
		for (var i = 0; i < state.Settings.PublicGoalCount; i++) active.Add(new(initial != null ? initial[i] : Draw()));
		state.Events.GoalsChanged();
	}
	internal void StartRound()
	{
		if (!initialized) return;
		var changed = false;
		for (var i = 0; i < active.Count; i++) if (active[i].Claim != null) { discard.Add(active[i].GoalId); active[i] = null; changed = true; }
		for (var i = 0; i < active.Count; i++) if (active[i] == null) active[i] = new(Draw());
		if (changed) state.Events.GoalsChanged();
	}
	public GoalsResult CheckAtBuildEnd()
	{
		if (!initialized || state.Phase != MatchPhase.Build || state.Round <= checkedRound) return new(state.Round,Array.Empty<GoalClaim>());
		checkedRound = state.Round; var claims = new List<GoalClaim>();
		foreach (var goal in active.Where(g => g.Claim == null))
		{
			var winners = state.Players.Keys.Where(id => GoalRules.Evaluate(state,id,GoalLibrary.Get(goal.GoalId)).Complete).ToArray();
			if (winners.Length == 0) continue;
			var vp = winners.Length > 1 ? state.Settings.TiedGoalVp : state.Settings.GoalVp;
			claims.Add(new(goal.GoalId,state.Round,winners.Select(id => new GoalAward(id,vp)).ToList().AsReadOnly()));
		}
		foreach (var claim in claims)
		{
			var slot = active.FindIndex(g => g.GoalId == claim.GoalId); active[slot] = active[slot] with { Claim = claim }; history.Add(claim);
			foreach (var award in claim.Awards) state.ChangeVp(award.Player,award.Vp);
		}
		var result = new GoalsResult(state.Round,claims.AsReadOnly());
		if (claims.Count > 0) { state.Events.GoalsChanged(); state.Events.GoalClaims(result); }
		return result;
	}
}