Author SHA1 Message Date
Claude d7d3c8a3ed Katan: open trade offers and counter-offers
CI / Build and check (pull_request) Successful in 7m28s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
- Offers can go to the whole table (asked in seat order, empty hands skipped) or one seat.
- The asked seat may accept (trade happens at once), decline, or counter with new terms.
- If nobody accepts and there are counters, the offerer takes one or turns them all down (new TRADE_COUNTERS phase).
- AI: fixes an inverted accept test (it weighed what it gives against what it gets backwards), picks the best counter.
- UI: Everyone option in the offer dialog, Counter… in the reply dialog, a counter-choice dialog.
- Tests and docs updated.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018njn2eMPAYqAoE49hGRg2X
2026-10-15 20:24:45 +00:00
devadmin 12fec14034 Merge pull request 'Katan: rolled-hex highlight, compact Build menu, clearer harbours' (#5) from feature/catan-roll-highlight-build-menu-ports into dev
CI / Build and check (push) Successful in 7m31s
CI / Deploy to Cloudflare Pages (push) Successful in 31s
2026-10-15 12:19:08 -06:00
Claude 5e84119670 Katan: mark build-menu label lambda @Composable
CI / Build and check (pull_request) Successful in 7m29s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018njn2eMPAYqAoE49hGRg2X
2026-10-15 18:11:16 +00:00
Claude b90058afda Katan: highlight rolled hexes, one-button Build menu on small screens, clearer harbours
CI / Build and check (pull_request) Failing after 2m39s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
- Hexes that paid out for the turn's roll are lit (not on a 7, not the robber's hex, not before the roll).
- Compact layouts fold Road/Settlement/City into one Build button with a menu.
- Harbour markers are drawn with a resource pictogram (? for 3:1), larger, and sit a full hex radius off the coast so the piers show which vertices they serve.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018njn2eMPAYqAoE49hGRg2X
2026-10-15 17:40:48 +00:00
devadminandClaude Sonnet 5.5 ef69704e00 Merge pull request 'Katan: no roads past an opponent's settlement, piece limits, stable layout' (#4) from fix/catan-road-rule-and-ui-stability into dev
CI / Build and check (push) Successful in 7m35s
CI / Deploy to Cloudflare Pages (push) Successful in 35s
Katan: no roads past an opponent's settlement, piece limits, stable layout

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-15 08:03:25 -06:00
Claude 3b0b307997 Katan: no roads past an opponent's settlement, piece limits, stable layout
CI / Build and check (pull_request) Successful in 7m22s
CI / Deploy to Cloudflare Pages (pull_request) Skipped
Rules
- A road may only start from a crossing where the player has a building, or
  where one of their roads ends AND no opponent's settlement or city stands.
  roadConnects ignored opponents' buildings, so a road (the computer's included)
  could be laid straight past a settlement. Also applies to Road Building.
- Enforce the piece supply: 15 roads, 5 settlements, 4 cities. It was
  unlimited.
- Tests: building past a settlement and a city, free roads, own settlement not
  blocking, and running out of each piece.

UI
- The layout rescaled the board whenever it became the player's turn, because
  the action buttons only existed then. Everything outside the board now has a
  fixed height: the action bar always shows every button (disabled, not absent),
  the prompt is pinned to two lines, player cards always draw every line, and
  the dice total is always present. Rule recorded in docs/UI-catan.md.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdMNbY5hLR7FjtfEE712xG
2026-10-15 13:55:03 +00:00
18 changed files with 1035 additions and 154 deletions
@@ -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> =
if (state.roads.count { it == seat } >= MAX_ROADS) {
emptyList()
} else {
(0 until CatanBoard.EDGE_COUNT) (0 until CatanBoard.EDGE_COUNT)
.filter { state.roads[it] == NO_PLAYER && roadConnects(state, seat, it) } .filter { state.roads[it] == NO_PLAYER && roadConnects(state, seat, it) }
.map { BuildRoad(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,15 +297,27 @@ 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> =
if (piecesOf(state, seat, BuildingType.SETTLEMENT) >= MAX_SETTLEMENTS) {
emptyList()
} else {
(0 until CatanBoard.VERTEX_COUNT).filter { v -> (0 until CatanBoard.VERTEX_COUNT).filter { v ->
vertexOpenForSettlement(state, v) && vertexOpenForSettlement(state, v) &&
CatanBoard.VERTEX_EDGES[v].any { state.roads[it] == seat } 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> =
if (piecesOf(state, seat, BuildingType.CITY) >= MAX_CITIES) {
emptyList()
} else {
(0 until CatanBoard.VERTEX_COUNT).filter { v -> (0 until CatanBoard.VERTEX_COUNT).filter { v ->
state.buildings[v] == CatanBuilding(BuildingType.SETTLEMENT, seat) 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()
@@ -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,39 +172,54 @@ 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()) { // Always drawn, so the card does not grow when somebody takes an award.
Text( CardLine(
text = awards.joinToString(" · "), text = awards.joinToString(" · ").ifEmpty { BLANK },
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
weight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
) )
} }
} }
/** 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,
)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -221,14 +253,13 @@ 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(
modifier = Modifier.fillMaxWidth().height(PRIMARY_ROW_HEIGHT),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = 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) OutlinedButton(onClick = onRepickSetup) { Text(CatanText.CANCEL) }
if (actions.canRepickSetup) {
OutlinedButton(onClick = onRepickSetup) { Text(CatanText.CANCEL) }
}
if (actions.showTurnActions) {
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) {
LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST)
}
OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) { Text(CatanText.PLAY_CARD) }
OutlinedButton(onClick = onTrade, enabled = actions.canTrade) { Text(CatanText.TRADE) }
}
if (actions.canEndFreeRoads) Button(onClick = onEndFreeRoads) { Text(CatanText.STOP_ROADS) } if (actions.canEndFreeRoads) Button(onClick = onEndFreeRoads) { Text(CatanText.STOP_ROADS) }
if (actions.canEndTurn) Button(onClick = onEndTurn) { Text(CatanText.END_TURN) } if (actions.canEndTurn) Button(onClick = onEndTurn) { Text(CatanText.END_TURN) }
} }
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
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) {
LabelWithCost(CatanText.BUY_CARD, CatanText.DEV_COST)
}
OutlinedButton(onClick = onPlayCard, enabled = actions.canPlayCard) {
LabelWithCost(CatanText.PLAY_CARD, CatanText.NO_COST)
}
OutlinedButton(onClick = onTrade, enabled = actions.canTrade) {
LabelWithCost(CatanText.TRADE, CatanText.NO_COST)
}
}
}
} }
/** 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(prompt, style = MaterialTheme.typography.bodyMedium)
if (notice != null) {
Text( Text(
notice, text = notice ?: prompt,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error, color = if (notice != null) MaterialTheme.colorScheme.error else Color.Unspecified,
minLines = 2,
maxLines = 2,
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,10 +198,22 @@ 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
View File
@@ -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
View File
@@ -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.