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 7f6d9f0..db56508 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 @@ -42,6 +42,8 @@ class CatanAi : GameAi { CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal) + CatanPhase.TRADE_COUNTERS -> chooseCounter(state, legal) + CatanPhase.PLAYING -> choosePlaying(state, seat, legal) CatanPhase.CHORE, CatanPhase.GAME_OVER -> legal.firstOrNull() @@ -264,17 +266,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/CatanCk.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanCk.kt index b1630ae..be3decf 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanCk.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanCk.kt @@ -287,6 +287,22 @@ object CatanCk { } } + CatanPhase.TRADE_COUNTERS -> { + val offer = state.ck!!.goodsOffer + if (offer == null) { + CatanRules.counterChoices(state) + } else { + val moves = mutableListOf() + for (counter in offer.counters) { + if (holds(state, counter.by, counter.give) && holds(state, offer.by, counter.get)) { + moves += AcceptCounter(counter.by) + } + } + moves += DismissCounters + moves + } + } + CatanPhase.CHORE -> choreMoves(state, seat) CatanPhase.DISCARDING -> emptyList() // replaced by chores in this rule set @@ -707,12 +723,22 @@ object CatanCk { fun structurallyLegal(state: CatanState, seat: Int, move: CatanMove): Boolean? = when (move) { is OfferGoods -> { state.phase == CatanPhase.PLAYING && state.turn == seat && - move.to in 0 until state.playerCount && move.to != seat && + (move.to == null || (move.to in 0 until state.playerCount && move.to != seat)) && move.give.isNotEmpty() && move.get.isNotEmpty() && countGoods(move.give) != countGoods(move.get) && holds(state, seat, move.give) } + is CounterGoods -> { + val offer = state.ck?.goodsOffer + offer != null && state.phase == CatanPhase.TRADE_PENDING && offer.asking == seat && + move.give.isNotEmpty() && move.get.isNotEmpty() && + countGoods(move.give) != countGoods(move.get) && + // Taking the offer as made is Accept, not a counter. + !(countGoods(move.give) == countGoods(offer.get) && countGoods(move.get) == countGoods(offer.give)) && + holds(state, seat, move.give) && holds(state, offer.by, move.get) + } + is DiscardGoods -> { val chore = state.ck?.chores?.firstOrNull() as? DiscardChore state.phase == CatanPhase.CHORE && chore != null && chore.seat == seat && @@ -743,6 +769,7 @@ object CatanCk { is PlayProgress -> applyProgress(state, seat, move) is GoodsBankTrade -> applyBankTrade(state, seat, move) is OfferGoods -> applyOfferGoods(state, seat, move) + is CounterGoods -> applyCounterGoods(state, seat, move) is DiscardGoods -> applyDiscard(state, seat, move) is GiveGoods -> applyGive(state, seat, move) is PillageCity -> applyPillageChoice(state, seat, move) @@ -936,23 +963,100 @@ object CatanCk { ) } - private fun applyOfferGoods(state: CatanState, seat: Int, move: OfferGoods): CatanState = - state.copy( - phase = CatanPhase.TRADE_PENDING, - ck = state.ck!!.copy(goodsOffer = GoodsOffer(seat, move.to, move.give, move.get)), - log = state.log + "Seat $seat offers ${describe(move.give)} for ${describe(move.get)} to seat ${move.to}.", + private fun applyOfferGoods(state: CatanState, seat: Int, move: OfferGoods): CatanState { + val audience = if (move.to == null) "the table" else "seat ${move.to}" + val offered = state.copy( + log = state.log + "Seat $seat offers ${describe(move.give)} for ${describe(move.get)} to $audience.", ) + val offer = GoodsOffer(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, + ck = offered.ck!!.copy(goodsOffer = offer.copy(asking = first)), + ) + } - /** Accepting or declining a [GoodsOffer], called by [CatanRules] when one is pending. */ + /** + * 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: GoodsOffer, 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 (goodsCount(state, 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 advanceOffer(state: CatanState, offer: GoodsOffer, after: Int, said: String): CatanState { + val next = nextAsker(state, offer, after) + val log = state.log + said + val ck = state.ck!! + if (next != null) { + return state.copy(ck = ck.copy(goodsOffer = offer.copy(asking = next)), log = log) + } + if (offer.counters.isNotEmpty()) { + return state.copy( + phase = CatanPhase.TRADE_COUNTERS, + ck = ck.copy(goodsOffer = offer.copy(asking = -1)), + log = log, + ) + } + return state.copy( + phase = CatanPhase.PLAYING, + ck = ck.copy(goodsOffer = null), + log = log + "Nobody takes the offer.", + ) + } + + /** Accepting or declining the [GoodsOffer] on the table, called by [CatanRules] when one is pending. */ fun answerOffer(state: CatanState, seat: Int, accept: Boolean): CatanState { val offer = state.ck!!.goodsOffer!! - var s = state.copy(ck = state.ck.copy(goodsOffer = null), phase = CatanPhase.PLAYING) - if (!accept) return s.copy(log = s.log + "Seat $seat declines the offer.") - s = give(s, offer.to, offer.by, offer.get) - s = give(s, offer.by, offer.to, offer.give) - return s.copy(log = s.log + "Seat $seat accepts — the cards cross the table.") + if (!accept) return advanceOffer(state, offer, seat, "Seat $seat declines the offer.") + var s = give(state, offer.by, seat, offer.give) + s = give(s, seat, offer.by, offer.get) + return s.copy( + phase = CatanPhase.PLAYING, + ck = s.ck!!.copy(goodsOffer = null), + log = s.log + "Seat $seat accepts — the cards cross the table.", + ) } + private fun applyCounterGoods(state: CatanState, seat: Int, move: CounterGoods): CatanState { + val offer = state.ck!!.goodsOffer!! + val counter = GoodsCounter(by = seat, give = move.give, get = move.get) + return advanceOffer( + state, + offer.copy(counters = offer.counters + counter), + seat, + "Seat $seat counters: ${describe(move.give)} for ${describe(move.get)}.", + ) + } + + /** The offerer takes the counter made by [move]'s seat. */ + fun acceptCounter(state: CatanState, move: AcceptCounter): CatanState { + val offer = state.ck!!.goodsOffer!! + val counter = offer.counters.first { it.by == move.seat } + var s = give(state, offer.by, counter.by, counter.get) + s = give(s, counter.by, offer.by, counter.give) + return s.copy( + phase = CatanPhase.PLAYING, + ck = s.ck!!.copy(goodsOffer = null), + log = s.log + "Seat ${offer.by} takes seat ${counter.by}'s counter — the cards cross the table.", + ) + } + + fun dismissCounters(state: CatanState): CatanState = state.copy( + phase = CatanPhase.PLAYING, + ck = state.ck!!.copy(goodsOffer = null), + log = state.log + "Seat ${state.turn} turns the counter-offers down.", + ) + // --- chores answered --- private fun applyDiscard(state: CatanState, seat: Int, move: DiscardGoods): CatanState { diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanCkAi.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanCkAi.kt index 141b7c4..28aac5c 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanCkAi.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanCkAi.kt @@ -31,6 +31,8 @@ internal class CatanCkAi(private val ai: CatanAi) { CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal) + CatanPhase.TRADE_COUNTERS -> chooseCounter(state, legal) + CatanPhase.PLAYING -> choosePlaying(state, seat, legal) CatanPhase.DISCARDING, CatanPhase.GAME_OVER -> legal.firstOrNull() @@ -309,6 +311,10 @@ internal class CatanCkAi(private val ai: CatanAi) { return if (incoming > outgoing) accept else decline } + /** Counters to a human's... the AI never makes an offer, so this only guards the phase: take none. */ + private fun chooseCounter(state: CatanState, legal: List): CatanMove = + legal.firstOrNull { it is DismissCounters } ?: legal.first() + // ----------------------------------------------------------------------- // Valuations // ----------------------------------------------------------------------- diff --git a/app/src/main/java/org/prolibertate/games/game/catan/CatanCkModel.kt b/app/src/main/java/org/prolibertate/games/game/catan/CatanCkModel.kt index 9a7f530..068867c 100644 --- a/app/src/main/java/org/prolibertate/games/game/catan/CatanCkModel.kt +++ b/app/src/main/java/org/prolibertate/games/game/catan/CatanCkModel.kt @@ -183,9 +183,25 @@ data class MetropolisAt(val seat: Int, val vertex: Int) @Serializable data class MerchantAt(val seat: Int, val hex: Int) -/** A domestic trade offer in goods, which may include commodities. */ +/** A counter-proposal to a [GoodsOffer]: seat [by] would [give] these if the offerer handed over [get]. */ @Serializable -data class GoodsOffer(val by: Int, val to: Int, val give: List, val get: List) +data class GoodsCounter(val by: Int, val give: List, val get: List) + +/** + * A domestic trade offer in goods, which may include commodities. It is the + * base game's [TradeOffer] in cards of any kind, and works the same way: [to] is + * the one seat it is addressed to or null for the whole table, [asking] is the + * seat that may answer now, and [counters] collect for the offerer to choose from. + */ +@Serializable +data class GoodsOffer( + val by: Int, + val to: Int?, + val give: List, + val get: List, + val asking: Int = to ?: -1, + val counters: List = emptyList(), +) /** Where play goes when the queue of [Chore]s runs out. */ @Serializable @@ -389,12 +405,22 @@ data class PlayProgress( data class GoodsBankTrade(val give: Good, val get: Good) : CatanMove /** - * Offer a trade in goods to seat [to]. Free-form like [OfferTrade], so it is - * checked structurally and is not in the enumerated move list. + * Offer a trade in goods to seat [to], or to the whole table when [to] is null. + * Free-form like [OfferTrade], so it is checked structurally and is not in the + * enumerated move list. */ @Serializable @SerialName("offerGoods") -data class OfferGoods(val to: Int, val give: List, val get: List) : CatanMove +data class OfferGoods(val to: Int?, val give: List, val get: List) : CatanMove + +/** + * Answer the goods offer on the table with different terms. Structural, like + * [OfferGoods]; the answers to the counters are the shared [AcceptCounter] and + * [DismissCounters]. + */ +@Serializable +@SerialName("counterGoods") +data class CounterGoods(val give: List, val get: List) : CatanMove /** * Give up exactly the cards named, to a [DiscardChore]. Structural, not 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 4ff834c..8e19895 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, /** @@ -305,7 +325,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 @@ -314,7 +335,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") @@ -324,6 +345,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 6e7fd30..904b9bb 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 @@ -110,7 +110,9 @@ 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 ?: state.ck?.goodsOffer?.to + CatanPhase.TRADE_PENDING -> state.pendingTrade?.asking?.takeIf { it >= 0 } + ?: state.ck?.goodsOffer?.asking?.takeIf { it >= 0 } + CatanPhase.TRADE_COUNTERS -> state.turn CatanPhase.CHORE -> state.ck?.chores?.firstOrNull()?.seat CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER, CatanPhase.PLAYING, CatanPhase.FREE_ROADS, @@ -140,6 +142,8 @@ object CatanRules : GameRules { is OfferTrade -> offerLegal(state, seat, move) + is CounterTrade -> counterLegal(state, seat, move) + else -> if (state.isCk) { CatanCk.structurallyLegal(state, seat, move) ?: (move in legalMoves(state, seat)) } else { @@ -167,6 +171,8 @@ object CatanRules : GameRules { CatanPhase.TRADE_PENDING -> tradeResponses(state, seat) + CatanPhase.TRADE_COUNTERS -> counterChoices(state) + CatanPhase.CHORE, CatanPhase.GAME_OVER -> emptyList() } } @@ -342,6 +348,29 @@ object CatanRules : GameRules { return responses } + /** What the offerer may do with the counters: take one that is still affordable, or none. */ + internal 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. */ internal fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean = state.buildings[v] == null && !hasKnight(state, v) && @@ -373,7 +402,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 @@ -405,6 +434,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) @@ -663,34 +695,117 @@ 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), - log = state.log + ("Seat $seat offers ${describe(countOf(move.give))} for " + - "${describe(countOf(move.get))} to seat ${move.to}."), + 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 $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), + ) + } + + /** + * 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, + 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 { if (state.ck?.goodsOffer != null) return CatanCk.answerOffer(state, seat, accept = true) 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) return state.copy( phase = CatanPhase.PLAYING, pendingTrade = null, - resources = resources, + 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 = - if (state.ck?.goodsOffer != null) CatanCk.answerOffer(state, seat, accept = false) else state.copy( + if (state.ck?.goodsOffer != null) { + CatanCk.answerOffer(state, seat, accept = false) + } else { + 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 { + if (state.ck?.goodsOffer != null) return CatanCk.acceptCounter(state, move) + val offer = state.pendingTrade!! + val counter = offer.counters.first { it.by == move.seat } + return state.copy( phase = CatanPhase.PLAYING, pendingTrade = null, - log = state.log + "Seat $seat declines the offer.", + 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 = + if (state.ck?.goodsOffer != null) CatanCk.dismissCounters(state) else state.copy( + phase = CatanPhase.PLAYING, + pendingTrade = null, + 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/CatanCkDialogs.kt b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanCkDialogs.kt index 83c0d95..73c8920 100644 --- a/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanCkDialogs.kt +++ b/app/src/main/java/org/prolibertate/games/ui/game/catan/CatanCkDialogs.kt @@ -34,15 +34,18 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import org.prolibertate.games.game.catan.AcceptCounter import org.prolibertate.games.game.catan.ActivateKnight import org.prolibertate.games.game.catan.BuyImprovement import org.prolibertate.games.game.catan.CatanCk import org.prolibertate.games.game.catan.CatanMove import org.prolibertate.games.game.catan.CatanState import org.prolibertate.games.game.catan.ChaseRobber +import org.prolibertate.games.game.catan.CounterGoods import org.prolibertate.games.game.catan.DropProgress import org.prolibertate.games.game.catan.GoodsBankTrade import org.prolibertate.games.game.catan.Good +import org.prolibertate.games.game.catan.GoodsCounter import org.prolibertate.games.game.catan.GoodsOffer import org.prolibertate.games.game.catan.IMPROVEMENT_MAX import org.prolibertate.games.game.catan.MoveKnight @@ -591,6 +594,7 @@ fun CatanGoodsTradeDialog( ) { var tab by remember { mutableStateOf(GoodsTab.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()) } @@ -641,9 +645,19 @@ fun CatanGoodsTradeDialog( } } else { Text("Offer to:", fontWeight = FontWeight.Bold) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + GoodsTabButton(CatanText.EVERYONE, everyone) { + everyone = true + partner = null + } (0 until state.playerCount).filter { it != seat }.forEach { other -> - GoodsTabButton(seats.label(other), partner == other) { partner = other } + GoodsTabButton(seats.label(other), !everyone && partner == other) { + everyone = false + partner = other + } } } Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold) @@ -655,12 +669,13 @@ fun CatanGoodsTradeDialog( }, confirmButton = { if (tab == GoodsTab.PLAYER) { - val to = partner + val to = if (everyone) null else partner val giveCards = give.asGoods() val getCards = get.asGoods() - val allowed = to != null && CatanInteraction.goodsOfferAllowed(state, seat, to, giveCards, getCards) + val allowed = (everyone || partner != null) && + CatanInteraction.goodsOfferAllowed(state, seat, to, giveCards, getCards) Button( - onClick = { if (to != null) onOffer(OfferGoods(to, giveCards, getCards)) }, + onClick = { onOffer(OfferGoods(to, giveCards, getCards)) }, enabled = allowed, ) { Text(CatanText.SEND_OFFER) } } @@ -696,25 +711,44 @@ private fun GoodsTabButton(label: String, selected: Boolean, onClick: () -> Unit } } -/** Another player has offered the local player a trade in goods. */ +/** + * A goods offer has reached the local player: accept it, decline it, or answer + * with different terms. The counter's two sides start as the offer turned around. + */ +@OptIn(ExperimentalLayoutApi::class) @Composable fun CatanGoodsTradeResponseDialog( + state: CatanState, + seat: Int, offer: GoodsOffer, seats: CatanSeats, canAccept: Boolean, onAccept: () -> Unit, onDecline: () -> Unit, + onCounter: (CounterGoods) -> Unit, ) { + var countering by remember(offer) { mutableStateOf(false) } + 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( - "${seats.label(offer.by)} offers ${CatanText.goods(offer.give)} to you " + - "for ${CatanText.goods(offer.get)}", + "${seats.label(offer.by)} offers ${CatanText.goods(offer.give)} to " + + "${if (offer.to == null) "the table" else "you"} for ${CatanText.goods(offer.get)}", ) - if (!canAccept) { + if (countering) { + Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold) + GoodCounters(give, { CatanCk.goodsOf(state, seat)[it] ?: 0 }) { give = it } + Text(CatanText.YOU_GET, fontWeight = FontWeight.Bold) + GoodCounters(get, { CatanCk.goodsOf(state, offer.by)[it] ?: 0 }) { get = it } + } else if (!canAccept) { Text( "You cannot accept this: one side no longer holds the cards.", style = MaterialTheme.typography.bodySmall, @@ -722,7 +756,58 @@ fun CatanGoodsTradeResponseDialog( } } }, - confirmButton = { Button(onClick = onAccept, enabled = canAccept) { Text(CatanText.ACCEPT) } }, - dismissButton = { TextButton(onClick = onDecline) { Text(CatanText.DECLINE) } }, + confirmButton = { + if (countering) { + val move = CounterGoods(give.asGoods(), get.asGoods()) + Button( + onClick = { onCounter(move) }, + enabled = CatanInteraction.goodsCounterAllowed(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 a goods offer: take a counter-offer, or none. */ +@Composable +fun CatanGoodsCounterChoiceDialog( + 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( + "${seats.label(counter.by)} would give ${CatanText.goods(counter.give)} " + + "for ${CatanText.goods(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/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 c62f975..5054be0 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.ActivateKnight import org.prolibertate.games.game.catan.BuildCity @@ -15,6 +16,7 @@ import org.prolibertate.games.game.catan.CatanRules import org.prolibertate.games.game.catan.CatanState import org.prolibertate.games.game.catan.ChaseRobber import org.prolibertate.games.game.catan.Chore +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 @@ -22,7 +24,9 @@ import org.prolibertate.games.game.catan.Discard import org.prolibertate.games.game.catan.EndFreeRoads import org.prolibertate.games.game.catan.EndSmith import org.prolibertate.games.game.catan.EndTurn +import org.prolibertate.games.game.catan.CounterGoods import org.prolibertate.games.game.catan.GoodsBankTrade +import org.prolibertate.games.game.catan.GoodsCounter import org.prolibertate.games.game.catan.Good import org.prolibertate.games.game.catan.HexCoord import org.prolibertate.games.game.catan.HireKnight @@ -34,6 +38,7 @@ import org.prolibertate.games.game.catan.OfferTrade import org.prolibertate.games.game.catan.PillageCity import org.prolibertate.games.game.catan.PlaceKnight import org.prolibertate.games.game.catan.PlaceMetropolis +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.PlayProgress @@ -319,12 +324,23 @@ object CatanInteraction { fun goodsOfferAllowed( state: CatanState, seat: Int, - to: Int, + to: Int?, give: List, get: List, ): Boolean = give.isNotEmpty() && get.isNotEmpty() && CatanRules.isLegalMove(state, seat, OfferGoods(to, give, get)) + /** Whether [seat] may answer the goods offer on the table with [give] for [get]. */ + fun goodsCounterAllowed(state: CatanState, seat: Int, give: List, get: List): Boolean = + give.isNotEmpty() && get.isNotEmpty() && + CatanRules.isLegalMove(state, seat, CounterGoods(give, get)) + + /** The goods counter-offers the offerer may take right now, in the order they came in. */ + fun goodsCounterChoices(state: CatanState, legal: List): List { + val takeable = legal.filterIsInstance().map { it.seat }.toSet() + return state.ck?.goodsOffer?.counters.orEmpty().filter { it.by in takeable } + } + /** The chore at the front of the queue, if the table is waiting on one. */ fun chore(state: CatanState): Chore? = if (state.phase == CatanPhase.CHORE) state.ck?.chores?.firstOrNull() else null @@ -543,9 +559,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 32b98d9..5eb3685 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 @@ -40,6 +40,7 @@ import org.prolibertate.games.game.catan.DiscardChore import org.prolibertate.games.game.catan.DiscardGoods import org.prolibertate.games.game.catan.DropProgress import org.prolibertate.games.game.catan.DropProgressChore +import org.prolibertate.games.game.catan.DismissCounters import org.prolibertate.games.game.catan.EndFreeRoads import org.prolibertate.games.game.catan.EndSmith import org.prolibertate.games.game.catan.EndTurn @@ -452,22 +453,44 @@ private fun CatanDialogHost( state.phase == CatanPhase.CHORE && CatanRules.currentSeat(state) == localSeat -> ChoreDialog(state, localSeat, legal, onSubmit) - state.phase == CatanPhase.TRADE_PENDING && goodsOffer != null && goodsOffer.to == localSeat -> + state.phase == CatanPhase.TRADE_PENDING && goodsOffer != null && goodsOffer.asking == localSeat -> CatanGoodsTradeResponseDialog( + state = state, + seat = localSeat, offer = goodsOffer, seats = seats, canAccept = CatanInteraction.tradeResponses(legal).first, onAccept = { onSubmit(AcceptTrade) }, onDecline = { onSubmit(DeclineTrade) }, + onCounter = onSubmit, ) - state.phase == CatanPhase.TRADE_PENDING && offer != null && offer.to == localSeat -> + state.phase == CatanPhase.TRADE_COUNTERS && goodsOffer != null && state.turn == localSeat && legal.isNotEmpty() -> + CatanGoodsCounterChoiceDialog( + counters = CatanInteraction.goodsCounterChoices(state, legal), + seats = seats, + onTake = onSubmit, + onNoDeal = { onSubmit(DismissCounters) }, + ) + + 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 8e47b77..8694f2f 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 @@ -120,6 +120,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 @@ -218,11 +222,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…" + } } } @@ -236,6 +252,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" @@ -393,14 +413,22 @@ object CatanText { CatanPhase.TRADE_PENDING -> { val offer = ck.goodsOffer + val asking = offer?.asking?.takeIf { it >= 0 } 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 == null || asking == null -> "Waiting for a reply…" + asking == me -> "${seats.label(offer.by)} offers you a trade." + offer.by == me -> "Waiting for ${seats.label(asking)} to answer your offer…" + else -> "${seats.label(offer.by)} has offered ${seats.label(asking)} a trade." } } + CatanPhase.TRADE_COUNTERS -> + if (mine) { + "Nobody accepted, but there are counter-offers. Take one or turn them down." + } else { + "$turn is weighing counter-offers…" + } + CatanPhase.DISCARDING -> "Discarding…" CatanPhase.CHORE -> { 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/CatanCkRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/catan/CatanCkRulesTest.kt index 9e6db45..9fca90a 100644 --- a/app/src/test/java/org/prolibertate/games/game/catan/CatanCkRulesTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/catan/CatanCkRulesTest.kt @@ -912,6 +912,68 @@ class CatanCkRulesTest { assertNull(s.ck!!.goodsOffer) } + @Test + fun `an open goods offer is asked of each seat in turn and the first to accept takes it`() { + var s = table().hand(0, Good.CLOTH to 2).hand(1, Good.ORE to 1).hand(2, Good.ORE to 1) + s = apply(s, 0, OfferGoods(null, listOf(Good.CLOTH, Good.CLOTH), listOf(Good.ORE))) + assertEquals(CatanPhase.TRADE_PENDING, s.phase) + assertEquals("the seat after the offerer is asked first", 1, CatanRules.currentSeat(s)) + s = apply(s, 1, DeclineTrade) + assertEquals(2, CatanRules.currentSeat(s)) + s = apply(s, 2, AcceptTrade) + assertEquals(CatanPhase.PLAYING, s.phase) + assertEquals(2, s.ck!!.commodities[2][Commodity.CLOTH]) + assertEquals(1, s.resources[0][Resource.ORE]) + assertEquals("seat 1 was never asked a second time and keeps its ore", 1, s.resources[1][Resource.ORE]) + assertNull(s.ck!!.goodsOffer) + } + + @Test + fun `an open goods offer nobody takes ends with a word and no change`() { + var s = table().hand(0, Good.CLOTH to 2).hand(1, Good.ORE to 1).hand(2, Good.ORE to 1) + s = apply(s, 0, OfferGoods(null, listOf(Good.CLOTH, Good.CLOTH), listOf(Good.ORE))) + s = apply(s, 1, DeclineTrade) + s = apply(s, 2, DeclineTrade) + assertEquals(CatanPhase.PLAYING, s.phase) + assertTrue(s.log.last().contains("Nobody takes the offer")) + assertEquals(2, s.ck!!.commodities[0][Commodity.CLOTH]) + } + + @Test + fun `a seat may counter a goods offer and the offerer takes the counter or none`() { + var s = table().hand(0, Good.CLOTH to 2).hand(1, Good.ORE to 1).hand(2, Good.ORE to 1) + s = apply(s, 0, OfferGoods(null, listOf(Good.CLOTH, Good.CLOTH), listOf(Good.ORE))) + // Seat 1 would swap one for one; taking the offer as made is Accept, not a counter. + assertFalse( + CatanRules.isLegalMove(s, 1, CounterGoods(listOf(Good.ORE), listOf(Good.CLOTH, Good.CLOTH))), + ) + s = apply(s, 1, CounterGoods(listOf(Good.ORE), listOf(Good.CLOTH))) + s = apply(s, 2, DeclineTrade) + assertEquals(CatanPhase.TRADE_COUNTERS, s.phase) + assertEquals(0, CatanRules.currentSeat(s)) + assertEquals( + setOf(AcceptCounter(1), DismissCounters), + CatanRules.legalMoves(s, 0).toSet(), + ) + val taken = apply(s, 0, AcceptCounter(1)) + assertEquals(CatanPhase.PLAYING, taken.phase) + assertEquals(1, taken.ck!!.commodities[0][Commodity.CLOTH]) + assertEquals(1, taken.resources[0][Resource.ORE]) + assertEquals(1, taken.ck!!.commodities[1][Commodity.CLOTH]) + assertEquals(0, taken.resources[1][Resource.ORE]) + + val refused = apply(s, 0, DismissCounters) + assertEquals(CatanPhase.PLAYING, refused.phase) + assertEquals(2, refused.ck!!.commodities[0][Commodity.CLOTH]) + } + + @Test + fun `an open offer skips seats with no cards at all`() { + var s = table().hand(0, Good.CLOTH to 2).hand(1).hand(2, Good.ORE to 1) + s = apply(s, 0, OfferGoods(null, listOf(Good.CLOTH, Good.CLOTH), listOf(Good.ORE))) + assertEquals(2, CatanRules.currentSeat(s)) + } + @Test fun `thirteen points win, with the longest road worth two and no largest army`() { val road = table().build(a, BuildingType.CITY, 0).path(0, *ring.toIntArray()) 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/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanCkInteractionTest.kt b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanCkInteractionTest.kt index bc0807c..085736f 100644 --- a/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanCkInteractionTest.kt +++ b/app/src/test/java/org/prolibertate/games/ui/game/catan/CatanCkInteractionTest.kt @@ -6,7 +6,10 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.catan.AcceptCounter import org.prolibertate.games.game.catan.AcceptTrade +import org.prolibertate.games.game.catan.CounterGoods +import org.prolibertate.games.game.catan.DismissCounters import org.prolibertate.games.game.catan.ActivateKnight import org.prolibertate.games.game.catan.BuildCity import org.prolibertate.games.game.catan.BuildRoad @@ -164,7 +167,8 @@ class CatanCkInteractionTest { // Structural: the discard and give dialogs build these and ask the engine. is DiscardGoods, is GiveGoods -> "goodsDialog" - is OfferGoods -> "tradeDialog" + is OfferGoods, is CounterGoods -> "tradeDialog" + is AcceptCounter, DismissCounters -> "counterDialog" else -> null } diff --git a/docs/RULES-catan-ck.md b/docs/RULES-catan-ck.md index 70029bb..f34fc8c 100644 --- a/docs/RULES-catan-ck.md +++ b/docs/RULES-catan-ck.md @@ -197,8 +197,10 @@ checked after every move and when a turn arrives. - **A knight that cannot find a refuge is lost**, as the almanac says of an intrigue. - **A claim on a metropolis with no city to carry it waits** and is made when a city is built. -- **Domestic trade** may include commodities; the one-offer-at-a-time rules of the base - game apply. +- **Domestic trade** works as in the base game — an offer to one seat or to the whole + table, asked in seat order, each seat accepting, declining or countering — and may + include commodities (`OfferGoods`, `CounterGoods`; accepting and dismissing counters + reuse the base game's `AcceptCounter` and `DismissCounters`). - **No Seafarers, no 5–6 player extension**, no scenario or variant of any kind. ## The computer opponent diff --git a/docs/RULES-catan.md b/docs/RULES-catan.md index b4c07a7..3e8df72 100644 --- a/docs/RULES-catan.md +++ b/docs/RULES-catan.md @@ -135,17 +135,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 @@ -305,7 +320,7 @@ 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 @@ -315,12 +330,8 @@ timing rules; Longest Road and Largest Army worth five; victory at ten. the base game so far; it is a one-constant change in `publicVictoryPoints`. - **A bank short of a resource pays nobody that roll**, even when only one player is owed it. The published ruling lets a lone claimant take what is left; Cities & Knights does. - -- **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 99be891..2ec24fa 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 @@ -165,6 +166,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.