Katan: open trade offers and counter-offers
CI / Build and check (pull_request) Successful in 7m28s
CI / Deploy to Cloudflare Pages (pull_request) Skipped

- Offers can go to the whole table (asked in seat order, empty hands skipped) or one seat.
- The asked seat may accept (trade happens at once), decline, or counter with new terms.
- If nobody accepts and there are counters, the offerer takes one or turns them all down (new TRADE_COUNTERS phase).
- AI: fixes an inverted accept test (it weighed what it gives against what it gets backwards), picks the best counter.
- UI: Everyone option in the offer dialog, Counter… in the reply dialog, a counter-choice dialog.
- Tests and docs updated.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018njn2eMPAYqAoE49hGRg2X
This commit is contained in:
Claude
2026-10-15 20:24:45 +00:00
parent 12fec14034
commit d7d3c8a3ed
11 changed files with 532 additions and 61 deletions
@@ -38,6 +38,8 @@ class CatanAi : GameAi<CatanState, CatanMove> {
CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal)
CatanPhase.TRADE_COUNTERS -> chooseCounter(state, legal)
CatanPhase.PLAYING -> choosePlaying(state, seat, legal)
CatanPhase.GAME_OVER -> legal.firstOrNull()
@@ -260,17 +262,32 @@ class CatanAi : GameAi<CatanState, CatanMove> {
}
private fun chooseResponse(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
val accept = legal.filterIsInstance<AcceptTrade>().firstOrNull() ?: run {
return legal.first { it is DeclineTrade }
}
val offer = state.pendingTrade ?: return legal.first()
// Accept when what arrives is worth more than what leaves: a mild
// table, not a shark.
val incoming = offer.get.sumOf { want(it) }
val outgoing = offer.give.sumOf { want(it) }
val accept = legal.filterIsInstance<AcceptTrade>().firstOrNull()
val offer = state.pendingTrade
if (accept == null || offer == null) return legal.first { it is DeclineTrade }
// Accept when what arrives (the offerer's give) is worth more than what
// leaves (the offerer's get): a mild table, not a shark.
val incoming = offer.give.sumOf { want(it) }
val outgoing = offer.get.sumOf { want(it) }
return if (incoming > outgoing) accept else legal.first { it is DeclineTrade }
}
/**
* Picks the counter-offer that gains the most by the same valuation, or
* turns them all down. The AI never counters itself: it only answers.
*/
private fun chooseCounter(state: CatanState, legal: List<CatanMove>): CatanMove {
val offer = state.pendingTrade ?: return legal.first()
val best = legal.filterIsInstance<AcceptCounter>()
.map { choice -> choice to offer.counters.first { it.by == choice.seat } }
.maxByOrNull { (_, c) -> c.give.sumOf { want(it) } - c.get.sumOf { want(it) } }
if (best != null) {
val (choice, c) = best
if (c.give.sumOf { want(it) } > c.get.sumOf { want(it) }) return choice
}
return legal.first { it is DismissCounters }
}
// -----------------------------------------------------------------------
// Valuations
// -----------------------------------------------------------------------
@@ -74,17 +74,34 @@ enum class DevCardType { KNIGHT, ROAD_BUILDING, MONOPOLY, YEAR_OF_PLENTY, VICTOR
data class DevCard(val id: Int, val type: DevCardType, val boughtTurn: Int = 0)
/**
* A domestic trade offer on the table. [by] is the offering seat and [to]
* the only seat that may respond — v1 has no counter-offers and no open
* auctions, so an offer is always addressed to one player.
* A counter-proposal to a [TradeOffer]: seat [by] would [give] these cards to
* the offerer if the offerer would hand over [get].
*/
@Serializable
data class TradeOffer(val by: Int, val to: Int, val give: List<Resource>, val get: List<Resource>)
data class TradeCounter(val by: Int, val give: List<Resource>, val get: List<Resource>)
/**
* A domestic trade offer on the table. [by] is the offering seat. [to] is the
* one seat it is addressed to, or null for an open offer to the whole table.
* Seats are asked one at a time, in seat order after the offerer; [asking] is
* the seat that may answer now. Each may accept (the trade happens at once),
* decline, or counter; counters are kept in [counters] and, if nobody accepts,
* the offerer chooses among them in [CatanPhase.TRADE_COUNTERS].
*/
@Serializable
data class TradeOffer(
val by: Int,
val to: Int?,
val give: List<Resource>,
val get: List<Resource>,
val asking: Int = to ?: -1,
val counters: List<TradeCounter> = emptyList(),
)
/**
* Where the game stands. The phases that involve more than the player
* whose turn it is carry their own bookkeeping: [discardSeat] during
* [CatanPhase.DISCARDING], [pendingTrade] during [CatanPhase.TRADE_PENDING].
* [CatanPhase.DISCARDING], [pendingTrade] during [CatanPhase.TRADE_PENDING] and [CatanPhase.TRADE_COUNTERS].
*/
@Serializable
enum class CatanPhase {
@@ -109,9 +126,12 @@ enum class CatanPhase {
/** A played Road Building card: up to two roads remain to be placed. */
FREE_ROADS,
/** The offered seat must accept or decline. */
/** The [TradeOffer.asking] seat must accept, decline or counter. */
TRADE_PENDING,
/** Nobody accepted but some seats countered: the offerer picks one or none. */
TRADE_COUNTERS,
GAME_OVER,
}
@@ -278,7 +298,8 @@ data class PlayMonopoly(val card: Int, val resource: Resource) : CatanMove
data class PlayYearOfPlenty(val card: Int, val take: List<Resource>) : CatanMove
/**
* Offer a domestic trade to [to]: [give] of ours for [get] of theirs.
* Offer a domestic trade to [to] (null: everyone, asked in turn): [give] of
* ours for [get] of theirs.
*
* Like a forced dice roll this is a free-form intent rather than an
* enumerated move — the space of two card multisets is combinatorial — so
@@ -287,7 +308,7 @@ data class PlayYearOfPlenty(val card: Int, val take: List<Resource>) : CatanMove
*/
@Serializable
@SerialName("offer")
data class OfferTrade(val to: Int, val give: List<Resource>, val get: List<Resource>) : CatanMove
data class OfferTrade(val to: Int?, val give: List<Resource>, val get: List<Resource>) : CatanMove
@Serializable
@SerialName("accept")
@@ -297,6 +318,25 @@ data object AcceptTrade : CatanMove
@SerialName("decline")
data object DeclineTrade : CatanMove
/**
* Answer the offer on the table with different terms: we would [give] these
* cards if the offerer handed over [get]. Free-form like [OfferTrade], so
* validated by [CatanRules.isLegalMove] rather than listed.
*/
@Serializable
@SerialName("counter")
data class CounterTrade(val give: List<Resource>, val get: List<Resource>) : CatanMove
/** The offerer takes the counter-proposal made by [seat]. */
@Serializable
@SerialName("acceptCounter")
data class AcceptCounter(val seat: Int) : CatanMove
/** The offerer takes none of the counter-proposals. */
@Serializable
@SerialName("dismissCounters")
data object DismissCounters : CatanMove
/** Bank trade: [give] at the best ratio this seat can trade at for [get]. */
@Serializable
@SerialName("bank")
@@ -101,7 +101,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
CatanPhase.SETUP_FIRST -> state.setupIndex
CatanPhase.SETUP_SECOND -> 2 * state.playerCount - 1 - state.setupIndex
CatanPhase.DISCARDING -> state.discardSeat
CatanPhase.TRADE_PENDING -> state.pendingTrade?.to
CatanPhase.TRADE_PENDING -> state.pendingTrade?.asking?.takeIf { it >= 0 }
CatanPhase.TRADE_COUNTERS -> state.turn
CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER,
CatanPhase.PLAYING, CatanPhase.FREE_ROADS,
-> state.turn
@@ -130,6 +131,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
is OfferTrade -> offerLegal(state, seat, move)
is CounterTrade -> counterLegal(state, seat, move)
else -> move in legalMoves(state, seat)
}
}
@@ -152,6 +155,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
CatanPhase.TRADE_PENDING -> tradeResponses(state, seat)
CatanPhase.TRADE_COUNTERS -> counterChoices(state)
CatanPhase.GAME_OVER -> emptyList()
}
}
@@ -324,6 +329,29 @@ object CatanRules : GameRules<CatanState, CatanMove> {
return responses
}
/** What the offerer may do with the counters: take one that is still affordable, or none. */
private fun counterChoices(state: CatanState): List<CatanMove> {
val offer = state.pendingTrade ?: return emptyList()
val moves = mutableListOf<CatanMove>()
for (counter in offer.counters) {
if (state.holdsAll(counter.by, counter.give) && state.holdsAll(offer.by, counter.get)) {
moves += AcceptCounter(counter.by)
}
}
moves += DismissCounters
return moves
}
private fun counterLegal(state: CatanState, seat: Int, move: CounterTrade): Boolean {
val offer = state.pendingTrade ?: return false
if (state.phase != CatanPhase.TRADE_PENDING || offer.asking != seat) return false
if (move.give.isEmpty() || move.get.isEmpty()) return false
if (countOf(move.give) == countOf(move.get)) return false
// Taking the offer as made is Accept, not a counter.
if (countOf(move.give) == countOf(offer.get) && countOf(move.get) == countOf(offer.give)) return false
return state.holdsAll(seat, move.give) && state.holdsAll(offer.by, move.get)
}
/** An empty vertex with no building on it or one edge away. */
private fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
state.buildings[v] == null &&
@@ -352,7 +380,7 @@ object CatanRules : GameRules<CatanState, CatanMove> {
private fun offerLegal(state: CatanState, seat: Int, move: OfferTrade): Boolean {
if (state.phase != CatanPhase.PLAYING || state.turn != seat) return false
if (move.to !in 0 until state.playerCount || move.to == seat) return false
if (move.to != null && (move.to !in 0 until state.playerCount || move.to == seat)) return false
if (move.give.isEmpty() || move.get.isEmpty()) return false
// Like-for-like is not a trade; the published game rejects it too.
if (countOf(move.give) == countOf(move.get)) return false
@@ -384,6 +412,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
is OfferTrade -> applyOfferTrade(state, seat, move)
AcceptTrade -> applyAcceptTrade(state, seat)
DeclineTrade -> applyDeclineTrade(state, seat)
is CounterTrade -> applyCounterTrade(state, seat, move)
is AcceptCounter -> applyAcceptCounter(state, move)
DismissCounters -> applyDismissCounters(state)
is MaritimeTrade -> applyMaritimeTrade(state, seat, move)
EndTurn -> applyEndTurn(state, seat)
EndFreeRoads -> applyEndFreeRoads(state, seat)
@@ -633,33 +664,111 @@ object CatanRules : GameRules<CatanState, CatanMove> {
)
}
private fun applyOfferTrade(state: CatanState, seat: Int, move: OfferTrade): CatanState =
state.copy(
phase = CatanPhase.TRADE_PENDING,
pendingTrade = TradeOffer(by = seat, to = move.to, give = move.give, get = move.get),
private fun applyOfferTrade(state: CatanState, seat: Int, move: OfferTrade): CatanState {
val audience = if (move.to == null) "the table" else "seat ${move.to}"
val offered = state.copy(
log = state.log + "Seat $seat offers ${describe(countOf(move.give))} for " +
"${describe(countOf(move.get))} to seat ${move.to}.",
"${describe(countOf(move.get))} to $audience.",
)
val offer = TradeOffer(by = seat, to = move.to, give = move.give, get = move.get)
val first = nextAsker(offered, offer, seat)
?: return offered.copy(log = offered.log + "Nobody has any cards to trade.")
return offered.copy(
phase = CatanPhase.TRADE_PENDING,
pendingTrade = offer.copy(asking = first),
)
}
private fun applyAcceptTrade(state: CatanState, seat: Int): CatanState {
val offer = state.pendingTrade!!
var resources = withCards(state.resources, offer.to, offer.get, -1)
resources = withCards(resources, offer.by, offer.give, -1)
resources = withCards(resources, offer.by, offer.get, 1)
resources = withCards(resources, offer.to, offer.give, 1)
/**
* The next seat to ask after [after], or null when the round is over. An
* addressed offer asks only its addressee; an open one walks the table in
* seat order, skipping seats with no cards (they could neither pay nor counter).
*/
private fun nextAsker(state: CatanState, offer: TradeOffer, after: Int): Int? {
if (offer.to != null) return if (after == offer.by) offer.to else null
var s = (after + 1) % state.playerCount
while (s != offer.by) {
if (state.resourceCount(s) > 0) return s
s = (s + 1) % state.playerCount
}
return null
}
/** Moves the offer on to the next seat, or closes the round when everyone has answered. */
private fun advanceTrade(state: CatanState, offer: TradeOffer, after: Int, said: String): CatanState {
val next = nextAsker(state, offer, after)
val log = state.log + said
if (next != null) {
return state.copy(pendingTrade = offer.copy(asking = next), log = log)
}
if (offer.counters.isNotEmpty()) {
return state.copy(
phase = CatanPhase.TRADE_COUNTERS,
pendingTrade = offer.copy(asking = -1),
log = log,
)
}
return state.copy(
phase = CatanPhase.PLAYING,
pendingTrade = null,
resources = resources,
log = log + "Nobody takes the offer.",
)
}
/** Seat [a] hands [aGives] to seat [b] and gets [bGives] back. */
private fun swapCards(
resources: List<Map<Resource, Int>>,
a: Int,
aGives: List<Resource>,
b: Int,
bGives: List<Resource>,
): List<Map<Resource, Int>> {
var r = withCards(resources, a, aGives, -1)
r = withCards(r, b, bGives, -1)
r = withCards(r, b, aGives, 1)
return withCards(r, a, bGives, 1)
}
private fun applyAcceptTrade(state: CatanState, seat: Int): CatanState {
val offer = state.pendingTrade!!
return state.copy(
phase = CatanPhase.PLAYING,
pendingTrade = null,
resources = swapCards(state.resources, offer.by, offer.give, seat, offer.get),
log = state.log + "Seat $seat accepts — the cards cross the table.",
)
}
private fun applyDeclineTrade(state: CatanState, seat: Int): CatanState =
advanceTrade(state, state.pendingTrade!!, seat, "Seat $seat declines the offer.")
private fun applyCounterTrade(state: CatanState, seat: Int, move: CounterTrade): CatanState {
val offer = state.pendingTrade!!
val counter = TradeCounter(by = seat, give = move.give, get = move.get)
return advanceTrade(
state,
offer.copy(counters = offer.counters + counter),
seat,
"Seat $seat counters: ${describe(countOf(move.give))} for ${describe(countOf(move.get))}.",
)
}
private fun applyAcceptCounter(state: CatanState, move: AcceptCounter): CatanState {
val offer = state.pendingTrade!!
val counter = offer.counters.first { it.by == move.seat }
return state.copy(
phase = CatanPhase.PLAYING,
pendingTrade = null,
resources = swapCards(state.resources, offer.by, counter.get, counter.by, counter.give),
log = state.log + "Seat ${offer.by} takes seat ${counter.by}'s counter — the cards cross the table.",
)
}
private fun applyDismissCounters(state: CatanState): CatanState =
state.copy(
phase = CatanPhase.PLAYING,
pendingTrade = null,
log = state.log + "Seat $seat declines the offer.",
log = state.log + "Seat ${state.turn} turns the counter-offers down.",
)
private fun applyMaritimeTrade(state: CatanState, seat: Int, move: MaritimeTrade): CatanState {
@@ -35,6 +35,9 @@ import org.prolibertate.games.game.catan.DevCardType
import org.prolibertate.games.game.catan.Discard
import org.prolibertate.games.game.catan.MaritimeTrade
import org.prolibertate.games.game.catan.OfferTrade
import org.prolibertate.games.game.catan.TradeCounter
import org.prolibertate.games.game.catan.CounterTrade
import org.prolibertate.games.game.catan.AcceptCounter
import org.prolibertate.games.game.catan.PlayKnight
import org.prolibertate.games.game.catan.PlayRoadBuilding
import org.prolibertate.games.game.catan.Resource
@@ -312,6 +315,7 @@ fun CatanTradeDialog(
) {
var tab by remember { mutableStateOf(TradeTab.BANK) }
var bankGive by remember { mutableStateOf<Resource?>(null) }
var everyone by remember { mutableStateOf(true) }
var partner by remember { mutableStateOf<Int?>(null) }
var give by remember { mutableStateOf(emptyMap<Resource, Int>()) }
var get by remember { mutableStateOf(emptyMap<Resource, Int>()) }
@@ -371,9 +375,16 @@ fun CatanTradeDialog(
}
} else {
Text("Offer to:", fontWeight = FontWeight.Bold)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
TabButton(CatanText.EVERYONE, everyone) { everyone = true; partner = null }
(0 until state.playerCount).filter { it != seat }.forEach { other ->
TabButton(seats.label(other), partner == other) { partner = other }
TabButton(seats.label(other), !everyone && partner == other) {
everyone = false
partner = other
}
}
}
Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold)
@@ -389,13 +400,13 @@ fun CatanTradeDialog(
},
confirmButton = {
if (tab == TradeTab.PLAYER) {
val to = partner
val to = if (everyone) null else partner
val giveCards = give.asCards()
val getCards = get.asCards()
val allowed = to != null &&
val allowed = (everyone || partner != null) &&
CatanInteraction.offerAllowed(state, seat, to, giveCards, getCards)
Button(
onClick = { if (to != null) onOffer(OfferTrade(to, giveCards, getCards)) },
onClick = { onOffer(OfferTrade(to, giveCards, getCards)) },
enabled = allowed,
) { Text(CatanText.SEND_OFFER) }
}
@@ -435,29 +446,57 @@ private fun TabButton(label: String, selected: Boolean, onClick: () -> Unit) {
}
}
/** Another player has offered the local player a trade. */
/**
* Another player has put an offer to the local player (alone, or as one of the
* table). Accept, decline, or — via a second step — answer with different terms.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CatanTradeResponseDialog(
state: CatanState,
seat: Int,
offer: TradeOffer,
seats: CatanSeats,
canAccept: Boolean,
onAccept: () -> Unit,
onDecline: () -> Unit,
onCounter: (CounterTrade) -> Unit,
) {
var countering by remember(offer) { mutableStateOf(false) }
// What we would give the offerer, and what we would ask of them.
var give by remember(offer) { mutableStateOf(offer.get.groupingBy { it }.eachCount()) }
var get by remember(offer) { mutableStateOf(offer.give.groupingBy { it }.eachCount()) }
AlertDialog(
onDismissRequest = {},
title = { Text("Trade offer") },
title = { Text(if (countering) "Counter-offer" else "Trade offer") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Column(
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
CatanText.tradeSummary(
by = seats.label(offer.by),
to = "you",
to = if (offer.to == null) "the table" else "you",
give = offer.give,
get = offer.get,
),
)
if (!canAccept) {
if (countering) {
Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold)
CounterRow(
counts = give,
limit = { state.resources[seat][it] ?: 0 },
onChange = { give = it },
)
Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold)
CounterRow(
counts = get,
limit = { state.resources[offer.by][it] ?: 0 },
onChange = { get = it },
)
} else if (!canAccept) {
Text(
"You cannot accept this: one side no longer holds the cards.",
style = MaterialTheme.typography.bodySmall,
@@ -465,8 +504,58 @@ fun CatanTradeResponseDialog(
}
}
},
confirmButton = { Button(onClick = onAccept, enabled = canAccept) { Text(CatanText.ACCEPT) } },
dismissButton = { TextButton(onClick = onDecline) { Text(CatanText.DECLINE) } },
confirmButton = {
if (countering) {
val move = CounterTrade(give.asCards(), get.asCards())
Button(
onClick = { onCounter(move) },
enabled = CatanInteraction.counterAllowed(state, seat, move.give, move.get),
) { Text(CatanText.SEND_COUNTER) }
} else {
Button(onClick = onAccept, enabled = canAccept) { Text(CatanText.ACCEPT) }
}
},
dismissButton = {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (countering) {
TextButton(onClick = { countering = false }) { Text(CatanText.CANCEL) }
} else {
TextButton(onClick = { countering = true }) { Text(CatanText.COUNTER) }
}
TextButton(onClick = onDecline) { Text(CatanText.DECLINE) }
}
},
)
}
/** The offerer's choice once nobody accepted: take a counter-offer, or none. */
@Composable
fun CatanCounterChoiceDialog(
counters: List<TradeCounter>,
seats: CatanSeats,
onTake: (AcceptCounter) -> Unit,
onNoDeal: () -> Unit,
) {
AlertDialog(
onDismissRequest = {},
title = { Text("Counter-offers") },
text = {
Column(
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (counters.isEmpty()) {
Text("None of the counter-offers can be met any more.")
}
counters.forEach { counter ->
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(CatanText.counterSummary(seats.label(counter.by), counter.give, counter.get))
Button(onClick = { onTake(AcceptCounter(counter.by)) }) { Text(CatanText.ACCEPT) }
}
}
}
},
confirmButton = { TextButton(onClick = onNoDeal) { Text(CatanText.NO_DEAL) } },
)
}
@@ -1,5 +1,6 @@
package org.prolibertate.games.ui.game.catan
import org.prolibertate.games.game.catan.AcceptCounter
import org.prolibertate.games.game.catan.AcceptTrade
import org.prolibertate.games.game.catan.BuildCity
import org.prolibertate.games.game.catan.BuildRoad
@@ -10,6 +11,7 @@ import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.CounterTrade
import org.prolibertate.games.game.catan.DeclineTrade
import org.prolibertate.games.game.catan.DevCard
import org.prolibertate.games.game.catan.DevCardType
@@ -20,6 +22,7 @@ import org.prolibertate.games.game.catan.HexCoord
import org.prolibertate.games.game.catan.MaritimeTrade
import org.prolibertate.games.game.catan.MoveRobber
import org.prolibertate.games.game.catan.OfferTrade
import org.prolibertate.games.game.catan.TradeCounter
import org.prolibertate.games.game.catan.PlayKnight
import org.prolibertate.games.game.catan.PlayMonopoly
import org.prolibertate.games.game.catan.PlayRoadBuilding
@@ -284,9 +287,20 @@ object CatanInteraction {
fun offerAllowed(
state: CatanState,
seat: Int,
to: Int,
to: Int?,
give: List<Resource>,
get: List<Resource>,
): Boolean = give.isNotEmpty() && get.isNotEmpty() &&
CatanRules.isLegalMove(state, seat, OfferTrade(to, give, get))
/** Whether [seat] may answer the offer on the table with [give] for [get]. */
fun counterAllowed(state: CatanState, seat: Int, give: List<Resource>, get: List<Resource>): Boolean =
give.isNotEmpty() && get.isNotEmpty() &&
CatanRules.isLegalMove(state, seat, CounterTrade(give, get))
/** The counter-offers the offerer may take right now, in the order they came in. */
fun counterChoices(state: CatanState, legal: List<CatanMove>): List<TradeCounter> {
val takeable = legal.filterIsInstance<AcceptCounter>().map { it.seat }.toSet()
return state.pendingTrade?.counters.orEmpty().filter { it.by in takeable }
}
}
@@ -34,6 +34,7 @@ import org.prolibertate.games.game.catan.CatanPhase
import org.prolibertate.games.game.catan.CatanRules
import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.DeclineTrade
import org.prolibertate.games.game.catan.DismissCounters
import org.prolibertate.games.game.catan.EndFreeRoads
import org.prolibertate.games.game.catan.EndTurn
import org.prolibertate.games.game.catan.MoveRobber
@@ -363,13 +364,24 @@ private fun CatanDialogHost(
onSubmit = onSubmit,
)
state.phase == CatanPhase.TRADE_PENDING && offer != null && offer.to == localSeat ->
state.phase == CatanPhase.TRADE_PENDING && offer != null && offer.asking == localSeat ->
CatanTradeResponseDialog(
state = state,
seat = localSeat,
offer = offer,
seats = seats,
canAccept = CatanInteraction.tradeResponses(legal).first,
onAccept = { onSubmit(AcceptTrade) },
onDecline = { onSubmit(DeclineTrade) },
onCounter = onSubmit,
)
state.phase == CatanPhase.TRADE_COUNTERS && state.turn == localSeat && legal.isNotEmpty() ->
CatanCounterChoiceDialog(
counters = CatanInteraction.counterChoices(state, legal),
seats = seats,
onTake = onSubmit,
onNoDeal = { onSubmit(DismissCounters) },
)
victimChoice != null ->
@@ -103,6 +103,10 @@ object CatanText {
const val CONFIRM = "Confirm"
const val STOP_ROADS = "Done with roads"
const val BUILD = "Build"
const val COUNTER = "Counter…"
const val SEND_COUNTER = "Send counter"
const val EVERYONE = "Everyone"
const val NO_DEAL = "No deal"
/**
* The empty second line of a button that has no cost, so every button in the
@@ -194,11 +198,23 @@ object CatanText {
val offer = state.pendingTrade
when {
offer == null -> "Waiting for a reply…"
offer.to == me -> "${seats.label(offer.by)} offers you a trade."
offer.by == me -> "Waiting for ${seats.label(offer.to)} to answer your offer…"
else -> "${seats.label(offer.by)} has offered ${seats.label(offer.to)} a trade."
offer.asking == me -> if (offer.to == null) {
"${seats.label(offer.by)} offers the table a trade. Accept, decline or counter."
} else {
"${seats.label(offer.by)} offers you a trade. Accept, decline or counter."
}
offer.by == me -> "Waiting for ${seats.label(offer.asking)} to answer your offer…"
else -> "${seats.label(offer.by)}'s offer is with ${seats.label(offer.asking)}."
}
}
CatanPhase.TRADE_COUNTERS ->
if (mine) {
"Nobody accepted, but there are counter-offers. Take one or turn them down."
} else {
"$turn is weighing counter-offers…"
}
}
}
@@ -212,6 +228,10 @@ object CatanText {
fun tradeSummary(by: String, to: String, give: List<Resource>, get: List<Resource>): String =
"$by offers ${cards(give)} to $to for ${cards(get)}"
/** One counter-offer as the offerer sees it: what that seat gives and wants. */
fun counterSummary(by: String, give: List<Resource>, get: List<Resource>): String =
"$by gives ${cards(give)} for your ${cards(get)}"
fun winnerHeadline(state: CatanState, seats: CatanSeats): String {
val winner = state.winner ?: return "Game over"
return if (winner == seats.me) "You won!" else "${seats.label(winner)} won"
@@ -142,4 +142,50 @@ class CatanAiTest {
assertEquals(CatanPhase.PLAYING, after.phase)
assertNull(after.pendingTrade)
}
private fun tradeTable(offer: TradeOffer, vararg hands: Map<Resource, Int>): CatanState =
CatanRules.initialState(config(CatanOptions(), seed = 7L)).copy(
phase = CatanPhase.TRADE_PENDING,
turn = 0,
turnNumber = 1,
pendingTrade = offer,
resources = hands.toList(),
)
@Test
fun `the AI accepts an offer that gives it more than it costs, and declines the reverse`() {
val generous = tradeTable(
TradeOffer(by = 0, to = null, give = listOf(Resource.ORE, Resource.ORE), get = listOf(Resource.WOOL), asking = 1),
hand(Resource.ORE to 2), hand(Resource.WOOL to 2), hand(),
)
assertEquals(AcceptTrade, CatanAi().chooseMove(generous, 1, CatanRules.legalMoves(generous, 1)))
val stingy = tradeTable(
TradeOffer(by = 0, to = null, give = listOf(Resource.WOOL), get = listOf(Resource.ORE), asking = 1),
hand(Resource.WOOL to 1), hand(Resource.ORE to 2), hand(),
)
assertEquals(DeclineTrade, CatanAi().chooseMove(stingy, 1, CatanRules.legalMoves(stingy, 1)))
}
@Test
fun `an AI offerer takes the best counter and refuses a losing one`() {
val counters = listOf(
TradeCounter(by = 1, give = listOf(Resource.ORE), get = listOf(Resource.LUMBER)),
TradeCounter(by = 2, give = listOf(Resource.ORE, Resource.GRAIN), get = listOf(Resource.WOOL)),
)
val state = tradeTable(
TradeOffer(by = 0, to = null, give = listOf(Resource.WOOL), get = listOf(Resource.ORE), asking = -1, counters = counters),
hand(Resource.LUMBER to 1, Resource.WOOL to 1), hand(Resource.ORE to 1), hand(Resource.ORE to 1, Resource.GRAIN to 1),
).copy(phase = CatanPhase.TRADE_COUNTERS)
val legal = CatanRules.legalMoves(state, 0)
assertEquals(AcceptCounter(2), CatanAi().chooseMove(state, 0, legal))
val bad = state.copy(
pendingTrade = state.pendingTrade!!.copy(
counters = listOf(TradeCounter(by = 1, give = listOf(Resource.WOOL), get = listOf(Resource.LUMBER))),
),
resources = listOf(hand(Resource.LUMBER to 1), hand(Resource.WOOL to 1), hand()),
)
assertEquals(DismissCounters, CatanAi().chooseMove(bad, 0, CatanRules.legalMoves(bad, 0)))
}
}
@@ -520,6 +520,113 @@ class CatanRulesTest {
assertEquals(listOf<CatanMove>(DeclineTrade), CatanRules.legalMoves(offered, 1))
}
private val twoLumberForBrick =
OfferTrade(to = null, give = listOf(Resource.LUMBER, Resource.LUMBER), get = listOf(Resource.BRICK))
private fun openTable() = craft().copy(
resources = hands(
hand(Resource.LUMBER to 3),
hand(Resource.BRICK to 2),
hand(Resource.BRICK to 1, Resource.WOOL to 1),
),
)
@Test
fun `an open offer asks each other seat in turn until one accepts`() {
val state = openTable()
assertTrue(CatanRules.isLegalMove(state, 0, twoLumberForBrick))
val asked1 = CatanRules.applyMove(state, 0, twoLumberForBrick)
assertEquals(CatanPhase.TRADE_PENDING, asked1.phase)
assertEquals(1, CatanRules.currentSeat(asked1))
val asked2 = CatanRules.applyMove(asked1, 1, DeclineTrade)
assertEquals("a decline passes the offer on", 2, CatanRules.currentSeat(asked2))
assertEquals(CatanPhase.TRADE_PENDING, asked2.phase)
val done = CatanRules.applyMove(asked2, 2, AcceptTrade)
assertEquals(CatanPhase.PLAYING, done.phase)
assertNull(done.pendingTrade)
assertEquals(hand(Resource.LUMBER to 1, Resource.BRICK to 1), done.resources[0])
assertEquals(hand(Resource.LUMBER to 2, Resource.WOOL to 1), done.resources[2])
assertEquals("seat 1 never took part", state.resources[1], done.resources[1])
}
@Test
fun `an open offer nobody wants lapses back to the offerer`() {
var table = CatanRules.applyMove(openTable(), 0, twoLumberForBrick)
table = CatanRules.applyMove(table, 1, DeclineTrade)
table = CatanRules.applyMove(table, 2, DeclineTrade)
assertEquals(CatanPhase.PLAYING, table.phase)
assertNull(table.pendingTrade)
assertEquals(0, CatanRules.currentSeat(table))
}
@Test
fun `seats with no cards are not asked`() {
val state = openTable().copy(resources = hands(hand(Resource.LUMBER to 3), hand(), hand(Resource.BRICK to 1)))
val table = CatanRules.applyMove(state, 0, twoLumberForBrick)
assertEquals(2, CatanRules.currentSeat(table))
}
@Test
fun `a counter is kept and the offerer chooses once everyone has answered`() {
var table = CatanRules.applyMove(openTable(), 0, twoLumberForBrick)
// Seat 1 will not give a brick for two lumber but would for three.
val counter = CounterTrade(give = listOf(Resource.BRICK), get = listOf(Resource.LUMBER, Resource.LUMBER, Resource.LUMBER))
assertTrue(CatanRules.isLegalMove(table, 1, counter))
table = CatanRules.applyMove(table, 1, counter)
assertEquals(2, CatanRules.currentSeat(table))
table = CatanRules.applyMove(table, 2, DeclineTrade)
assertEquals(CatanPhase.TRADE_COUNTERS, table.phase)
assertEquals("the offerer chooses", 0, CatanRules.currentSeat(table))
assertEquals(
listOf<CatanMove>(AcceptCounter(1), DismissCounters),
CatanRules.legalMoves(table, 0),
)
val taken = CatanRules.applyMove(table, 0, AcceptCounter(1))
assertEquals(CatanPhase.PLAYING, taken.phase)
assertNull(taken.pendingTrade)
assertEquals(hand(Resource.BRICK to 1), taken.resources[0])
assertEquals(hand(Resource.LUMBER to 3, Resource.BRICK to 1), taken.resources[1])
}
@Test
fun `counters can all be turned down`() {
var table = CatanRules.applyMove(openTable(), 0, twoLumberForBrick)
table = CatanRules.applyMove(
table, 1,
CounterTrade(listOf(Resource.BRICK), listOf(Resource.LUMBER, Resource.LUMBER, Resource.LUMBER)),
)
table = CatanRules.applyMove(table, 2, DeclineTrade)
val after = CatanRules.applyMove(table, 0, DismissCounters)
assertEquals(CatanPhase.PLAYING, after.phase)
assertNull(after.pendingTrade)
assertEquals(openTable().resources, after.resources)
}
@Test
fun `a counter must differ from the offer and be affordable`() {
val table = CatanRules.applyMove(openTable(), 0, twoLumberForBrick)
assertFalse(
"the offer as made is an accept, not a counter",
CatanRules.isLegalMove(table, 1, CounterTrade(listOf(Resource.BRICK), listOf(Resource.LUMBER, Resource.LUMBER))),
)
assertFalse(
"cannot promise cards not held",
CatanRules.isLegalMove(table, 1, CounterTrade(listOf(Resource.ORE), listOf(Resource.LUMBER))),
)
assertFalse(
"cannot ask for cards the offerer lacks",
CatanRules.isLegalMove(table, 1, CounterTrade(listOf(Resource.BRICK), listOf(Resource.ORE))),
)
assertFalse(
"only the seat being asked may counter",
CatanRules.isLegalMove(table, 2, CounterTrade(listOf(Resource.WOOL), listOf(Resource.LUMBER))),
)
}
@Test
fun `the bank trades four for one and harbours do better`() {
val base = craft()
@@ -912,6 +1019,10 @@ class CatanRulesTest {
OfferTrade(1, listOf(Resource.LUMBER, Resource.LUMBER), listOf(Resource.ORE)),
AcceptTrade,
DeclineTrade,
OfferTrade(null, listOf(Resource.LUMBER), listOf(Resource.ORE)),
CounterTrade(listOf(Resource.WOOL), listOf(Resource.GRAIN, Resource.GRAIN)),
AcceptCounter(2),
DismissCounters,
MaritimeTrade(Resource.WOOL, Resource.GRAIN),
EndTurn,
EndFreeRoads,