From d7d3c8a3ed36651d63b53762cbcb8d304b7b3147 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:24:45 +0000 Subject: [PATCH] Katan: open trade offers and counter-offers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_018njn2eMPAYqAoE49hGRg2X --- .../prolibertate/games/game/catan/CatanAi.kt | 33 ++++- .../games/game/catan/CatanModel.kt | 56 ++++++- .../games/game/catan/CatanRules.kt | 139 ++++++++++++++++-- .../games/ui/game/catan/CatanDialogs.kt | 113 ++++++++++++-- .../games/ui/game/catan/CatanInteraction.kt | 16 +- .../games/ui/game/catan/CatanScreen.kt | 14 +- .../games/ui/game/catan/CatanText.kt | 26 +++- .../games/game/catan/CatanAiTest.kt | 46 ++++++ .../games/game/catan/CatanRulesTest.kt | 111 ++++++++++++++ docs/RULES-catan.md | 34 +++-- docs/UI-catan.md | 5 +- 11 files changed, 532 insertions(+), 61 deletions(-) diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt index c8d0a69..5dd53b3 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanAi.kt @@ -38,6 +38,8 @@ class CatanAi : GameAi { 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 { } private fun chooseResponse(state: CatanState, seat: Int, legal: List): CatanMove { - val accept = legal.filterIsInstance().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().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 { + val offer = state.pendingTrade ?: return legal.first() + val best = legal.filterIsInstance() + .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 // ----------------------------------------------------------------------- diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt index 721fe9c..e361253 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanModel.kt @@ -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, val get: List) +data class TradeCounter(val by: Int, val give: List, val get: List) + +/** + * 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, + val get: List, + val asking: Int = to ?: -1, + val counters: List = 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) : 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) : CatanMove */ @Serializable @SerialName("offer") -data class OfferTrade(val to: Int, val give: List, val get: List) : CatanMove +data class OfferTrade(val to: Int?, val give: List, val get: List) : 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, val get: List) : 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") diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt index 82fdb1d..6d98c86 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanRules.kt @@ -101,7 +101,8 @@ object CatanRules : GameRules { 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 { 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 { CatanPhase.TRADE_PENDING -> tradeResponses(state, seat) + CatanPhase.TRADE_COUNTERS -> counterChoices(state) + CatanPhase.GAME_OVER -> emptyList() } } @@ -324,6 +329,29 @@ object CatanRules : GameRules { return responses } + /** What the offerer may do with the counters: take one that is still affordable, or none. */ + private fun counterChoices(state: CatanState): List { + val offer = state.pendingTrade ?: return emptyList() + val moves = mutableListOf() + 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 { 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 { 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 { ) } - 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>, + a: Int, + aGives: List, + b: Int, + bGives: List, + ): List> { + 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 { diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanDialogs.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanDialogs.kt index d475779..56868b5 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanDialogs.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanDialogs.kt @@ -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(null) } + var everyone by remember { mutableStateOf(true) } var partner by remember { mutableStateOf(null) } var give by remember { mutableStateOf(emptyMap()) } var get by remember { mutableStateOf(emptyMap()) } @@ -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, + 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) } }, ) } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt index 9a9ca7d..153a736 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanInteraction.kt @@ -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, get: List, ): 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, get: List): 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): List { + val takeable = legal.filterIsInstance().map { it.seat }.toSet() + return state.pendingTrade?.counters.orEmpty().filter { it.by in takeable } + } } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt index 9818719..12a1af2 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanScreen.kt @@ -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 -> diff --git a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt index 2b83a41..0c523af 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanText.kt @@ -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, get: List): 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, get: List): 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" diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanAiTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanAiTest.kt index 96a0a25..f1bf43f 100644 --- a/app/src/test/java/org/prolibertate/games/game/catan/CatanAiTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanAiTest.kt @@ -142,4 +142,50 @@ class CatanAiTest { assertEquals(CatanPhase.PLAYING, after.phase) assertNull(after.pendingTrade) } + + private fun tradeTable(offer: TradeOffer, vararg hands: Map): 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))) + } } diff --git a/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt index 5cf8bc6..f31b86f 100644 --- a/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanRulesTest.kt @@ -520,6 +520,113 @@ class CatanRulesTest { assertEquals(listOf(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(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, diff --git a/docs/RULES-catan.md b/docs/RULES-catan.md index c90c0e5..b46c415 100644 --- a/docs/RULES-catan.md +++ b/docs/RULES-catan.md @@ -122,17 +122,32 @@ is the same code path. ### Domestic -An offer names **one** addressee and two card multisets: what the offerer gives, -what they want. There are no counter-offers and no open auctions in v1. +An offer is addressed to **one** seat or, with `to = null`, to **everyone**, and +names two card multisets: what the offerer gives, what they want. + +Seats are asked **one at a time**, in seat order after the offerer (seats holding +no cards are skipped; an addressed offer asks only its addressee). The seat being +asked may: + +- **accept** — the trade happens at once and the offer closes (first accept wins); +- **decline** — the offer moves on to the next seat; +- **counter** (`CounterTrade`) — propose different terms: what they would give and + what they want from the offerer. A counter must differ from the offer as made + (that is an accept), and both sides must hold the cards. The offer moves on. + +When every seat has answered and nobody accepted: with no counters the offer +simply lapses; with counters the phase becomes `TRADE_COUNTERS` and the offerer +either **takes one** (`AcceptCounter(seat)`, still checked for affordability) or +**turns them all down** (`DismissCounters`). An offer is legal only when the offerer's turn is in PLAYING, the addressee is another seat, both legs are non-empty, the two legs are not the same multiset (`countOf(give) != countOf(get)` — like-for-like is not a trade), and the offerer actually holds the cards being offered. -The addressee may **accept** only if they hold what was asked and the offerer -still holds what was promised; otherwise only **decline** is offered. A decline -clears the table and leaves every card where it was. +A seat may **accept** only if it holds what was asked and the offerer still holds +what was promised; otherwise only decline (or counter) is offered. A refusal +leaves every card where it was. Offers are free-form intents rather than enumerated moves (the space of card multisets is combinatorial), so they are validated structurally by @@ -292,15 +307,12 @@ timing rules; Longest Road and Largest Army worth five; victory at ten. - **Year of Plenty two-of-a-kind needs the bank to hold both cards**, not one. - **The robber must move.** Some printings allow staying put; this engine always requires a different hex. -- **Offers are one-to-one, no auctions or counter-offers** in v1. +- **Offers may be open to the whole table** and can be countered; the first accept wins (see Trading). ## Known gaps -- **No domestic auction or counter-offer.** An offer is addressed to a single - seat. Open trading to the whole table, and counter-offers, are the obvious next - step and are not implemented. -- **No player-to-player trade over the wire beyond accept/decline.** The engine - supports it; whether the UI surfaces it is the screen task's concern. +- **No timer or cancel on offers.** An offer waits for each asked seat in turn; the offerer cannot withdraw it mid-round, and a player who never answers stalls the table. +- **The AI never counters.** It accepts, declines, and picks among counters it is sent, but does not propose them. - **No trading away a development card.** Not part of the base game. - **The screen is built, not yet verified on two devices.** The Katan screen and setup options exist (see [UI-catan.md](UI-catan.md)); the catalogue entry is a diff --git a/docs/UI-catan.md b/docs/UI-catan.md index 6582b20..809e317 100644 --- a/docs/UI-catan.md +++ b/docs/UI-catan.md @@ -89,7 +89,8 @@ screen's UI state. The legal-move list stays the gate. | Seven | **Discard** dialog (forced); then taps a hex to move the robber; a victim dialog appears only if more than one player can be robbed | | Main turn | arms **Road / Settlement / City** and taps a highlighted spot; **Buy card**; **Play card**; **Trade**; **End turn** | | Road Building | taps highlighted edges, or **Done with roads** | -| Offered a trade | **Accept / Decline** dialog (forced) | +| Offered a trade | **Accept / Decline / Counter…** dialog (forced); *Counter…* opens a picker pre-filled with the mirror of the offer | +| Counter-offers received | **Counter-offers** dialog: *Accept* beside the one to take, or *No deal* | | Game over | result dialog | Trade offers are free-form (two card multisets), so they are validated with @@ -137,6 +138,6 @@ Not done yet, in rough priority order: it does not say which hexes paid out, so that needs either a diff of two states or an engine addition. 3. Art: terrain textures, resource and harbour icons, a real robber pawn. -4. Open trade to the whole table and counter-offers (an engine limitation too). +4. A timer or cancel button on offers (the engine has neither). 5. A board that shows the *last* move (a faint highlight of what just changed). 6. Landscape polish on large tablets; the right-hand panel scrolls but is plain.