Release: dev to main (Cities & Knights) #9

Merged
devadmin merged 6 commits from dev into main 2026-10-17 12:43:31 -06:00
18 changed files with 898 additions and 101 deletions
Showing only changes of commit f9b793d56e - Show all commits
@@ -42,6 +42,8 @@ class CatanAi : GameAi<CatanState, CatanMove> {
CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal) CatanPhase.TRADE_PENDING -> chooseResponse(state, seat, legal)
CatanPhase.TRADE_COUNTERS -> chooseCounter(state, legal)
CatanPhase.PLAYING -> choosePlaying(state, seat, legal) CatanPhase.PLAYING -> choosePlaying(state, seat, legal)
CatanPhase.CHORE, CatanPhase.GAME_OVER -> legal.firstOrNull() 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 { private fun chooseResponse(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
val accept = legal.filterIsInstance<AcceptTrade>().firstOrNull() ?: run { val accept = legal.filterIsInstance<AcceptTrade>().firstOrNull()
return legal.first { it is DeclineTrade } val offer = state.pendingTrade
} if (accept == null || offer == null) return legal.first { it is DeclineTrade }
val offer = state.pendingTrade ?: return legal.first() // Accept when what arrives (the offerer's give) is worth more than what
// Accept when what arrives is worth more than what leaves: a mild // leaves (the offerer's get): a mild table, not a shark.
// table, not a shark. val incoming = offer.give.sumOf { want(it) }
val incoming = offer.get.sumOf { want(it) } val outgoing = offer.get.sumOf { want(it) }
val outgoing = offer.give.sumOf { want(it) }
return if (incoming > outgoing) accept else legal.first { it is DeclineTrade } 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 // 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.CHORE -> choreMoves(state, seat)
CatanPhase.DISCARDING -> emptyList() // replaced by chores in this rule set 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) { fun structurallyLegal(state: CatanState, seat: Int, move: CatanMove): Boolean? = when (move) {
is OfferGoods -> { is OfferGoods -> {
state.phase == CatanPhase.PLAYING && state.turn == seat && 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() && move.give.isNotEmpty() && move.get.isNotEmpty() &&
countGoods(move.give) != countGoods(move.get) && countGoods(move.give) != countGoods(move.get) &&
holds(state, seat, move.give) 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 -> { is DiscardGoods -> {
val chore = state.ck?.chores?.firstOrNull() as? DiscardChore val chore = state.ck?.chores?.firstOrNull() as? DiscardChore
state.phase == CatanPhase.CHORE && chore != null && chore.seat == seat && state.phase == CatanPhase.CHORE && chore != null && chore.seat == seat &&
@@ -743,6 +769,7 @@ object CatanCk {
is PlayProgress -> applyProgress(state, seat, move) is PlayProgress -> applyProgress(state, seat, move)
is GoodsBankTrade -> applyBankTrade(state, seat, move) is GoodsBankTrade -> applyBankTrade(state, seat, move)
is OfferGoods -> applyOfferGoods(state, seat, move) is OfferGoods -> applyOfferGoods(state, seat, move)
is CounterGoods -> applyCounterGoods(state, seat, move)
is DiscardGoods -> applyDiscard(state, seat, move) is DiscardGoods -> applyDiscard(state, seat, move)
is GiveGoods -> applyGive(state, seat, move) is GiveGoods -> applyGive(state, seat, move)
is PillageCity -> applyPillageChoice(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 = private fun applyOfferGoods(state: CatanState, seat: Int, move: OfferGoods): CatanState {
state.copy( val audience = if (move.to == null) "the table" else "seat ${move.to}"
phase = CatanPhase.TRADE_PENDING, val offered = state.copy(
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 $audience.",
log = state.log + "Seat $seat offers ${describe(move.give)} for ${describe(move.get)} to seat ${move.to}.",
) )
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 { fun answerOffer(state: CatanState, seat: Int, accept: Boolean): CatanState {
val offer = state.ck!!.goodsOffer!! val offer = state.ck!!.goodsOffer!!
var s = state.copy(ck = state.ck.copy(goodsOffer = null), phase = CatanPhase.PLAYING) if (!accept) return advanceOffer(state, offer, seat, "Seat $seat declines the offer.")
if (!accept) return s.copy(log = s.log + "Seat $seat declines the offer.") var s = give(state, offer.by, seat, offer.give)
s = give(s, offer.to, offer.by, offer.get) s = give(s, seat, offer.by, offer.get)
s = give(s, offer.by, offer.to, offer.give) return s.copy(
return s.copy(log = s.log + "Seat $seat accepts — the cards cross the table.") 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 --- // --- chores answered ---
private fun applyDiscard(state: CatanState, seat: Int, move: DiscardGoods): CatanState { 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_PENDING -> chooseResponse(state, seat, legal)
CatanPhase.TRADE_COUNTERS -> chooseCounter(state, legal)
CatanPhase.PLAYING -> choosePlaying(state, seat, legal) CatanPhase.PLAYING -> choosePlaying(state, seat, legal)
CatanPhase.DISCARDING, CatanPhase.GAME_OVER -> legal.firstOrNull() 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 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 // Valuations
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -183,9 +183,25 @@ data class MetropolisAt(val seat: Int, val vertex: Int)
@Serializable @Serializable
data class MerchantAt(val seat: Int, val hex: Int) 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 @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. */ /** Where play goes when the queue of [Chore]s runs out. */
@Serializable @Serializable
@@ -389,12 +405,22 @@ data class PlayProgress(
data class GoodsBankTrade(val give: Good, val get: Good) : CatanMove 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 * Offer a trade in goods to seat [to], or to the whole table when [to] is null.
* checked structurally and is not in the enumerated move list. * Free-form like [OfferTrade], so it is checked structurally and is not in the
* enumerated move list.
*/ */
@Serializable @Serializable
@SerialName("offerGoods") @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 * 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) 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] * A counter-proposal to a [TradeOffer]: seat [by] would [give] these cards to
* the only seat that may respond — v1 has no counter-offers and no open * the offerer if the offerer would hand over [get].
* auctions, so an offer is always addressed to one player.
*/ */
@Serializable @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 * Where the game stands. The phases that involve more than the player
* whose turn it is carry their own bookkeeping: [discardSeat] during * 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 @Serializable
enum class CatanPhase { enum class CatanPhase {
@@ -109,9 +126,12 @@ enum class CatanPhase {
/** A played Road Building card: up to two roads remain to be placed. */ /** A played Road Building card: up to two roads remain to be placed. */
FREE_ROADS, FREE_ROADS,
/** The offered seat must accept or decline. */ /** The [TradeOffer.asking] seat must accept, decline or counter. */
TRADE_PENDING, TRADE_PENDING,
/** Nobody accepted but some seats countered: the offerer picks one or none. */
TRADE_COUNTERS,
GAME_OVER, 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 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 * 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 * 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 @Serializable
@SerialName("offer") @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 @Serializable
@SerialName("accept") @SerialName("accept")
@@ -324,6 +345,25 @@ data object AcceptTrade : CatanMove
@SerialName("decline") @SerialName("decline")
data object DeclineTrade : CatanMove 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]. */ /** Bank trade: [give] at the best ratio this seat can trade at for [get]. */
@Serializable @Serializable
@SerialName("bank") @SerialName("bank")
@@ -110,7 +110,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
CatanPhase.SETUP_FIRST -> state.setupIndex CatanPhase.SETUP_FIRST -> state.setupIndex
CatanPhase.SETUP_SECOND -> 2 * state.playerCount - 1 - state.setupIndex CatanPhase.SETUP_SECOND -> 2 * state.playerCount - 1 - state.setupIndex
CatanPhase.DISCARDING -> state.discardSeat 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.CHORE -> state.ck?.chores?.firstOrNull()?.seat
CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER, CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER,
CatanPhase.PLAYING, CatanPhase.FREE_ROADS, CatanPhase.PLAYING, CatanPhase.FREE_ROADS,
@@ -140,6 +142,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
is OfferTrade -> offerLegal(state, seat, move) is OfferTrade -> offerLegal(state, seat, move)
is CounterTrade -> counterLegal(state, seat, move)
else -> if (state.isCk) { else -> if (state.isCk) {
CatanCk.structurallyLegal(state, seat, move) ?: (move in legalMoves(state, seat)) CatanCk.structurallyLegal(state, seat, move) ?: (move in legalMoves(state, seat))
} else { } else {
@@ -167,6 +171,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
CatanPhase.TRADE_PENDING -> tradeResponses(state, seat) CatanPhase.TRADE_PENDING -> tradeResponses(state, seat)
CatanPhase.TRADE_COUNTERS -> counterChoices(state)
CatanPhase.CHORE, CatanPhase.GAME_OVER -> emptyList() CatanPhase.CHORE, CatanPhase.GAME_OVER -> emptyList()
} }
} }
@@ -342,6 +348,29 @@ object CatanRules : GameRules<CatanState, CatanMove> {
return responses 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. */ /** An empty vertex with no building on it or one edge away. */
internal fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean = internal fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
state.buildings[v] == null && !hasKnight(state, v) && 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 { private fun offerLegal(state: CatanState, seat: Int, move: OfferTrade): Boolean {
if (state.phase != CatanPhase.PLAYING || state.turn != seat) return false 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 if (move.give.isEmpty() || move.get.isEmpty()) return false
// Like-for-like is not a trade; the published game rejects it too. // Like-for-like is not a trade; the published game rejects it too.
if (countOf(move.give) == countOf(move.get)) return false if (countOf(move.give) == countOf(move.get)) return false
@@ -405,6 +434,9 @@ object CatanRules : GameRules<CatanState, CatanMove> {
is OfferTrade -> applyOfferTrade(state, seat, move) is OfferTrade -> applyOfferTrade(state, seat, move)
AcceptTrade -> applyAcceptTrade(state, seat) AcceptTrade -> applyAcceptTrade(state, seat)
DeclineTrade -> applyDeclineTrade(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) is MaritimeTrade -> applyMaritimeTrade(state, seat, move)
EndTurn -> applyEndTurn(state, seat) EndTurn -> applyEndTurn(state, seat)
EndFreeRoads -> applyEndFreeRoads(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 = private fun applyOfferTrade(state: CatanState, seat: Int, move: OfferTrade): CatanState {
state.copy( val audience = if (move.to == null) "the table" else "seat ${move.to}"
phase = CatanPhase.TRADE_PENDING, val offered = state.copy(
pendingTrade = TradeOffer(by = seat, to = move.to, give = move.give, get = move.get), log = state.log + "Seat $seat offers ${describe(countOf(move.give))} for " +
log = state.log + ("Seat $seat offers ${describe(countOf(move.give))} for " + "${describe(countOf(move.get))} to $audience.",
"${describe(countOf(move.get))} to seat ${move.to}."),
) )
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 { private fun applyAcceptTrade(state: CatanState, seat: Int): CatanState {
if (state.ck?.goodsOffer != null) return CatanCk.answerOffer(state, seat, accept = true) if (state.ck?.goodsOffer != null) return CatanCk.answerOffer(state, seat, accept = true)
val offer = state.pendingTrade!! 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( return state.copy(
phase = CatanPhase.PLAYING, phase = CatanPhase.PLAYING,
pendingTrade = null, 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.", log = state.log + "Seat $seat accepts — the cards cross the table.",
) )
} }
private fun applyDeclineTrade(state: CatanState, seat: Int): CatanState = 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, phase = CatanPhase.PLAYING,
pendingTrade = null, 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 { 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.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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.ActivateKnight
import org.prolibertate.games.game.catan.BuyImprovement import org.prolibertate.games.game.catan.BuyImprovement
import org.prolibertate.games.game.catan.CatanCk import org.prolibertate.games.game.catan.CatanCk
import org.prolibertate.games.game.catan.CatanMove import org.prolibertate.games.game.catan.CatanMove
import org.prolibertate.games.game.catan.CatanState import org.prolibertate.games.game.catan.CatanState
import org.prolibertate.games.game.catan.ChaseRobber 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.DropProgress
import org.prolibertate.games.game.catan.GoodsBankTrade import org.prolibertate.games.game.catan.GoodsBankTrade
import org.prolibertate.games.game.catan.Good 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.GoodsOffer
import org.prolibertate.games.game.catan.IMPROVEMENT_MAX import org.prolibertate.games.game.catan.IMPROVEMENT_MAX
import org.prolibertate.games.game.catan.MoveKnight import org.prolibertate.games.game.catan.MoveKnight
@@ -591,6 +594,7 @@ fun CatanGoodsTradeDialog(
) { ) {
var tab by remember { mutableStateOf(GoodsTab.BANK) } var tab by remember { mutableStateOf(GoodsTab.BANK) }
var bankGive by remember { mutableStateOf<Good?>(null) } var bankGive by remember { mutableStateOf<Good?>(null) }
var everyone by remember { mutableStateOf(true) }
var partner by remember { mutableStateOf<Int?>(null) } var partner by remember { mutableStateOf<Int?>(null) }
var give by remember { mutableStateOf(emptyMap<Good, Int>()) } var give by remember { mutableStateOf(emptyMap<Good, Int>()) }
var get by remember { mutableStateOf(emptyMap<Good, Int>()) } var get by remember { mutableStateOf(emptyMap<Good, Int>()) }
@@ -641,9 +645,19 @@ fun CatanGoodsTradeDialog(
} }
} else { } else {
Text("Offer to:", fontWeight = FontWeight.Bold) 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 -> (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) Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold)
@@ -655,12 +669,13 @@ fun CatanGoodsTradeDialog(
}, },
confirmButton = { confirmButton = {
if (tab == GoodsTab.PLAYER) { if (tab == GoodsTab.PLAYER) {
val to = partner val to = if (everyone) null else partner
val giveCards = give.asGoods() val giveCards = give.asGoods()
val getCards = get.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( Button(
onClick = { if (to != null) onOffer(OfferGoods(to, giveCards, getCards)) }, onClick = { onOffer(OfferGoods(to, giveCards, getCards)) },
enabled = allowed, enabled = allowed,
) { Text(CatanText.SEND_OFFER) } ) { 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 @Composable
fun CatanGoodsTradeResponseDialog( fun CatanGoodsTradeResponseDialog(
state: CatanState,
seat: Int,
offer: GoodsOffer, offer: GoodsOffer,
seats: CatanSeats, seats: CatanSeats,
canAccept: Boolean, canAccept: Boolean,
onAccept: () -> Unit, onAccept: () -> Unit,
onDecline: () -> 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( AlertDialog(
onDismissRequest = {}, onDismissRequest = {},
title = { Text("Trade offer") }, title = { Text(if (countering) "Counter-offer" else "Trade offer") },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text( Text(
"${seats.label(offer.by)} offers ${CatanText.goods(offer.give)} to you " + "${seats.label(offer.by)} offers ${CatanText.goods(offer.give)} to " +
"for ${CatanText.goods(offer.get)}", "${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( Text(
"You cannot accept this: one side no longer holds the cards.", "You cannot accept this: one side no longer holds the cards.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -722,7 +756,58 @@ fun CatanGoodsTradeResponseDialog(
} }
} }
}, },
confirmButton = { Button(onClick = onAccept, enabled = canAccept) { Text(CatanText.ACCEPT) } }, confirmButton = {
dismissButton = { TextButton(onClick = onDecline) { Text(CatanText.DECLINE) } }, 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.Discard
import org.prolibertate.games.game.catan.MaritimeTrade import org.prolibertate.games.game.catan.MaritimeTrade
import org.prolibertate.games.game.catan.OfferTrade 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.PlayKnight
import org.prolibertate.games.game.catan.PlayRoadBuilding import org.prolibertate.games.game.catan.PlayRoadBuilding
import org.prolibertate.games.game.catan.Resource import org.prolibertate.games.game.catan.Resource
@@ -312,6 +315,7 @@ fun CatanTradeDialog(
) { ) {
var tab by remember { mutableStateOf(TradeTab.BANK) } var tab by remember { mutableStateOf(TradeTab.BANK) }
var bankGive by remember { mutableStateOf<Resource?>(null) } var bankGive by remember { mutableStateOf<Resource?>(null) }
var everyone by remember { mutableStateOf(true) }
var partner by remember { mutableStateOf<Int?>(null) } var partner by remember { mutableStateOf<Int?>(null) }
var give by remember { mutableStateOf(emptyMap<Resource, Int>()) } var give by remember { mutableStateOf(emptyMap<Resource, Int>()) }
var get by remember { mutableStateOf(emptyMap<Resource, Int>()) } var get by remember { mutableStateOf(emptyMap<Resource, Int>()) }
@@ -371,9 +375,16 @@ fun CatanTradeDialog(
} }
} else { } else {
Text("Offer to:", fontWeight = FontWeight.Bold) 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 -> (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) Text(CatanText.YOU_GIVE, fontWeight = FontWeight.Bold)
@@ -389,13 +400,13 @@ fun CatanTradeDialog(
}, },
confirmButton = { confirmButton = {
if (tab == TradeTab.PLAYER) { if (tab == TradeTab.PLAYER) {
val to = partner val to = if (everyone) null else partner
val giveCards = give.asCards() val giveCards = give.asCards()
val getCards = get.asCards() val getCards = get.asCards()
val allowed = to != null && val allowed = (everyone || partner != null) &&
CatanInteraction.offerAllowed(state, seat, to, giveCards, getCards) CatanInteraction.offerAllowed(state, seat, to, giveCards, getCards)
Button( Button(
onClick = { if (to != null) onOffer(OfferTrade(to, giveCards, getCards)) }, onClick = { onOffer(OfferTrade(to, giveCards, getCards)) },
enabled = allowed, enabled = allowed,
) { Text(CatanText.SEND_OFFER) } ) { 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 @Composable
fun CatanTradeResponseDialog( fun CatanTradeResponseDialog(
state: CatanState,
seat: Int,
offer: TradeOffer, offer: TradeOffer,
seats: CatanSeats, seats: CatanSeats,
canAccept: Boolean, canAccept: Boolean,
onAccept: () -> Unit, onAccept: () -> Unit,
onDecline: () -> 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( AlertDialog(
onDismissRequest = {}, onDismissRequest = {},
title = { Text("Trade offer") }, title = { Text(if (countering) "Counter-offer" else "Trade offer") },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(
modifier = Modifier.heightIn(max = 460.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text( Text(
CatanText.tradeSummary( CatanText.tradeSummary(
by = seats.label(offer.by), by = seats.label(offer.by),
to = "you", to = if (offer.to == null) "the table" else "you",
give = offer.give, give = offer.give,
get = offer.get, 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( Text(
"You cannot accept this: one side no longer holds the cards.", "You cannot accept this: one side no longer holds the cards.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -465,8 +504,58 @@ fun CatanTradeResponseDialog(
} }
} }
}, },
confirmButton = { Button(onClick = onAccept, enabled = canAccept) { Text(CatanText.ACCEPT) } }, confirmButton = {
dismissButton = { TextButton(onClick = onDecline) { Text(CatanText.DECLINE) } }, 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 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.AcceptTrade
import org.prolibertate.games.game.catan.ActivateKnight import org.prolibertate.games.game.catan.ActivateKnight
import org.prolibertate.games.game.catan.BuildCity 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.CatanState
import org.prolibertate.games.game.catan.ChaseRobber import org.prolibertate.games.game.catan.ChaseRobber
import org.prolibertate.games.game.catan.Chore 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.DeclineTrade
import org.prolibertate.games.game.catan.DevCard import org.prolibertate.games.game.catan.DevCard
import org.prolibertate.games.game.catan.DevCardType 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.EndFreeRoads
import org.prolibertate.games.game.catan.EndSmith import org.prolibertate.games.game.catan.EndSmith
import org.prolibertate.games.game.catan.EndTurn 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.GoodsBankTrade
import org.prolibertate.games.game.catan.GoodsCounter
import org.prolibertate.games.game.catan.Good import org.prolibertate.games.game.catan.Good
import org.prolibertate.games.game.catan.HexCoord import org.prolibertate.games.game.catan.HexCoord
import org.prolibertate.games.game.catan.HireKnight 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.PillageCity
import org.prolibertate.games.game.catan.PlaceKnight import org.prolibertate.games.game.catan.PlaceKnight
import org.prolibertate.games.game.catan.PlaceMetropolis 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.PlayKnight
import org.prolibertate.games.game.catan.PlayMonopoly import org.prolibertate.games.game.catan.PlayMonopoly
import org.prolibertate.games.game.catan.PlayProgress import org.prolibertate.games.game.catan.PlayProgress
@@ -319,12 +324,23 @@ object CatanInteraction {
fun goodsOfferAllowed( fun goodsOfferAllowed(
state: CatanState, state: CatanState,
seat: Int, seat: Int,
to: Int, to: Int?,
give: List<Good>, give: List<Good>,
get: List<Good>, get: List<Good>,
): Boolean = give.isNotEmpty() && get.isNotEmpty() && ): Boolean = give.isNotEmpty() && get.isNotEmpty() &&
CatanRules.isLegalMove(state, seat, OfferGoods(to, give, get)) 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. */ /** The chore at the front of the queue, if the table is waiting on one. */
fun chore(state: CatanState): Chore? = fun chore(state: CatanState): Chore? =
if (state.phase == CatanPhase.CHORE) state.ck?.chores?.firstOrNull() else null if (state.phase == CatanPhase.CHORE) state.ck?.chores?.firstOrNull() else null
@@ -543,9 +559,20 @@ object CatanInteraction {
fun offerAllowed( fun offerAllowed(
state: CatanState, state: CatanState,
seat: Int, seat: Int,
to: Int, to: Int?,
give: List<Resource>, give: List<Resource>,
get: List<Resource>, get: List<Resource>,
): Boolean = give.isNotEmpty() && get.isNotEmpty() && ): Boolean = give.isNotEmpty() && get.isNotEmpty() &&
CatanRules.isLegalMove(state, seat, OfferTrade(to, give, get)) 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.DiscardGoods
import org.prolibertate.games.game.catan.DropProgress import org.prolibertate.games.game.catan.DropProgress
import org.prolibertate.games.game.catan.DropProgressChore 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.EndFreeRoads
import org.prolibertate.games.game.catan.EndSmith import org.prolibertate.games.game.catan.EndSmith
import org.prolibertate.games.game.catan.EndTurn import org.prolibertate.games.game.catan.EndTurn
@@ -452,22 +453,44 @@ private fun CatanDialogHost(
state.phase == CatanPhase.CHORE && CatanRules.currentSeat(state) == localSeat -> state.phase == CatanPhase.CHORE && CatanRules.currentSeat(state) == localSeat ->
ChoreDialog(state, localSeat, legal, onSubmit) 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( CatanGoodsTradeResponseDialog(
state = state,
seat = localSeat,
offer = goodsOffer, offer = goodsOffer,
seats = seats, seats = seats,
canAccept = CatanInteraction.tradeResponses(legal).first, canAccept = CatanInteraction.tradeResponses(legal).first,
onAccept = { onSubmit(AcceptTrade) }, onAccept = { onSubmit(AcceptTrade) },
onDecline = { onSubmit(DeclineTrade) }, 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( CatanTradeResponseDialog(
state = state,
seat = localSeat,
offer = offer, offer = offer,
seats = seats, seats = seats,
canAccept = CatanInteraction.tradeResponses(legal).first, canAccept = CatanInteraction.tradeResponses(legal).first,
onAccept = { onSubmit(AcceptTrade) }, onAccept = { onSubmit(AcceptTrade) },
onDecline = { onSubmit(DeclineTrade) }, 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 -> victimChoice != null ->
@@ -120,6 +120,10 @@ object CatanText {
const val CONFIRM = "Confirm" const val CONFIRM = "Confirm"
const val STOP_ROADS = "Done with roads" const val STOP_ROADS = "Done with roads"
const val BUILD = "Build" 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 * 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 val offer = state.pendingTrade
when { when {
offer == null -> "Waiting for a reply…" offer == null -> "Waiting for a reply…"
offer.to == me -> "${seats.label(offer.by)} offers you a trade." offer.asking == me -> if (offer.to == null) {
offer.by == me -> "Waiting for ${seats.label(offer.to)} to answer your offer…" "${seats.label(offer.by)} offers the table a trade. Accept, decline or counter."
else -> "${seats.label(offer.by)} has offered ${seats.label(offer.to)} a trade." } 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 = fun tradeSummary(by: String, to: String, give: List<Resource>, get: List<Resource>): String =
"$by offers ${cards(give)} to $to for ${cards(get)}" "$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 { fun winnerHeadline(state: CatanState, seats: CatanSeats): String {
val winner = state.winner ?: return "Game over" val winner = state.winner ?: return "Game over"
return if (winner == seats.me) "You won!" else "${seats.label(winner)} won" return if (winner == seats.me) "You won!" else "${seats.label(winner)} won"
@@ -393,14 +413,22 @@ object CatanText {
CatanPhase.TRADE_PENDING -> { CatanPhase.TRADE_PENDING -> {
val offer = ck.goodsOffer val offer = ck.goodsOffer
val asking = offer?.asking?.takeIf { it >= 0 }
when { when {
offer == null -> "Waiting for a reply…" offer == null || asking == null -> "Waiting for a reply…"
offer.to == me -> "${seats.label(offer.by)} offers you a trade." asking == me -> "${seats.label(offer.by)} offers you a trade."
offer.by == me -> "Waiting for ${seats.label(offer.to)} to answer your offer…" offer.by == me -> "Waiting for ${seats.label(asking)} to answer your offer…"
else -> "${seats.label(offer.by)} has offered ${seats.label(offer.to)} a trade." 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.DISCARDING -> "Discarding…"
CatanPhase.CHORE -> { CatanPhase.CHORE -> {
@@ -142,4 +142,50 @@ class CatanAiTest {
assertEquals(CatanPhase.PLAYING, after.phase) assertEquals(CatanPhase.PLAYING, after.phase)
assertNull(after.pendingTrade) assertNull(after.pendingTrade)
} }
private fun tradeTable(offer: TradeOffer, vararg hands: Map<Resource, Int>): CatanState =
CatanRules.initialState(config(CatanOptions(), seed = 7L)).copy(
phase = CatanPhase.TRADE_PENDING,
turn = 0,
turnNumber = 1,
pendingTrade = offer,
resources = hands.toList(),
)
@Test
fun `the AI accepts an offer that gives it more than it costs, and declines the reverse`() {
val generous = tradeTable(
TradeOffer(by = 0, to = null, give = listOf(Resource.ORE, Resource.ORE), get = listOf(Resource.WOOL), asking = 1),
hand(Resource.ORE to 2), hand(Resource.WOOL to 2), hand(),
)
assertEquals(AcceptTrade, CatanAi().chooseMove(generous, 1, CatanRules.legalMoves(generous, 1)))
val stingy = tradeTable(
TradeOffer(by = 0, to = null, give = listOf(Resource.WOOL), get = listOf(Resource.ORE), asking = 1),
hand(Resource.WOOL to 1), hand(Resource.ORE to 2), hand(),
)
assertEquals(DeclineTrade, CatanAi().chooseMove(stingy, 1, CatanRules.legalMoves(stingy, 1)))
}
@Test
fun `an AI offerer takes the best counter and refuses a losing one`() {
val counters = listOf(
TradeCounter(by = 1, give = listOf(Resource.ORE), get = listOf(Resource.LUMBER)),
TradeCounter(by = 2, give = listOf(Resource.ORE, Resource.GRAIN), get = listOf(Resource.WOOL)),
)
val state = tradeTable(
TradeOffer(by = 0, to = null, give = listOf(Resource.WOOL), get = listOf(Resource.ORE), asking = -1, counters = counters),
hand(Resource.LUMBER to 1, Resource.WOOL to 1), hand(Resource.ORE to 1), hand(Resource.ORE to 1, Resource.GRAIN to 1),
).copy(phase = CatanPhase.TRADE_COUNTERS)
val legal = CatanRules.legalMoves(state, 0)
assertEquals(AcceptCounter(2), CatanAi().chooseMove(state, 0, legal))
val bad = state.copy(
pendingTrade = state.pendingTrade!!.copy(
counters = listOf(TradeCounter(by = 1, give = listOf(Resource.WOOL), get = listOf(Resource.LUMBER))),
),
resources = listOf(hand(Resource.LUMBER to 1), hand(Resource.WOOL to 1), hand()),
)
assertEquals(DismissCounters, CatanAi().chooseMove(bad, 0, CatanRules.legalMoves(bad, 0)))
}
} }
@@ -912,6 +912,68 @@ class CatanCkRulesTest {
assertNull(s.ck!!.goodsOffer) 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<CatanMove>(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 @Test
fun `thirteen points win, with the longest road worth two and no largest army`() { 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()) val road = table().build(a, BuildingType.CITY, 0).path(0, *ring.toIntArray())
@@ -520,6 +520,113 @@ class CatanRulesTest {
assertEquals(listOf<CatanMove>(DeclineTrade), CatanRules.legalMoves(offered, 1)) assertEquals(listOf<CatanMove>(DeclineTrade), CatanRules.legalMoves(offered, 1))
} }
private val twoLumberForBrick =
OfferTrade(to = null, give = listOf(Resource.LUMBER, Resource.LUMBER), get = listOf(Resource.BRICK))
private fun openTable() = craft().copy(
resources = hands(
hand(Resource.LUMBER to 3),
hand(Resource.BRICK to 2),
hand(Resource.BRICK to 1, Resource.WOOL to 1),
),
)
@Test
fun `an open offer asks each other seat in turn until one accepts`() {
val state = openTable()
assertTrue(CatanRules.isLegalMove(state, 0, twoLumberForBrick))
val asked1 = CatanRules.applyMove(state, 0, twoLumberForBrick)
assertEquals(CatanPhase.TRADE_PENDING, asked1.phase)
assertEquals(1, CatanRules.currentSeat(asked1))
val asked2 = CatanRules.applyMove(asked1, 1, DeclineTrade)
assertEquals("a decline passes the offer on", 2, CatanRules.currentSeat(asked2))
assertEquals(CatanPhase.TRADE_PENDING, asked2.phase)
val done = CatanRules.applyMove(asked2, 2, AcceptTrade)
assertEquals(CatanPhase.PLAYING, done.phase)
assertNull(done.pendingTrade)
assertEquals(hand(Resource.LUMBER to 1, Resource.BRICK to 1), done.resources[0])
assertEquals(hand(Resource.LUMBER to 2, Resource.WOOL to 1), done.resources[2])
assertEquals("seat 1 never took part", state.resources[1], done.resources[1])
}
@Test
fun `an open offer nobody wants lapses back to the offerer`() {
var table = CatanRules.applyMove(openTable(), 0, twoLumberForBrick)
table = CatanRules.applyMove(table, 1, DeclineTrade)
table = CatanRules.applyMove(table, 2, DeclineTrade)
assertEquals(CatanPhase.PLAYING, table.phase)
assertNull(table.pendingTrade)
assertEquals(0, CatanRules.currentSeat(table))
}
@Test
fun `seats with no cards are not asked`() {
val state = openTable().copy(resources = hands(hand(Resource.LUMBER to 3), hand(), hand(Resource.BRICK to 1)))
val table = CatanRules.applyMove(state, 0, twoLumberForBrick)
assertEquals(2, CatanRules.currentSeat(table))
}
@Test
fun `a counter is kept and the offerer chooses once everyone has answered`() {
var table = CatanRules.applyMove(openTable(), 0, twoLumberForBrick)
// Seat 1 will not give a brick for two lumber but would for three.
val counter = CounterTrade(give = listOf(Resource.BRICK), get = listOf(Resource.LUMBER, Resource.LUMBER, Resource.LUMBER))
assertTrue(CatanRules.isLegalMove(table, 1, counter))
table = CatanRules.applyMove(table, 1, counter)
assertEquals(2, CatanRules.currentSeat(table))
table = CatanRules.applyMove(table, 2, DeclineTrade)
assertEquals(CatanPhase.TRADE_COUNTERS, table.phase)
assertEquals("the offerer chooses", 0, CatanRules.currentSeat(table))
assertEquals(
listOf<CatanMove>(AcceptCounter(1), DismissCounters),
CatanRules.legalMoves(table, 0),
)
val taken = CatanRules.applyMove(table, 0, AcceptCounter(1))
assertEquals(CatanPhase.PLAYING, taken.phase)
assertNull(taken.pendingTrade)
assertEquals(hand(Resource.BRICK to 1), taken.resources[0])
assertEquals(hand(Resource.LUMBER to 3, Resource.BRICK to 1), taken.resources[1])
}
@Test
fun `counters can all be turned down`() {
var table = CatanRules.applyMove(openTable(), 0, twoLumberForBrick)
table = CatanRules.applyMove(
table, 1,
CounterTrade(listOf(Resource.BRICK), listOf(Resource.LUMBER, Resource.LUMBER, Resource.LUMBER)),
)
table = CatanRules.applyMove(table, 2, DeclineTrade)
val after = CatanRules.applyMove(table, 0, DismissCounters)
assertEquals(CatanPhase.PLAYING, after.phase)
assertNull(after.pendingTrade)
assertEquals(openTable().resources, after.resources)
}
@Test
fun `a counter must differ from the offer and be affordable`() {
val table = CatanRules.applyMove(openTable(), 0, twoLumberForBrick)
assertFalse(
"the offer as made is an accept, not a counter",
CatanRules.isLegalMove(table, 1, CounterTrade(listOf(Resource.BRICK), listOf(Resource.LUMBER, Resource.LUMBER))),
)
assertFalse(
"cannot promise cards not held",
CatanRules.isLegalMove(table, 1, CounterTrade(listOf(Resource.ORE), listOf(Resource.LUMBER))),
)
assertFalse(
"cannot ask for cards the offerer lacks",
CatanRules.isLegalMove(table, 1, CounterTrade(listOf(Resource.BRICK), listOf(Resource.ORE))),
)
assertFalse(
"only the seat being asked may counter",
CatanRules.isLegalMove(table, 2, CounterTrade(listOf(Resource.WOOL), listOf(Resource.LUMBER))),
)
}
@Test @Test
fun `the bank trades four for one and harbours do better`() { fun `the bank trades four for one and harbours do better`() {
val base = craft() val base = craft()
@@ -912,6 +1019,10 @@ class CatanRulesTest {
OfferTrade(1, listOf(Resource.LUMBER, Resource.LUMBER), listOf(Resource.ORE)), OfferTrade(1, listOf(Resource.LUMBER, Resource.LUMBER), listOf(Resource.ORE)),
AcceptTrade, AcceptTrade,
DeclineTrade, 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), MaritimeTrade(Resource.WOOL, Resource.GRAIN),
EndTurn, EndTurn,
EndFreeRoads, EndFreeRoads,
@@ -6,7 +6,10 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import org.prolibertate.games.game.GameCatalog 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.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.ActivateKnight
import org.prolibertate.games.game.catan.BuildCity import org.prolibertate.games.game.catan.BuildCity
import org.prolibertate.games.game.catan.BuildRoad 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. // Structural: the discard and give dialogs build these and ask the engine.
is DiscardGoods, is GiveGoods -> "goodsDialog" is DiscardGoods, is GiveGoods -> "goodsDialog"
is OfferGoods -> "tradeDialog" is OfferGoods, is CounterGoods -> "tradeDialog"
is AcceptCounter, DismissCounters -> "counterDialog"
else -> null else -> null
} }
+4 -2
View File
@@ -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 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 - **A claim on a metropolis with no city to carry it waits** and is made when a city is
built. built.
- **Domestic trade** may include commodities; the one-offer-at-a-time rules of the base - **Domestic trade** works as in the base game — an offer to one seat or to the whole
game apply. 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. - **No Seafarers, no 5–6 player extension**, no scenario or variant of any kind.
## The computer opponent ## The computer opponent
+23 -12
View File
@@ -135,17 +135,32 @@ is the same code path.
### Domestic ### Domestic
An offer names **one** addressee and two card multisets: what the offerer gives, An offer is addressed to **one** seat or, with `to = null`, to **everyone**, and
what they want. There are no counter-offers and no open auctions in v1. 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 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 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 (`countOf(give) != countOf(get)` — like-for-like is not a trade), and the
offerer actually holds the cards being offered. offerer actually holds the cards being offered.
The addressee may **accept** only if they hold what was asked and the offerer A seat may **accept** only if it holds what was asked and the offerer still holds
still holds what was promised; otherwise only **decline** is offered. A decline what was promised; otherwise only decline (or counter) is offered. A refusal
clears the table and leaves every card where it was. leaves every card where it was.
Offers are free-form intents rather than enumerated moves (the space of card Offers are free-form intents rather than enumerated moves (the space of card
multisets is combinatorial), so they are validated structurally by 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. - **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 - **The robber must move.** Some printings allow staying put; this engine always
requires a different hex. 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 ## 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`. 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 - **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. it. The published ruling lets a lone claimant take what is left; Cities & Knights does.
- **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.
- **No domestic auction or counter-offer.** An offer is addressed to a single - **The AI never counters.** It accepts, declines, and picks among counters it is sent, but does not propose them.
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 trading away a development card.** Not part of the base game. - **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 - **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 setup options exist (see [UI-catan.md](UI-catan.md)); the catalogue entry is a
+3 -2
View File
@@ -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 | | 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** | | 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** | | 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 | | Game over | result dialog |
Trade offers are free-form (two card multisets), so they are validated with 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 it does not say which hexes paid out, so that needs either a diff of two states
or an engine addition. or an engine addition.
3. Art: terrain textures, resource and harbour icons, a real robber pawn. 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). 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. 6. Landscape polish on large tablets; the right-hand panel scrolls but is plain.