Compare commits
2
Commits
12fec14034
...
2d85df0de0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d85df0de0 | ||
|
|
d7d3c8a3ed |
@@ -38,6 +38,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.GAME_OVER -> legal.firstOrNull()
|
CatanPhase.GAME_OVER -> legal.firstOrNull()
|
||||||
@@ -260,17 +262,32 @@ class CatanAi : GameAi<CatanState, CatanMove> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun chooseResponse(state: CatanState, seat: Int, legal: List<CatanMove>): CatanMove {
|
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
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +298,8 @@ data class PlayMonopoly(val card: Int, val resource: Resource) : CatanMove
|
|||||||
data class PlayYearOfPlenty(val card: Int, val take: List<Resource>) : CatanMove
|
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
|
||||||
@@ -287,7 +308,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")
|
||||||
@@ -297,6 +318,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")
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ 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
|
CatanPhase.TRADE_PENDING -> state.pendingTrade?.asking?.takeIf { it >= 0 }
|
||||||
|
CatanPhase.TRADE_COUNTERS -> state.turn
|
||||||
CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER,
|
CatanPhase.AWAIT_ROLL, CatanPhase.ROBBER,
|
||||||
CatanPhase.PLAYING, CatanPhase.FREE_ROADS,
|
CatanPhase.PLAYING, CatanPhase.FREE_ROADS,
|
||||||
-> state.turn
|
-> state.turn
|
||||||
@@ -130,6 +131,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 -> move in legalMoves(state, seat)
|
else -> move in legalMoves(state, seat)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,6 +155,8 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
|
|
||||||
CatanPhase.TRADE_PENDING -> tradeResponses(state, seat)
|
CatanPhase.TRADE_PENDING -> tradeResponses(state, seat)
|
||||||
|
|
||||||
|
CatanPhase.TRADE_COUNTERS -> counterChoices(state)
|
||||||
|
|
||||||
CatanPhase.GAME_OVER -> emptyList()
|
CatanPhase.GAME_OVER -> emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,6 +329,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. */
|
||||||
|
private fun counterChoices(state: CatanState): List<CatanMove> {
|
||||||
|
val offer = state.pendingTrade ?: return emptyList()
|
||||||
|
val moves = mutableListOf<CatanMove>()
|
||||||
|
for (counter in offer.counters) {
|
||||||
|
if (state.holdsAll(counter.by, counter.give) && state.holdsAll(offer.by, counter.get)) {
|
||||||
|
moves += AcceptCounter(counter.by)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moves += DismissCounters
|
||||||
|
return moves
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun counterLegal(state: CatanState, seat: Int, move: CounterTrade): Boolean {
|
||||||
|
val offer = state.pendingTrade ?: return false
|
||||||
|
if (state.phase != CatanPhase.TRADE_PENDING || offer.asking != seat) return false
|
||||||
|
if (move.give.isEmpty() || move.get.isEmpty()) return false
|
||||||
|
if (countOf(move.give) == countOf(move.get)) return false
|
||||||
|
// Taking the offer as made is Accept, not a counter.
|
||||||
|
if (countOf(move.give) == countOf(offer.get) && countOf(move.get) == countOf(offer.give)) return false
|
||||||
|
return state.holdsAll(seat, move.give) && state.holdsAll(offer.by, move.get)
|
||||||
|
}
|
||||||
|
|
||||||
/** An empty vertex with no building on it or one edge away. */
|
/** An empty vertex with no building on it or one edge away. */
|
||||||
private fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
|
private fun vertexOpenForSettlement(state: CatanState, v: Int): Boolean =
|
||||||
state.buildings[v] == null &&
|
state.buildings[v] == null &&
|
||||||
@@ -352,7 +380,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
|
||||||
@@ -384,6 +412,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)
|
||||||
@@ -633,33 +664,111 @@ 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 seat ${move.to}.",
|
"${describe(countOf(move.get))} to $audience.",
|
||||||
)
|
)
|
||||||
|
val offer = TradeOffer(by = seat, to = move.to, give = move.give, get = move.get)
|
||||||
|
val first = nextAsker(offered, offer, seat)
|
||||||
|
?: return offered.copy(log = offered.log + "Nobody has any cards to trade.")
|
||||||
|
return offered.copy(
|
||||||
|
phase = CatanPhase.TRADE_PENDING,
|
||||||
|
pendingTrade = offer.copy(asking = first),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun applyAcceptTrade(state: CatanState, seat: Int): CatanState {
|
/**
|
||||||
val offer = state.pendingTrade!!
|
* The next seat to ask after [after], or null when the round is over. An
|
||||||
var resources = withCards(state.resources, offer.to, offer.get, -1)
|
* addressed offer asks only its addressee; an open one walks the table in
|
||||||
resources = withCards(resources, offer.by, offer.give, -1)
|
* seat order, skipping seats with no cards (they could neither pay nor counter).
|
||||||
resources = withCards(resources, offer.by, offer.get, 1)
|
*/
|
||||||
resources = withCards(resources, offer.to, offer.give, 1)
|
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(
|
return state.copy(
|
||||||
phase = CatanPhase.PLAYING,
|
phase = CatanPhase.PLAYING,
|
||||||
pendingTrade = null,
|
pendingTrade = null,
|
||||||
resources = resources,
|
log = log + "Nobody takes the offer.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seat [a] hands [aGives] to seat [b] and gets [bGives] back. */
|
||||||
|
private fun swapCards(
|
||||||
|
resources: List<Map<Resource, Int>>,
|
||||||
|
a: Int,
|
||||||
|
aGives: List<Resource>,
|
||||||
|
b: Int,
|
||||||
|
bGives: List<Resource>,
|
||||||
|
): List<Map<Resource, Int>> {
|
||||||
|
var r = withCards(resources, a, aGives, -1)
|
||||||
|
r = withCards(r, b, bGives, -1)
|
||||||
|
r = withCards(r, b, aGives, 1)
|
||||||
|
return withCards(r, a, bGives, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyAcceptTrade(state: CatanState, seat: Int): CatanState {
|
||||||
|
val offer = state.pendingTrade!!
|
||||||
|
return state.copy(
|
||||||
|
phase = CatanPhase.PLAYING,
|
||||||
|
pendingTrade = null,
|
||||||
|
resources = swapCards(state.resources, offer.by, offer.give, seat, offer.get),
|
||||||
log = state.log + "Seat $seat accepts — the cards cross the table.",
|
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 =
|
||||||
|
advanceTrade(state, state.pendingTrade!!, seat, "Seat $seat declines the offer.")
|
||||||
|
|
||||||
|
private fun applyCounterTrade(state: CatanState, seat: Int, move: CounterTrade): CatanState {
|
||||||
|
val offer = state.pendingTrade!!
|
||||||
|
val counter = TradeCounter(by = seat, give = move.give, get = move.get)
|
||||||
|
return advanceTrade(
|
||||||
|
state,
|
||||||
|
offer.copy(counters = offer.counters + counter),
|
||||||
|
seat,
|
||||||
|
"Seat $seat counters: ${describe(countOf(move.give))} for ${describe(countOf(move.get))}.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyAcceptCounter(state: CatanState, move: AcceptCounter): CatanState {
|
||||||
|
val offer = state.pendingTrade!!
|
||||||
|
val counter = offer.counters.first { it.by == move.seat }
|
||||||
|
return state.copy(
|
||||||
|
phase = CatanPhase.PLAYING,
|
||||||
|
pendingTrade = null,
|
||||||
|
resources = swapCards(state.resources, offer.by, counter.get, counter.by, counter.give),
|
||||||
|
log = state.log + "Seat ${offer.by} takes seat ${counter.by}'s counter — the cards cross the table.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyDismissCounters(state: CatanState): CatanState =
|
||||||
state.copy(
|
state.copy(
|
||||||
phase = CatanPhase.PLAYING,
|
phase = CatanPhase.PLAYING,
|
||||||
pendingTrade = null,
|
pendingTrade = null,
|
||||||
log = state.log + "Seat $seat declines the offer.",
|
log = state.log + "Seat ${state.turn} turns the counter-offers down.",
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun applyMaritimeTrade(state: CatanState, seat: Int, move: MaritimeTrade): CatanState {
|
private fun applyMaritimeTrade(state: CatanState, seat: Int, move: MaritimeTrade): CatanState {
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ import org.prolibertate.games.game.catan.DevCardType
|
|||||||
import org.prolibertate.games.game.catan.Discard
|
import org.prolibertate.games.game.catan.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.BuildCity
|
import org.prolibertate.games.game.catan.BuildCity
|
||||||
import org.prolibertate.games.game.catan.BuildRoad
|
import org.prolibertate.games.game.catan.BuildRoad
|
||||||
@@ -10,6 +11,7 @@ import org.prolibertate.games.game.catan.CatanMove
|
|||||||
import org.prolibertate.games.game.catan.CatanPhase
|
import org.prolibertate.games.game.catan.CatanPhase
|
||||||
import org.prolibertate.games.game.catan.CatanRules
|
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.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
|
||||||
@@ -20,6 +22,7 @@ import org.prolibertate.games.game.catan.HexCoord
|
|||||||
import org.prolibertate.games.game.catan.MaritimeTrade
|
import org.prolibertate.games.game.catan.MaritimeTrade
|
||||||
import org.prolibertate.games.game.catan.MoveRobber
|
import org.prolibertate.games.game.catan.MoveRobber
|
||||||
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.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.PlayRoadBuilding
|
import org.prolibertate.games.game.catan.PlayRoadBuilding
|
||||||
@@ -284,9 +287,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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import org.prolibertate.games.game.catan.CatanPhase
|
|||||||
import org.prolibertate.games.game.catan.CatanRules
|
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.DeclineTrade
|
import org.prolibertate.games.game.catan.DeclineTrade
|
||||||
|
import org.prolibertate.games.game.catan.DismissCounters
|
||||||
import org.prolibertate.games.game.catan.EndFreeRoads
|
import org.prolibertate.games.game.catan.EndFreeRoads
|
||||||
import org.prolibertate.games.game.catan.EndTurn
|
import org.prolibertate.games.game.catan.EndTurn
|
||||||
import org.prolibertate.games.game.catan.MoveRobber
|
import org.prolibertate.games.game.catan.MoveRobber
|
||||||
@@ -363,13 +364,24 @@ private fun CatanDialogHost(
|
|||||||
onSubmit = onSubmit,
|
onSubmit = onSubmit,
|
||||||
)
|
)
|
||||||
|
|
||||||
state.phase == CatanPhase.TRADE_PENDING && offer != null && offer.to == localSeat ->
|
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 ->
|
||||||
|
|||||||
@@ -103,6 +103,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
|
||||||
@@ -194,11 +198,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…"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +228,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"
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+23
-11
@@ -122,17 +122,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
|
||||||
@@ -292,15 +307,12 @@ timing rules; Longest Road and Largest Army worth five; victory at ten.
|
|||||||
- **Year of Plenty two-of-a-kind needs the bank to hold both cards**, not one.
|
- **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
|
||||||
|
|
||||||
- **No domestic auction or counter-offer.** An offer is addressed to a single
|
- **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.
|
||||||
seat. Open trading to the whole table, and counter-offers, are the obvious next
|
- **The AI never counters.** It accepts, declines, and picks among counters it is sent, but does not propose them.
|
||||||
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
@@ -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
|
||||||
@@ -137,6 +138,6 @@ Not done yet, in rough priority order:
|
|||||||
it does not say which hexes paid out, so that needs either a diff of two states
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user