Match/TriggerChecks.cs

This file contains unit tests for the CitySim game matching logic. It validates card placement rules, scoring, trigger behaviors, and game state persistence using JSON serialization.

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

namespace CitySim.Match;

public static class TriggerChecks
{
    static readonly Guid A = new("11111111-1111-1111-1111-111111111111"), B = new("22222222-2222-2222-2222-222222222222");
    public static IReadOnlyList<string> Run()
    {
        var passed = new List<string>();
        void Check(bool ok,string name) { if (!ok) throw new InvalidOperationException(name); passed.Add("PASS "+name); }
        MatchState Board()
        {
            var s = new MatchState(12,12,42); s.AddPlayer(A,"A"); s.AddPlayer(B,"B");
            for (var x=0;x<12;x++) s.SeedStreet(x,6);
            s.SetPhase(MatchPhase.Build,1,40); return s;
        }
        void Place(MatchState s,Guid id,string card,int x,int y)
        {
            if (!s.GiveCard(id,card) || !s.TryPlace(id,card,x,y).Success) throw new InvalidOperationException($"Setup {card} {x},{y}");
        }
        var circle=Board(); Place(circle,A,"roundabout",2,5);
        circle.GiveCard(B,"house"); var before=circle.Players[A].Vp;
        var hypothetical = new BoardCell(2,4,"house",B,100,1);
        Check(PlacementScoring.Calculate(circle,hypothetical).Awards.Any(a => a.Player==A && a.Reason=="#cards.roundabout.name" && a.Vp==1) && circle.Players[A].Vp==before,"Roundabout preview awards owner without mutation");
        Check(circle.TryPlace(B,"house",2,4).Success && circle.Players[A].Vp==before+1,"rival building awards Roundabout owner");
        Place(circle,B,"street",1,5);
        Check(circle.Players[A].Vp==before+1,"adjacent road does not trigger Roundabout");
        Place(circle,A,"park",3,5);
        Check(circle.Players[A].Vp>=before+2 && PlacementScoring.Calculate(circle,new BoardCell(3,4,"street",A,101,1)).Awards.All(a => a.Reason!="#cards.roundabout.name"),"own building triggers Roundabout and road preview does not");
        var contractor=Board(); Place(contractor,A,"contractor",0,5);
        var first=contractor.Players[A].Coins; contractor.GiveCard(A,"house");
        for (var i=0;i<3;i++) PlacementScoring.Preview(contractor,"house",1,5,A);
        Check(contractor.TriggerUses(contractor.Get(0,5).Order,1)==0,"previews do not record Contractor triggers");
        Check(!contractor.TryPlace(A,"house",0,5).Success && contractor.TriggerUses(contractor.Get(0,5).Order,1)==0,"rejected placement does not record Contractor triggers");
        Check(contractor.TryPlace(A,"house",1,5).Success && contractor.Players[A].Coins==first+1,"Contractor pays for own House");
        Place(contractor,B,"shop",2,5); Check(contractor.Players[A].Coins==first+1,"Contractor ignores rival Shop");
        Place(contractor,A,"apartment",3,5); Check(contractor.Players[A].Coins==first+1,"Contractor counts House card, not Apartment");
        Place(contractor,A,"shop",4,5); Place(contractor,A,"house",5,5);
        Check(contractor.Players[A].Coins==first+3 && contractor.TriggerUses(contractor.Get(0,5).Order,1)==0,"Contractor pays for every own House and Shop without a round cap");
        var saved=JsonSerializer.Deserialize<MatchCheckpoint>(JsonSerializer.Serialize(contractor.Capture()));
        var restored=MatchState.Restore(saved); var restoredCoins=restored.Players[A].Coins;
        Place(restored,A,"shop",6,5);
        Check(restored.Players[A].Coins==restoredCoins+1 && restored.TriggerUses(restored.Get(0,5).Order,1)==0,"Contractor keeps paying after checkpoint restore");
        restored.SetPhase(MatchPhase.Build,2,80); Place(restored,A,"house",7,5);
        Check(restored.Players[A].Coins==restoredCoins+2 && restored.TriggerUses(restored.Get(0,5).Order,2)==0,"Contractor keeps paying in the next round");
        var rejectedUsage=false;
        try { MatchState.Restore(saved with { TriggerUsage = new[] { new TriggerUsageCheckpoint(1,1,3) } }); }
        catch (ArgumentException) { rejectedUsage=true; }
        Check(rejectedUsage,"checkpoint rejects invalid capped trigger usage");
        var legacy=MatchState.Restore(saved with {TriggerUsage=null});
        Check(legacy.TriggerUses(legacy.Get(0,5).Order,1)==0,"older checkpoint without trigger usage loads with empty usage");
        return passed.AsReadOnly();
    }
}