The file defines data structures and logic to calculate and apply payouts based on card effects and game rules within a match. It processes various rule triggers, applies coin and VP changes, and advances the match state.
using System;
using System.Collections.Generic;
using System.Linq;
using CitySim.Cards;
namespace CitySim.Match;
public sealed record PayoutEntry( Guid Player, int Coins, int Vp, string Reason, BoardCell Source = null, BoardCell Partner = null );
public sealed record PayoutResult( int Round, IReadOnlyList<PayoutEntry> Entries )
{
public int CoinsFor( Guid player ) => Entries.Where(e => e.Player == player).Sum(e => e.Coins);
}
public static class Payout
{
public static PayoutResult Calculate( MatchState state )
{
// Preserve row-major order for each owner's entries; their stable order is visible in the tally.
var cells = state.Cells().ToArray();
var byOwner = cells.ToLookup(c => c.Owner);
var entries = new List<PayoutEntry>();
var rush = CardLibrary.Get("rush-hour").Effects.First(e => e.Rule == "rush-hour");
foreach (var player in state.Players.Values.Where(p => state.RushHourActive(p.Id)))
{
var received = byOwner[player.Id].Count(c => c.CardId == "shop");
var bonus = (int)Math.Min(int.MaxValue, (long)Math.Min(rush.Cap,received * rush.Amount) * state.RushHourCount(player.Id));
if (bonus > 0) entries.Add(new(player.Id,bonus,0,"#cards.rush_hour.name"));
}
foreach ( var cottage in cells.Where(c => c.CardId == "cottage") )
if ( state.Neighbours(cottage.X,cottage.Y).Any(c => BoardRules.IsPark(c) || c.CardId == "lake") )
entries.Add(new(cottage.Owner,CardLibrary.Get("cottage").Effects.First(e => e.Rule == "beside-park-coins").Amount,0,"#rules.payout.beside_park",cottage));
foreach (var cell in cells.OrderBy(c => c.Order))
foreach (var effect in CardLibrary.Get(cell.CardId).Effects.Where(e => e.Hook == EffectHook.Payout))
{
var own = byOwner[cell.Owner];
var amount = effect.Rule switch
{
"coins" => effect.Amount,
"avenue-buildings" => state.Neighbours(cell.X,cell.Y).Count(c => CardLibrary.Get(c.CardId).Type is not (CardType.Road or CardType.Event)) >= effect.Cap ? effect.Amount : 0,
"nearby-shops" => Math.Min(effect.Cap,own.Count(c => c.CardId == "shop" && BoardRules.Distance(cell,c) <= effect.Range)*effect.Amount),
"delayed-coins" => cell.Age >= effect.Cap ? effect.Amount : 0,
"ramping-coins" => Math.Min(effect.Cap,(cell.Age+1)*effect.Amount),
"civic-threshold" => own.Count(c => CardLibrary.Get(c.CardId).Tags.HasFlag(Tag.Civic)) >= effect.Cap ? effect.Amount : 0,
"shop-condition" => cells.Any(c => c.CardId == "shop" && BoardRules.Distance(cell,c) <= effect.Range) ? effect.Amount : 0,
"nearby-any-homes" => Math.Min(effect.Cap,cells.Count(c => BoardRules.IsHome(c) && BoardRules.Distance(cell,c) <= effect.Range)*effect.Amount),
"adjacent-farm-condition" => state.Neighbours(cell.X,cell.Y).Any(BoardRules.IsFarm) ? effect.Amount : 0,
"cafe-neighbours" => Math.Min(effect.Cap,state.Neighbours(cell.X,cell.Y).Count(c => BoardRules.IsHome(c) || c.CardId == "shop")*effect.Amount),
"owned-farms" => Math.Min(effect.Cap,own.Count(BoardRules.IsFarm)*effect.Amount),
"owned-shops-markets" => Math.Min(effect.Cap,own.Count(c => c.CardId is "shop" or "market")*effect.Amount),
"culture-count" => Math.Min(effect.Cap,own.Count(c => CardLibrary.Get(c.CardId).Tags.HasFlag(Tag.Culture))*effect.Amount),
"house-groups" => own.Count(BoardRules.IsHome)/effect.Divisor*effect.Amount,
"road-groups" => Math.Min(effect.Cap,own.Count(c => CardLibrary.Get(c.CardId).Type == CardType.Road)/effect.Divisor*effect.Amount),
"nearby-homes" => Math.Min(effect.Cap,own.Count(c => BoardRules.IsHome(c) && BoardRules.Distance(cell,c) <= effect.Range)*effect.Amount),
"industry-count" => Math.Min(effect.Cap,own.Count(c => CardLibrary.Get(c.CardId).Tags.HasFlag(Tag.Industry))*effect.Amount),
"trade-pairs" => Math.Min(effect.Cap,own.Count(c => CardLibrary.Get(c.CardId).Tags.HasFlag(Tag.Trade))/effect.Divisor*effect.Amount),
_ => 0
};
if (amount > 0) entries.Add(new(cell.Owner,amount,0,CardLibrary.Get(cell.CardId).Name,cell));
if (effect.Rule == "lake-houses")
foreach (var home in state.Neighbours(cell.X,cell.Y).Where(BoardRules.IsHome))
entries.Add(new(home.Owner,effect.Amount,0,"#rules.payout.beside_lake",home,cell));
if (effect.Rule == "stadium-neighbours")
foreach (var neighbour in state.Neighbours(cell.X,cell.Y))
{
if (BoardRules.IsHome(neighbour)) entries.Add(new(neighbour.Owner,effect.Amount,0,"#rules.payout.stadium_adjacent_home",neighbour,cell));
if (neighbour.CardId == "cafe") entries.Add(new(neighbour.Owner,-effect.Amount,0,"#rules.payout.stadium_cafe_noise",neighbour,cell));
}
}
// A source-ordered result is also the view's animation script. No car arrival changes it.
entries = entries.OrderBy(e => e.Source?.Order ?? int.MaxValue).ToList();
var lastVp = state.Players.Count == 0 ? 0 : state.Players.Values.Min(p => p.Vp);
foreach ( var player in state.Players.Values )
{
entries.Add(new(player.Id,state.Settings.BaseIncome,0,"#rules.payout.base_income"));
if ( state.Round > 1 && player.Vp == lastVp ) entries.Add(new(player.Id,state.Settings.CatchUpCoins,0,"#rules.payout.catch_up"));
}
// Noise can consume this payout's income and saved coins, but cannot create debt.
// Clip explained deductions before settlement so their sum always matches the bank change.
foreach (var player in state.Players.Values)
{
long available = player.Coins + entries.Where(e => e.Player == player.Id && e.Coins > 0).Sum(e => (long)e.Coins);
for (var i=0;i<entries.Count;i++)
{
var entry=entries[i]; if (entry.Player != player.Id || entry.Coins >= 0) continue;
var loss=(int)Math.Min(available,-(long)entry.Coins); available-=loss;
entries[i]=entry with { Coins=-loss };
}
}
return new(state.Round,entries.AsReadOnly());
}
internal static bool Apply( MatchState state, PayoutResult result )
{
if (state.Phase != MatchPhase.Payout || result.Round != state.Round || result.Round <= state.LastPaidRound) return false;
state.LastPaidRound = result.Round;
foreach (var group in result.Entries.GroupBy(e => e.Player)) state.ChangeCoins(group.Key,group.Sum(e => e.Coins));
foreach ( var entry in result.Entries )
{
if (entry.Vp != 0) state.ChangeVp(entry.Player,entry.Vp);
}
state.Events.Payout(result);
Growth.Advance(state);
return true;
}
}