Merge origin/dev into feature/cities-and-knights
Brings in open trade offers and counter-offers. Cities & Knights gets the same flow for goods: OfferGoods may be open to the table, CounterGoods answers an offer, and AcceptCounter / DismissCounters are shared with the base game. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_016eTor2tpxKb4dXbd8RqnvZ
This commit is contained in:
@@ -42,6 +42,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.CHORE, CatanPhase.GAME_OVER -> legal.firstOrNull()
|
||||
@@ -264,17 +266,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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -287,6 +287,22 @@ object CatanCk {
|
||||
}
|
||||
}
|
||||
|
||||
CatanPhase.TRADE_COUNTERS -> {
|
||||
val offer = state.ck!!.goodsOffer
|
||||
if (offer == null) {
|
||||
CatanRules.counterChoices(state)
|
||||
} else {
|
||||
val moves = mutableListOf<CatanMove>()
|
||||
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 {
|
||||
|
||||
@@ -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>): CatanMove =
|
||||
legal.firstOrNull { it is DismissCounters } ?: legal.first()
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Valuations
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -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<Good>, val get: List<Good>)
|
||||
data class GoodsCounter(val by: Int, val give: List<Good>, val get: List<Good>)
|
||||
|
||||
/**
|
||||
* 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<Good>,
|
||||
val get: List<Good>,
|
||||
val asking: Int = to ?: -1,
|
||||
val counters: List<GoodsCounter> = 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<Good>, val get: List<Good>) : CatanMove
|
||||
data class OfferGoods(val to: Int?, val give: List<Good>, val get: List<Good>) : 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<Good>, val get: List<Good>) : CatanMove
|
||||
|
||||
/**
|
||||
* Give up exactly the cards named, to a [DiscardChore]. Structural, not
|
||||
|
||||
@@ -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,
|
||||
|
||||
/**
|
||||
@@ -305,7 +325,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
|
||||
@@ -314,7 +335,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")
|
||||
@@ -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<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")
|
||||
|
||||
@@ -110,7 +110,9 @@ 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 ?: 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<CatanState, CatanMove> {
|
||||
|
||||
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<CatanState, CatanMove> {
|
||||
|
||||
CatanPhase.TRADE_PENDING -> tradeResponses(state, seat)
|
||||
|
||||
CatanPhase.TRADE_COUNTERS -> counterChoices(state)
|
||||
|
||||
CatanPhase.CHORE, CatanPhase.GAME_OVER -> emptyList()
|
||||
}
|
||||
}
|
||||
@@ -342,6 +348,29 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
||||
return responses
|
||||
}
|
||||
|
||||
/** What the offerer may do with the counters: take one that is still affordable, or none. */
|
||||
internal 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. */
|
||||
internal fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
|
||||
state.buildings[v] == null && !hasKnight(state, v) &&
|
||||
@@ -373,7 +402,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
|
||||
@@ -405,6 +434,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)
|
||||
@@ -663,34 +695,117 @@ 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),
|
||||
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<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 {
|
||||
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 {
|
||||
|
||||
@@ -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<Good?>(null) }
|
||||
var everyone by remember { mutableStateOf(true) }
|
||||
var partner by remember { mutableStateOf<Int?>(null) }
|
||||
var give by remember { mutableStateOf(emptyMap<Good, Int>()) }
|
||||
var get by remember { mutableStateOf(emptyMap<Good, Int>()) }
|
||||
@@ -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<GoodsCounter>,
|
||||
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) } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.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<Good>,
|
||||
get: List<Good>,
|
||||
): 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<Good>, get: List<Good>): 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<CatanMove>): List<GoodsCounter> {
|
||||
val takeable = legal.filterIsInstance<AcceptCounter>().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<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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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<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"
|
||||
@@ -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 -> {
|
||||
|
||||
Reference in New Issue
Block a user