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