This file implements the logic for calculating scoring effects when a card is placed on the board. It determines Victory Point generation based on card adjacency and grants bonuses to neighboring cards.
using System;
using System.Collections.Generic;
using System.Linq;
using CitySim.Cards;
namespace CitySim.Match;
public sealed record PlacementEffects( BoardCell Cell, IReadOnlyList<RuleAward> Awards, ParkScoreResult Park );
public static class PlacementScoring
{
public static PlacementEffects Calculate(MatchState state,BoardCell added)
{
var card = CardLibrary.Get(added.CardId); var neighbours = state.Neighbours(added.X,added.Y).ToArray();
var awards = new List<RuleAward>();
foreach (var effect in card.Effects.Where(e => e.Hook == EffectHook.Place))
{
var vp = effect.Rule switch {
"adjacent-house-vp" => effect.Amount*neighbours.Count(BoardRules.IsHome),
"adjacent-home-tag-vp" => effect.Amount*neighbours.Count(c => CardLibrary.Get(c.CardId).Tags.HasFlag(Tag.Home)),
"adjacent-park-vp" => effect.Amount*neighbours.Count(c => BoardRules.IsPark(c) || c.CardId == "lake"), _ => 0 };
if (effect.Rule=="beside-road-vp" && neighbours.Any(c=>CardLibrary.Get(c.CardId).Type==CardType.Road))
awards.Add(new(added.Owner,effect.Amount,0,"#rules.placement_scoring.beside_road",added));
if (vp != 0) awards.Add(new(added.Owner,vp,0,"#rules.placement_scoring.placement_adjacency",added));
}
ParkScoreResult park = null;
if (card.ParkSize > 0) { park = ParkScoring.OnTileAdded(state,added); awards.AddRange(park.Awards); }
if (card.Type != CardType.Road && card.Type != CardType.Event)
{
// A new building pays each neighbouring Street owner once. Existing buildings,
// road placement and later payouts never retrigger this award.
foreach (var source in neighbours.Where(n=>n.CardId=="street" && state.Players.ContainsKey(n.Owner))
.GroupBy(n=>n.Owner).Select(g=>g.OrderBy(n=>n.Order).First()))
awards.Add(new(source.Owner,CardLibrary.Get("street").Effects.Single(e=>e.Rule=="street-building-vp").Amount,0,"#rules.placement_scoring.street_building_placed",source));
foreach (var source in neighbours.Where(n => n.CardId == "roundabout" && state.Players.ContainsKey(n.Owner)))
foreach (var effect in CardLibrary.Get(source.CardId).Effects.Where(e => e.Rule == "roundabout-vp"))
awards.Add(new(source.Owner,effect.Amount,0,"#cards.roundabout.name",source));
}
var owned = state.Cells().Where(c => c.Owner == added.Owner).ToList();
// Persistent effects may trigger themselves unless the rule explicitly says otherwise.
foreach (var source in owned.Append(added))
foreach (var effect in CardLibrary.Get(source.CardId).Effects.Where(e => e.Hook == EffectHook.OwnerPlacedCard))
{
if (effect.Rule == "contractor-coins" && (card.Id == "house" || card.Id == "shop"))
awards.Add(new(added.Owner,0,effect.Amount,"#cards.contractor.name",source));
if (effect.Rule == "warehouse-street-coins" && card.Id == "street" && state.TriggerUses(source.Order,state.Round) < effect.Cap)
awards.Add(new(added.Owner,0,effect.Amount,"#cards.warehouse.name",source));
if (effect.Rule == "park-coins" && card.ParkSize > 0) awards.Add(new(added.Owner,0,effect.Amount,"#cards.gardeners_guild.name",source));
if (effect.Rule == "first-building-beside-own" && card.Type != CardType.Road &&
state.BuildingsPlacedThisRound(added.Owner)==0 && neighbours.Any(c => c.Owner == added.Owner))
awards.Add(new(added.Owner,0,effect.Amount,"#cards.architects_studio.name",source));
}
return new(added,awards.AsReadOnly(),park);
}
public static int Preview(MatchState state,string cardId,int x,int y,Guid? placer = null)
{
var owner = placer ?? (state.Players.Count == 1 ? state.Players.Keys.Single() : Guid.Empty);
var cell = new BoardCell(x,y,cardId,owner,state.NextPlacementOrder,state.Round);
return Calculate(state,cell).Awards.Where(a => a.Player == owner).Sum(a => a.Vp);
}
}