Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7d3c8a3ed | ||
|
|
12fec14034 | ||
|
|
5e84119670 | ||
|
|
b90058afda | ||
|
|
ef69704e00 | ||
|
|
3b0b307997 |
@@ -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
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ const val LONGEST_ROAD_MIN = 5
|
|||||||
/** Knights required to take Largest Army. */
|
/** Knights required to take Largest Army. */
|
||||||
const val LARGEST_ARMY_MIN = 3
|
const val LARGEST_ARMY_MIN = 3
|
||||||
|
|
||||||
|
/** Pieces each player has: a player who has laid them all cannot build another. */
|
||||||
|
const val MAX_ROADS = 15
|
||||||
|
const val MAX_SETTLEMENTS = 5
|
||||||
|
const val MAX_CITIES = 4
|
||||||
|
|
||||||
/** Resource cards of each type the bank starts with. */
|
/** Resource cards of each type the bank starts with. */
|
||||||
const val BANK_PER_RESOURCE = 19
|
const val BANK_PER_RESOURCE = 19
|
||||||
|
|
||||||
@@ -69,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 {
|
||||||
@@ -104,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,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
|
||||||
@@ -282,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")
|
||||||
@@ -292,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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,11 +277,18 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
return takes
|
return takes
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Road placements that connect to [seat]'s network, cost aside. */
|
/**
|
||||||
|
* Road placements that connect to [seat]'s network, cost aside. A player
|
||||||
|
* with all fifteen roads laid has none.
|
||||||
|
*/
|
||||||
private fun roadMoves(state: CatanState, seat: Int): List<BuildRoad> =
|
private fun roadMoves(state: CatanState, seat: Int): List<BuildRoad> =
|
||||||
(0 until CatanBoard.EDGE_COUNT)
|
if (state.roads.count { it == seat } >= MAX_ROADS) {
|
||||||
.filter { state.roads[it] == NO_PLAYER && roadConnects(state, seat, it) }
|
emptyList()
|
||||||
.map { BuildRoad(it) }
|
} else {
|
||||||
|
(0 until CatanBoard.EDGE_COUNT)
|
||||||
|
.filter { state.roads[it] == NO_PLAYER && roadConnects(state, seat, it) }
|
||||||
|
.map { BuildRoad(it) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settlement placements: an open vertex at least two edges from every
|
* Settlement placements: an open vertex at least two edges from every
|
||||||
@@ -285,16 +297,28 @@ object CatanRules : GameRules<CatanState, CatanMove> {
|
|||||||
* own move.)
|
* own move.)
|
||||||
*/
|
*/
|
||||||
private fun settlementVertices(state: CatanState, seat: Int): List<Int> =
|
private fun settlementVertices(state: CatanState, seat: Int): List<Int> =
|
||||||
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
|
if (piecesOf(state, seat, BuildingType.SETTLEMENT) >= MAX_SETTLEMENTS) {
|
||||||
vertexOpenForSettlement(state, v) &&
|
emptyList()
|
||||||
CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat }
|
} else {
|
||||||
|
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
|
||||||
|
vertexOpenForSettlement(state, v) &&
|
||||||
|
CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cityVertices(state: CatanState, seat: Int): List<Int> =
|
private fun cityVertices(state: CatanState, seat: Int): List<Int> =
|
||||||
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
|
if (piecesOf(state, seat, BuildingType.CITY) >= MAX_CITIES) {
|
||||||
state.buildings[v] == CatanBuilding(BuildingType.SETTLEMENT, seat)
|
emptyList()
|
||||||
|
} else {
|
||||||
|
(0 until CatanBoard.VERTEX_COUNT).filter { v ->
|
||||||
|
state.buildings[v] == CatanBuilding(BuildingType.SETTLEMENT, seat)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How many settlements or cities [seat] currently has standing. */
|
||||||
|
private fun piecesOf(state: CatanState, seat: Int, type: BuildingType): Int =
|
||||||
|
state.buildings.count { it?.owner == seat && it.type == type }
|
||||||
|
|
||||||
private fun tradeResponses(state: CatanState, seat: Int): List<CatanMove> {
|
private fun tradeResponses(state: CatanState, seat: Int): List<CatanMove> {
|
||||||
val offer = state.pendingTrade ?: return emptyList()
|
val offer = state.pendingTrade ?: return emptyList()
|
||||||
val responses = mutableListOf<CatanMove>()
|
val responses = mutableListOf<CatanMove>()
|
||||||
@@ -305,23 +329,58 @@ 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 &&
|
||||||
CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
|
CatanBoard.VERTEX_NEIGHBORS[v].all { state.buildings[it] == null }
|
||||||
|
|
||||||
/** A road on [edge] would join [seat]'s network of roads and buildings. */
|
/**
|
||||||
|
* A road on [edge] would join [seat]'s network of roads and buildings.
|
||||||
|
*
|
||||||
|
* It must start from a crossing the network actually reaches. An opponent's
|
||||||
|
* settlement or city on a crossing breaks the network there: roads on either
|
||||||
|
* side of it do not join, so a road cannot be laid *past* it, even with one
|
||||||
|
* of your own roads ending at the same crossing.
|
||||||
|
*/
|
||||||
private fun roadConnects(state: CatanState, seat: Int, edge: Int): Boolean {
|
private fun roadConnects(state: CatanState, seat: Int, edge: Int): Boolean {
|
||||||
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
|
val (a, b) = CatanBoard.EDGE_VERTICES[edge]
|
||||||
return CatanBoard.VERTEX_EDGES[a].any { state.roads[it] == seat } ||
|
return networkReaches(state, seat, a) || networkReaches(state, seat, b)
|
||||||
CatanBoard.VERTEX_EDGES[b].any { state.roads[it] == seat } ||
|
}
|
||||||
state.buildings[a]?.owner == seat ||
|
|
||||||
state.buildings[b]?.owner == seat
|
/** Whether [seat]'s roads and buildings reach [vertex] so a road may start there. */
|
||||||
|
private fun networkReaches(state: CatanState, seat: Int, vertex: Int): Boolean {
|
||||||
|
val building = state.buildings[vertex]
|
||||||
|
// Your own building is always a way in; anyone else's is a wall.
|
||||||
|
if (building != null) return building.owner == seat
|
||||||
|
return CatanBoard.VERTEX_EDGES[vertex].any { state.roads[it] == seat }
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -353,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)
|
||||||
@@ -602,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 {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.compose.runtime.rememberUpdatedState
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.Path
|
import androidx.compose.ui.graphics.Path
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
@@ -30,6 +31,7 @@ import org.prolibertate.games.game.catan.BuildingType
|
|||||||
import org.prolibertate.games.game.catan.CatanBoard
|
import org.prolibertate.games.game.catan.CatanBoard
|
||||||
import org.prolibertate.games.game.catan.CatanState
|
import org.prolibertate.games.game.catan.CatanState
|
||||||
import org.prolibertate.games.game.catan.NO_PLAYER
|
import org.prolibertate.games.game.catan.NO_PLAYER
|
||||||
|
import org.prolibertate.games.game.catan.Resource
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The island, drawn and tappable.
|
* The island, drawn and tappable.
|
||||||
@@ -54,6 +56,8 @@ fun CatanBoardView(
|
|||||||
onHexTap: (Int) -> Unit,
|
onHexTap: (Int) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
style: CatanStyle = LocalCatanStyle.current,
|
style: CatanStyle = LocalCatanStyle.current,
|
||||||
|
/** Hexes to light up as having produced on the latest roll. */
|
||||||
|
rolledHexes: Set<Int> = emptySet(),
|
||||||
) {
|
) {
|
||||||
val measurer = rememberTextMeasurer()
|
val measurer = rememberTextMeasurer()
|
||||||
var size by remember { mutableStateOf(IntSize.Zero) }
|
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||||
@@ -99,6 +103,7 @@ fun CatanBoardView(
|
|||||||
drawRect(color = style.sea)
|
drawRect(color = style.sea)
|
||||||
drawPorts(state, board, style, measurer)
|
drawPorts(state, board, style, measurer)
|
||||||
drawHexes(state, board, style, measurer)
|
drawHexes(state, board, style, measurer)
|
||||||
|
drawRolled(rolledHexes, board, style)
|
||||||
drawRobber(state, board, style)
|
drawRobber(state, board, style)
|
||||||
drawRoads(state, board, style)
|
drawRoads(state, board, style)
|
||||||
drawEdgeTargets(targets, board, style)
|
drawEdgeTargets(targets, board, style)
|
||||||
@@ -130,6 +135,17 @@ private fun DrawScope.drawHexes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A bright wash and rim on each hex that paid out, drawn over the terrain and under the pieces. */
|
||||||
|
private fun DrawScope.drawRolled(hexes: Set<Int>, board: BoardLayout, style: CatanStyle) {
|
||||||
|
for (hex in hexes) {
|
||||||
|
val centre = board.hexCenter(hex)
|
||||||
|
val path = polygon(board.hexCorners(hex).map { insetToward(it, centre, style.hexInset + 0.04f) })
|
||||||
|
drawPath(path, color = style.rolled.copy(alpha = 0.28f))
|
||||||
|
drawPath(path, color = style.pieceOutline, style = Stroke(width = board.radius * 0.13f))
|
||||||
|
drawPath(path, color = style.rolled, style = Stroke(width = board.radius * 0.075f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun DrawScope.drawToken(
|
private fun DrawScope.drawToken(
|
||||||
number: Int,
|
number: Int,
|
||||||
at: Pt,
|
at: Pt,
|
||||||
@@ -206,7 +222,7 @@ private fun DrawScope.drawPorts(
|
|||||||
val anchor = board.portAnchor(port.edge)
|
val anchor = board.portAnchor(port.edge)
|
||||||
val (a, b) = board.edgeEnds(port.edge)
|
val (a, b) = board.edgeEnds(port.edge)
|
||||||
val tint = port.resource?.let { style.resource.getValue(it) } ?: style.portInk
|
val tint = port.resource?.let { style.resource.getValue(it) } ?: style.portInk
|
||||||
// Two short piers from the marker to the vertices that can use the harbour.
|
// Two piers from the marker to the vertices that can use the harbour.
|
||||||
for (end in listOf(a, b)) {
|
for (end in listOf(a, b)) {
|
||||||
drawLine(
|
drawLine(
|
||||||
color = style.portMarker.copy(alpha = 0.9f),
|
color = style.portMarker.copy(alpha = 0.9f),
|
||||||
@@ -224,10 +240,26 @@ private fun DrawScope.drawPorts(
|
|||||||
center = anchor.offset(),
|
center = anchor.offset(),
|
||||||
style = Stroke(width = board.radius * 0.07f),
|
style = Stroke(width = board.radius * 0.07f),
|
||||||
)
|
)
|
||||||
|
// What the harbour trades: a picture of the resource, or "?" for any.
|
||||||
|
val iconAt = Pt(anchor.x, anchor.y - radius * 0.27f)
|
||||||
|
val iconSize = radius * 0.5f
|
||||||
|
val resource = port.resource
|
||||||
|
if (resource != null) {
|
||||||
|
drawResourceIcon(resource, iconAt, iconSize, tint, style.pieceOutline)
|
||||||
|
} else {
|
||||||
|
drawCentered(
|
||||||
|
measurer = measurer,
|
||||||
|
text = "?",
|
||||||
|
at = iconAt,
|
||||||
|
fontPx = board.radius * style.portText * 1.5f,
|
||||||
|
color = style.portInk,
|
||||||
|
weight = FontWeight.ExtraBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
drawCentered(
|
drawCentered(
|
||||||
measurer = measurer,
|
measurer = measurer,
|
||||||
text = "${port.ratio}:1",
|
text = "${port.ratio}:1",
|
||||||
at = anchor,
|
at = Pt(anchor.x, anchor.y + radius * 0.5f),
|
||||||
fontPx = board.radius * style.portText,
|
fontPx = board.radius * style.portText,
|
||||||
color = style.portInk,
|
color = style.portInk,
|
||||||
weight = FontWeight.Bold,
|
weight = FontWeight.Bold,
|
||||||
@@ -235,6 +267,81 @@ private fun DrawScope.drawPorts(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small pictogram of [resource] centred on [at], about 2·[s] across: a tree,
|
||||||
|
* bricks, a sheep, an ear of wheat or a rock. Drawn with paths so there are no
|
||||||
|
* image assets to ship or scale.
|
||||||
|
*/
|
||||||
|
private fun DrawScope.drawResourceIcon(resource: Resource, at: Pt, s: Float, fill: Color, line: Color) {
|
||||||
|
val outline = Stroke(width = s * 0.12f)
|
||||||
|
when (resource) {
|
||||||
|
Resource.LUMBER -> {
|
||||||
|
val crown = Path().apply {
|
||||||
|
moveTo(at.x, at.y - s)
|
||||||
|
lineTo(at.x + s * 0.8f, at.y + s * 0.3f)
|
||||||
|
lineTo(at.x - s * 0.8f, at.y + s * 0.3f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawRect(color = line, topLeft = Offset(at.x - s * 0.14f, at.y + s * 0.3f), size = Size(s * 0.28f, s * 0.6f))
|
||||||
|
drawPath(crown, color = fill)
|
||||||
|
drawPath(crown, color = line, style = outline)
|
||||||
|
}
|
||||||
|
|
||||||
|
Resource.BRICK -> {
|
||||||
|
val w = s * 0.9f
|
||||||
|
val h = s * 0.42f
|
||||||
|
for ((row, shift) in listOf(0f to 0f, 1f to w * 0.5f)) {
|
||||||
|
val top = at.y - h + row * h
|
||||||
|
val left = at.x - w + shift - w * 0.25f
|
||||||
|
for (col in 0..1) {
|
||||||
|
val x = left + col * w
|
||||||
|
drawRect(color = fill, topLeft = Offset(x, top), size = Size(w, h))
|
||||||
|
drawRect(color = line, topLeft = Offset(x, top), size = Size(w, h), style = outline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Resource.WOOL -> {
|
||||||
|
for ((dx, dy) in listOf(-0.45f to 0f, 0.1f to -0.25f, 0.5f to 0.05f, 0f to 0.2f)) {
|
||||||
|
drawCircle(color = fill, radius = s * 0.42f, center = Offset(at.x + dx * s, at.y + dy * s))
|
||||||
|
}
|
||||||
|
drawCircle(color = line, radius = s * 0.24f, center = Offset(at.x - s * 0.85f, at.y + s * 0.15f))
|
||||||
|
drawRect(color = line, topLeft = Offset(at.x - s * 0.5f, at.y + s * 0.5f), size = Size(s * 0.14f, s * 0.45f))
|
||||||
|
drawRect(color = line, topLeft = Offset(at.x + s * 0.3f, at.y + s * 0.5f), size = Size(s * 0.14f, s * 0.45f))
|
||||||
|
}
|
||||||
|
|
||||||
|
Resource.GRAIN -> {
|
||||||
|
drawLine(color = line, start = Offset(at.x, at.y + s), end = Offset(at.x, at.y - s * 0.7f), strokeWidth = s * 0.14f)
|
||||||
|
for (i in 0..2) {
|
||||||
|
val y = at.y - s * 0.7f + i * s * 0.5f
|
||||||
|
for (side in listOf(-1f, 1f)) {
|
||||||
|
val kernel = Path().apply {
|
||||||
|
moveTo(at.x, y + s * 0.3f)
|
||||||
|
lineTo(at.x + side * s * 0.6f, y - s * 0.05f)
|
||||||
|
lineTo(at.x + side * s * 0.15f, y - s * 0.2f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(kernel, color = fill)
|
||||||
|
drawPath(kernel, color = line, style = Stroke(width = s * 0.08f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Resource.ORE -> {
|
||||||
|
val peak = Path().apply {
|
||||||
|
moveTo(at.x - s, at.y + s * 0.8f)
|
||||||
|
lineTo(at.x - s * 0.3f, at.y - s * 0.6f)
|
||||||
|
lineTo(at.x + s * 0.1f, at.y + s * 0.05f)
|
||||||
|
lineTo(at.x + s * 0.45f, at.y - s * 0.3f)
|
||||||
|
lineTo(at.x + s, at.y + s * 0.8f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(peak, color = fill)
|
||||||
|
drawPath(peak, color = line, style = outline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Pieces
|
// Pieces
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ object CatanGeometry {
|
|||||||
private const val LATTICE_Y_UNITS = 16
|
private const val LATTICE_Y_UNITS = 16
|
||||||
|
|
||||||
/** Empty space round the island, in hex radii, for the sea and the harbours. */
|
/** Empty space round the island, in hex radii, for the sea and the harbours. */
|
||||||
const val MARGIN = 1.15f
|
const val MARGIN = 1.75f
|
||||||
|
|
||||||
private val boardWidthInRadii = LATTICE_X_UNITS * SQRT3 / 2f + 2 * MARGIN
|
private val boardWidthInRadii = LATTICE_X_UNITS * SQRT3 / 2f + 2 * MARGIN
|
||||||
private val boardHeightInRadii = LATTICE_Y_UNITS / 2f + 2 * MARGIN
|
private val boardHeightInRadii = LATTICE_Y_UNITS / 2f + 2 * MARGIN
|
||||||
@@ -119,5 +119,5 @@ object CatanGeometry {
|
|||||||
|
|
||||||
private const val SQRT3 = 1.7320508f
|
private const val SQRT3 = 1.7320508f
|
||||||
|
|
||||||
/** How far past the coast a harbour marker sits, in hex radii. */
|
/** How far past the coast a harbour marker sits, in hex radii. Far enough that its piers are clearly visible. */
|
||||||
private const val PORT_OFFSET = 0.62f
|
internal const val PORT_OFFSET = 1.0f
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -171,6 +174,25 @@ object CatanInteraction {
|
|||||||
|
|
||||||
fun canRoll(legal: List<CatanMove>): Boolean = legal.any { it is RollDice }
|
fun canRoll(legal: List<CatanMove>): Boolean = legal.any { it is RollDice }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hexes that paid out for this turn's roll, so the board can light them.
|
||||||
|
* Empty until the turn's roll has happened (the dice left over from the last
|
||||||
|
* turn are not this turn's), on a seven, and for the hex the robber sits on.
|
||||||
|
*/
|
||||||
|
fun rolledHexes(state: CatanState): Set<Int> {
|
||||||
|
if (state.lastDice.size != 2) return emptySet()
|
||||||
|
if (state.phase == CatanPhase.AWAIT_ROLL || state.phase == CatanPhase.SETUP_FIRST ||
|
||||||
|
state.phase == CatanPhase.SETUP_SECOND
|
||||||
|
) {
|
||||||
|
return emptySet()
|
||||||
|
}
|
||||||
|
val total = state.lastDice.sum()
|
||||||
|
if (total == 7) return emptySet()
|
||||||
|
return (0 until CatanBoard.HEX_COUNT)
|
||||||
|
.filter { state.numbers[it] == total && it != state.robber }
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
fun canEndTurn(legal: List<CatanMove>): Boolean = legal.any { it is EndTurn }
|
fun canEndTurn(legal: List<CatanMove>): Boolean = legal.any { it is EndTurn }
|
||||||
|
|
||||||
fun canEndFreeRoads(legal: List<CatanMove>): Boolean = legal.any { it is EndFreeRoads }
|
fun canEndFreeRoads(legal: List<CatanMove>): Boolean = legal.any { it is EndFreeRoads }
|
||||||
@@ -265,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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.FlowRow
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.defaultMinSize
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
@@ -18,17 +19,25 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.text.TextStyle
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import org.prolibertate.games.game.catan.CatanPhase
|
import org.prolibertate.games.game.catan.CatanPhase
|
||||||
@@ -118,11 +127,18 @@ fun CatanHand(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One player's public standing: colour, name, points, how many cards they hold
|
* One player's public standing: colour, name, points, how many cards they hold
|
||||||
* (not which), development cards, knights and their longest road.
|
* (not which), and — in the roomier layout — development cards, knights and
|
||||||
|
* their longest road.
|
||||||
|
*
|
||||||
|
* **Fixed height.** Every line is always present (blank when there is nothing to
|
||||||
|
* say) and never wraps, so the card is the same height whatever the game is
|
||||||
|
* doing. The board is given whatever room is left, so anything that made this
|
||||||
|
* card taller or shorter would rescale the island under the player's thumb.
|
||||||
*
|
*
|
||||||
* [points] is passed in rather than computed here because the local player's
|
* [points] is passed in rather than computed here because the local player's
|
||||||
* own count includes hidden victory-point cards and everyone else's does not;
|
* own count includes hidden victory-point cards and everyone else's does not;
|
||||||
* the screen decides which.
|
* the screen decides which. [compact] is the narrow portrait strip (four cards
|
||||||
|
* across a phone); the default has room for the detail lines.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CatanPlayerCard(
|
fun CatanPlayerCard(
|
||||||
@@ -132,6 +148,7 @@ fun CatanPlayerCard(
|
|||||||
points: Int,
|
points: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
style: CatanStyle = LocalCatanStyle.current,
|
style: CatanStyle = LocalCatanStyle.current,
|
||||||
|
compact: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val onTurn = state.turn == seat && state.phase != CatanPhase.GAME_OVER
|
val onTurn = state.turn == seat && state.phase != CatanPhase.GAME_OVER
|
||||||
val shape = RoundedCornerShape(12.dp)
|
val shape = RoundedCornerShape(12.dp)
|
||||||
@@ -155,41 +172,56 @@ fun CatanPlayerCard(
|
|||||||
.background(style.seats[seat % style.seats.size])
|
.background(style.seats[seat % style.seats.size])
|
||||||
.border(1.dp, style.pieceOutline, CircleShape),
|
.border(1.dp, style.pieceOutline, CircleShape),
|
||||||
)
|
)
|
||||||
Text(
|
CardLine(seats.label(seat), MaterialTheme.typography.labelLarge, FontWeight.Bold)
|
||||||
text = seats.label(seat),
|
}
|
||||||
fontWeight = FontWeight.Bold,
|
CardLine("$points VP", MaterialTheme.typography.titleMedium, FontWeight.Bold)
|
||||||
maxLines = 1,
|
if (compact) {
|
||||||
style = MaterialTheme.typography.labelLarge,
|
CardLine("${state.resourceCount(seat)} cards", MaterialTheme.typography.bodySmall)
|
||||||
|
} else {
|
||||||
|
CardLine(
|
||||||
|
"${state.resourceCount(seat)} cards · ${state.devHandCounts[seat]} dev",
|
||||||
|
MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
CardLine(
|
||||||
|
"Knights ${state.knightsPlayed[seat]} · Road ${CatanRules.longestRoadFor(state, seat)}",
|
||||||
|
MaterialTheme.typography.bodySmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
|
||||||
text = "$points VP",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "${state.resourceCount(seat)} cards · ${state.devHandCounts[seat]} dev",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Knights ${state.knightsPlayed[seat]} · Road ${CatanRules.longestRoadFor(state, seat)}",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
val awards = buildList {
|
val awards = buildList {
|
||||||
if (state.longestRoadOwner == seat) add("Longest road")
|
if (state.longestRoadOwner == seat) add(if (compact) "Road" else "Longest road")
|
||||||
if (state.largestArmyOwner == seat) add("Largest army")
|
if (state.largestArmyOwner == seat) add(if (compact) "Army" else "Largest army")
|
||||||
}
|
|
||||||
if (awards.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = awards.joinToString(" · "),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
// Always drawn, so the card does not grow when somebody takes an award.
|
||||||
|
CardLine(
|
||||||
|
text = awards.joinToString(" · ").ifEmpty { BLANK },
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
weight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A non-breaking space: a line of text with nothing in it, still one line tall. */
|
||||||
|
private const val BLANK = "\u00A0"
|
||||||
|
|
||||||
|
/** One line of a player card: never wraps, so the card's height cannot change. */
|
||||||
|
@Composable
|
||||||
|
private fun CardLine(
|
||||||
|
text: String,
|
||||||
|
style: TextStyle,
|
||||||
|
weight: FontWeight? = null,
|
||||||
|
color: Color = Color.Unspecified,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = style,
|
||||||
|
fontWeight = weight,
|
||||||
|
color = color,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Dice
|
// Dice
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -221,13 +253,12 @@ fun CatanDice(dice: List<Int>, modifier: Modifier = Modifier) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (dice.size == 2) {
|
// Always drawn ("= –" before the first roll) so the row never changes width.
|
||||||
Text(
|
Text(
|
||||||
text = "= ${dice.sum()}",
|
text = if (dice.size == 2) "= ${dice.sum()}" else "= –",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +269,6 @@ fun CatanDice(dice: List<Int>, modifier: Modifier = Modifier) {
|
|||||||
/** What the action bar offers right now. Built by the screen from [CatanInteraction]. */
|
/** What the action bar offers right now. Built by the screen from [CatanInteraction]. */
|
||||||
data class CatanActions(
|
data class CatanActions(
|
||||||
val canRoll: Boolean = false,
|
val canRoll: Boolean = false,
|
||||||
val showTurnActions: Boolean = false,
|
|
||||||
val canBuild: Set<BuildKind> = emptySet(),
|
val canBuild: Set<BuildKind> = emptySet(),
|
||||||
val building: BuildKind? = null,
|
val building: BuildKind? = null,
|
||||||
val canBuyCard: Boolean = false,
|
val canBuyCard: Boolean = false,
|
||||||
@@ -250,7 +280,16 @@ data class CatanActions(
|
|||||||
val canRepickSetup: Boolean = false,
|
val canRepickSetup: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** The buttons for whatever the player can do now. Pure display: every press is a lambda. */
|
/**
|
||||||
|
* The buttons for whatever the player can do now. Pure display: every press is a lambda.
|
||||||
|
*
|
||||||
|
* **The bar never changes size.** The top row holds the one "primary" action
|
||||||
|
* (roll, end turn, done with roads, cancel) and keeps its height when it is
|
||||||
|
* empty; the second row is always all six buttons, greyed out when they do not
|
||||||
|
* apply. Buttons appearing and disappearing used to resize this area and, with
|
||||||
|
* it, the board above — so nothing here may be conditionally *present*, only
|
||||||
|
* conditionally *enabled*.
|
||||||
|
*/
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun CatanActionBar(
|
fun CatanActionBar(
|
||||||
@@ -264,34 +303,49 @@ fun CatanActionBar(
|
|||||||
onEndFreeRoads: () -> Unit,
|
onEndFreeRoads: () -> Unit,
|
||||||
onRepickSetup: () -> Unit,
|
onRepickSetup: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
/** Small screens: fold Road / Settlement / City into one "Build" button with a menu. */
|
||||||
|
compact: Boolean = false,
|
||||||
) {
|
) {
|
||||||
FlowRow(
|
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
modifier = modifier.fillMaxWidth(),
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
modifier = Modifier.fillMaxWidth().height(PRIMARY_ROW_HEIGHT),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
if (actions.canRoll) Button(onClick = onRoll) { Text(CatanText.ROLL) }
|
) {
|
||||||
|
if (actions.canRoll) Button(onClick = onRoll) { Text(CatanText.ROLL) }
|
||||||
if (actions.canRepickSetup) {
|
if (actions.canRepickSetup) OutlinedButton(onClick = onRepickSetup) { Text(CatanText.CANCEL) }
|
||||||
OutlinedButton(onClick = onRepickSetup) { Text(CatanText.CANCEL) }
|
if (actions.canEndFreeRoads) Button(onClick = onEndFreeRoads) { Text(CatanText.STOP_ROADS) }
|
||||||
|
if (actions.canEndTurn) Button(onClick = onEndTurn) { Text(CatanText.END_TURN) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actions.showTurnActions) {
|
FlowRow(
|
||||||
BuildButton(BuildKind.ROAD, CatanText.ROAD, CatanText.ROAD_COST, actions, onBuild)
|
modifier = Modifier.fillMaxWidth(),
|
||||||
BuildButton(BuildKind.SETTLEMENT, CatanText.SETTLEMENT, CatanText.SETTLEMENT_COST, actions, onBuild)
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
BuildButton(BuildKind.CITY, CatanText.CITY, CatanText.CITY_COST, actions, onBuild)
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
if (compact) {
|
||||||
|
BuildMenuButton(actions, onBuild)
|
||||||
|
} else {
|
||||||
|
BuildButton(BuildKind.ROAD, CatanText.ROAD, CatanText.ROAD_COST, actions, onBuild)
|
||||||
|
BuildButton(BuildKind.SETTLEMENT, CatanText.SETTLEMENT, CatanText.SETTLEMENT_COST, actions, onBuild)
|
||||||
|
BuildButton(BuildKind.CITY, CatanText.CITY, CatanText.CITY_COST, actions, onBuild)
|
||||||
|
}
|
||||||
OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) {
|
OutlinedButton(onClick = onBuyCard, enabled = actions.canBuyCard) {
|
||||||
LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST)
|
LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST)
|
||||||
}
|
}
|
||||||
OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) { Text(CatanText.PLAY_CARD) }
|
OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) {
|
||||||
OutlinedButton(onClick = onTrade, enabled = actions.canTrade) { Text(CatanText.TRADE) }
|
LabelWithCost(CatanText.PLAY_CARD, CatanText.NO_COST)
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = onTrade, enabled = actions.canTrade) {
|
||||||
|
LabelWithCost(CatanText.TRADE, CatanText.NO_COST)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actions.canEndFreeRoads) Button(onClick = onEndFreeRoads) { Text(CatanText.STOP_ROADS) }
|
|
||||||
if (actions.canEndTurn) Button(onClick = onEndTurn) { Text(CatanText.END_TURN) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Room for one button, held whether or not there is one to show. */
|
||||||
|
private val PRIMARY_ROW_HEIGHT = 48.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BuildButton(
|
private fun BuildButton(
|
||||||
kind: BuildKind,
|
kind: BuildKind,
|
||||||
@@ -310,6 +364,52 @@ private fun BuildButton(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The small-screen stand-in for the three build buttons: one "Build" button
|
||||||
|
* whose menu lists Road, Settlement and City with their costs. While a build
|
||||||
|
* mode is armed the button shows which, and choosing that entry again cancels it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BuildMenuButton(actions: CatanActions, onBuild: (BuildKind?) -> Unit) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
val armed = actions.building
|
||||||
|
val anyEnabled = actions.canBuild.isNotEmpty() || armed != null
|
||||||
|
val label: @Composable () -> Unit = {
|
||||||
|
LabelWithCost(CatanText.BUILD, armed?.let { buildLabel(it) } ?: CatanText.NO_COST)
|
||||||
|
}
|
||||||
|
Box {
|
||||||
|
if (armed != null) {
|
||||||
|
FilledTonalButton(onClick = { open = true }, enabled = anyEnabled) { label() }
|
||||||
|
} else {
|
||||||
|
OutlinedButton(onClick = { open = true }, enabled = anyEnabled) { label() }
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||||
|
for (kind in BuildKind.entries) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { LabelWithCost(buildLabel(kind), buildCost(kind)) },
|
||||||
|
enabled = kind in actions.canBuild,
|
||||||
|
onClick = {
|
||||||
|
open = false
|
||||||
|
onBuild(if (armed == kind) null else kind)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildLabel(kind: BuildKind): String = when (kind) {
|
||||||
|
BuildKind.ROAD -> CatanText.ROAD
|
||||||
|
BuildKind.SETTLEMENT -> CatanText.SETTLEMENT
|
||||||
|
BuildKind.CITY -> CatanText.CITY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildCost(kind: BuildKind): String = when (kind) {
|
||||||
|
BuildKind.ROAD -> CatanText.ROAD_COST
|
||||||
|
BuildKind.SETTLEMENT -> CatanText.SETTLEMENT_COST
|
||||||
|
BuildKind.CITY -> CatanText.CITY_COST
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LabelWithCost(label: String, cost: String) {
|
private fun LabelWithCost(label: String, cost: String) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
|||||||
@@ -112,14 +112,13 @@ private fun PanelsPreview() {
|
|||||||
Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
(0 until state.playerCount).forEach { seat ->
|
(0 until state.playerCount).forEach { seat ->
|
||||||
CatanPlayerCard(state, seat, seats, points[seat], Modifier.weight(1f))
|
CatanPlayerCard(state, seat, seats, points[seat], Modifier.weight(1f), compact = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CatanHand(state, seat = 0)
|
CatanHand(state, seat = 0)
|
||||||
CatanDice(state.lastDice)
|
CatanDice(state.lastDice)
|
||||||
CatanActionBar(
|
CatanActionBar(
|
||||||
actions = CatanActions(
|
actions = CatanActions(
|
||||||
showTurnActions = true,
|
|
||||||
canBuild = setOf(BuildKind.ROAD, BuildKind.CITY),
|
canBuild = setOf(BuildKind.ROAD, BuildKind.CITY),
|
||||||
building = BuildKind.ROAD,
|
building = BuildKind.ROAD,
|
||||||
canBuyCard = true,
|
canBuyCard = true,
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.prolibertate.games.game.catan.AcceptTrade
|
import org.prolibertate.games.game.catan.AcceptTrade
|
||||||
import org.prolibertate.games.game.catan.BuyDevCard
|
import org.prolibertate.games.game.catan.BuyDevCard
|
||||||
@@ -32,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
|
||||||
@@ -130,7 +133,6 @@ fun CatanScreen(
|
|||||||
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
|
val playing = current.phase == CatanPhase.PLAYING && legal.isNotEmpty()
|
||||||
val actions = CatanActions(
|
val actions = CatanActions(
|
||||||
canRoll = CatanInteraction.canRoll(legal),
|
canRoll = CatanInteraction.canRoll(legal),
|
||||||
showTurnActions = playing,
|
|
||||||
canBuild = CatanInteraction.canBuild(legal),
|
canBuild = CatanInteraction.canBuild(legal),
|
||||||
building = building,
|
building = building,
|
||||||
canBuyCard = CatanInteraction.canBuyDevCard(legal),
|
canBuyCard = CatanInteraction.canBuyDevCard(legal),
|
||||||
@@ -140,6 +142,7 @@ fun CatanScreen(
|
|||||||
canEndFreeRoads = CatanInteraction.canEndFreeRoads(legal),
|
canEndFreeRoads = CatanInteraction.canEndFreeRoads(legal),
|
||||||
canRepickSetup = setupVertex != null,
|
canRepickSetup = setupVertex != null,
|
||||||
)
|
)
|
||||||
|
val rolledHexes = CatanInteraction.rolledHexes(current)
|
||||||
val prompt = CatanText.prompt(current, seats, setupVertexChosen = setupVertex != null, building = building)
|
val prompt = CatanText.prompt(current, seats, setupVertexChosen = setupVertex != null, building = building)
|
||||||
|
|
||||||
val handle: (TapOutcome) -> Unit = { outcome ->
|
val handle: (TapOutcome) -> Unit = { outcome ->
|
||||||
@@ -160,6 +163,8 @@ fun CatanScreen(
|
|||||||
ScreenScaffold(title = CatanText.TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
ScreenScaffold(title = CatanText.TITLE, onBack = onExit, actions = endGameAction) { modifier ->
|
||||||
BoxWithConstraints(modifier = modifier) {
|
BoxWithConstraints(modifier = modifier) {
|
||||||
val wide = maxWidth > maxHeight * 1.1f
|
val wide = maxWidth > maxHeight * 1.1f
|
||||||
|
// Phones (either way up) get the one-button build menu.
|
||||||
|
val compactBuild = !wide || maxWidth < 600.dp
|
||||||
|
|
||||||
val board: @Composable (Modifier) -> Unit = { boardModifier ->
|
val board: @Composable (Modifier) -> Unit = { boardModifier ->
|
||||||
CatanBoardView(
|
CatanBoardView(
|
||||||
@@ -171,6 +176,7 @@ fun CatanScreen(
|
|||||||
onHexTap = { handle(CatanInteraction.onHexTap(it, legal)) },
|
onHexTap = { handle(CatanInteraction.onHexTap(it, legal)) },
|
||||||
modifier = boardModifier,
|
modifier = boardModifier,
|
||||||
style = style,
|
style = style,
|
||||||
|
rolledHexes = rolledHexes,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val players: @Composable (Boolean) -> Unit = { grid ->
|
val players: @Composable (Boolean) -> Unit = { grid ->
|
||||||
@@ -194,6 +200,7 @@ fun CatanScreen(
|
|||||||
legal.firstOrNull { m -> m is EndFreeRoads }?.let(submit)
|
legal.firstOrNull { m -> m is EndFreeRoads }?.let(submit)
|
||||||
},
|
},
|
||||||
onRepickSetup = { setupVertex = null },
|
onRepickSetup = { setupVertex = null },
|
||||||
|
compact = compactBuild,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,6 +282,7 @@ private fun PlayerCards(
|
|||||||
points = pointsOf(seat),
|
points = pointsOf(seat),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
style = style,
|
style = style,
|
||||||
|
compact = !twoPerRow,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Keep an odd last row's card the same width as the others.
|
// Keep an odd last row's card the same width as the others.
|
||||||
@@ -284,6 +292,14 @@ private fun PlayerCards(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one line telling the player what is going on, and the dice.
|
||||||
|
*
|
||||||
|
* **Fixed height.** The text always occupies exactly two lines (padded when it is
|
||||||
|
* shorter, clipped when it is longer) and a refused move replaces the prompt
|
||||||
|
* rather than adding to it, so this row is the same height whatever is said and
|
||||||
|
* the board below it never moves.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun PromptRow(prompt: String, notice: String?, state: CatanState) {
|
private fun PromptRow(prompt: String, notice: String?, state: CatanState) {
|
||||||
Row(
|
Row(
|
||||||
@@ -291,16 +307,15 @@ private fun PromptRow(prompt: String, notice: String?, state: CatanState) {
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Text(
|
||||||
Text(prompt, style = MaterialTheme.typography.bodyMedium)
|
text = notice ?: prompt,
|
||||||
if (notice != null) {
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
Text(
|
color = if (notice != null) MaterialTheme.colorScheme.error else Color.Unspecified,
|
||||||
notice,
|
minLines = 2,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
maxLines = 2,
|
||||||
color = MaterialTheme.colorScheme.error,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
modifier = Modifier.weight(1f),
|
||||||
}
|
)
|
||||||
}
|
|
||||||
CatanDice(state.lastDice)
|
CatanDice(state.lastDice)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,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 ->
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ data class CatanStyle(
|
|||||||
val tokenHotInk: Color,
|
val tokenHotInk: Color,
|
||||||
/** The robber's pawn. */
|
/** The robber's pawn. */
|
||||||
val robber: Color,
|
val robber: Color,
|
||||||
|
/** Outline and wash on hexes that produced for the latest dice roll. */
|
||||||
|
val rolled: Color,
|
||||||
/** Harbour marker disc and its ink. */
|
/** Harbour marker disc and its ink. */
|
||||||
val portMarker: Color,
|
val portMarker: Color,
|
||||||
val portInk: Color,
|
val portInk: Color,
|
||||||
@@ -64,12 +66,12 @@ data class CatanStyle(
|
|||||||
val citySize: Float = 0.42f,
|
val citySize: Float = 0.42f,
|
||||||
val tokenRadius: Float = 0.36f,
|
val tokenRadius: Float = 0.36f,
|
||||||
val robberSize: Float = 0.34f,
|
val robberSize: Float = 0.34f,
|
||||||
val portRadius: Float = 0.34f,
|
val portRadius: Float = 0.46f,
|
||||||
val targetRadius: Float = 0.20f,
|
val targetRadius: Float = 0.20f,
|
||||||
/** How close a tap must land to a vertex / edge / hex centre to count. */
|
/** How close a tap must land to a vertex / edge / hex centre to count. */
|
||||||
val tapReach: Float = 0.55f,
|
val tapReach: Float = 0.55f,
|
||||||
val tokenText: Float = 0.34f,
|
val tokenText: Float = 0.34f,
|
||||||
val portText: Float = 0.26f,
|
val portText: Float = 0.27f,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** The light palette, which is also the reference for the dark one. */
|
/** The light palette, which is also the reference for the dark one. */
|
||||||
@@ -88,6 +90,7 @@ private val LightStyle = CatanStyle(
|
|||||||
tokenInk = Color(0xFF2B2B2B),
|
tokenInk = Color(0xFF2B2B2B),
|
||||||
tokenHotInk = Color(0xFFC62828),
|
tokenHotInk = Color(0xFFC62828),
|
||||||
robber = Color(0xFF263238),
|
robber = Color(0xFF263238),
|
||||||
|
rolled = Color(0xFFFFFFFF),
|
||||||
portMarker = Color(0xFFFFF8E1),
|
portMarker = Color(0xFFFFF8E1),
|
||||||
portInk = Color(0xFF2B2B2B),
|
portInk = Color(0xFF2B2B2B),
|
||||||
resource = mapOf(
|
resource = mapOf(
|
||||||
|
|||||||
@@ -103,6 +103,16 @@ 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
|
||||||
|
* action bar is the same height. A non-breaking space: blank, but a line.
|
||||||
|
*/
|
||||||
|
const val NO_COST = "\u00A0"
|
||||||
const val YOUR_HAND = "Your hand"
|
const val YOUR_HAND = "Your hand"
|
||||||
const val CARDS = "cards"
|
const val CARDS = "cards"
|
||||||
const val ACCEPT = "Accept"
|
const val ACCEPT = "Accept"
|
||||||
@@ -188,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…"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,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)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -340,6 +340,89 @@ class CatanRulesTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Seat 0 owns a settlement at vertex 0 and a two-road chain v0 - w - x; `beyond` continues past x. */
|
||||||
|
private data class Chain(val state: CatanState, val x: Int, val beyond: Int, val spur: Int?)
|
||||||
|
|
||||||
|
private fun chain(): Chain {
|
||||||
|
val e1 = CatanBoard.VERTEX_EDGES[0][0]
|
||||||
|
val w = CatanBoard.otherEnd(e1, 0)
|
||||||
|
val e2 = CatanBoard.VERTEX_EDGES[w].first { it != e1 }
|
||||||
|
val x = CatanBoard.otherEnd(e2, w)
|
||||||
|
val beyond = CatanBoard.VERTEX_EDGES[x].first { it != e2 }
|
||||||
|
val spur = CatanBoard.VERTEX_EDGES[w].firstOrNull { it != e1 && it != e2 }
|
||||||
|
val state = craft()
|
||||||
|
.copy(resources = hands(hand(Resource.LUMBER to 5, Resource.BRICK to 5)))
|
||||||
|
.withBuilding(0, BuildingType.SETTLEMENT, 0)
|
||||||
|
.withRoad(e1, 0)
|
||||||
|
.withRoad(e2, 0)
|
||||||
|
return Chain(state, x, beyond, spur)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a road cannot be built past an opponent's settlement`() {
|
||||||
|
val (open, x, beyond, spur) = chain()
|
||||||
|
assertTrue("open crossing: the road continues", BuildRoad(beyond) in CatanRules.legalMoves(open, 0))
|
||||||
|
|
||||||
|
val blocked = open.withBuilding(x, BuildingType.SETTLEMENT, 1)
|
||||||
|
assertFalse(
|
||||||
|
"an opponent's settlement on the crossing breaks the network",
|
||||||
|
BuildRoad(beyond) in CatanRules.legalMoves(blocked, 0),
|
||||||
|
)
|
||||||
|
// The same through a city, and while laying free roads from Road Building.
|
||||||
|
val city = open.withBuilding(x, BuildingType.CITY, 1)
|
||||||
|
assertFalse(BuildRoad(beyond) in CatanRules.legalMoves(city, 0))
|
||||||
|
val free = blocked.copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2)
|
||||||
|
assertFalse(BuildRoad(beyond) in CatanRules.legalMoves(free, 0))
|
||||||
|
// The rest of the network is untouched: the spur at the other crossing still works.
|
||||||
|
if (spur != null) assertTrue(BuildRoad(spur) in CatanRules.legalMoves(blocked, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `your own settlement on the crossing does not block your road`() {
|
||||||
|
val (open, x, beyond, _) = chain()
|
||||||
|
val own = open.withBuilding(x, BuildingType.SETTLEMENT, 0)
|
||||||
|
assertTrue(BuildRoad(beyond) in CatanRules.legalMoves(own, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a player runs out of pieces`() {
|
||||||
|
val rich = hands(
|
||||||
|
hand(
|
||||||
|
Resource.LUMBER to 9, Resource.BRICK to 9, Resource.WOOL to 9,
|
||||||
|
Resource.GRAIN to 9, Resource.ORE to 9,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Roads: fifteen, and no sixteenth - in the main phase or from Road Building.
|
||||||
|
var roads = craft().copy(resources = rich).withBuilding(0, BuildingType.SETTLEMENT, 0)
|
||||||
|
repeat(14) {
|
||||||
|
roads = roads.withRoad(CatanRules.legalMoves(roads, 0).filterIsInstance<BuildRoad>().first().edge, 0)
|
||||||
|
}
|
||||||
|
assertTrue("fourteen laid: one more is allowed", CatanRules.legalMoves(roads, 0).any { it is BuildRoad })
|
||||||
|
roads = roads.withRoad(CatanRules.legalMoves(roads, 0).filterIsInstance<BuildRoad>().first().edge, 0)
|
||||||
|
assertEquals(15, roads.roads.count { it == 0 })
|
||||||
|
assertTrue("fifteen laid: none left", CatanRules.legalMoves(roads, 0).none { it is BuildRoad })
|
||||||
|
val free = roads.copy(phase = CatanPhase.FREE_ROADS, pendingFreeRoads = 2)
|
||||||
|
assertEquals(listOf<CatanMove>(EndFreeRoads), CatanRules.legalMoves(free, 0))
|
||||||
|
|
||||||
|
// Settlements: five.
|
||||||
|
val homes = scattered(7)
|
||||||
|
var settled = craft().copy(resources = rich)
|
||||||
|
for (i in 0 until 4) settled = settled.withBuilding(homes[i], BuildingType.SETTLEMENT, 0)
|
||||||
|
settled = settled.withRoad(CatanBoard.VERTEX_EDGES[homes[5]][0], 0)
|
||||||
|
assertTrue("four standing: a fifth is allowed", BuildSettlement(homes[5]) in CatanRules.legalMoves(settled, 0))
|
||||||
|
settled = settled.withBuilding(homes[4], BuildingType.SETTLEMENT, 0)
|
||||||
|
assertFalse("five standing: no sixth", BuildSettlement(homes[5]) in CatanRules.legalMoves(settled, 0))
|
||||||
|
|
||||||
|
// Cities: four. A raised settlement returns to the supply, so five settlements stays possible.
|
||||||
|
var raised = craft().copy(resources = rich)
|
||||||
|
for (i in 0 until 3) raised = raised.withBuilding(homes[i], BuildingType.CITY, 0)
|
||||||
|
raised = raised.withBuilding(homes[3], BuildingType.SETTLEMENT, 0)
|
||||||
|
assertTrue("three cities: a fourth is allowed", BuildCity(homes[3]) in CatanRules.legalMoves(raised, 0))
|
||||||
|
raised = raised.withBuilding(homes[3], BuildingType.CITY, 0).withBuilding(homes[4], BuildingType.SETTLEMENT, 0)
|
||||||
|
assertFalse("four cities: no fifth", BuildCity(homes[4]) in CatanRules.legalMoves(raised, 0))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a settlement needs an open vertex on the network, a city one of yours`() {
|
fun `a settlement needs an open vertex on the network, a city one of yours`() {
|
||||||
val base = craft()
|
val base = craft()
|
||||||
@@ -437,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()
|
||||||
@@ -829,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,
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ class CatanGeometryTest {
|
|||||||
val anchor = layout.portAnchor(e)
|
val anchor = layout.portAnchor(e)
|
||||||
val mid = layout.edgeMidpoint(e)
|
val mid = layout.edgeMidpoint(e)
|
||||||
assertTrue("harbour on edge $e should be past the coast", dist(anchor, centre) > dist(mid, centre))
|
assertTrue("harbour on edge $e should be past the coast", dist(anchor, centre) > dist(mid, centre))
|
||||||
assertTrue("and not floating far from it", abs(dist(anchor, mid) - layout.radius * 0.62f) < 1f)
|
assertTrue("and not floating far from it", abs(dist(anchor, mid) - layout.radius * PORT_OFFSET) < 1f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -322,4 +322,27 @@ class CatanInteractionTest {
|
|||||||
val short = pending.withHand(1, hand())
|
val short = pending.withHand(1, hand())
|
||||||
assertEquals(false to true, CatanInteraction.tradeResponses(CatanRules.legalMoves(short, 1)))
|
assertEquals(false to true, CatanInteraction.tradeResponses(CatanRules.legalMoves(short, 1)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Rolled-hex highlight --------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rolled hexes are those carrying the number, minus the robber's`() {
|
||||||
|
val base = playing(turn = 0)
|
||||||
|
val number = base.numbers.first { it != 0 && it != 7 }
|
||||||
|
val state = base.copy(lastDice = if (number <= 7) listOf(number - 1, 1) else listOf(6, number - 6))
|
||||||
|
val all = (0 until CatanBoard.HEX_COUNT).filter { state.numbers[it] == number }
|
||||||
|
assertTrue(all.isNotEmpty())
|
||||||
|
assertEquals(all.filter { it != state.robber }.toSet(), CatanInteraction.rolledHexes(state))
|
||||||
|
val blocked = state.copy(robber = all.first())
|
||||||
|
assertFalse(all.first() in CatanInteraction.rolledHexes(blocked))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nothing is highlighted before this turn's roll, on a seven, or in setup`() {
|
||||||
|
val rolled = playing(turn = 0).copy(lastDice = listOf(3, 3))
|
||||||
|
assertTrue(CatanInteraction.rolledHexes(rolled.copy(phase = CatanPhase.AWAIT_ROLL)).isEmpty())
|
||||||
|
assertTrue(CatanInteraction.rolledHexes(rolled.copy(lastDice = listOf(3, 4))).isEmpty())
|
||||||
|
assertTrue(CatanInteraction.rolledHexes(rolled.copy(phase = CatanPhase.SETUP_FIRST)).isEmpty())
|
||||||
|
assertTrue(CatanInteraction.rolledHexes(rolled.copy(lastDice = emptyList())).isEmpty())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-12
@@ -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
|
||||||
@@ -159,7 +174,19 @@ its edge.
|
|||||||
A settlement goes on an **empty vertex** at least two edges from every standing
|
A settlement goes on an **empty vertex** at least two edges from every standing
|
||||||
building and connected to the seat's own road network. A city upgrades one of
|
building and connected to the seat's own road network. A city upgrades one of
|
||||||
the seat's own settlements. A road goes on an empty edge that connects to the
|
the seat's own settlements. A road goes on an empty edge that connects to the
|
||||||
seat's network (a road or a building at one of its ends).
|
seat's network: it must start from a crossing where the seat has a building, or
|
||||||
|
where one of its roads ends.
|
||||||
|
|
||||||
|
**An opponent's settlement or city on a crossing breaks the network there.** A
|
||||||
|
road cannot be laid past it, even from a crossing where one of your own roads
|
||||||
|
ends — only a crossing you own a building on is a way through. The same rule
|
||||||
|
applies to the free roads of a Road Building card, and it is the same wall that
|
||||||
|
cuts Longest Road (below).
|
||||||
|
|
||||||
|
**Pieces are limited**: each player has **15 roads, 5 settlements and 4 cities**.
|
||||||
|
A player who has laid them all cannot build another of that kind. Raising a
|
||||||
|
settlement into a city returns the settlement to the supply, so the five
|
||||||
|
settlements remain available to be laid again.
|
||||||
|
|
||||||
The development deck is the published 25 cards: **14 Knights, 2 Road Building,
|
The development deck is the published 25 cards: **14 Knights, 2 Road Building,
|
||||||
2 Monopoly, 2 Year of Plenty, 5 Victory Point**. Cards are bought blind from the
|
2 Monopoly, 2 Year of Plenty, 5 Victory Point**. Cards are bought blind from the
|
||||||
@@ -280,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
|
||||||
|
|||||||
+11
-2
@@ -71,6 +71,14 @@ screen's UI state. The legal-move list stays the gate.
|
|||||||
size of the deck; it is not known to a client. Card *counts* (`resourceCount`,
|
size of the deck; it is not known to a client. Card *counts* (`resourceCount`,
|
||||||
`devHandCounts`) are public.
|
`devHandCounts`) are public.
|
||||||
5. **The state is immutable.** Never mutate it; submit a move.
|
5. **The state is immutable.** Never mutate it; submit a move.
|
||||||
|
6. **The layout must not move.** Everything outside the board has a *fixed
|
||||||
|
height*, and the board takes whatever is left, so the island's scale is the
|
||||||
|
same all game. Concretely: a control is *enabled or disabled*, never
|
||||||
|
*present or absent* (the action bar always shows every button); text that can
|
||||||
|
vary in length is pinned to a fixed number of lines (`minLines = maxLines`);
|
||||||
|
a line that is sometimes empty is still drawn (a non-breaking space), as the
|
||||||
|
award line on a player card is. A change that makes any non-board element
|
||||||
|
taller or shorter at runtime will rescale the board under the player's thumb.
|
||||||
|
|
||||||
## Interaction model (what the player actually does)
|
## Interaction model (what the player actually does)
|
||||||
|
|
||||||
@@ -81,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
|
||||||
@@ -129,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