Match/Auction.cs

The file defines records and an internal session class for managing an in-game auction. It handles player bids, processes purchase attempts, and calculates the final auction results within a match.

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

namespace CitySim.Match;

public sealed record AuctionLot( int Index, string CardId, int MinimumBid );
public sealed record AuctionWin( Guid Player, int Lot, string CardId, int Price );
public sealed record AuctionAccount( Guid Player, int Reserved, int Paid, int Refunded );
public sealed record AuctionResult( int AuctionId, int Round, int MiniAuction, IReadOnlyList<AuctionWin> Wins,
	IReadOnlyList<AuctionAccount> Accounts, IReadOnlyList<AuctionLot> Discarded );
public sealed record AuctionParticipant( Guid Player, string Name, bool Eligible, bool Ready, bool HasBid = false, int SelectedLot = -1 );
public sealed record OwnLotBid( AuctionLot Lot, int Amount, bool Allowed );

/// <summary>A recipient-specific market projection. Purchases are public; hands remain private.</summary>
public sealed record AuctionView( int Id, int Round, int Number, int Cap, Guid Viewer, int AvailableCoins, int ReservedCoins,
	bool Eligible, bool Ready, bool Resolving, IReadOnlyList<OwnLotBid> Lots, IReadOnlyList<AuctionParticipant> Players );

/// <summary>The host owns the current market selection; checkpoint data records completed purchases.</summary>
internal sealed class AuctionSession
{
	readonly MatchState state;
	readonly Dictionary<Guid,int[]> bids = new();
	readonly HashSet<Guid> ready = new();
	public int Id { get; }
	public int Number { get; }
	public IReadOnlyList<AuctionLot> Lots { get; }
	public bool Resolved { get; private set; }
	public bool AllReady => bids.Keys.All(ready.Contains);
	internal AuctionCheckpoint Capture() => new(Id,Number,Lots.ToArray(),Resolved,bids.Select(p => new BidCheckpoint(p.Key,p.Value.ToArray(),ready.Contains(p.Key))).ToArray());
	internal static AuctionSession Restore(MatchState state,AuctionCheckpoint data)
	{
		var session = new AuctionSession(state,data.Id,data.Number,Array.AsReadOnly(data.Lots.ToArray())) { Resolved = data.Resolved };
		session.bids.Clear(); session.ready.Clear();
		foreach (var p in data.Bids) { session.bids[p.Player] = p.Amounts.ToArray(); if (p.Ready) session.ready.Add(p.Player); }
		return session;
	}
	public AuctionSession( MatchState state, int id, int number, IReadOnlyList<AuctionLot> lots )
	{
		this.state = state; Id = id; Number = number; Lots = lots;
		foreach (var p in state.Players.Values.Where(p => p.Hand.Count < state.Settings.HandLimit))
			bids[p.Id] = new int[lots.Count];
	}
	bool Allowed(Guid owner,AuctionLot lot)
	{
		var card = CardLibrary.Get(lot.CardId);
		return card.OwnershipLimit == 0 || CardDraw.OwnedCount(state,owner,card.Id) < card.OwnershipLimit;
	}
	public AuctionView View( Guid owner )
	{
		var own = bids.GetValueOrDefault(owner);
		var reserved = 0;
		var coins = state.Players.GetValueOrDefault(owner)?.Coins ?? 0;
		return new(Id,state.Round,Number,state.Settings.MiniAuctionCap,owner,coins,reserved,own != null,ready.Contains(owner),Resolved,
			Lots.Select(l => new OwnLotBid(l,own?[l.Index] ?? 0,own != null && Allowed(owner,l) && coins >= l.MinimumBid)).ToList().AsReadOnly(),
			state.Players.Values.Select(p => new AuctionParticipant(p.Id,p.Name,bids.ContainsKey(p.Id),ready.Contains(p.Id),bids.TryGetValue(p.Id,out var amounts) && amounts.Any(amount => amount > 0), bids.TryGetValue(p.Id,out var selections) ? Array.FindIndex(selections, amount => amount > 0) : -1)).ToList().AsReadOnly());
	}
	public bool Buy( Guid owner, int lotIndex )
	{
		if (Resolved || ready.Contains(owner) || !bids.TryGetValue(owner,out var own) || lotIndex < 0 || lotIndex >= Lots.Count) return false;
		var lot = Lots[lotIndex];
		if (!Allowed(owner,lot) || state.Players[owner].Hand.Count >= state.Settings.HandLimit || state.Players[owner].Coins < lot.MinimumBid) return false;
		own[lotIndex] = lot.MinimumBid;
		ready.Add(owner);
		state.ChangeCoins(owner,-lot.MinimumBid);
		state.GiveCard(owner,lot.CardId);
		state.Events.Purchased(new(owner,lot.CardId,lot.MinimumBid,Id,lotIndex));
		state.Events.Bid(owner); state.Events.BidStatus(owner); state.Events.Ready(owner);
		return true;
	}
	public bool Ready( Guid owner )
	{
		if (Resolved || !bids.ContainsKey(owner) || !ready.Add(owner)) return false;
		state.Events.Ready(owner); return true;
	}
	public AuctionResult Resolve()
	{
		if (Resolved) throw new InvalidOperationException("Auction is already resolved.");
		var wins = bids.SelectMany(pair => pair.Value.Select((price,lot) => (pair.Key,lot,price)))
			.Where(choice => choice.price > 0)
			.Select(choice => new AuctionWin(choice.Key,choice.lot,Lots[choice.lot].CardId,choice.price)).ToArray();
		var result = new AuctionResult(Id,state.Round,Number,Array.AsReadOnly(wins),
			Array.AsReadOnly(bids.Keys.Select(player => new AuctionAccount(player,0,wins.FirstOrDefault(w => w.Player == player)?.Price ?? 0,0)).ToArray()),
			Array.AsReadOnly(Lots.Where(lot => wins.All(w => w.Lot != lot.Index)).ToArray()));
		Resolved = true;
		return result;
	}
}