Match/FoundationChecks.cs

This static class contains a test suite for game foundations, validating the logic of card rules, tag interactions, and match state behavior.

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

namespace CitySim.Match;

/// <summary>Engine-independent checks, shared by the console command and the headless runner.</summary>
public static class FoundationChecks
{
	public static IReadOnlyList<string> Run()
	{
		var results = new List<string>();
		void Check( bool ok, string name ) { if (!ok) throw new InvalidOperationException(name); results.Add($"PASS {name}"); }
		Check(CardLibrary.All.Select(c => c.Id).Distinct().Count() == CardLibrary.All.Count,"unique catalog card IDs");
		var cafeRules = CardRuleText.Split(CardLibrary.Get("cafe").Rules).Where(part => part.Label != null).ToArray();
		Check(cafeRules.Select(p=>p.Label).SequenceEqual(new[]{"#timing.place.label","#timing.payout.label","#timing.end.label"}) && cafeRules.All(part => !string.IsNullOrEmpty(part.Help)),"card timing labels explain placement, payout and end sections");
		var houseRules = CardRuleText.Split(CardLibrary.Get("house").Rules);
		Check(houseRules.Count == 2 && houseRules[0].Label == "#timing.place.label" && houseRules[1].Label == "#timing.payout.label" && CardRuleText.Words(houseRules[1].Text).Any(word => word.Shop),"House explains payout and placement separately");
		Check(CardRuleText.Split("A plain rule.").Single().Text == "A plain rule.","plain card text remains intact");
		var apartmentWords = CardRuleText.Words(CardRuleText.Split(CardLibrary.Get("apartment").Rules).First().Text);
		Check(apartmentWords.Any(w => w.Tag == Tag.Home),"Apartment description renders its Home-tag target as a tag");
		Check(CardRuleText.Words(CardLibrary.Get("park").Rules).Any(w => w.Tag==Tag.Nature) && CardRuleText.Words(CardLibrary.Get("cottage").Rules).Any(w => w.Park),"Nature-card and park references become highlightable chips");
		var tagWords = CardRuleText.Words(CardLibrary.Get("eco-council").Rules);
		Check(tagWords.Any(w => w.Tag == Tag.Nature) && tagWords.Any(w => w.Tag == Tag.Industry),"multiple tag references produce distinct inline chips");
		Check(CardRuleText.Words(CardLibrary.Get("house").Rules).Any(w => w.Shop && !string.IsNullOrEmpty(w.Text)) && CardRuleText.Words(CardLibrary.Get("apartment").Rules).Any(w => w.Shop && !string.IsNullOrEmpty(w.Text)),"singular and plural Shop references become chips");
		Check(CardRuleText.Words(CardLibrary.Get("contractor").Rules).All(w => w.Tag == Tag.None),"specific House and Shop targets do not become category tags");
        var avenueWords = CardRuleText.Words(CardLibrary.Get("avenue").Rules);
        Check(avenueWords.Any(w => w.Kind == InteractionKind.Building) && avenueWords.Any(w => w.Kind == InteractionKind.Road),"Avenue exposes building and road hover targets");
        Check(CardRuleText.Words(CardLibrary.Get("granary").Rules).Where(w => w.Kind == InteractionKind.Card).Select(w => w.CardId).SequenceEqual(new[] { "farm", "orchard" }),"Farm and Orchard references retain distinct targets");
        foreach (var card in CardLibrary.All)
        foreach (var word in CardRuleText.Words(card.Rules).Where(w => w.Kind == InteractionKind.Card))
            Check(CardLibrary.TryGet(word.CardId,out _),$"{card.Name} inline reference {word.Text} resolves to a card");
		var expected = new[] { "street", "house", "cottage", "shop", "farm", "park", "wild-garden", "grant", "apartment", "cafe", "fountain", "factory", "sapling", "bank", "town-hall", "museum", "chamber-of-commerce", "gardeners-guild", "architects-studio", "land-claim", "monument" };
		Check(expected.All(id => CardLibrary.TryGet(id,out _)),"original GDD section 5.6 playtest cards remain available");
		Check(CardLibrary.All.All(c => c.Weight > 0 && !string.IsNullOrWhiteSpace(c.Rules) && !string.IsNullOrWhiteSpace(c.Icon)),"card metadata is populated");
		Check(CardLibrary.All.Count(c => c.Rarity == Rarity.Common) == 10,"ten Common cards including the two low-weight economy events");
		Check(CardLibrary.Get("bank").OwnershipLimit == 3 && CardLibrary.Get("museum").OwnershipLimit == 1,"Civic ownership limits");
		Check(CardLibrary.All.Where(c=>c.ParkSize>0).All(c=>c.ParkSize==1),"every park card represents one physical tile");
		Check(!CardLibrary.TryGet("missing",out _),"unknown cards rejected");
		var match = new MatchState(5,5,123);
		var id = new Guid("11111111-1111-1111-1111-111111111111");
		var player = match.AddPlayer(id,"Test mayor");
		var placed = 0; var rejected = 0; var hands = 0; var phases = 0; var coins = 0;
		match.Events.CardPlaced += _ => placed++;
		match.Events.PlacementRejected += _ => rejected++;
		match.Events.HandChanged += _ => hands++;
		match.Events.PhaseChanged += _ => phases++;
		match.Events.CoinsChanged += _ => coins++;
		Check(player.Coins == 8,"starting coins");
		Check(match.SeedStreet(2,2),"neutral seed road");
		Check(match.GiveCard(id,"house"),"deal a House");
		Check(!match.TryPlace(id,"house",2,1).Success && player.Hand.Count == 1,"no placement outside Build");
		match.SetPhase(MatchPhase.Build,1,40);
		Check(!match.TryPlace(id,"house",99,0).Success && player.Hand.Count == 1,"invalid placement preserves hand");
		var placement = match.TryPlace(id,"house",2,1);
		Check(placement.Success && placement.Cell.Owner == id && player.Hand.Count == 0,"place House from hand");
		Check(placement.Cell.Order == 2 && placement.Cell.Age == 0 && placement.Cell.PlacedRound == 1,"placement order and age");
		match.GiveCard(id,"park");
		Check(!match.TryPlace(id,"park",2,1).Success && match.Get(2,1).CardId == "house","occupied tile cannot be overwritten");
		Check(!match.TryPlace(id,"park",-1,0).Success,"out of bounds rejected");
		Check(match.TryPlace(id,"park",0,0).Success,"Park needs no road");
		match.GiveCard(id,"cottage");
		Check(match.TryPlace(id,"cottage",4,4).Success,"Cottage needs no road despite Home tag");
		match.GiveCard(id,"grant");
		Check(!match.TryPlace(id,"grant",4,3).Success,"events do not occupy board cells");
		Check(!match.TryPlace(Guid.NewGuid(),"park",3,3).Success,"unknown player rejected");
		Check(!match.SeedStreet(3,3),"cannot seed roads during Build");
		Check(!match.ChangeCoins(id,-9) && player.Coins == 8 && coins == 0,"overspending cannot mutate coins or emit a change");
		Check(match.ChangeCoins(id,3) && player.Coins == 11 && coins == 1,"coin changes emit one event");
		Check(placed == 4 && rejected == 6 && phases == 1 && hands == 7,"exactly one notification per successful mutation or rejection");
		while (player.Hand.Count < match.Settings.HandLimit) match.GiveCard(id,"street");
		Check(!match.GiveCard(id,"street") && player.Hand.Count == 5,"hand limit enforced");
		Check(match.Cells().Count() == 4 && match.Get(2,2).Owner == Guid.Empty,"board enumeration and neutral ownership");
		var limits = new MatchState(5,5,settings: new MatchSettings { RareBuildCost=0 });
		limits.AddPlayer(id,"Limits");
		limits.SetPhase(MatchPhase.Build,1,40);
		for ( var x = 0; x < 3; x++ ) { limits.GiveCard(id,"bank"); Check(limits.TryPlace(id,"bank",x,0).Success,$"Bank stack {x+1}"); }
		limits.GiveCard(id,"bank");
		Check(!limits.TryPlace(id,"bank",3,0).Success && limits.Players[id].Hand.Count == 1,"fourth Bank rejected without consuming card");
		return results.AsReadOnly();
	}
}